diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9ea6ec2..f59957a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,3 +31,5 @@ jobs: run: npm run declarations env: CI: true + - name: Package imports + run: npm run test:package diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2be615b..f1b2321 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: node-version: 16 - run: npm ci - run: npm test - - run: npm lint + - run: npm run lint publish-npm: needs: test @@ -28,6 +28,7 @@ jobs: - run: npm ci - run: npm run build - run: npm run declarations + - run: npm run test:package - run: npm publish env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} diff --git a/README.md b/README.md index e90fbc0..2070a83 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,8 @@ Formats message strings with number, date, plural, and select placeholders to create localized messages. -* **Small.** Between 700 bytes and 1.3 kilobytes (minified and gzipped). - Zero dependencies. -* **Fast.** Does absolute minimum amount of computations necessary. View [benchmarks](#benchmarks). +* **Small.** Zero runtime dependencies. +* **Fast.** Precompiles messages and reuses formatters. View [benchmarks](#benchmarks). * **Tree Shakable.** Includes separate global transformers config that can be omitted. * **Pipe syntax.** Transformer functions can customized and chained. * **View framework support.** Use React/Preact etc. components as transformers. @@ -111,6 +110,12 @@ const msgPipe = MessagePipe({ This would be valid use case for it: `"Greetings {name | capitalize}!"`. ### Argument + +Single- and double-quoted argument values may contain delimiters such as commas, +colons, pipes, and braces. JavaScript string escapes are preserved inside quotes. +Outside placeholders, a backslash escapes the following character; use two +backslashes for a literal backslash. + To allow more functionality, we can use arguments, that are passed to transformer function. ```ts @@ -197,13 +202,33 @@ const msg = compile('Currently it is {now | time}') msg({ now: new Date('1983-05-25 16:42') }) // "Currently it is 16:42:00" ``` +#### relativeTime + +Formats a duration in **milliseconds**, using hours by default. Set `type` to an +Intl relative-time unit (singular or plural); other options, such as `numeric`, +are forwarded to `Intl.RelativeTimeFormat`. + +```ts +const msg = compile('{duration | relativeTime, type:"day", numeric:"auto"}') +msg({ duration: 86400000 }) // "tomorrow" (English locale) +``` + +Months, quarters, and years use fixed durations of 30, 90, and 365 days. For +calendar-aware differences, compute the duration in your application first. + +Intl formatters are reused per `intlTransformers(locale)` instance, with at most +100 option combinations retained per formatter kind. Dynamic option values +continue to work. Date and time component defaults are omitted when `dateStyle` +or `timeStyle` is supplied. Plural selection falls back to `other` and replaces +every `#` in the selected text; an explicitly empty selection stays empty. + ## API ### `MessagePipe` This is the main function that takes in all the transformers that will be available to all the messages. ```ts -function MessagePipe(transformers?: MessagePipeTransformers): { +function MessagePipe(transformers?: MessagePipeTransformers, options?: { disableCache?: boolean }): { compileRaw(message: string): (props?: Record) => string[] compile(message: string): (props?: Record) => string } @@ -217,12 +242,31 @@ const messagePipe = MessagePipe({ }) ``` +Built-in number, date, time, plural, and select pipes prepare scalar literal +options on first render. Dynamic arguments are still evaluated on every render, +with preparation reused for at most 16 primitive option combinations per pipe. +Object-valued options use the generic path to preserve mutable coercion behavior. +Each prepared number/date/time/plural variant retains at most 100 scalar results +(up to 1,600 for a dynamic pipe). After 100 consecutive misses, that variant's +result cache is cleared and bypassed for its lifetime. Mutable inputs, omitted +values, and negative zero bypass result caching. Custom transformers keep their +normal per-call behavior. Already-compiled messages observe replacement functions +and resolve the transformer before evaluating its arguments. + +Compiled messages are cached within each MessagePipe instance, so transformer +sets and locales stay independent. Set `disableCache: true` to bypass message +caching. Omitted props are treated as an empty object. + +Message templates are trusted application code: compilation uses `Function` +and evaluates JavaScript selector and argument expressions. Do not compile +untrusted templates. This also requires a CSP that allows dynamic compilation. + Now all the messages that get compiled from `messagePipe` can use this transformer like so `"{name | hello}"`. #### `compile` This is where given [message](#message) gets parsed and prepared for usage. It is very efficient compiler that does only 1 pass and prepares very tiny and performant function from it. -Given this message `"Hello {name | capitalize}!"`, compiler will output this function `(a) => "Hello " + capitalize(a.name) + "!"` and that is the only thing that runs when executing it. No hidden performance penalties. +Given this message `"Hello {name | capitalize}!"`, compiler will output this function `(a) => "Hello " + capitalize(a.name) + "!"` for the custom transformer call. #### `compileRaw` This is practically the same as [compile](#compile) but instead of it returning one string, it returns array of all of the things as a separate chunks so that this compiler can be used as part of React component for example. @@ -230,13 +274,82 @@ This is practically the same as [compile](#compile) but instead of it returning So from the example that was before, the output of that message would be `(a) => ["Hello ", capitalize(a.name), "!"]`. ## Benchmarks -It is necessary for me that this library is as small and as fast as possible. Since this library compares directly with MessageFormat, I treated both as equal in benchmarks. -Message | MessageFormat | MessagePipe | Improvement -|-|-|-|-| -["Wow"](https://fastbench.dev/Format-%22Wow%22-JtYOF/0) | 926,368 ops/s | __1,847,253 ops/s__ | 2x -["Hello {planet}"](https://fastbench.dev/Format-%22Hello-planet%22-tKWLnH/0) | 560,131 ops/s | __1,024,051 ops/s__ | 1.8x -[select transformer](https://fastbench.dev/Format-select-MovWE/0) | 209,513 ops/s | __337,226 ops/s__ | 1.6x +### MessagePipe vs. MessageFormat + +Measured on 2026-10-28 against **`@messageformat/core@3.4.1`**, the +[ICU MessageFormat compiler](https://messageformat.github.io/messageformat/api/core.messageformat/). +This is the MessageFormat v3 API, rather than the newer MessageFormat 2 syntax +implemented by the `messageformat` v4 package. The comparison dependency is +pinned for reproducibility and is development-only. + +| Workload | MessageFormat (ops/s) | MessagePipe (ops/s) | MessagePipe / MessageFormat | +| --- | ---: | ---: | ---: | +| Plain text: `Wow` | 256,043,589 | 269,588,503 | 1.05× | +| Interpolation: `Hello {planet}` | 130,454,294 | 129,213,173 | 0.99× | +| Select: gender pronoun | 74,260,093 | 107,320,883 | 1.45× | +| Number: EUR currency | 4,548,900 | 89,069,331 | 19.58× | +| Number: dynamic fraction digits | 2,184,894 | 8,686,990 | 3.98× | +| Date: day/month/year | 2,355,474 | 86,348,441 | 36.66× | +| Plural: item count | 4,297,437 | 86,933,654 | 20.23× | + +Higher throughput is better. Select and the Intl workloads are faster in these +measurements; interpolation is effectively tied. The small plain-text difference +is sensitive to loop overhead and does not establish a universal speed advantage. + +The number, dynamic-number, date, and plural rows above rotate three values and benefit from +bounded formatted-value reuse. To expose cache-miss costs, a second run rotates +**1,000 distinct inputs** for interpolation and each Intl workload: + +| Workload | MessageFormat (ops/s) | MessagePipe (ops/s) | MessagePipe / MessageFormat | +| --- | ---: | ---: | ---: | +| Interpolation: `Hello {planet}` | 167,120,929 | 168,019,475 | 1.01× | +| Number: EUR currency | 4,586,144 | 6,132,395 | 1.34× | +| Number: dynamic fraction digits | 1,991,615 | 2,734,228 | 1.37× | +| Date: day/month/year | 2,305,404 | 2,684,219 | 1.16× | +| Plural: item count | 3,337,506 | 5,730,987 | 1.72× | + +Plain text compiles to a constant return. Simple interpolation compiles to a +property read and string concatenation. Neither uses a rendered-string cache: +lookup overhead can exceed the concatenation cost when inputs vary. These +paths have little remaining work to eliminate, so small throughput differences +should not be treated as reliable improvements. + +Both libraries compile once before timing and receive equivalent templates, +locales, and inputs. Every input must produce the same expected string in both +libraries before measurement; timed calls contribute to a checked output +checksum. The dynamic-number workload alternates two and three fraction digits; +MessagePipe uses dynamic pipe arguments and MessageFormat selects between two +compiled number skeletons. Compiler initialization and compilation are excluded. + +Tables use the median of **21 samples** per library and workload, collected in +three independent processes with seven samples each. Each sample performs +1,000,000 calls after 10,000 warmup calls. Measurement order alternates between +libraries. The runner prints templates, inputs, samples, and runtime metadata. +Host: Linux x64 (WSL), AMD Ryzen 9 9950X, Node 22.23.2, ICU 78.2, `en-GB`, UTC. +MessagePipe uses its minified CommonJS build; MessageFormat uses the functions +returned by its installed compiler. + +```sh +npm ci +npm run build +npm run benchmark +npm run benchmark -- --varied +``` + +Run each benchmark command three times to reproduce the table's sampling method. +An optional CommonJS bundle path compares another MessagePipe build: + +```sh +npm run benchmark -- /path/to/messagepipe/dist/index.js +``` + +These are warm formatting microbenchmarks. Literal options are prepared once, +and repeated immutable values may reuse a formatted result. The varied-input +mode exceeds the result cache capacity and exercises its automatic bypass. +Compilation, dynamic options, mutable objects, other runtimes, and other messages +can give different results. The shared input-rotation/checksum loop matters most +for the fastest cases. These results do not prove superiority on every workload. ## Framework integration Works with React and Preact out of the box. Just swap out [`compile`](#compile) with [`compileRaw`](#compile) and good to go. This works because it returns raw array of values that was the output of selectors and transformers. diff --git a/package-lock.json b/package-lock.json index 9f954d2..806a002 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "messagepipe", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "messagepipe", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "devDependencies": { + "@messageformat/core": "3.4.1", "esbuild": "^0.15.5", "rome": "^0.9.0-next", "tsm": "^2.2.2", @@ -32,6 +33,55 @@ "node": ">=12" } }, + "node_modules/@messageformat/core": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.1.tgz", + "integrity": "sha512-98uXHLDjPe2WZ8Xr05MhFQGfpMRbUcJDEYgjLV5ZBTgnntIkKS0VKGE+g8ug/hbgggyPEAeZBRJryIsBUp1vvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@messageformat/date-skeleton": "2.0.0-0", + "@messageformat/number-skeleton": "2.0.0-0", + "@messageformat/parser": "^5.2.0", + "@messageformat/runtime": "^3.0.3", + "make-plural": "^7.0.0", + "safe-identifier": "^0.4.1" + } + }, + "node_modules/@messageformat/date-skeleton": { + "version": "2.0.0-0", + "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-2.0.0-0.tgz", + "integrity": "sha512-gkCatAK4MYlhjKYWxkfRM6Ql0M1VmA1DK6A9rszUBprZpQox/XqX987AI2w7YoEz0p5DgkPOpI6PK1mW08yNqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@messageformat/number-skeleton": { + "version": "2.0.0-0", + "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-2.0.0-0.tgz", + "integrity": "sha512-bDrIrVSKwn0sNrbU+N6tjwljV+Zl9pRCR/D9Xim6Q7lLzZ20nFYtuuKOZ3QKisN+vPxa9sTqu3ufJ+uwKNpHbQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@messageformat/parser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.2.0.tgz", + "integrity": "sha512-sc3hNZ0JtC32GhL5nUgPbh2ilcwji4G+Vmj/vhMd8hZaH//NDOvkfwKK1mFnRWG5h2DpksN8xr/HB5/+vMg3iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "moo": "^0.5.1" + } + }, + "node_modules/@messageformat/runtime": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.3.tgz", + "integrity": "sha512-FE0hP0m15+9p/ia5HWQ/JsUSNLcxZkl1jl1HlRrWCiMAsgiulg7AuOFLaBR6dpPqtPWWuApY7VLo3rIAcrgrkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "make-plural": "^7.0.0" + } + }, "node_modules/@rometools/cli-darwin-arm64": { "version": "0.9.0-next", "resolved": "https://registry.npmjs.org/@rometools/cli-darwin-arm64/-/cli-darwin-arm64-0.9.0-next.tgz", @@ -514,6 +564,20 @@ "node": ">=6" } }, + "node_modules/make-plural": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.5.0.tgz", + "integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==", + "dev": true, + "license": "Unicode-DFS-2016" + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -559,6 +623,13 @@ "node": ">=6" } }, + "node_modules/safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "dev": true, + "license": "ISC" + }, "node_modules/tsm": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/tsm/-/tsm-2.2.2.tgz", @@ -986,6 +1057,50 @@ "dev": true, "optional": true }, + "@messageformat/core": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.1.tgz", + "integrity": "sha512-98uXHLDjPe2WZ8Xr05MhFQGfpMRbUcJDEYgjLV5ZBTgnntIkKS0VKGE+g8ug/hbgggyPEAeZBRJryIsBUp1vvw==", + "dev": true, + "requires": { + "@messageformat/date-skeleton": "2.0.0-0", + "@messageformat/number-skeleton": "2.0.0-0", + "@messageformat/parser": "^5.2.0", + "@messageformat/runtime": "^3.0.3", + "make-plural": "^7.0.0", + "safe-identifier": "^0.4.1" + } + }, + "@messageformat/date-skeleton": { + "version": "2.0.0-0", + "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-2.0.0-0.tgz", + "integrity": "sha512-gkCatAK4MYlhjKYWxkfRM6Ql0M1VmA1DK6A9rszUBprZpQox/XqX987AI2w7YoEz0p5DgkPOpI6PK1mW08yNqQ==", + "dev": true + }, + "@messageformat/number-skeleton": { + "version": "2.0.0-0", + "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-2.0.0-0.tgz", + "integrity": "sha512-bDrIrVSKwn0sNrbU+N6tjwljV+Zl9pRCR/D9Xim6Q7lLzZ20nFYtuuKOZ3QKisN+vPxa9sTqu3ufJ+uwKNpHbQ==", + "dev": true + }, + "@messageformat/parser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.2.0.tgz", + "integrity": "sha512-sc3hNZ0JtC32GhL5nUgPbh2ilcwji4G+Vmj/vhMd8hZaH//NDOvkfwKK1mFnRWG5h2DpksN8xr/HB5/+vMg3iw==", + "dev": true, + "requires": { + "moo": "^0.5.1" + } + }, + "@messageformat/runtime": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.3.tgz", + "integrity": "sha512-FE0hP0m15+9p/ia5HWQ/JsUSNLcxZkl1jl1HlRrWCiMAsgiulg7AuOFLaBR6dpPqtPWWuApY7VLo3rIAcrgrkA==", + "dev": true, + "requires": { + "make-plural": "^7.0.0" + } + }, "@rometools/cli-darwin-arm64": { "version": "0.9.0-next", "resolved": "https://registry.npmjs.org/@rometools/cli-darwin-arm64/-/cli-darwin-arm64-0.9.0-next.tgz", @@ -1236,6 +1351,18 @@ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true }, + "make-plural": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.5.0.tgz", + "integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==", + "dev": true + }, + "moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "dev": true + }, "mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1268,6 +1395,12 @@ "mri": "^1.1.0" } }, + "safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "dev": true + }, "tsm": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/tsm/-/tsm-2.2.2.tgz", diff --git a/package.json b/package.json index 8e2dbec..65ab02a 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,20 @@ { "name": "messagepipe", - "version": "0.2.3", + "version": "0.2.4", "description": "Formats message strings with number, date, plural, and select placeholders to create localized messages", "main": "dist/index.js", - "module": "dist/index.esm.js", + "module": "dist/index.mjs", "types": "dist/index.d.ts", "scripts": { "dev": "node -r tsm dev.ts", "build": "npm run build:esm && npm run build:cjs", - "build:esm": "esbuild src/index.ts --minify --outfile=dist/index.esm.js --bundle --format=esm --sourcemap --define:process.env.NODE_ENV=\\\"production\\\"", + "build:esm": "esbuild src/index.ts --minify --outfile=dist/index.mjs --bundle --format=esm --sourcemap --define:process.env.NODE_ENV=\\\"production\\\"", "build:cjs": "esbuild src/index.ts --minify --outfile=dist/index.js --bundle --format=cjs --sourcemap --define:process.env.NODE_ENV=\\\"production\\\"", "declarations": "tsc --declarationDir dist --emitDeclarationOnly --declaration", "test": "uvu -r tsm", - "lint": "rome check ./src/" + "lint": "rome check ./src/", + "test:package": "node scripts/test-package.cjs", + "benchmark": "node scripts/benchmark.cjs" }, "repository": { "type": "git", @@ -25,6 +27,7 @@ }, "homepage": "https://github.com/Marcisbee/messagepipe#readme", "devDependencies": { + "@messageformat/core": "3.4.1", "esbuild": "^0.15.5", "rome": "^0.9.0-next", "tsm": "^2.2.2", @@ -36,8 +39,9 @@ ], "exports": { ".": { + "types": "./dist/index.d.ts", "require": "./dist/index.js", - "import": "./dist/index.esm.js" + "import": "./dist/index.mjs" } } } diff --git a/scripts/benchmark.cjs b/scripts/benchmark.cjs new file mode 100644 index 0000000..67b2cfc --- /dev/null +++ b/scripts/benchmark.cjs @@ -0,0 +1,111 @@ +// Fix the date workload's timezone across hosts. +process.env.TZ = 'UTC'; + +const assert = require('node:assert/strict'); +const { cpus } = require('node:os'); +const { resolve } = require('node:path'); +const { performance } = require('node:perf_hooks'); +const MessageFormat = require('@messageformat/core'); +const varied = process.argv.includes('--varied'); +const bundle = resolve(process.argv.slice(2).find(arg => arg !== '--varied') || 'dist/index.js'); +const { MessagePipe, defaultTransformers, intlTransformers } = require(bundle); +const pipe = MessagePipe({ ...defaultTransformers(), ...intlTransformers('en-GB') }); +const messageformat = new MessageFormat('en-GB'); + +const warmup = 10000; +const iterations = 1000000; +const repeats = 7; +const workloads = [ + ['plain', 'Wow', 'Wow', [[{}, 'Wow']]], + ['interpolation', 'Hello {planet}', 'Hello {planet}', [ + [{ planet: 'Mars' }, 'Hello Mars'], [{ planet: 'Venus' }, 'Hello Venus'], + [{ planet: 'Earth' }, 'Hello Earth'], + ]], + ['select', '{gender|select,male:"He",female:"She",other:"They"} liked this.', + '{gender, select, male{He} female{She} other{They}} liked this.', [ + [{ gender: 'male' }, 'He liked this.'], [{ gender: 'female' }, 'She liked this.'], + [{ gender: 'other' }, 'They liked this.'], + ]], + ['number', '{x|number,style:"currency",currency:"EUR"}', '{x, number, ::currency/EUR}', [ + [{ x: 1234.5 }, '€1,234.50'], [{ x: 0 }, '€0.00'], [{ x: -42 }, '-€42.00'], + ]], + ['dynamic number', '{x|number,minimumFractionDigits:{digits},maximumFractionDigits:{digits}}', + '{digits, select, 2{{x, number, ::.00}} other{{x, number, ::.000}}}', [ + [{ x: 1.25, digits: 2 }, '1.25'], [{ x: 42, digits: 3 }, '42.000'], + [{ x: -8, digits: 2 }, '-8.00'], + ]], + ['date', '{x|date}', '{x, date, ::ddMMyyyy}', [ + [{ x: 0 }, '01/01/1970'], [{ x: 86400000 }, '02/01/1970'], + [{ x: 172800000 }, '03/01/1970'], + ]], + ['plural', '{x|plural,one:"one",other:"# items"}', '{x, plural, one{one} other{# items}}', [ + [{ x: 0 }, '0 items'], [{ x: 1 }, 'one'], [{ x: 5 }, '5 items'], + ]], +]; + +if (varied) { + const number = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'EUR' }); + const date = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'numeric', day: 'numeric' }); + for (const workload of workloads) { + const [name] = workload; + if (name === 'interpolation') { + workload[3] = Array.from({ length: 1000 }, (_, i) => [{ planet: 'Planet ' + i }, 'Hello Planet ' + i]); + } else if (name === 'dynamic number') { + workload[3] = Array.from({ length: 1000 }, (_, i) => { + const digits = i % 2 ? 3 : 2; + const x = i / 4; + return [{ x, digits }, new Intl.NumberFormat('en-GB', { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(x)]; + }); + } else if (name === 'number' || name === 'date' || name === 'plural') { + workload[3] = Array.from({ length: 1000 }, (_, i) => { + const x = name === 'date' ? i * 86400000 : name === 'plural' ? i : i + 1000; + return [{ x }, name === 'number' ? number.format(x) : name === 'date' ? date.format(x) : x === 1 ? 'one' : x + ' items']; + }); + } + } +} + +function run(render, cases, count) { + let checksum = 0; + for (let i = 0; i < count; i++) checksum += render(cases[i % cases.length][0]).length; + return checksum; +} + +const results = []; +for (const [name, pipeTemplate, mfTemplate, cases] of workloads) { + const renderers = { + messagepipe: pipe.compile(pipeTemplate), + messageformat: messageformat.compile(mfTemplate), + }; + for (const render of Object.values(renderers)) { + for (const [props, expected] of cases) assert.equal(render(props), expected); + run(render, cases, warmup); + } + const cycleLength = cases.reduce((sum, [, expected]) => sum + expected.length, 0); + const expectedChecksum = Math.floor(iterations / cases.length) * cycleLength + + cases.slice(0, iterations % cases.length).reduce((sum, [, expected]) => sum + expected.length, 0); + const samples = { messagepipe: [], messageformat: [] }; + for (let sample = 0; sample < repeats; sample++) { + // Alternate measurement order to reduce systematic warmup/order bias. + const order = sample % 2 ? ['messageformat', 'messagepipe'] : ['messagepipe', 'messageformat']; + for (const library of order) { + const start = performance.now(); + const checksum = run(renderers[library], cases, iterations); + samples[library].push(performance.now() - start); + assert.equal(checksum, expectedChecksum); + } + } + const timings = {}; + for (const [library, values] of Object.entries(samples)) { + const median = [...values].sort((a, b) => a - b)[Math.floor(repeats / 2)]; + timings[library] = { samples_ms: values, median_ms: median, ops_per_second: iterations * 1000 / median }; + } + results.push({ name, messagepipe_template: pipeTemplate, messageformat_template: mfTemplate, cases, timings }); +} +console.log(JSON.stringify({ + bundle, messageformat: `@messageformat/core@${require('@messageformat/core/package.json').version}`, + node: process.version, icu: process.versions.icu, + platform: process.platform, arch: process.arch, cpu: cpus()[0].model, + locale: 'en-GB', timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + varied, warmup, iterations, repeats, results, +}, null, 2)); diff --git a/scripts/test-package.cjs b/scripts/test-package.cjs new file mode 100644 index 0000000..37b918d --- /dev/null +++ b/scripts/test-package.cjs @@ -0,0 +1,16 @@ +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); + +// Exercise the package export map, rather than importing a source file. +for (const [mode, load] of [ + ['commonjs', "const { MessagePipe } = require('messagepipe');"], + ['module', "import { MessagePipe } from 'messagepipe';"], +]) { + const result = spawnSync(process.execPath, [ + `--input-type=${mode}`, '-e', + `${load} console.log(MessagePipe().compile('Hello {name}')({ name: 'Ada' }));`, + ], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), 'Hello Ada'); +} +console.log('CommonJS and ESM package imports passed'); diff --git a/src/messagepipe.ts b/src/messagepipe.ts index e10f56d..fd64daf 100644 --- a/src/messagepipe.ts +++ b/src/messagepipe.ts @@ -1,4 +1,5 @@ import { parser } from './parser'; +import { preparations, prepareDynamic, type Transform } from './prepared'; export interface MessagePipeTransformer { (value: Input, options?: Record): Output; @@ -12,29 +13,73 @@ export interface MessagePipeOptions { disableCache?: boolean; } -const rcache: Record = {}; -const scache: Record = {}; - export function MessagePipe(transformers: MessagePipeTransformers = {}, { disableCache = false }: MessagePipeOptions = {}) { + const rcache: Record = Object.create(null); + const scache: Record = Object.create(null); + + function build(message: string, raw: boolean) { + const prepared: Function[] = []; + const chunks = parser(message, (name, input, props) => { + const original = Object.getOwnPropertyDescriptor(transformers, name)?.value; + const prepare = preparations.get(original); + // Only scalar literals are safe to move out of the render path. Dynamic + // selectors, expressions and custom transformers keep their original behavior. + const literal = /^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|true|false|null)$/; + if (!prepare || props.some(([key]) => !/^[A-Za-z_$][\w$]*$/.test(key.trim()) || key.trim() === '__proto__')) { + return undefined; + } + const constant = props.every(([, value]) => value === undefined || literal.test(value.trim())); + const source = '{' + props.map(([key, value = 'true']) => key + ':' + value).join(',') + '}'; + const target = 'b[' + JSON.stringify(name) + ']'; + if (!constant) { + const getRender = prepareDynamic(prepare, props.map(([key]) => key.trim())); + const index = prepared.push((current: Transform, value: any, options: Record) => { + if (current === original) { + const render = getRender(options); + if (render) { + return render(value); + } + } + return current.call(transformers, value, options); + }) - 1; + return 'c[' + index + '](' + target + ',' + (input.trim() ? input : 'void 0') + ',' + source + ')'; + } + const createOptions = Function('return (' + source + ')'); + const options = createOptions(); + // Preparation stays lazy so invalid Intl options still throw on rendering. + let render: Transform | undefined; + const index = prepared.push((current: Transform, value: any) => { + if (current !== original) { + if (props.length) { + return current.call(transformers, value, createOptions()); + } + return input.trim() ? current.call(transformers, value) : Reflect.apply(current, transformers, []); + } + render ??= prepare(options); + return render(value); + }) - 1; + return 'c[' + index + '](' + target + (input.trim() ? ',' + input : '') + ')'; + }); + const expression = raw ? '[' + chunks.join(',') + ']' + : chunks.filter((chunk, index) => index === 0 || chunk !== '""').join('+'); + return Function('b', 'c', 'return (a={})=>' + expression)(transformers, prepared); + } + function compileRaw(message: string): (props?: Record) => Output { if (!disableCache && rcache[message]) { return rcache[message]; } - - const parsedMessage = Function('b', 'return (a)=>[' + parser(message).join(',') + ']'); - - return disableCache ? parsedMessage(transformers) : rcache[message] = parsedMessage(transformers); + const render = build(message, true); + return disableCache ? render : rcache[message] = render; } function compile(message: string): (props?: Record) => Output { if (!disableCache && scache[message]) { return scache[message]; } - - const parsedMessage = Function('b', 'return (a)=>' + parser(message).join('+')); - - return disableCache ? parsedMessage(transformers) : scache[message] = parsedMessage(transformers); + const render = build(message, false); + return disableCache ? render : scache[message] = render; } return { diff --git a/src/parser.ts b/src/parser.ts index b7ff578..d6a88cc 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -27,10 +27,11 @@ function reducePipe(acc: string, [pipeName, ...pipeProps]: [string, ...[string, + ')'; } -export function parser(text: string): string[] { +export function parser(text: string, prepare?: ( + name: string, input: string, props: [string, string][] +) => string | undefined): string[] { const output: string[] = []; let chunk = ''; - let previousToken = ''; // This is where message starts. // "Hello {name}!" @@ -40,6 +41,8 @@ export function parser(text: string): string[] { let localSelector = ''; let localPipes: [string, ...[string, string][]][] = []; let localPipeIndex = -1; + let quote = ''; + let escaped = false; function end(index: number) { const selectorTrim = localSelector.trim(); @@ -48,7 +51,10 @@ export function parser(text: string): string[] { ? 'a' + selectorTrim : 'a.' + selectorTrim : ''; - const output = localPipes.reduce(reducePipe, selector); + const output = localPipes.reduce((input, pipe) => { + const [name, ...props] = pipe; + return prepare?.(name.trim(), input, props) ?? reducePipe(input, pipe); + }, selector); return { index, @@ -58,10 +64,24 @@ export function parser(text: string): string[] { for (let i = startIndex; i < text.length; i++) { const char = text[i]; - const escaped = previousToken === tokenEscape; - previousToken = char; - - if (char === tokenEscape) { + // Quoted JavaScript strings are opaque to message delimiters. + if (quote || char === '"' || char === "'") { + if (localPipeIndex === -1) { + localSelector += char; + } else { + const pipe = localPipes[localPipeIndex]; + const args = pipe[pipe.length - 1]; + if (!Array.isArray(args)) { + fail(char, i); + } + args[args.length - 1] += char; + } + if (!quote) { + quote = char; + } else if (!escaped && char === quote) { + quote = ''; + } + escaped = !escaped && char === tokenEscape; continue; } @@ -137,14 +157,12 @@ export function parser(text: string): string[] { for (let i = 0; i < text.length; i++) { const char = text[i]; - const escaped = previousToken === tokenEscape; - previousToken = char; - if (char === tokenEscape) { + chunk += text[++i] ?? tokenEscape; continue; } - if (tokenStart === char && !escaped) { + if (tokenStart === char) { output.push(JSON.stringify(chunk)); chunk = ''; const messageData = messageStart(i + 1); diff --git a/src/prepared.ts b/src/prepared.ts new file mode 100644 index 0000000..29e39c2 --- /dev/null +++ b/src/prepared.ts @@ -0,0 +1,71 @@ +// Internal compiler hooks: only built-ins opt in to constant-option preparation. +export type Transform = (value: any, options?: Record) => any; +export const preparations = new WeakMap) => Transform>(); + +// Cache only immutable scalar inputs. Objects may have mutable coercion behavior, +// and undefined can mean the current time. Keep signed zero distinct by bypassing it. +export function memoizeFormat(format: Transform): Transform { + const cache = new Map(); + let misses = 0; + return value => { + if (misses >= 100) { + return format(value); + } + const type = typeof value; + if ((type !== 'string' && type !== 'number' && type !== 'bigint') || Object.is(value, -0)) { + return format(value); + } + const cached = cache.get(value); + if (cached !== undefined) { + misses = 0; + return cached; + } + const output = format(value); + if (++misses >= 100) { + cache.clear(); + return output; + } + if (cache.size >= 100) { + cache.delete(cache.keys().next().value); + } + cache.set(value, output); + return output; + }; +} + +// Dynamic arguments are evaluated by the generated function before this lookup. +// Only immutable option values can safely reuse a prepared closure. +export function prepareDynamic(prepare: (options: Record) => Transform, keys: string[]) { + const cache = new Map(); + let previous: Record | undefined; + let previousRender: Transform | undefined; + return (options: Record): Transform | undefined => { + let same = previous !== undefined; + let key = ''; + for (const name of keys) { + const value = options[name]; + const type = typeof value; + if ((value !== null && type !== 'string' && type !== 'number' && type !== 'boolean' && type !== 'undefined') + || (type === 'number' && !Number.isFinite(value))) { + return undefined; + } + same = same && Object.is(value, previous![name]); + const text = Object.is(value, -0) ? '-0' : String(value); + key += type + ':' + text.length + ':' + text; + } + if (same) { + return previousRender; + } + let render = cache.get(key); + if (!render) { + render = prepare(options); + if (cache.size >= 16) { + cache.delete(cache.keys().next().value!); + } + cache.set(key, render); + } + previous = options; + previousRender = render; + return render; + }; +} diff --git a/src/regressions.test.ts b/src/regressions.test.ts new file mode 100644 index 0000000..83f2b66 --- /dev/null +++ b/src/regressions.test.ts @@ -0,0 +1,204 @@ +import { test } from 'uvu'; +import assert from 'uvu/assert'; +import { MessagePipe, defaultTransformers, intlTransformers } from './index'; + +for (const method of ['compile', 'compileRaw'] as const) { + test(`${method} isolates transformers and accepts prototype-named messages`, () => { + const first = MessagePipe({ label: () => 'first' }); + const second = MessagePipe({ label: () => 'second' }); + const output = (value: string) => method === 'compile' ? value : ['', value, '']; + assert.equal(first[method]('{x|label}')({}), output('first')); + assert.equal(second[method]('{x|label}')({}), output('second')); + for (const text of ['constructor', 'toString', '__proto__']) { + assert.equal(first[method](text)(), method === 'compile' ? text : [text]); + } + }); +} + +test('quoted arguments and selectors preserve delimiters and escapes', () => { + const pipe = MessagePipe(defaultTransformers()); + assert.equal(pipe.compile('{x|select,other:"a,b:c|d{e}"}')({}), 'a,b:c|d{e}'); + assert.equal(pipe.compile(String.raw`{x|select,other:"say \"hi\""}`)({}), 'say "hi"'); + assert.equal(pipe.compile('{["a|b,c:d}"]}')({ 'a|b,c:d}': 'found' }), 'found'); + assert.equal(pipe.compile(String.raw`\\{name}`)({ name: 'Ada' }), '\\Ada'); +}); + +test('select preserves empty choices and ignores inherited keys', () => { + const pipe = MessagePipe(defaultTransformers()); + const message = pipe.compile('{x|select,empty:"",other:"fallback"}'); + assert.equal(message({ x: 'empty' }), ''); + assert.equal(message({ x: 'toString' }), 'fallback'); +}); + +test('Intl options reach formatters and plural falls back and replaces every marker', () => { + const pipe = MessagePipe(intlTransformers('en-GB')); + assert.equal(pipe.compile('{x|number,minimumFractionDigits:2}')({ x: 1 }), '1.00'); + assert.equal(pipe.compile('{x|date,year:"2-digit",month:"long",day:"2-digit",timeZone:"UTC"}')({ x: 0 }), '01 January 70'); + assert.equal(pipe.compile('{x|time,hour12:false,timeZone:"UTC"}')({ x: 0 }), '00:00:00'); + assert.equal(pipe.compile('{x|plural,other:"# of #"}')({ x: 1 }), '1 of 1'); + assert.equal(pipe.compile('{x|plural,one:"one",other:"many",minimumFractionDigits:2}')({ x: 1 }), 'many'); +}); + +test('formatter reuse respects changing options and locale isolation', () => { + const gb = MessagePipe(intlTransformers('en-GB')); + const de = MessagePipe(intlTransformers('de-DE')); + const text = '{x|number,minimumFractionDigits:{digits}}'; + const render = gb.compile(text); + assert.equal(render({ x: 1.5, digits: 2 }), '1.50'); + assert.equal(render({ x: 1.5, digits: 3 }), '1.500'); + assert.equal(render({ x: 1.5, digits: 2 }), '1.50'); + assert.equal(de.compile(text)({ x: 1.5, digits: 2 }), '1,50'); +}); + +test('omitted props behave like an empty props object', () => { + const pipe = MessagePipe(); + assert.equal(pipe.compile('Hello {name}')(), 'Hello undefined'); + assert.equal(pipe.compileRaw('{name}')(), ['', undefined, '']); +}); + +for (const [type, milliseconds, expected] of [ + ['second', 1000, 'in 1 second'], + ['minutes', -60000, '1 minute ago'], + ['hour', 3600000, 'in 1 hour'], + ['day', 86400000, 'in 1 day'], + ['week', 604800000, 'in 1 week'], + ['month', 2592000000, 'in 1 month'], + ['quarter', 7776000000, 'in 1 quarter'], + ['year', 31536000000, 'in 1 year'], +] as const) { + test(`relativeTime converts milliseconds to ${type}`, () => { + const render = MessagePipe(intlTransformers('en-GB')).compile('{x|relativeTime,type:{unit}}'); + assert.equal(render({ x: milliseconds, unit: type }), expected); + }); +} + +test('raw compilation preserves values and bypassed caches stay independent', () => { + const object = { id: 1 }; + const pipe = MessagePipe({ identity: value => value }, { disableCache: true }); + assert.equal(pipe.compileRaw('{x|identity}')({ x: object }), ['', object, '']); + assert.is.not(pipe.compile('x'), pipe.compile('x')); + assert.is.not(pipe.compileRaw('x'), pipe.compileRaw('x')); +}); + +test('number options preserve inherited and non-enumerable properties', () => { + const { number } = intlTransformers('en-GB'); + assert.equal(number('0.5'), '0.5'); + assert.equal(number('0.5', Object.create({ style: 'percent' })), '50%'); + assert.equal(number('0.5', Object.defineProperty({}, 'style', { value: 'percent' })), '50%'); + assert.equal(number('0.5'), '0.5'); +}); + +test('number options evaluate getters once per call', () => { + const { number } = intlTransformers('en-GB'); + let reads = 0; + const options = { get style() { return reads++ === 0 ? 'percent' : 'decimal'; } }; + assert.equal(number('0.5', options), '50%'); + assert.equal(reads, 1); + assert.equal(number('0.5', options), '0.5'); + assert.equal(reads, 2); +}); + +test('prepared formatting preserves signed zero, mutable inputs and cache eviction', () => { + const pipe = MessagePipe(intlTransformers('en-GB')); + const number = pipe.compile('{x|number,minimumFractionDigits:2}'); + assert.equal(number({ x: 0 }), '0.00'); + assert.equal(number({ x: -0 }), '-0.00'); + assert.equal(number({ x: 0 }), '0.00'); + let amount = 1; + const mutable = { valueOf: () => amount++ }; + assert.equal(number({ x: mutable }), '1.00'); + assert.equal(number({ x: mutable }), '2.00'); + for (let i = 1; i <= 110; i++) { + assert.equal(number({ x: i }), i + '.00'); + } + assert.equal(number({ x: 0 }), '0.00'); + const date = pipe.compile('{x|date,timeZone:"UTC"}'); + const instant = new Date(0); + assert.equal(date({ x: instant }), '01/01/1970'); + instant.setUTCDate(2); + assert.equal(date({ x: instant }), '02/01/1970'); +}); + +test('compiled built-ins remain replaceable and custom options stay fresh', () => { + const transformers = intlTransformers('en-GB'); + const original = transformers.number; + const pipe = MessagePipe(transformers); + const render = pipe.compile('{x|number,minimumFractionDigits:2}'); + assert.equal(render({ x: 1 }), '1.00'); + transformers.number = function(value, options) { + assert.is(this, transformers); + const digits = options.minimumFractionDigits; + options.minimumFractionDigits = 9; + return value + ':' + digits; + }; + assert.equal(render({ x: 1 }), '1:2'); + assert.equal(render({ x: 2 }), '2:2'); + transformers.number = original; + assert.equal(render({ x: 1 }), '1.00'); +}); + +test('constant preparation stays lazy and dynamic arguments remain live', () => { + const pipe = MessagePipe(intlTransformers('en-GB')); + const invalid = pipe.compile('{x|number,style:"invalid"}'); + assert.throws(() => invalid({ x: 1 }), RangeError); + const render = pipe.compile('{x|number,minimumFractionDigits:{digits}}'); + assert.equal(render({ x: 1, digits: 1 }), '1.0'); + assert.equal(render({ x: 1, digits: 3 }), '1.000'); + assert.equal(pipe.compileRaw('{x|number,minimumFractionDigits:2}')({ x: 1 }), ['', '1.00', '']); +}); + +test('adjacent placeholders preserve string coercion and raw values', () => { + const pipe = MessagePipe(); + assert.equal(pipe.compile('{a}{b}')({ a: 1, b: 2 }), '12'); + assert.equal(pipe.compileRaw('{a}{b}')({ a: 1, b: 2 }), ['', 1, '', 2, '']); +}); + +for (const locale of ['en-GB', 'de-DE', 'ar', 'lv']) { + test(`prepared formats preserve native Intl behavior in ${locale}`, () => { + const pipe = MessagePipe(intlTransformers(locale)); + const number = pipe.compile('{x|number,style:"currency",currency:"EUR"}'); + const expectedNumber = new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }); + const plural = pipe.compile('{x|plural,zero:"zero # #",one:"one # #",two:"two # #",few:"few # #",many:"many # #",other:"other # #"}'); + const rules = new Intl.PluralRules(locale); + for (const x of [0, -0, 1, 2, 5, 21, 1.5, NaN]) { + assert.equal(number({ x }), expectedNumber.format(x)); + assert.equal(plural({ x }), rules.select(x) + ' ' + x + ' ' + x); + } + }); +} + +for (const template of ['{x|number,minimumFractionDigits:2}', '{x|number,minimumFractionDigits:{digits}}']) { + test(`transformer lookup precedes argument evaluation: ${template}`, () => { + const transformers = intlTransformers('en-GB'); + const original = transformers.number; + const render = MessagePipe(transformers).compile(template); + const props = { get x() { transformers.number = () => 'replacement'; return 1; }, digits: 2 }; + assert.equal(render(props), '1.00'); + assert.equal(render({ x: 1, digits: 2 }), 'replacement'); + transformers.number = original; + assert.equal(render({ x: 1, get digits() { transformers.number = () => 'replacement'; return 2; } }), '1.00'); + }); +} + +test('dynamic preparation preserves option changes, objects and undefined choices', () => { + const pipe = MessagePipe({ ...intlTransformers('en-GB'), ...defaultTransformers() }); + const number = pipe.compile('{x|number,minimumFractionDigits:{digits},maximumFractionDigits:{digits}}'); + for (let digits = 0; digits < 20; digits++) { + assert.equal(number({ x: 1, digits }), digits ? '1.' + '0'.repeat(digits) : '1'); + } + assert.equal(number({ x: 1, digits: 2 }), '1.00'); + let digits = 3; + const mutable = { valueOf: () => digits }; + assert.equal(number({ x: 1, digits: mutable }), '1.000'); + digits = 2; + assert.equal(number({ x: 1, digits: mutable }), '1.00'); + assert.throws(() => number({ x: 1, digits: -1 }), RangeError); + const select = pipe.compileRaw('{key|select,match:{choice},other:"fallback"}'); + assert.equal(select({ key: 'match', choice: undefined }), ['', undefined, '']); + assert.equal(select({ key: 'match', choice: null }), ['', null, '']); + assert.equal(select({ key: 'match', choice: '' }), ['', '', '']); + assert.equal(select({ key: 'missing', choice: undefined }), ['', 'fallback', '']); +}); + + +test.run(); diff --git a/src/transformers.ts b/src/transformers.ts index 3f970c6..c2dd913 100644 --- a/src/transformers.ts +++ b/src/transformers.ts @@ -1,38 +1,112 @@ +import { preparations, memoizeFormat } from './prepared'; + +// Bound retention when dynamic messages produce many distinct Intl options. +function formatterCache(create: (options: Record) => T) { + const cache = new Map(); + return (options: Record): T => { + const prototype = Object.getPrototypeOf(options); + if (prototype !== Object.prototype && prototype !== null) { + return create(options); + } + const descriptors = Object.entries(Object.getOwnPropertyDescriptors(options)); + // Let Intl read accessors itself, exactly once and in its specified order. + if (descriptors.some(([, descriptor]) => !('value' in descriptor))) { + return create(options); + } + const entries = descriptors + .map(([key, descriptor]) => [key, descriptor.value] as [string, any]) + .filter(([, value]) => value !== undefined); + // Preserve Intl coercion semantics for non-primitive values and non-finite numbers. + if (entries.some(([, value]) => !['string', 'number', 'boolean'].includes(typeof value) + || (typeof value === 'number' && !Number.isFinite(value)))) { + return create(options); + } + const key = JSON.stringify(entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)); + let formatter = cache.get(key); + if (!formatter) { + formatter = create(options); + if (cache.size >= 100) { + cache.delete(cache.keys().next().value!); + } + cache.set(key, formatter); + } + return formatter; + }; +} + export function intlTransformers(locale: string = 'en-GB') { + const number = formatterCache(options => new Intl.NumberFormat(locale, options)); + const date = formatterCache(options => new Intl.DateTimeFormat(locale, options)); + const plural = formatterCache(options => new Intl.PluralRules(locale, options)); + const relativeTime = formatterCache(options => new Intl.RelativeTimeFormat(locale, options)); const globalTransformers: Record) => string> = { relativeTime(value, { type = 'hour', ...options } = {}) { - return new Intl.RelativeTimeFormat(locale, { - ...options, - }).format(Number(value) / 1000 / 60 / 60, type); + // Calendar units use fixed approximations because the input is a duration. + const seconds: Record = { + second: 1, minute: 60, hour: 3600, day: 86400, week: 604800, + month: 2592000, quarter: 7776000, year: 31536000, + }; + const unit = type.replace(/s$/, ''); + return relativeTime(options).format(Number(value) / 1000 / (seconds[unit] || 1), type); }, - date(value) { - return new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - }).format(value as any); + date(value, options = {}) { + const defaults = options.dateStyle || options.timeStyle ? {} : { + year: 'numeric', month: 'numeric', day: 'numeric', + }; + return date({ ...defaults, ...options }).format(value as any); }, - time(value) { - return new Intl.DateTimeFormat(locale, { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - }).format(value as any); + time(value, options = {}) { + const defaults = options.dateStyle || options.timeStyle ? {} : { + hour: 'numeric', minute: 'numeric', second: 'numeric', + }; + return date({ ...defaults, ...options }).format(value as any); }, - plural(value, { type = 'cardinal', ...options } = {}) { - return options[new Intl.PluralRules(locale, { type }).select(value as any)]?.replace('#', value); + plural(value, { zero, one, two, few, many, other, ...options } = {}) { + const choices: Record = { zero, one, two, few, many, other }; + return (choices[plural(options).select(value as any)] ?? other)?.split('#').join(value); }, - number(value, { style, currency, unit, unitDisplay, maximumSignificantDigits } = {}) { - return new Intl.NumberFormat(locale, { - style, - currency, - unit, - unitDisplay, - maximumSignificantDigits, - }).format(value as any); + number(value, options = {}) { + return number(options).format(value as any); }, }; + preparations.set(globalTransformers.number, options => { + const format = number(options).format; + return memoizeFormat(value => format(value)); + }); + for (const kind of ['date', 'time'] as const) { + preparations.set(globalTransformers[kind], options => { + const defaults = options.dateStyle || options.timeStyle ? {} : kind === 'date' + ? { year: 'numeric', month: 'numeric', day: 'numeric' } + : { hour: 'numeric', minute: 'numeric', second: 'numeric' }; + const format = date({ ...defaults, ...options }).format; + return memoizeFormat(value => format(value)); + }); + } + preparations.set(globalTransformers.plural, ({ zero, one, two, few, many, other, ...options }) => { + const rules = plural(options); + const choices: Record = { zero, one, two, few, many, other }; + const parts: Record = Object.create(null); + for (const [category, text] of Object.entries(choices)) { + if (typeof text === 'string') { + parts[category] = text.split('#'); + } + } + return memoizeFormat(value => { + const category = rules.select(value); + const chunks = parts[category] ?? (choices[category] == null ? parts.other : undefined); + if (chunks) { + // The common single-marker case needs no temporary join operation. + // Preserve join's coercion behavior for objects and other input types. + if (chunks.length === 2 && (typeof value === 'number' || typeof value === 'string')) { + return chunks[0] + value + chunks[1]; + } + return chunks.join(value); + } + return (choices[category] ?? other)?.split('#').join(value); + }); + }); + return globalTransformers; } @@ -42,9 +116,21 @@ export function defaultTransformers() { return JSON.stringify(value); }, select(value, options = {}) { - return options[value] || options.other; + return Object.prototype.hasOwnProperty.call(options, value) ? options[value] : options.other; }, }; + const select = globalTransformers.select; + preparations.set(select, options => { + const choices = Object.assign(Object.create(null), options); + return value => { + if (typeof value !== 'string') { + return select(value, options); + } + const choice = choices[value]; + return choice !== undefined || Object.prototype.hasOwnProperty.call(choices, value) ? choice : options.other; + }; + }); + return globalTransformers; }