diff --git a/.changeset/lucky-moons-shave.md b/.changeset/lucky-moons-shave.md new file mode 100644 index 00000000..3d4aa4fd --- /dev/null +++ b/.changeset/lucky-moons-shave.md @@ -0,0 +1,9 @@ +--- +'saykit': minor +'@saykit/config': minor +'@saykit/react': minor +'babel-plugin-saykit': minor +'unplugin-saykit': minor +--- + +Compile catalogues to data modules with `saykit compile`, and drop the runtime message parser diff --git a/.gitignore b/.gitignore index 50f6f50e..733833d5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,8 @@ node_modules/ .turbo/ .coverage/ dist/ -RELEASE_NOTES.md \ No newline at end of file +RELEASE_NOTES.md +# Locale modules the saykit CLI compiles from the catalogues beside them. +# Their declarations are committed, so `tsc` passes before anything is generated. +examples/**/locales/*.js +examples/**/_locales/**/*.js diff --git a/README.md b/README.md index e54a954f..b2e3c9fa 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ This is a pnpm monorepo. The published packages live in [`packages/*`](./package | Package | Description | | --------------------------------------------------- | -------------------------------------------------------------------- | -| [`saykit`](./packages/integration) | Core runtime: catalogues, views, macros, and ICU formatting | +| [`saykit`](./packages/integration) | Core runtime: catalogues, views, macros, and `Intl` helpers | | [`@saykit/config`](./packages/config) | Config schema (`defineConfig`) and the `saykit` CLI | | [`@saykit/react`](./packages/integration-react) | React integration: ``, `SayProvider`, server helpers | | [`@saykit/carbon`](./packages/integration-carbon) | Carbon Discord-bot integration | diff --git a/examples/babel/README.md b/examples/babel/README.md index 46003b6b..d219bf55 100644 --- a/examples/babel/README.md +++ b/examples/babel/README.md @@ -3,10 +3,10 @@ SayKit compiled by **Babel and nothing else**: no bundler, no dev server, no loader. `pnpm build`, then `node dist/main.js`. -This exists to pin down the `catalogues: 'inline'` default: `babel-plugin-saykit` on its -own has to rewrite the macros _and_ resolve the catalogue imports, with no other tool -involved. Every other example runs the plugin alongside a bundler integration, so this is -the only one that would notice if that stopped being true. +This exists to pin down what `babel-plugin-saykit` does on its own: rewrite the macros, +and nothing else. Catalogues are compiled to `.js` modules by `saykit compile`, which Node +imports directly. Every other example runs the plugin alongside a bundler, so this is the +only one that would notice if the plugin started needing one. ## Run @@ -31,18 +31,15 @@ English source string, merged in at compile time rather than looked up at runtim ## What to look at -| Thing | Where | -| ----------------------------------------------------------------- | ----------------- | -| `plugins: ['saykit']`, no options: the inlining default | `babel.config.js` | -| `import en from './locales/en.po'`, gone by the time Node sees it | `src/main.ts` | -| The compiled record, one object literal per locale | `dist/main.js` | +| Thing | Where | +| ------------------------------------------------------ | ------------------- | +| `plugins: ['saykit']`, no options and nothing else | `babel.config.js` | +| `import en from './locales/en.js'`, an ordinary import | `src/main.ts` | +| One readable function per message | `src/locales/fr.js` | -Open `dist/main.js` after a build: there is no `.po` file, no PO parser and no SayKit -extractor in the output, just `const en = { … }` and `say.call()` invocations. +Open `dist/main.js` after a build: no `.po` file, no PO parser, no message parser and no +SayKit extractor, just `say.call()` invocations against the imported locale modules. -## Hot reload - -There is none, by design. The record lands inside `dist/main.js`, whose own bytes only -change when you rebuild, which is exactly why a dev server wants `catalogues: 'module'` -and a bundler integration instead. See the +Open `src/locales/fr.js` to see what those modules hold: one plain function per message, +with the locale and every number and date format already resolved. See the [Babel integration docs](../../website/content/integrations/babel.mdx). diff --git a/examples/babel/package.json b/examples/babel/package.json index a6545601..4c68f099 100644 --- a/examples/babel/package.json +++ b/examples/babel/package.json @@ -4,8 +4,12 @@ "scripts": { "check": "tsc --noEmit", "extract": "saykit extract", - "build": "babel src --extensions .ts --out-dir dist --ignore \"src/**/*.d.po.ts\"", - "start": "pnpm build && node dist/main.js" + "build": "babel src --extensions .ts,.js --out-dir dist --ignore \"src/**/*.d.ts\"", + "start": "pnpm build && node dist/main.js", + "compile": "saykit compile", + "prebuild": "saykit compile", + "precheck": "saykit compile", + "prestart": "saykit compile" }, "dependencies": { "saykit": "workspace:^" diff --git a/examples/babel/src/locales/en.d.po.ts b/examples/babel/src/locales/en.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/babel/src/locales/en.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/babel/src/locales/en.d.ts b/examples/babel/src/locales/en.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/babel/src/locales/en.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/babel/src/locales/fr.d.po.ts b/examples/babel/src/locales/fr.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/babel/src/locales/fr.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/babel/src/locales/fr.d.ts b/examples/babel/src/locales/fr.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/babel/src/locales/fr.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/babel/src/main.ts b/examples/babel/src/main.ts index bd06d9bb..79831db5 100644 --- a/examples/babel/src/main.ts +++ b/examples/babel/src/main.ts @@ -1,6 +1,6 @@ import { createCatalogue } from 'saykit'; -import en from './locales/en.po'; -import fr from './locales/fr.po'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; const catalogue = createCatalogue({ en, fr }); diff --git a/examples/browser-extension/_locales/de/messages.d.json.ts b/examples/browser-extension/_locales/de/messages.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/browser-extension/_locales/de/messages.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/browser-extension/_locales/de/messages.d.ts b/examples/browser-extension/_locales/de/messages.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/browser-extension/_locales/de/messages.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/browser-extension/_locales/en/messages.d.json.ts b/examples/browser-extension/_locales/en/messages.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/browser-extension/_locales/en/messages.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/browser-extension/_locales/en/messages.d.ts b/examples/browser-extension/_locales/en/messages.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/browser-extension/_locales/en/messages.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/browser-extension/_locales/fr/messages.d.json.ts b/examples/browser-extension/_locales/fr/messages.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/browser-extension/_locales/fr/messages.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/browser-extension/_locales/fr/messages.d.ts b/examples/browser-extension/_locales/fr/messages.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/browser-extension/_locales/fr/messages.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/browser-extension/package.json b/examples/browser-extension/package.json index 6e351a3a..8279877d 100644 --- a/examples/browser-extension/package.json +++ b/examples/browser-extension/package.json @@ -6,7 +6,11 @@ "check": "tsc --noEmit", "extract": "saykit extract", "build": "vite build", - "dev": "vite build --watch" + "dev": "vite build --watch", + "compile": "saykit compile", + "predev": "saykit compile", + "prebuild": "saykit compile", + "precheck": "saykit compile" }, "dependencies": { "saykit": "workspace:^" diff --git a/examples/browser-extension/src/i18n.ts b/examples/browser-extension/src/i18n.ts index 9dfdebdf..7201115f 100644 --- a/examples/browser-extension/src/i18n.ts +++ b/examples/browser-extension/src/i18n.ts @@ -1,7 +1,7 @@ import { createCatalogue } from 'saykit'; -import de from '../_locales/de/messages.json'; -import en from '../_locales/en/messages.json'; -import fr from '../_locales/fr/messages.json'; +import de from '../_locales/de/messages.js'; +import en from '../_locales/en/messages.js'; +import fr from '../_locales/fr/messages.js'; export const locales = ['en', 'fr', 'de'] as const; export type Locale = (typeof locales)[number]; diff --git a/examples/carbon/package.json b/examples/carbon/package.json index 49c85277..616e39f9 100644 --- a/examples/carbon/package.json +++ b/examples/carbon/package.json @@ -6,7 +6,11 @@ "check": "tsc --noEmit", "extract": "saykit extract", "build": "tsdown", - "dev": "wrangler dev" + "dev": "wrangler dev", + "compile": "saykit compile", + "predev": "saykit compile", + "prebuild": "saykit compile", + "precheck": "saykit compile" }, "dependencies": { "@buape/carbon": "0.16.0", diff --git a/examples/carbon/src/i18n.ts b/examples/carbon/src/i18n.ts index 4ca03f4b..db40e893 100644 --- a/examples/carbon/src/i18n.ts +++ b/examples/carbon/src/i18n.ts @@ -1,9 +1,9 @@ import { createWithSay } from '@saykit/carbon'; import { createCatalogue } from 'saykit'; -import de from './locales/de.json'; -import en from './locales/en-US.json'; -import fr from './locales/fr.json'; -import ja from './locales/ja.json'; +import de from './locales/de.js'; +import en from './locales/en-US.js'; +import fr from './locales/fr.js'; +import ja from './locales/ja.js'; export const locales = ['en-US', 'fr', 'de', 'ja'] as const; export type Locale = (typeof locales)[number]; diff --git a/examples/carbon/src/locales/de.d.json.ts b/examples/carbon/src/locales/de.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/carbon/src/locales/de.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/carbon/src/locales/de.d.ts b/examples/carbon/src/locales/de.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/carbon/src/locales/de.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/carbon/src/locales/en-US.d.json.ts b/examples/carbon/src/locales/en-US.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/carbon/src/locales/en-US.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/carbon/src/locales/en-US.d.ts b/examples/carbon/src/locales/en-US.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/carbon/src/locales/en-US.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/carbon/src/locales/fr.d.json.ts b/examples/carbon/src/locales/fr.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/carbon/src/locales/fr.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/carbon/src/locales/fr.d.ts b/examples/carbon/src/locales/fr.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/carbon/src/locales/fr.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/carbon/src/locales/ja.d.json.ts b/examples/carbon/src/locales/ja.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/carbon/src/locales/ja.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/carbon/src/locales/ja.d.ts b/examples/carbon/src/locales/ja.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/carbon/src/locales/ja.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/custom-formatter/package.json b/examples/custom-formatter/package.json index c6f4cbc7..4a7aa6aa 100644 --- a/examples/custom-formatter/package.json +++ b/examples/custom-formatter/package.json @@ -6,7 +6,11 @@ "check": "tsc --noEmit", "extract": "saykit extract", "build": "tsdown", - "start": "node dist/main.mjs" + "start": "node dist/main.mjs", + "compile": "saykit compile", + "prebuild": "saykit compile", + "precheck": "saykit compile", + "prestart": "saykit compile" }, "dependencies": { "saykit": "workspace:^", diff --git a/examples/custom-formatter/src/i18n.ts b/examples/custom-formatter/src/i18n.ts index f8e1bc1d..e3259d7c 100644 --- a/examples/custom-formatter/src/i18n.ts +++ b/examples/custom-formatter/src/i18n.ts @@ -1,7 +1,7 @@ import { createCatalogue } from 'saykit'; -import de from './locales/de.yml'; -import en from './locales/en.yml'; -import fr from './locales/fr.yml'; +import de from './locales/de.js'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; export const locales = ['en', 'fr', 'de'] as const; export type Locale = (typeof locales)[number]; diff --git a/examples/custom-formatter/src/locales/de.d.ts b/examples/custom-formatter/src/locales/de.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/custom-formatter/src/locales/de.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/custom-formatter/src/locales/de.d.yml.ts b/examples/custom-formatter/src/locales/de.d.yml.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/custom-formatter/src/locales/de.d.yml.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/custom-formatter/src/locales/en.d.ts b/examples/custom-formatter/src/locales/en.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/custom-formatter/src/locales/en.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/custom-formatter/src/locales/en.d.yml.ts b/examples/custom-formatter/src/locales/en.d.yml.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/custom-formatter/src/locales/en.d.yml.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/custom-formatter/src/locales/fr.d.ts b/examples/custom-formatter/src/locales/fr.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/custom-formatter/src/locales/fr.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/custom-formatter/src/locales/fr.d.yml.ts b/examples/custom-formatter/src/locales/fr.d.yml.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/custom-formatter/src/locales/fr.d.yml.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/expo/README.md b/examples/expo/README.md index 1d636f8e..08efd6e5 100644 --- a/examples/expo/README.md +++ b/examples/expo/README.md @@ -42,7 +42,7 @@ would otherwise produce loose strings between elements. Metro is not a Vite/Rollup-family bundler, so `unplugin-saykit` does not apply. Metro compiles every module through Babel, and `babel-plugin-saykit` hooks in there instead, doing the same two jobs: -rewriting the macros, and inlining `import en from './locales/en.json'` as a plain object. +rewriting the macros, and inlining `import en from './locales/en.js'` as a plain object. ```js module.exports = function babelConfig(api) { diff --git a/examples/expo/babel.config.js b/examples/expo/babel.config.js index c58d4ade..2f5d8a0e 100644 --- a/examples/expo/babel.config.js +++ b/examples/expo/babel.config.js @@ -3,6 +3,6 @@ module.exports = function babelConfig(api) { return { presets: ['babel-preset-expo'], - plugins: [['saykit', { catalogues: 'module' }]], + plugins: ['saykit'], }; }; diff --git a/examples/expo/metro.config.js b/examples/expo/metro.config.js index 9242d1da..6dfd4fde 100644 --- a/examples/expo/metro.config.js +++ b/examples/expo/metro.config.js @@ -1,8 +1,7 @@ const path = require('node:path'); const { getDefaultConfig } = require('expo/metro-config'); -const { withSayKit } = require('babel-plugin-saykit/metro'); -const config = withSayKit(getDefaultConfig(__dirname)); +const config = getDefaultConfig(__dirname); // Two copies of React break hooks, so force every request to this app's copy config.resolver.resolveRequest = (context, moduleName, platform) => { diff --git a/examples/expo/package.json b/examples/expo/package.json index 1dac5e34..7822dc40 100644 --- a/examples/expo/package.json +++ b/examples/expo/package.json @@ -5,7 +5,10 @@ "scripts": { "check": "tsc --noEmit", "extract": "saykit extract", - "dev": "expo start" + "dev": "expo start", + "compile": "saykit compile", + "predev": "saykit compile", + "precheck": "saykit compile" }, "dependencies": { "@formatjs/intl-getcanonicallocales": "^3.2.11", diff --git a/examples/expo/src/i18n.ts b/examples/expo/src/i18n.ts index 24701d09..3d699625 100644 --- a/examples/expo/src/i18n.ts +++ b/examples/expo/src/i18n.ts @@ -1,8 +1,8 @@ import { getLocales } from 'expo-localization'; import { createCatalogue, createStore } from 'saykit'; -import en from './locales/en.json'; -import fr from './locales/fr.json'; -import ja from './locales/ja.json'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; +import ja from './locales/ja.js'; export const locales = ['en', 'fr', 'ja'] as const; export type Locale = (typeof locales)[number]; diff --git a/examples/expo/src/locales/en.d.json.ts b/examples/expo/src/locales/en.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/expo/src/locales/en.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/expo/src/locales/en.d.ts b/examples/expo/src/locales/en.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/expo/src/locales/en.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/expo/src/locales/fr.d.json.ts b/examples/expo/src/locales/fr.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/expo/src/locales/fr.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/expo/src/locales/fr.d.ts b/examples/expo/src/locales/fr.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/expo/src/locales/fr.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/expo/src/locales/ja.d.json.ts b/examples/expo/src/locales/ja.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/expo/src/locales/ja.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/expo/src/locales/ja.d.ts b/examples/expo/src/locales/ja.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/expo/src/locales/ja.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index 5476b06a..69cabe71 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -37,9 +37,10 @@ The component you write is identical in both cases: ``` -Because both halves are fed from the same view, with the client provider -reading its locale and messages straight off it, server output and client hydration cannot disagree -about which locale is active. +Because both halves are fed from the same view, with the client provider reading its locale and +messages straight off it, server output and client hydration cannot disagree about which locale is +active. Messages compile to data rather than to functions, so they cross the boundary as the JSON +they already are. ## Why `src/config.ts` exists separately @@ -56,7 +57,7 @@ Next.js compiles with its own toolchain, so the macros are rewritten by `babel-p { "presets": ["next/babel"], "plugins": ["saykit"] } ``` -The plugin also rewrites `import en from './locales/en.po'` into an inline object. It requires a +The plugin also rewrites `import en from './locales/en.js'` into an inline object. It requires a **default** import for catalogue files and throws on a named one, which is the intended failure mode, not a bug. diff --git a/examples/nextjs/next.config.mjs b/examples/nextjs/next.config.mjs index fccad7ba..c4a407b7 100644 --- a/examples/nextjs/next.config.mjs +++ b/examples/nextjs/next.config.mjs @@ -1,3 +1,2 @@ -import { withSayKit } from 'babel-plugin-saykit/next'; - -export default withSayKit({}); +/** @type {import('next').NextConfig} */ +export default {}; diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 3d9d5965..e831cabf 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -7,7 +7,12 @@ "extract": "saykit extract", "dev": "next dev", "build": "next build", - "start": "next start" + "start": "next start", + "compile": "saykit compile", + "predev": "saykit compile", + "prebuild": "saykit compile", + "precheck": "saykit compile", + "prestart": "saykit compile" }, "dependencies": { "@saykit/react": "workspace:^", diff --git a/examples/nextjs/src/i18n.ts b/examples/nextjs/src/i18n.ts index a3b604ac..f199e583 100644 --- a/examples/nextjs/src/i18n.ts +++ b/examples/nextjs/src/i18n.ts @@ -1,8 +1,8 @@ import { createWithSay } from '@saykit/react/server'; import { createCatalogue } from 'saykit'; -import en from './locales/en.po'; -import fr from './locales/fr.po'; -import pl from './locales/pl.po'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; +import pl from './locales/pl.js'; export const catalogue = createCatalogue({ en, fr, pl }); diff --git a/examples/nextjs/src/locales/en.d.po.ts b/examples/nextjs/src/locales/en.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/nextjs/src/locales/en.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/nextjs/src/locales/en.d.ts b/examples/nextjs/src/locales/en.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/nextjs/src/locales/en.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/nextjs/src/locales/fr.d.po.ts b/examples/nextjs/src/locales/fr.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/nextjs/src/locales/fr.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/nextjs/src/locales/fr.d.ts b/examples/nextjs/src/locales/fr.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/nextjs/src/locales/fr.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/nextjs/src/locales/pl.d.po.ts b/examples/nextjs/src/locales/pl.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/nextjs/src/locales/pl.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/nextjs/src/locales/pl.d.ts b/examples/nextjs/src/locales/pl.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/nextjs/src/locales/pl.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/react/package.json b/examples/react/package.json index d88910f0..64df4dcc 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -7,7 +7,11 @@ "extract": "saykit extract", "build": "vite build", "dev": "vite dev", - "preview": "vite preview" + "preview": "vite preview", + "compile": "saykit compile", + "predev": "saykit compile", + "prebuild": "saykit compile", + "precheck": "saykit compile" }, "dependencies": { "@saykit/react": "workspace:^", diff --git a/examples/react/src/i18n.ts b/examples/react/src/i18n.ts index 9e8b2812..75090d7f 100644 --- a/examples/react/src/i18n.ts +++ b/examples/react/src/i18n.ts @@ -4,10 +4,10 @@ export const locales = ['en', 'fr', 'pl', 'ja'] as const; export type Locale = (typeof locales)[number]; export const catalogue = createCatalogue({ - en: () => import('./locales/en.po'), - fr: () => import('./locales/fr.po'), - pl: () => import('./locales/pl.po'), - ja: () => import('./locales/ja.po'), + en: () => import('./locales/en.js'), + fr: () => import('./locales/fr.js'), + pl: () => import('./locales/pl.js'), + ja: () => import('./locales/ja.js'), }); const initial = catalogue.match(navigator.languages as string[]); diff --git a/examples/react/src/locales/en.d.po.ts b/examples/react/src/locales/en.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/react/src/locales/en.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/react/src/locales/en.d.ts b/examples/react/src/locales/en.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/react/src/locales/en.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/react/src/locales/fr.d.po.ts b/examples/react/src/locales/fr.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/react/src/locales/fr.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/react/src/locales/fr.d.ts b/examples/react/src/locales/fr.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/react/src/locales/fr.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/react/src/locales/ja.d.po.ts b/examples/react/src/locales/ja.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/react/src/locales/ja.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/react/src/locales/ja.d.ts b/examples/react/src/locales/ja.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/react/src/locales/ja.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/react/src/locales/pl.d.po.ts b/examples/react/src/locales/pl.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/react/src/locales/pl.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/react/src/locales/pl.d.ts b/examples/react/src/locales/pl.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/react/src/locales/pl.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/tanstack-start/package.json b/examples/tanstack-start/package.json index 4f7d9dc0..e328fb3e 100644 --- a/examples/tanstack-start/package.json +++ b/examples/tanstack-start/package.json @@ -7,7 +7,11 @@ "check": "tsc --noEmit", "extract": "saykit extract", "build": "vite build && tsc --noEmit", - "dev": "vite dev" + "dev": "vite dev", + "compile": "saykit compile", + "predev": "saykit compile", + "prebuild": "saykit compile", + "precheck": "saykit compile" }, "dependencies": { "@saykit/react": "workspace:^", diff --git a/examples/tanstack-start/src/i18n.ts b/examples/tanstack-start/src/i18n.ts index 8d747674..13788be9 100644 --- a/examples/tanstack-start/src/i18n.ts +++ b/examples/tanstack-start/src/i18n.ts @@ -1,8 +1,8 @@ import { createCatalogue } from 'saykit'; -import enGB from './locales/en-GB.json'; -import enNZ from './locales/en-NZ.json'; -import en from './locales/en.json'; -import fr from './locales/fr.json'; +import enGB from './locales/en-GB.js'; +import enNZ from './locales/en-NZ.js'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; export const catalogue = createCatalogue({ en: en, diff --git a/examples/tanstack-start/src/locales/en-GB.d.json.ts b/examples/tanstack-start/src/locales/en-GB.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/tanstack-start/src/locales/en-GB.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/tanstack-start/src/locales/en-GB.d.ts b/examples/tanstack-start/src/locales/en-GB.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/tanstack-start/src/locales/en-GB.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/tanstack-start/src/locales/en-NZ.d.json.ts b/examples/tanstack-start/src/locales/en-NZ.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/tanstack-start/src/locales/en-NZ.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/tanstack-start/src/locales/en-NZ.d.ts b/examples/tanstack-start/src/locales/en-NZ.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/tanstack-start/src/locales/en-NZ.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/tanstack-start/src/locales/en.d.json.ts b/examples/tanstack-start/src/locales/en.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/tanstack-start/src/locales/en.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/tanstack-start/src/locales/en.d.ts b/examples/tanstack-start/src/locales/en.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/tanstack-start/src/locales/en.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/tanstack-start/src/locales/fr.d.json.ts b/examples/tanstack-start/src/locales/fr.d.json.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/tanstack-start/src/locales/fr.d.json.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/tanstack-start/src/locales/fr.d.ts b/examples/tanstack-start/src/locales/fr.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/tanstack-start/src/locales/fr.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/vanilla/README.md b/examples/vanilla/README.md index ba360253..8c1bab44 100644 --- a/examples/vanilla/README.md +++ b/examples/vanilla/README.md @@ -52,7 +52,7 @@ longer exist in the source. 1. **transform** rewrites `` say`Due in ${n} days` `` into a `say.call({ id, … })` with a stable hashed id. The macro bodies in the runtime throw if this step is missing, so a misconfigured build fails loudly rather than silently shipping English. -2. **load** turns `import en from './locales/en.po'` into a plain JS object. No PO parser reaches +2. **load** turns `import en from './locales/en.js'` into a plain JS object. No PO parser reaches the browser bundle. Further reading: [core concepts](../../website/content/core-concepts) and the diff --git a/examples/vanilla/package.json b/examples/vanilla/package.json index f8a3b3c0..77e3a9a9 100644 --- a/examples/vanilla/package.json +++ b/examples/vanilla/package.json @@ -7,7 +7,11 @@ "extract": "saykit extract", "build": "vite build", "dev": "vite dev", - "preview": "vite preview" + "preview": "vite preview", + "compile": "saykit compile", + "predev": "saykit compile", + "prebuild": "saykit compile", + "precheck": "saykit compile" }, "dependencies": { "saykit": "workspace:^" diff --git a/examples/vanilla/src/i18n.ts b/examples/vanilla/src/i18n.ts index e86bdd11..1a1989e2 100644 --- a/examples/vanilla/src/i18n.ts +++ b/examples/vanilla/src/i18n.ts @@ -1,8 +1,8 @@ import { createCatalogue } from 'saykit'; -import en from './locales/en.po'; -import fr from './locales/fr.po'; -import ja from './locales/ja.po'; -import pl from './locales/pl.po'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; +import ja from './locales/ja.js'; +import pl from './locales/pl.js'; export const locales = ['en', 'fr', 'pl', 'ja'] as const; export type Locale = (typeof locales)[number]; diff --git a/examples/vanilla/src/locales/en.d.po.ts b/examples/vanilla/src/locales/en.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/vanilla/src/locales/en.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/vanilla/src/locales/en.d.ts b/examples/vanilla/src/locales/en.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/vanilla/src/locales/en.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/vanilla/src/locales/fr.d.po.ts b/examples/vanilla/src/locales/fr.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/vanilla/src/locales/fr.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/vanilla/src/locales/fr.d.ts b/examples/vanilla/src/locales/fr.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/vanilla/src/locales/fr.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/vanilla/src/locales/ja.d.po.ts b/examples/vanilla/src/locales/ja.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/vanilla/src/locales/ja.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/vanilla/src/locales/ja.d.ts b/examples/vanilla/src/locales/ja.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/vanilla/src/locales/ja.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/examples/vanilla/src/locales/pl.d.po.ts b/examples/vanilla/src/locales/pl.d.po.ts deleted file mode 100644 index 630f17a5..00000000 --- a/examples/vanilla/src/locales/pl.d.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const messages: Record; -export default messages; diff --git a/examples/vanilla/src/locales/pl.d.ts b/examples/vanilla/src/locales/pl.d.ts new file mode 100644 index 00000000..bd79a084 --- /dev/null +++ b/examples/vanilla/src/locales/pl.d.ts @@ -0,0 +1,4 @@ +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; diff --git a/packages/config/package.json b/packages/config/package.json index f14f3e17..577fd0c2 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -81,13 +81,15 @@ "@commander-js/extra-typings": "^15.0.0", "@messageformat/date-skeleton": "2.0.0-0", "@messageformat/number-skeleton": "2.0.0-0", + "@messageformat/parser": "^5.1.1", "commander": "^15.0.0", "js-sha256": "^0.12.0", "picomatch": "^4.0.5", "zod": "^4.4.3" }, "devDependencies": { - "@types/picomatch": "^4.0.3" + "@types/picomatch": "^4.0.3", + "saykit": "workspace:*" }, "engines": { "node": ">=22.18" diff --git a/packages/config/src/commands/compile.ts b/packages/config/src/commands/compile.ts new file mode 100644 index 00000000..bd4b0bdc --- /dev/null +++ b/packages/config/src/commands/compile.ts @@ -0,0 +1,34 @@ +import { Command } from '@commander-js/extra-typings'; +import { emitCatalogueModule } from '~/features/catalogue/emit.js'; +import { resolveConfig } from '~/features/loader/index.js'; +import Logger from '~/features/logger.js'; +import { normalisePathForLogs } from '~/features/workers/shared.js'; + +/** + * Compile every catalogue into the modules the app imports. + * + * `extract` does this too, at the end of a run. This is the same step on its + * own, for the two cases that have no extraction to do: a CI build from a fresh + * clone, where the generated modules are not committed, and a pull from a TMS, + * which changes translations without touching any source. + */ +export default new Command('compile') + .description('Compile catalogues into locale modules') + .option('-v, --verbose', 'enable verbose logging', false) + .option('-q, --quiet', 'suppress all logging', false) + .action(async (options) => { + const config = resolveConfig(); + const logger = new Logger(options); + logger.header('⚙ Compiling Catalogues'); + + for (const bucket of config.buckets) { + logger.info(`Compiling ${config.locales.length} locale(s): ${bucket.include}`); + + for (const locale of config.locales) { + const { path, count } = await emitCatalogueModule(config, bucket, locale); + logger.step(`Wrote ${count} message(s) to ${normalisePathForLogs(path)}`); + } + } + + logger.success('Catalogues compiled'); + }); diff --git a/packages/config/src/commands/index.ts b/packages/config/src/commands/index.ts index 575c7418..228a03d2 100644 --- a/packages/config/src/commands/index.ts +++ b/packages/config/src/commands/index.ts @@ -2,6 +2,7 @@ import { program } from '@commander-js/extra-typings'; import clean from './clean.js'; +import compile from './compile.js'; import extract from './extract.js'; program @@ -9,5 +10,6 @@ program .helpOption('-h, --help', 'Display help for command') .helpCommand('help [command]', 'Display help for command') .addCommand(extract) + .addCommand(compile) .addCommand(clean) .parse(); diff --git a/packages/config/src/features/catalogue/emit.test.ts b/packages/config/src/features/catalogue/emit.test.ts new file mode 100644 index 00000000..3adb19ea --- /dev/null +++ b/packages/config/src/features/catalogue/emit.test.ts @@ -0,0 +1,128 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { compileMessage } from 'saykit'; +import { afterAll, describe, expect, it } from 'vitest'; +import { generateHash } from '~/features/messages/hash.js'; +import type { Bucket, Config } from '~/shapes.js'; +import { emitCatalogueModule, generateCatalogueModule, modulePathFor } from './emit.js'; + +const dir = mkdtempSync(join(tmpdir(), 'saykit-emit-')); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +/** A bucket whose formatter reads the JSON the fixtures are written as. */ +const bucket = { + output: Object.assign(join(dir, '{locale}', 'messages.{extension}'), { match: () => true }), + formatter: { + extension: '.json', + parse: (content: string) => (content ? JSON.parse(content) : []), + }, +} as unknown as Bucket; + +const config = { + locales: ['en', 'en-GB', 'en-NZ', 'fr'], + fallbackLocales: { 'en-NZ': ['en-GB'] }, + buckets: [bucket], +} as unknown as Config; + +function write(locale: string, content: unknown) { + const file = join(dir, locale, 'messages.json'); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, typeof content === 'string' ? content : JSON.stringify(content)); + return file; +} + +/** Load an emitted module the way a bundler would, and read it as a view does. */ +async function load(locale: string) { + const source = readFileSync(modulePathFor(bucket, locale), 'utf8'); + const module = await import( + `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` + ); + const messages = module.default as Record[0]>; + return (id: string, values: Record = {}) => + compileMessage(messages[id]!, locale)(values); +} + +describe('generateCatalogueModule', () => { + it('emits data, with the message it came from beside it', () => { + const source = generateCatalogueModule({ + greeting: 'Bonjour', + items: '{count, plural, one {# article} other {# articles}}', + }); + + // Nothing imported and no locale bound in: a view compiles what it reads, + // and the module is the same JSON on either side of a boundary + expect(source).not.toContain('import'); + expect(source).not.toContain('=>'); + expect(source).toContain('"greeting": "Bonjour"'); + // The message it came from, so the file can be read and reviewed + expect(source).toContain('// Bonjour'); + }); +}); + +describe('emitCatalogueModule', () => { + it('compiles the source locale, hashing a key when a message carries no id', async () => { + write('en', [ + { message: 'Hello', translation: 'Hello', id: 'greeting' }, + { message: 'Bye', translation: 'Bye' }, + ]); + + const { path, count } = await emitCatalogueModule(config, bucket, 'en'); + expect(path.endsWith('.js')).toBe(true); + expect(count).toBe(2); + + const messages = await load('en'); + expect(messages('greeting')).toBe('Hello'); + expect(messages(generateHash('Bye', undefined))).toBe('Bye'); + }); + + it('writes a declaration beside the module, so tsc needs no codegen', async () => { + write('en', [{ message: 'Hello', id: 'greeting' }]); + const { path } = await emitCatalogueModule(config, bucket, 'en'); + + const declaration = path.replace(/\.js$/, '.d.ts'); + expect(existsSync(declaration)).toBe(true); + expect(readFileSync(declaration, 'utf8')).toContain('export default messages'); + }); + + it('falls back to the source string for keys untranslated in a non-source locale', async () => { + write('en', [ + { message: 'Hello', id: 'greeting' }, + { message: 'Bye', id: 'farewell' }, + ]); + write('fr', [{ message: 'Hello', translation: 'Bonjour', id: 'greeting' }]); + + await emitCatalogueModule(config, bucket, 'fr'); + const messages = await load('fr'); + expect(messages('greeting')).toBe('Bonjour'); // real translation wins + expect(messages('farewell')).toBe('Bye'); // untranslated -> source fallback + }); + + it('resolves an empty non-source locale entirely to source strings', async () => { + write('en', [{ message: 'Hello', id: 'greeting' }]); + write('fr', ''); + + await emitCatalogueModule(config, bucket, 'fr'); + expect((await load('fr'))('greeting')).toBe('Hello'); + }); + + it('resolves a configured fallback chain before the source locale', async () => { + write('en', [ + { message: 'A', id: 'a' }, + { message: 'B', id: 'b' }, + { message: 'C', id: 'c' }, + ]); + write('en-GB', [ + { message: 'B', translation: 'B-GB', id: 'b' }, + { message: 'C', translation: 'C-GB', id: 'c' }, + ]); + write('en-NZ', [{ message: 'C', translation: 'C-NZ', id: 'c' }]); + + await emitCatalogueModule(config, bucket, 'en-NZ'); + const messages = await load('en-NZ'); + expect(messages('a')).toBe('A'); // only in the source + expect(messages('b')).toBe('B-GB'); // from the en-GB fallback + expect(messages('c')).toBe('C-NZ'); // en-NZ wins over en-GB and the source + }); +}); diff --git a/packages/config/src/features/catalogue/emit.ts b/packages/config/src/features/catalogue/emit.ts new file mode 100644 index 00000000..a373b6d4 --- /dev/null +++ b/packages/config/src/features/catalogue/emit.ts @@ -0,0 +1,91 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, parse as parsePath, resolve } from 'node:path'; +import { compileMessage } from '~/features/messages/compile.js'; +import type { Bucket, Config } from '~/shapes.js'; +import { expandBucketOutputPath } from './path.js'; +import { assembleCatalogueRecord, resolveFallbackChain } from './record.js'; + +/** + * The generated module a locale is actually imported as. + * + * The catalogue file on disk is what a translator edits; this is what a bundler + * sees. It is a real file, so an ordinary module graph, an ordinary watch and + * an ordinary hot update all apply to it, and no bundler needs teaching how to + * read a catalogue format. + */ + +/** + * The declaration beside the generated module. + * + * Every call site is written by the transform, never by hand, so there is + * nothing per-id worth typing. It is the same two lines for every locale and + * never changes, which is why it is committed while the module it types is + * not: `tsc` passes on a fresh clone without anything having been generated. + */ +const DECLARATION_CONTENT = ` +import type { Message } from 'saykit'; + +declare const messages: Record; +export default messages; +`.trimStart(); + +/** Where a locale's generated module goes: beside its catalogue, as `.js`. */ +export function modulePathFor(bucket: Bucket, locale: string) { + const { dir, name } = parsePath(expandBucketOutputPath(bucket, locale)); + return resolve(dir, `${name}.js`); +} + +/** + * Build the source of a locale's generated module from its assembled record. + * + * The module is data and nothing else: no imports, no functions, no locale + * bound into it. A view compiles what it reads from here, so the same messages + * can be handed across a server/client boundary as the JSON they already are. + * + * @param record The locale's messages, keyed by id, as ICU + */ +export function generateCatalogueModule(record: Record) { + const entries = Object.entries(record).map(([id, icu]) => { + // ponytail: the one call site that decides how a message is compiled. A + // `compiler` slot on the bucket config replaces this line when a second + // message format turns up + const message = compileMessage(icu); + + // The message it came from, so the file can be read and reviewed + const comment = icu + .split('\n') + .map((line) => ` // ${line}`) + .join('\n'); + return `${comment}\n ${JSON.stringify(id)}: ${JSON.stringify(message)},`; + }); + + return ( + '// Generated by saykit. Do not edit.\n' + `export default {\n${entries.join('\n\n')}\n};\n` + ); +} + +/** + * Compile one locale's catalogue, and its fallbacks, into a module on disk. + * + * The fallback chain is merged here, so an untranslated key resolves to a + * fallback string while the app still imports a single module per locale. + */ +export async function emitCatalogueModule(config: Config, bucket: Bucket, locale: string) { + const sources = resolveFallbackChain(config, locale).map((l) => + expandBucketOutputPath(bucket, l), + ); + const contents = await Promise.all( + sources.map((source) => readFile(source, 'utf8').catch(() => '')), + ); + + const record = assembleCatalogueRecord(bucket, contents); + const path = modulePathFor(bucket, locale); + + await mkdir(dirname(path), { recursive: true }); + await Promise.all([ + writeFile(path, generateCatalogueModule(record)), + writeFile(path.replace(/\.js$/, '.d.ts'), DECLARATION_CONTENT), + ]); + + return { path, count: Object.keys(record).length }; +} diff --git a/packages/config/src/features/catalogue/index.ts b/packages/config/src/features/catalogue/index.ts index 15ab96b0..1e4237ef 100644 --- a/packages/config/src/features/catalogue/index.ts +++ b/packages/config/src/features/catalogue/index.ts @@ -1,3 +1,4 @@ +export * from './emit.js'; export * from './merge.js'; export * from './path.js'; export * from './record.js'; diff --git a/packages/config/src/features/catalogue/path.ts b/packages/config/src/features/catalogue/path.ts index 958e9e3f..3c110215 100644 --- a/packages/config/src/features/catalogue/path.ts +++ b/packages/config/src/features/catalogue/path.ts @@ -1,4 +1,4 @@ -import { parse, resolve } from 'node:path'; +import { resolve } from 'node:path'; import type { Bucket } from '~/shapes.js'; export function expandBucketOutputPath( @@ -11,16 +11,3 @@ export function expandBucketOutputPath( .replaceAll('{extension}', extension.slice(1)); return resolve(outputMessageTemplate); } - -/** - * The declaration file that types a catalogue, e.g. `en.json` -> `en.d.json.ts`. - * - * TypeScript resolves `./en.json` by stripping the extension and looking for - * `en.d.json.ts`; the `en.json.d.ts` form is only consulted for extensions the - * resolver does not recognise. Non-JS extensions additionally require - * `allowArbitraryExtensions` in the consumer's tsconfig. - */ -export function declarationPathFor(cataloguePath: string) { - const { dir, name, ext } = parse(cataloguePath); - return resolve(dir, `${name}.d${ext}.ts`); -} diff --git a/packages/config/src/features/catalogue/storage.test.ts b/packages/config/src/features/catalogue/storage.test.ts index da3f1b81..2a921dcc 100644 --- a/packages/config/src/features/catalogue/storage.test.ts +++ b/packages/config/src/features/catalogue/storage.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; @@ -26,13 +26,13 @@ const message = { }; describe('writeCatalogueMessages / readCatalogueMessages', () => { - it('writes the catalogue and its .d.ts declaration', async () => { + it('writes the catalogue, and nothing beside it', async () => { const path = join(dir, 'fr.json'); - const declaration = join(dir, 'fr.d.json.ts'); await writeCatalogueMessages(bucket, 'fr', [message], path); expect(existsSync(path)).toBe(true); - expect(existsSync(declaration)).toBe(true); - expect(readFileSync(declaration, 'utf8')).toContain('export default messages'); + // A catalogue is what a translator edits; the module the app imports and + // the declaration that types it are `emitCatalogueModule`'s business + expect(existsSync(join(dir, 'fr.d.json.ts'))).toBe(false); }); it('round-trips through read', async () => { diff --git a/packages/config/src/features/catalogue/storage.ts b/packages/config/src/features/catalogue/storage.ts index 0aa3a058..b0a3abf8 100644 --- a/packages/config/src/features/catalogue/storage.ts +++ b/packages/config/src/features/catalogue/storage.ts @@ -1,12 +1,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import type { Bucket, Message } from '~/shapes.js'; -import { declarationPathFor, expandBucketOutputPath } from './path.js'; - -const DECLARATION_CONTENT = ` -declare const messages: Record; -export default messages; -`.trimStart(); +import { expandBucketOutputPath } from './path.js'; export async function readCatalogueMessages( bucket: Bucket, @@ -26,11 +21,9 @@ export async function writeCatalogueMessages( ) { const existingContent = await readFile(path, 'utf8').catch(() => undefined); const catalogueContent = bucket.formatter.stringify(messages, { locale, existingContent }); - const declarationPath = declarationPathFor(path); + // No declaration beside it: a catalogue is what a translator edits, not what + // the app imports. The generated module carries that, and its own `.d.ts` await mkdir(dirname(path), { recursive: true }); - await Promise.all([ - writeFile(path, catalogueContent), - writeFile(declarationPath, DECLARATION_CONTENT), - ]); + await writeFile(path, catalogueContent); } diff --git a/packages/config/src/features/messages/compile.test.ts b/packages/config/src/features/messages/compile.test.ts new file mode 100644 index 00000000..f258216f --- /dev/null +++ b/packages/config/src/features/messages/compile.test.ts @@ -0,0 +1,301 @@ +import { compileMessage as bind } from 'saykit'; +import { describe, expect, it } from 'vitest'; +import { compileMessage } from './compile.js'; + +/** + * The compiled message is checked two ways: a snapshot, so a change to what is + * emitted is visible in review, and a call, so a snapshot cannot be right about + * a tree that is wrong about output. + * + * The call goes through the real runtime rather than a mirror of it, since what + * matters is that the pair agree: this package decides the shape, and `saykit` + * is the only thing that reads it. + * + * Every style the extractor accepts is exercised here. A style is authored once + * and read by everyone, so this is the table that says what an author gets. + */ +function format(icu: string, values: Record = {}, locale = 'en-US') { + return bind(compileMessage(icu), locale)(values); +} + +describe('compileMessage', () => { + it('compiles a message with no placeholders to the string itself', () => { + expect(compileMessage('Hello!')).toBe('Hello!'); + }); + + it('reads a placeholder from behind its underscore', () => { + expect(compileMessage('Hello, {name}')).toMatchInlineSnapshot(` + [ + "c", + "Hello, ", + [ + "v", + "_name", + ], + ] + `); + expect(format('Hello, {name}', { _name: 'Ada' })).toBe('Hello, Ada'); + }); + + it('reads a numbered placeholder', () => { + expect(format('Hello, {0}', { _0: 'Ada' })).toBe('Hello, Ada'); + }); + + it('reads a placeholder whose name already starts with an underscore', () => { + expect(format('Total {_total}', { __total: '9' })).toBe('Total 9'); + }); + + it('does not treat the descriptor id as a value', () => { + // `{id}` is filled from the descriptor's `_id`, never from the id that + // chose the message + expect(format('Order {id}', { id: 'identified', _id: '42' })).toBe('Order 42'); + }); + + it('keeps literal text literal, with nothing to escape it for', () => { + // `'{'` is ICU's way of writing a brace that opens nothing. Text is data + // here rather than source, so none of this is escaped on the way through + const icu = "A `backtick`, a $'{'hole} and a \\ slash"; + expect(compileMessage(icu)).toBe('A `backtick`, a ${hole} and a \\ slash'); + expect(format(icu)).toBe('A `backtick`, a ${hole} and a \\ slash'); + }); + + it('reads a placeholder written straight after a dollar sign', () => { + expect(format('costs ${amount}', { _amount: 5 })).toBe('costs $5'); + }); + + it('leaves markup as the literal text the renderer reads', () => { + expect(compileMessage('Click here')).toBe('Click here'); + }); + + it('rejects a style with a placeholder in it', () => { + expect(() => compileMessage('{v, number, {x}}')).toThrow(); + }); +}); + +describe('choices', () => { + it('compiles a plural to a conditional over the categories', () => { + const icu = '{count, plural, one {# item} other {# items}}'; + expect(compileMessage(icu)).toMatchInlineSnapshot(` + [ + "?", + [ + "=", + [ + "f", + "plural", + [ + "v", + "_count", + ], + ], + "one", + ], + [ + "c", + [ + "f", + "number", + [ + "v", + "_count", + ], + ], + " item", + ], + [ + "c", + [ + "f", + "number", + [ + "v", + "_count", + ], + ], + " items", + ], + ] + `); + expect(format(icu, { _count: 1 })).toBe('1 item'); + expect(format(icu, { _count: 5 })).toBe('5 items'); + }); + + it('matches an exact case before a category, however the message writes them', () => { + const icu = '{count, plural, one {# item} =0 {nothing} other {# items}}'; + expect(JSON.stringify(compileMessage(icu))).toMatchInlineSnapshot( + `"["?",["=",["v","_count"],0],"nothing",["?",["=",["f","plural",["v","_count"]],"one"],["c",["f","number",["v","_count"]]," item"],["c",["f","number",["v","_count"]]," items"]]]"`, + ); + expect(format(icu, { _count: 0 })).toBe('nothing'); + expect(format(icu, { _count: 1 })).toBe('1 item'); + }); + + it('applies a plural offset', () => { + const icu = '{n, plural, offset:1 one {you and # other} other {you and # others}}'; + expect(format(icu, { _n: 3 })).toBe('you and 2 others'); + expect(format(icu, { _n: 2 })).toBe('you and 1 other'); + }); + + /** + * ICU tests an exact value against the *original* number, before the offset + * is applied; the offset only reaches the CLDR category and `#`. So in the + * "you and N others" idiom, where the selector counts everyone including you, + * the branch meaning "nobody else" is `=1`, not `=0`. + */ + it('matches an exact branch before applying the offset', () => { + const correct = '{n, plural, offset:1 =1 {nobody else} other {you and # others}}'; + expect(format(correct, { _n: 1 })).toBe('nobody else'); + expect(format(correct, { _n: 3 })).toBe('you and 2 others'); + + const wrong = '{n, plural, offset:1 =0 {nobody else} other {you and # others}}'; + expect(format(wrong, { _n: 1 })).toBe('you and 0 others'); + }); + + it('compiles an ordinal', () => { + const icu = '{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}'; + expect(JSON.stringify(compileMessage(icu))).toContain('["f","plural",["v","_n"],"ordinal"]'); + expect(format(icu, { _n: 1 })).toBe('1st'); + expect(format(icu, { _n: 2 })).toBe('2nd'); + expect(format(icu, { _n: 3 })).toBe('3rd'); + expect(format(icu, { _n: 4 })).toBe('4th'); + }); + + it('compiles a select', () => { + const icu = '{g, select, male {He} female {She} other {They}}'; + expect(JSON.stringify(compileMessage(icu))).toMatchInlineSnapshot( + `"["?",["=",["s",["v","_g"]],"male"],"He",["?",["=",["s",["v","_g"]],"female"],"She","They"]]"`, + ); + expect(format(icu, { _g: 'female' })).toBe('She'); + expect(format(icu, { _g: 'other' })).toBe('They'); + }); + + /** + * ICU `select` has no exact-value syntax: `=0` there is a parse error, while + * a bare `0` matches the number and the string alike. + */ + it.each([ + [0, 'Free'], + ['0', 'Free'], + [1, 'Pro'], + ['enterprise', 'Custom'], + ])('selects the bare numeric case for %o', (tier, expected) => { + expect(format('{tier, select, 0 {Free} 1 {Pro} other {Custom}}', { _tier: tier })).toBe( + expected, + ); + }); + + it('writes nothing when a choice has no other case and none matched', () => { + expect(format('{g, select, male {He}}', { _g: 'female' })).toBe(''); + }); + + it('nests a choice inside a choice, with # reaching through a select', () => { + const icu = + '{count, plural, one {{g, select, male {he has #} other {they have #}}} other {# of them}}'; + expect(format(icu, { _count: 1, _g: 'male' })).toBe('he has 1'); + expect(format(icu, { _count: 1, _g: 'x' })).toBe('they have 1'); + expect(format(icu, { _count: 5, _g: 'male' })).toBe('5 of them'); + }); +}); + +describe('numbers', () => { + it.each([ + ['{n, number}', { _n: 1234.5 }, '1,234.5'], + ['{n, number, integer}', { _n: 1234.5 }, '1,235'], + ['{n, number, percent}', { _n: 0.25 }, '25%'], + ['{n, number, #,##0.00}', { _n: 1234.5 }, '1,234.50'], + ])('formats %s', (icu, values, expected) => { + expect(format(icu, values)).toBe(expected); + }); + + it.each([ + ['{n, number, ::.00}', { _n: 1234.5 }, '1,234.50'], + ['{n, number, ::group-off}', { _n: 1234.5 }, '1234.5'], + ['{n, number, ::compact-short}', { _n: 12345 }, '12K'], + ['{n, number, ::scale/1000}', { _n: 1.5 }, '1,500'], + // A skeleton's `percent` only writes the sign. The named MF1 style scales + // as well, which is `percent scale/100` spelled out + ['{n, number, ::percent}', { _n: 25 }, '25%'], + ['{n, number, ::percent scale/100}', { _n: 0.25 }, '25%'], + ])('formats the number skeleton %s', (icu, values, expected) => { + expect(format(icu, values)).toBe(expected); + }); + + // MF1 has nowhere to write a currency code, so `{n, number, currency}` cannot + // name one and falls back to a plain number. A skeleton carries the code + it('formats a currency, which only a skeleton can ask for', () => { + expect(format('{n, number, ::currency/EUR}', { _n: 1234.5 })).toBe('€1,234.50'); + expect(format('{n, number, currency}', { _n: 1234.5 })).toBe('1,234.5'); + }); + + it('applies a scale as a multiplier, since Intl has no option for one', () => { + expect(JSON.stringify(compileMessage('{v, number, ::scale/100}'))).toMatchInlineSnapshot( + `"["f","number",["*",["v","_v"],100]]"`, + ); + }); + + it('falls back to a plain number for an unreadable number style', () => { + expect(JSON.stringify(compileMessage('{n, number, ::bogus}'))).toMatchInlineSnapshot( + `"["f","number",["v","_n"]]"`, + ); + expect(format('{n, number, ::bogus}', { _n: 1234.5 })).toBe('1,234.5'); + }); +}); + +describe('dates and times', () => { + // Built in local time, at midday, so the calendar date is the same one in + // every timezone the suite might run in + const when = new Date(2020, 0, 2, 12, 4, 5); + + it.each([ + ['{d, date}', 'Jan 2, 2020'], + ['{d, date, short}', '1/2/2020'], + ['{d, date, medium}', 'Jan 2, 2020'], + ['{d, date, long}', 'January 2, 2020'], + ['{d, date, full}', 'Thursday, January 2, 2020'], + ])('formats %s', (icu, expected) => { + expect(format(icu, { _d: when })).toBe(expected); + }); + + it.each(['{d, time}', '{d, time, short}', '{d, time, medium}', '{d, time, long}'])( + 'formats %s', + (icu) => { + expect(format(icu, { _d: when })).toMatch(/\d{1,2}:\d{2}/); + }, + ); + + it.each([ + ['{d, date, ::yyyyMMdd}', '01/02/2020'], + ['{d, date, ::yMMMM}', 'January 2020'], + ['{d, date, ::MMMd}', 'Jan 2'], + ['{d, date, ::EEEE}', 'Thursday'], + ['{d, time, ::Hm}', '12:04'], + ])('formats the date skeleton %s', (icu, expected) => { + expect(format(icu, { _d: when })).toBe(expected); + }); + + // A message that renders in a slightly wrong shape is recoverable; one that + // renders nothing is not + it.each([ + ['{d, date, bogus}', 'Jan 2, 2020'], + ['{d, date, ::qqqq}', 'Jan 2, 2020'], + ])('falls back to the default format for %s', (icu, expected) => { + expect(format(icu, { _d: when })).toBe(expected); + }); +}); + +describe('other argument types', () => { + // No macro authors a `duration`, so this only ever arrives from a catalogue + // written by hand + it('compiles a duration to the runtime helper', () => { + expect(JSON.stringify(compileMessage('{s, duration}'))).toMatchInlineSnapshot( + `"["f","duration",["v","_s"]]"`, + ); + expect(format('{s, duration}', { _s: 3661 })).toBe('1:01:01'); + }); + + // An argument type with no formatter still writes its value, rather than + // taking the rest of the message down with it + it('falls back to the plain value for an unknown argument type', () => { + expect(JSON.stringify(compileMessage('{n, spellout}'))).toMatchInlineSnapshot(`"["v","_n"]"`); + expect(format('{n, spellout}', { _n: 42 })).toBe('42'); + }); +}); diff --git a/packages/config/src/features/messages/compile.ts b/packages/config/src/features/messages/compile.ts new file mode 100644 index 00000000..07e70e6b --- /dev/null +++ b/packages/config/src/features/messages/compile.ts @@ -0,0 +1,205 @@ +import { + type Content, + type FunctionArg, + type Octothorpe, + parse, + type PlainArg, + type Select, +} from '@messageformat/parser'; +import type { Message } from 'saykit'; +import { dateTimeStyle, type NumberOptions, numberStyle, StyleError } from './styles.js'; + +/** + * ICU MessageFormat 1, compiled to a {@link Message}. + * + * Everything a message needs is known here: a style resolves to an `Intl` + * options bag and is written into the tree as a literal, so what ships is data + * over four runtime helpers, and nothing parses anything at runtime. + * + * Data rather than source is what makes a locale serialisable, which is what a + * server handing its messages to a client tree needs. It is also what keeps ICU + * at arm's length: another message format compiles to the same nodes, and the + * runtime never learns which one it came from. + * + * The locale is not here either. A message is bound to one when the view that + * holds it compiles it. + */ + +type Token = Content | PlainArg | FunctionArg | Select | Octothorpe; + +/** `=3` names the number three; anything else names a CLDR category. */ +const EXACT = /^=\d+$/; + +/** + * The descriptor key an ICU argument reads from. + * + * The transform prefixes every value with one underscore, so `{name}` is + * `_name` and a message argument really called `_total` is `__total`. The + * mapping is known here, so it is written in rather than applied per call. + */ +function reference(argument: string): Message { + return ['v', `_${argument}`]; +} + +/** + * A run of nodes, as one node. + * + * A single node stands on its own rather than being wrapped in a concatenation + * that would only stringify what is already a string, and an empty run is the + * empty string. + */ +function concat(nodes: Message[]): Message { + if (nodes.length === 0) return ''; + if (nodes.length === 1) return nodes[0]!; + return ['c', ...nodes]; +} + +/** + * Compile one ICU MessageFormat 1 message. + * + * @param icu The message, in ICU MessageFormat 1 syntax + * @returns The compiled message + * @throws If the message is not valid ICU MessageFormat 1 + */ +export function compileMessage(icu: string): Message { + /** + * Build the node for a `{arg, type, style}` placeholder. + * + * A style that cannot be read does not fail the message: the argument falls + * back to its bare formatter, so `{n, number, bogus}` still writes a number. + * A slightly wrong shape beats rendering nothing. + */ + function argument(token: FunctionArg): Message { + const value = reference(token.arg); + + let style = ''; + for (const part of token.param ?? []) { + // Only literal text can be read here: a placeholder nested in a style has + // no value until the message is called + if (part.type !== 'content') throw new Error(`Unsupported style part: ${part.type}`); + style += part.value; + } + style = style.trim(); + + /** Resolve a style, falling back to the type's default if it will not. */ + const resolve = (read: (style: string) => T, fallback: () => T) => { + try { + return read(style); + } catch (error) { + if (!(error instanceof StyleError)) throw error; + return fallback(); + } + }; + + switch (token.key) { + case 'date': + case 'time': { + const options = resolve( + (s) => dateTimeStyle(token.key as 'date' | 'time', s), + () => dateTimeStyle(token.key as 'date' | 'time', ''), + ); + return ['f', 'datetime', value, options]; + } + + case 'number': { + const { scale, ...options } = resolve(numberStyle, () => ({})); + // `Intl` has no option for a scale, so it stays what it is: a + // multiplier on the value + const scaled: Message = scale === undefined ? value : ['*', value, scale]; + return Object.keys(options).length === 0 + ? ['f', 'number', scaled] + : ['f', 'number', scaled, options]; + } + + case 'duration': + return ['f', 'duration', value]; + + // An argument type we have no formatter for still writes its value + default: + return value; + } + } + + /** + * Build the conditional for a `plural`, `selectordinal` or `select`. + * + * @param hash The node a `#` in scope prints, if any + */ + function choice(token: Select, hash: Message | null): Message { + const value = reference(token.arg); + const offset = token.pluralOffset ?? 0; + // ponytail: a plural offset on a `bigint` count throws, since the two + // cannot be subtracted. Coerce here if one ever turns up + const shifted: Message = offset === 0 ? value : ['-', value, offset]; + + // A `#` inside a `select` still refers to the plural enclosing it; inside a + // plural it refers to that plural's own offset value + const inner = token.type === 'select' ? hash : shifted; + + let fallback: Message = ''; + const arms: { exact: boolean; test: Message; value: Message }[] = []; + + for (const branch of token.cases) { + const written = pattern(branch.tokens, inner); + + if (branch.key === 'other') { + fallback = written; + continue; + } + + if (token.type === 'select') { + // A select matches its cases as literal strings, and the selector may + // be written as a number + arms.push({ exact: false, test: ['=', ['s', value], branch.key], value: written }); + continue; + } + + if (EXACT.test(branch.key)) { + // Matched against the number as written, before the offset, so `=1` + // still means one + arms.push({ + exact: true, + test: ['=', value, Number(branch.key.slice(1))], + value: written, + }); + continue; + } + + const category: Message = + token.type === 'selectordinal' + ? ['f', 'plural', shifted, 'ordinal'] + : ['f', 'plural', shifted]; + arms.push({ exact: false, test: ['=', category, branch.key], value: written }); + } + + // An exact case beats a category, however the message writes them + const ordered = [...arms.filter((a) => a.exact), ...arms.filter((a) => !a.exact)]; + + return ordered.reduceRight((rest, arm) => ['?', arm.test, arm.value, rest], fallback); + } + + /** One token, as the node it contributes. */ + function fragment(token: Token, hash: Message | null): Message { + switch (token.type) { + case 'content': + return token.value; + case 'argument': + return reference(token.arg); + case 'function': + return argument(token); + case 'octothorpe': + // The parser only makes a `#` a token when a plural encloses it + /* v8 ignore next */ + return hash ? ['f', 'number', hash] : '#'; + default: + return choice(token, hash); + } + } + + /** A run of tokens, as one node. */ + function pattern(tokens: Token[], hash: Message | null): Message { + return concat(tokens.map((token) => fragment(token, hash))); + } + + return pattern(parse(icu) as Token[], null); +} diff --git a/packages/config/src/features/messages/index.ts b/packages/config/src/features/messages/index.ts index 4806c6bd..f09ca18b 100644 --- a/packages/config/src/features/messages/index.ts +++ b/packages/config/src/features/messages/index.ts @@ -1,6 +1,8 @@ +export * from './compile.js'; export * from './convert.js'; export * from './escape.js'; export * from './format.js'; export * from './hash.js'; export * from './identifier.js'; +export * from './styles.js'; export * from './types.js'; diff --git a/packages/integration/src/messageformat/styles.ts b/packages/config/src/features/messages/styles.ts similarity index 92% rename from packages/integration/src/messageformat/styles.ts rename to packages/config/src/features/messages/styles.ts index f6810ce6..0601d563 100644 --- a/packages/integration/src/messageformat/styles.ts +++ b/packages/config/src/features/messages/styles.ts @@ -4,17 +4,17 @@ import { parseNumberPattern, parseNumberSkeleton, } from '@messageformat/number-skeleton'; -import type { NumberOptions } from './options.js'; - /** * An argument style, resolved to the `Intl` options it asks for. * - * This is the half of the conversion MF2's own option vocabulary cannot do: it - * tops out at `{ length, fields }` for a date, so a skeleton has nowhere to - * land. Both skeleton parsers emit `Intl` option bags, which is what the - * formatter wants anyway, so a style resolves straight to one. + * Both skeleton parsers emit `Intl` option bags, which is what the compiler + * wants anyway, so a style resolves straight to one and is written into the + * generated function as a literal. Nothing resolves a style at runtime. */ +/** The extra key a number's bag may carry, which `Intl` has no option for. */ +export type NumberOptions = Intl.NumberFormatOptions & { scale?: number }; + /** A style the conversion could not read. */ export class StyleError extends Error {} diff --git a/packages/config/src/features/workers/extract-worker.test.ts b/packages/config/src/features/workers/extract-worker.test.ts index c9816fa1..ab19699b 100644 --- a/packages/config/src/features/workers/extract-worker.test.ts +++ b/packages/config/src/features/workers/extract-worker.test.ts @@ -96,7 +96,18 @@ describe('BucketExtractWorker.write', () => { await extract([msg({ message: 'Hello', id: 'greeting' })]); expect(readLocale('de')).toEqual([]); - expect(existsSync(join(dir, 'de', 'messages.d.json.ts'))).toBe(true); + }); + + it('compiles every locale into a module the app can import', async () => { + await extract([msg({ message: 'Hello', id: 'greeting' })]); + + for (const locale of ['en', 'de']) { + const module = join(dir, locale, 'messages.js'); + expect(existsSync(module)).toBe(true); + expect(existsSync(join(dir, locale, 'messages.d.ts'))).toBe(true); + // `de` has no translations, so it resolves to the source strings + expect(readFileSync(module, 'utf8')).toContain('"greeting": "Hello"'); + } }); it('leaves an existing non-source locale completely untouched', async () => { diff --git a/packages/config/src/features/workers/extract-worker.ts b/packages/config/src/features/workers/extract-worker.ts index 49cb46ee..2d24f5a1 100644 --- a/packages/config/src/features/workers/extract-worker.ts +++ b/packages/config/src/features/workers/extract-worker.ts @@ -1,6 +1,7 @@ import { access } from 'node:fs/promises'; import { join } from 'node:path'; import type { Message } from '~/shapes'; +import { emitCatalogueModule } from '../catalogue/emit'; import { extractMessagesFromFile } from '../catalogue/extractor'; import { mergeExtractedMessages } from '../catalogue/merge'; import { expandBucketOutputPath } from '../catalogue/path'; @@ -83,9 +84,19 @@ export class BucketExtractWorker extends BucketWorker { await writeCatalogueMessages(this.bucket, locale, []); } + await this.compile(); + this.logger.success(`Extraction complete for bucket: ${this.bucket.include}`); } + /** Compile every locale's catalogue into the module the app imports. */ + async compile() { + for (const locale of this.config.locales) { + const { path, count } = await emitCatalogueModule(this.config, this.bucket, locale); + this.logger.step(`Compiled ${count} message(s) to ${normalisePathForLogs(path)}`); + } + } + async update(path: string) { const changed = await this.#indexPath(path); if (changed) await this.write(); @@ -96,9 +107,18 @@ export class BucketExtractWorker extends BucketWorker { this.logger.header(`👀 Watching bucket for changes: ${this.bucket.include}`); for await (const event of watchDebounced('.', { recursive: true })) { - if (!event.filename || !this.bucket.match(event.filename)) continue; + if (!event.filename) continue; const path = join(process.cwd(), event.filename); - await this.update(path); + + if (this.bucket.match(event.filename)) { + await this.update(path); + continue; + } + + // A translation changed rather than a call site, which is a recompile and + // nothing else: extraction only ever writes the source locale, and this + // is how an edit in a `.po` reaches the running app + if (this.bucket.output.match(event.filename)) await this.compile(); } } } diff --git a/packages/integration-react/src/runtime/client.server.ts b/packages/integration-react/src/runtime/client.server.ts index 8c5e5518..5c5a0961 100644 --- a/packages/integration-react/src/runtime/client.server.ts +++ b/packages/integration-react/src/runtime/client.server.ts @@ -8,10 +8,11 @@ import { getSay } from './server.js'; * * A store is a live object and cannot cross the server/client boundary, and a * server component cannot hand its own scope to a client one either. What can - * cross is the locale and its messages, so this reads them off the view the - * the enclosing segment established with {@link import('./server.js').setSay}, and passes them - * to the real provider, which is why `` written on the server - * takes no props. + * cross is the locale and its messages, which are compiled to data rather than + * to functions for exactly this reason, so this reads them off the view the + * enclosing segment established with {@link import('./server.js').setSay} and + * passes them to the real provider, which is why `` written on + * the server takes no props. */ export function SayProvider({ children }: { children?: ReactNode }) { const say = getSay(); diff --git a/packages/integration-react/src/runtime/client.test.tsx b/packages/integration-react/src/runtime/client.test.tsx index 076f4bc0..6f389d73 100644 --- a/packages/integration-react/src/runtime/client.test.tsx +++ b/packages/integration-react/src/runtime/client.test.tsx @@ -9,7 +9,7 @@ import { SayProvider, useSay } from '~/runtime/client.js'; function Consumer() { const say = useSay(); - return createElement('span', null, `${say.locale}:${say.messages.greeting}`); + return createElement('span', null, `${say.locale}:${say.call({ id: 'greeting' })}`); } const catalogue = () => createCatalogue({ en: { greeting: 'Hello' }, fr: { greeting: 'Bonjour' } }); diff --git a/packages/integration-react/src/runtime/client.ts b/packages/integration-react/src/runtime/client.ts index 3a94ce07..2e7aa168 100644 --- a/packages/integration-react/src/runtime/client.ts +++ b/packages/integration-react/src/runtime/client.ts @@ -21,15 +21,16 @@ SayContext.displayName = 'SayContext'; * cross the server/client boundary. * * A locale and its messages are the serialisable form a server can hand across - * that boundary. Only that one locale comes over, so the provider built from - * it has nothing to switch to: switching is the server's to do, normally + * that boundary, which compiling messages to data rather than to functions is + * what makes possible. Only that one locale comes over, so the provider built + * from it has nothing to switch to: switching is the server's to do, normally * through navigation. */ export type SayProviderProps = | { store: Store; locale?: never; messages?: never } | { store?: never; locale: string; messages: View.Messages } - // Nothing at all, which is what `` on the server is: the - // server build of this module reads the scope and fills the props in + // Nothing at all, which is what `` on the server is: the server + // build of this module reads the established view and fills the props in | { store?: never; locale?: never; messages?: never }; /** diff --git a/packages/integration-react/src/runtime/index.ts b/packages/integration-react/src/runtime/index.ts index ff5f8d1b..56a473e1 100644 --- a/packages/integration-react/src/runtime/index.ts +++ b/packages/integration-react/src/runtime/index.ts @@ -8,6 +8,7 @@ import { import type { DateTimeOptions, Disallow, + Message, Named, NumberOptions, NumeralOptions, @@ -34,7 +35,7 @@ export function Say( ): ReactElement; export function Say(props: { id?: string; - message?: string; + message?: Message; whitespace?: boolean; [match: string]: unknown; }) { @@ -48,9 +49,9 @@ export function Say(props: { const values = resolveValuePropKeys(rest); return createElement(Renderer, { - // The props go through still prefixed, since `View#call` does the single - // strip for every caller. The id is merged in last, so a message free to - // name a value `id` cannot displace the message being looked up + // The props go through still prefixed, since a compiled message reads each + // value from behind its underscore. The id is merged in last, so a message + // free to name a value `id` cannot displace the message being looked up html: say.call(message === undefined ? { ...rest, id: id! } : { ...rest, message }), whitespace, components(tag?: string) { diff --git a/packages/integration-react/src/runtime/server.test.tsx b/packages/integration-react/src/runtime/server.test.tsx index ded608a6..17a55dd2 100644 --- a/packages/integration-react/src/runtime/server.test.tsx +++ b/packages/integration-react/src/runtime/server.test.tsx @@ -47,7 +47,7 @@ describe('server runtime', () => { it('withSay negotiates the locale, loads it, and renders the component', async () => { const Component = vi.fn((props: { params: Promise<{ locale: string }> }) => { void props; - return createElement('span', null, getSay().messages.greeting); + return createElement('span', null, getSay().call({ id: 'greeting' })); }); const Wrapped = createWithSay(make())(Component, (props) => props.params.then((params) => params.locale), @@ -80,6 +80,7 @@ describe('server runtime', () => { const element = SayProvider({ children: null }); const props = element.props as { locale: string; messages: { greeting: string } }; expect(props.locale).toBe('en'); + // Data, which is the only thing that crosses the boundary expect(props.messages.greeting).toBe('Hi'); }); diff --git a/packages/integration-react/src/runtime/server.ts b/packages/integration-react/src/runtime/server.ts index 7913bfaf..c27db928 100644 --- a/packages/integration-react/src/runtime/server.ts +++ b/packages/integration-react/src/runtime/server.ts @@ -31,7 +31,7 @@ const SECOND_VIEW = (established: string, next: string) => "request. A view is per request rather than per subtree: React renders a server component's " + 'children after it returns, so there is nowhere to put the previous view back, and everything ' + `rendered after this point reads '${next}' - including components outside the one that ` + - "established it, and the messages 'SayProvider' serialises to the client. Render the other " + + "established it, and the messages 'SayProvider' hands to the client. Render the other " + 'locale in its own request, or resolve its view yourself and pass it to the components that ' + 'need it.'; @@ -67,7 +67,7 @@ export function getSay(): View { * Per request is the limit. React renders a server component's children after * it returns, so a view does not end where a subtree does: a second view takes * over for everything rendered after it, including the messages - * `` serialises. Development warns when that happens. + * `` hands to the client. Development warns when that happens. * * @param view The view to establish */ @@ -104,7 +104,8 @@ export function setSay(view: View): void { * * A `` written inside a wrapped component takes no props of its * own: the server build of `@saykit/react/client` reads the established view - * and serialises the locale and its messages across the boundary. + * and hands the locale and its messages across the boundary, which compiling + * messages to data rather than to functions is what allows. * * @example * ```tsx diff --git a/packages/integration/package.json b/packages/integration/package.json index 7cbb393e..a65c530c 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -40,11 +40,5 @@ "check": "tsc --noEmit", "build": "tsdown", "prepack": "pnpm build" - }, - "dependencies": { - "@messageformat/date-skeleton": "2.0.0-0", - "@messageformat/number-skeleton": "2.0.0-0", - "@messageformat/parser": "^5.1.1", - "messageformat": "^4.0.0" } } diff --git a/packages/integration/src/catalogue.test.ts b/packages/integration/src/catalogue.test.ts index ab2e782a..628f03e5 100644 --- a/packages/integration/src/catalogue.test.ts +++ b/packages/integration/src/catalogue.test.ts @@ -6,12 +6,15 @@ type Locale = 'en' | 'fr' | 'de'; const messages = { en: { greeting: 'Hello', - items: '{count, plural, one {# item} other {# items}}', - named: 'Hello, {name}', - identified: 'Order {id}', - underscored: 'Total {_total}', + items: ['?', ['=', ['v', '_count'], 1], '1 item', ['c', ['v', '_count'], ' items']], + named: ['c', 'Hello, ', ['v', '_name']], + identified: ['c', 'Order ', ['v', '_id']], + underscored: ['c', 'Total ', ['v', '__total']], + }, + fr: { + greeting: 'Bonjour', + items: ['?', ['=', ['v', '_count'], 1], '1 article', ['c', ['v', '_count'], ' articles']], }, - fr: { greeting: 'Bonjour', items: '{count, plural, one {# article} other {# articles}}' }, } satisfies Partial>; /** @@ -106,7 +109,7 @@ describe('Catalogue#load', () => { const view = catalogue.load('de'); expect(view).toBe(catalogue.locale('de')); expect(thunk).toHaveBeenCalledOnce(); - expect(catalogue.locale('de').messages).toEqual({ greeting: 'Hallo' }); + expect(catalogue.locale('de').call({ id: 'greeting' })).toBe('Hallo'); }); it('does not call a thunk for a locale that already has messages', () => { @@ -123,12 +126,17 @@ describe('Catalogue#load', () => { const catalogue = createCatalogue({ de: async () => ({ default: { greeting: 'Hallo' } }) }); const view = await catalogue.load('de'); - expect(view.messages).toEqual({ greeting: 'Hallo' }); + expect(view.call({ id: 'greeting' })).toBe('Hallo'); }); it('shares one call between loads that overlap', async () => { let call = 0; - const catalogue = createCatalogue({ de: async () => ({ greeting: `load-${++call}` }) }); + const catalogue = createCatalogue({ + de: async () => { + const loaded = `load-${++call}`; + return { greeting: loaded }; + }, + }); // Two loads in flight at once: the second finds the first still running // and waits on it rather than starting the thunk again @@ -148,7 +156,7 @@ describe('Catalogue#load', () => { }); await expect(catalogue.load('de')).rejects.toThrow('offline'); - expect((await catalogue.load('de')).messages).toEqual({ greeting: 'Hallo' }); + expect((await catalogue.load('de')).call({ id: 'greeting' })).toBe('Hallo'); }); it('throws for a locale the catalogue does not have', () => { @@ -165,7 +173,7 @@ describe('Catalogue#load', () => { expect(catalogue.load('en')).not.toBeInstanceOf(Promise); const result = catalogue.load('de'); expect(result).toBeInstanceOf(Promise); - expect((await result).messages).toEqual({ greeting: 'Hallo' }); + expect((await result).call({ id: 'greeting' })).toBe('Hallo'); }); }); diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index 1548c2e2..d62510ea 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -1,4 +1,5 @@ export { createCatalogue, type Catalogue } from './catalogue.js'; +export { compileMessage, type Message } from './message.js'; export { createScope, type Scope } from './scope.js'; export { createStore, type Store } from './store.js'; export { createView, type View } from './view.js'; diff --git a/packages/integration/src/message.test.ts b/packages/integration/src/message.test.ts new file mode 100644 index 00000000..adbd10f2 --- /dev/null +++ b/packages/integration/src/message.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { compileMessage, type Message } from './message.js'; +import { createView } from './index.js'; + +/** + * The evaluator, node by node. What the CLI emits for a given ICU message is + * its own business and has its own tests; this is only about what each node + * means once it is here. + */ +const format = (message: Message, values: Record = {}, locale = 'en-US') => + compileMessage(message, locale)(values); + +describe('compileMessage', () => { + it('formats a message with no placeholders as itself', () => { + expect(format('Hello')).toBe('Hello'); + }); + + it('joins the parts of a concatenation', () => { + expect(format(['c', 'Hello, ', ['v', '_name'], '!'], { _name: 'Ada' })).toBe('Hello, Ada!'); + }); + + it('writes nothing for a value that is not there', () => { + expect(format(['c', 'Hello, ', ['v', '_name']])).toBe('Hello, '); + }); + + it('takes the branch its test chooses', () => { + const message: Message = ['?', ['=', ['v', '_n'], 1], 'one', 'many']; + expect(format(message, { _n: 1 })).toBe('one'); + expect(format(message, { _n: 2 })).toBe('many'); + }); + + it('formats a number the way the locale writes one', () => { + expect(format(['f', 'number', ['v', '_n']], { _n: 1234.5 })).toBe('1,234.5'); + // French groups with a space and marks the decimal with a comma + expect(format(['f', 'number', ['v', '_n']], { _n: 1234.5 }, 'fr')).toMatch(/^1.234,5$/u); + }); + + it('hands a helper the options it was compiled with', () => { + const message: Message = ['f', 'number', ['v', '_n'], { style: 'percent' }]; + expect(format(message, { _n: 0.25 })).toBe('25%'); + }); + + it('formats a date, and a duration with no locale of its own', () => { + const when = new Date(2020, 0, 2, 12, 4, 5); + expect(format(['f', 'datetime', ['v', '_d'], { dateStyle: 'long' }], { _d: when })).toBe( + 'January 2, 2020', + ); + expect(format(['f', 'duration', ['v', '_s']], { _s: 3661 })).toBe('1:01:01'); + }); + + it('selects a plural category, applying an offset first', () => { + const message: Message = ['f', 'plural', ['-', ['v', '_n'], 1]]; + expect(format(message, { _n: 2 })).toBe('one'); + expect(format(message, { _n: 4 })).toBe('other'); + }); + + it('scales a number by a multiplier', () => { + expect(format(['f', 'number', ['*', ['v', '_n'], 100]], { _n: 1.5 })).toBe('150'); + }); + + it('matches a select case against the value as text', () => { + const message: Message = ['?', ['=', ['s', ['v', '_tier']], '0'], 'Free', 'Paid']; + expect(format(message, { _tier: 0 })).toBe('Free'); + expect(format(message, { _tier: '0' })).toBe('Free'); + expect(format(message, { _tier: 1 })).toBe('Paid'); + }); +}); + +describe('a view over compiled messages', () => { + it('calls a message as many times as asked', () => { + const say = createView('en', { greeting: ['c', 'Hello, ', ['v', '_name']] }); + + expect(say.call({ id: 'greeting', _name: 'Ada' })).toBe('Hello, Ada'); + expect(say.call({ id: 'greeting', _name: 'Grace' })).toBe('Hello, Grace'); + }); + + it('keeps its messages as the data they were given as', () => { + const say = createView('en', { greeting: 'Hello' }); + expect(say.messages.greeting).toBe('Hello'); + // Serialisable, which is the whole point of compiling to data + expect(JSON.parse(JSON.stringify(say.messages))).toEqual({ greeting: 'Hello' }); + }); +}); diff --git a/packages/integration/src/message.ts b/packages/integration/src/message.ts new file mode 100644 index 00000000..ebacef13 --- /dev/null +++ b/packages/integration/src/message.ts @@ -0,0 +1,144 @@ +import { datetime, duration, number, plural } from './runtime.js'; + +/** + * A compiled message. + * + * The CLI compiles a catalogue's messages down to this: arrays and strings, + * with every style already resolved to the options bag it stands for. Nothing + * here is parsed at runtime, and nothing here is a function, so a locale's + * messages are data - they cross a server/client boundary the way any other + * JSON does, which a compiled function cannot. + * + * A message with no placeholders is its own string, so the common case costs + * nothing at all. + */ +export type Message = + // Literal text, or a literal number a branch compares against + | string + | number + // Concatenation: the parts of one message, in order + | ['c', ...Message[]] + // A value from the descriptor, by key + | ['v', string] + // A runtime helper over one value, with the options it was compiled with + | ['f', Message.Helper, Message, unknown?] + // A conditional: test, then, else + | ['?', Message, Message, Message] + // Equality between two nodes + | ['=', Message, Message] + // Subtraction, for a plural offset + | ['-', Message, Message] + // Multiplication, for a number scale + | ['*', Message, Message] + // String coercion, for a select matching its cases as text + | ['s', Message]; + +export namespace Message { + /** The helpers a compiled message may call. */ + export type Helper = 'number' | 'datetime' | 'duration' | 'plural'; + + /** What a compiled message is called with: the descriptor the transform built. */ + export type Values = Record; +} + +/** + * The helpers, by the name a message calls them under. + * + * Uniform on purpose: every one takes the locale, the value, and whatever + * options it was compiled with, so `'f'` is one call shape rather than four. + */ +const HELPERS: Record unknown> = { + number: number as never, + datetime: datetime as never, + plural: plural as never, + // The one helper with nothing locale-dependent about it + duration: ((_locale: string, value: number) => duration(value)) as never, +}; + +type Formatter = (values: Message.Values) => unknown; + +/** + * Build the closure one node evaluates to. + * + * The walk happens once per message, and every branch of it resolves what it + * can while it walks: a helper is looked up here rather than per call, and so + * are a conditional's arms. What is left at call time is the work that depends + * on the values. + */ +function build(node: Message, locale: string): Formatter { + if (!Array.isArray(node)) return () => node; + + switch (node[0]) { + case 'c': { + const parts = node.slice(1).map((part) => build(part as Message, locale)); + return (values) => { + let written = ''; + for (const part of parts) written += part(values) ?? ''; + return written; + }; + } + + case 'v': { + const key = node[1]; + return (values) => values[key]; + } + + case 'f': { + const helper = HELPERS[node[1]]; + const value = build(node[2], locale); + const options = node[3]; + return (values) => helper(locale, value(values) as never, options as never); + } + + case '?': { + const test = build(node[1], locale); + const then = build(node[2], locale); + const otherwise = build(node[3], locale); + return (values) => (test(values) ? then(values) : otherwise(values)); + } + + case '=': { + const left = build(node[1], locale); + const right = build(node[2], locale); + return (values) => left(values) === right(values); + } + + case '-': { + const left = build(node[1], locale); + const right = build(node[2], locale); + return (values) => (left(values) as number) - (right(values) as number); + } + + case '*': { + const left = build(node[1], locale); + const right = build(node[2], locale); + return (values) => (left(values) as number) * (right(values) as number); + } + + default: { + const value = build(node[1], locale); + return (values) => String(value(values)); + } + } +} + +/** + * Compile one message into the function that formats it. + * + * Called once per message per locale: a view memoises what this returns, so a + * message walked here is called from the closure afterwards. + * + * @param message The compiled message, as the CLI emitted it + * @param locale The locale to bind its helpers to + * @returns A function from a descriptor to the formatted string + */ +export function compileMessage( + message: Message, + locale: string, +): (values: Message.Values) => string { + // A message with no placeholders is already its own answer + if (typeof message === 'string') return () => message; + + const formatter = build(message, locale); + return (values) => String(formatter(values) ?? ''); +} diff --git a/packages/integration/src/messageformat/convert.ts b/packages/integration/src/messageformat/convert.ts deleted file mode 100644 index 0f168d16..00000000 --- a/packages/integration/src/messageformat/convert.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { Content, FunctionArg, Octothorpe, PlainArg, Select } from '@messageformat/parser'; -import type { Model } from 'messageformat'; -import { literal } from './options.js'; -import { dateTimeStyle, numberStyle, StyleError } from './styles.js'; - -/** - * The MF1 syntax tree, rewritten as an MF2 message. - * - * The two disagree about where a choice may appear. MF1 nests selects freely; - * MF2 has exactly one `.match` at the top of a message, with one key per - * selector. Most of this module is that reconciliation: every selector is - * lifted to the top, and the body rewritten once per combination of cases. - * - * Adapted from `@messageformat/icu-messageformat-1` (Apache-2.0), which solves - * the same problem the same way. - */ - -export type Token = Content | PlainArg | FunctionArg | Select | Octothorpe; - -const isSelect = (token: Token): token is Select => - token.type === 'plural' || token.type === 'select' || token.type === 'selectordinal'; - -/** `=3` names the number three; anything else names a CLDR category. */ -const asKey = (key: string) => (/^=\d+$/.test(key) ? Number(key.slice(1)) : key); - -/** - * Build the expression for a `{arg, type, style}` placeholder. - * - * A style the conversion cannot read does not fail the message: the argument - * falls back to its bare formatter, so `{n, number, bogus}` still writes a - * number. A slightly wrong shape beats rendering `{$n}`. - */ -function functionRef(token: FunctionArg): Model.FunctionRef { - let style = ''; - for (const part of token.param ?? []) { - // Only literal text can be read at compile time: a placeholder nested in a - // style has no value yet, and MF2 has no way to defer one - if (part.type !== 'content') throw new Error(`Unsupported style part: ${part.type}`); - style += part.value; - } - style = style.trim(); - - const ref = (name: string, value?: unknown): Model.FunctionRef => ({ - type: 'function', - name, - ...(value === undefined ? {} : { options: { options: literal(value) } }), - }); - - try { - switch (token.key) { - case 'date': - case 'time': - return ref('say:datetime', dateTimeStyle(token.key, style)); - case 'number': - return ref('say:number', numberStyle(style)); - case 'duration': - return ref('say:duration'); - default: - throw new StyleError(`Unsupported argument type ${token.key}`); - } - } catch (error) { - // Only a style is recovered from. Nothing else in the `try` throws today, - // so this guards against a parser one day doing so - /* v8 ignore next */ - if (!(error instanceof StyleError)) throw error; - switch (token.key) { - case 'date': - case 'time': - return ref('say:datetime', dateTimeStyle(token.key, '')); - case 'number': - return ref('say:number', {}); - default: - return ref('say:string'); - } - } -} - -/** - * Convert one MF1 token into an MF2 pattern part. - * - * @param plural The argument a `#` in scope refers to, if any - */ -function toPart(token: Token, plural: string | null): Model.Expression | string { - switch (token.type) { - case 'content': - return token.value; - case 'argument': - return { type: 'expression', arg: { type: 'variable', name: token.arg } }; - case 'function': - return { - type: 'expression', - arg: { type: 'variable', name: token.arg }, - functionRef: functionRef(token), - }; - case 'octothorpe': - // The parser only makes a `#` a token when a plural encloses it, so the - // text case is a guard rather than a path - /* v8 ignore next */ - return plural ? { type: 'expression', arg: { type: 'variable', name: plural } } : '#'; - /* v8 ignore next 2 -- the token union has no other members */ - default: - throw new Error(`Unsupported token type: ${(token as Token).type}`); - } -} - -type Selector = { - arg: string; - /** - * The variable this selector reads: `arg` for the first selector on an - * argument, a generated name for any later one. MF2 declares a name once, so - * two selects asking different questions of one argument need two names. The - * `#` in a generated name cannot appear in an MF1 argument name, so it can - * never collide with one the message uses. - */ - name: string; - type: Select['type']; - offset: number; - keys: (string | number)[]; -}; - -/** - * Two selects are the same selector when they ask the same question of the same - * argument. Their keys then merge into one dimension rather than two, which - * keeps a repeated `{n, plural, ...}` from squaring the variants. - */ -const sameSelector = (a: Pick) => (b: Selector) => - a.arg === b.arg && a.type === b.type && a.offset === b.offset; - -/** Collect every selector in the message, at any depth, outermost first. */ -function findSelectors(tokens: Token[]) { - const selectors: Selector[] = []; - - const add = (selector: Omit) => { - const existing = selectors.find(sameSelector(selector)); - if (existing) { - existing.keys.push(...selector.keys); - return; - } - // The first selector on an argument reads the argument itself; a second - // question about the same one needs somewhere else to live - const taken = selectors.filter((s) => s.arg === selector.arg).length; - const name = taken === 0 ? selector.arg : `${selector.arg}#${taken}`; - selectors.push({ ...selector, name }); - }; - - for (const token of tokens) { - if (!isSelect(token)) continue; - add({ - arg: token.arg, - type: token.type, - offset: token.pluralOffset ?? 0, - keys: token.cases.map((c) => (token.type === 'select' ? c.key : asKey(c.key))), - }); - for (const c of token.cases) for (const inner of findSelectors(c.tokens)) add(inner); - } - - return selectors; -} - -/** - * The declaration binding a selector's name to the function that selects on it: - * `say:string` for a `select`, `say:plural` otherwise. - * - * The first selector on an argument declares the argument itself, so a plain - * `{n}` elsewhere and a `#` inside a branch both resolve to the selected value. - * A later one declares a name of its own. - */ -function declaration({ arg, name, type, offset }: Selector): Model.Declaration { - const functionRef: Model.FunctionRef = - type === 'select' - ? { type: 'function', name: 'say:string' } - : { - type: 'function', - name: 'say:plural', - options: { options: literal({ offset, ordinal: type === 'selectordinal' }) }, - }; - - const value: Model.Expression = { - type: 'expression', - arg: { type: 'variable', name: arg }, - functionRef, - }; - - return name === arg ? { type: 'input', name, value } : { type: 'local', name, value }; -} - -/** - * Order a selector's keys so exact numbers come before categories and `other` - * comes last. MF2 takes the first variant whose keys all match, so this - * ordering is what makes `=1` beat `one` and `one` beat `other`. - */ -function sortKeys(keys: (string | number)[]) { - // Ranked rather than compared pairwise, so the order is total: comparing two - // exact numbers by the rules alone answers `-1` either way round, which is - // not an ordering - const rank = (key: string | number) => (typeof key === 'number' ? 0 : key === 'other' ? 2 : 1); - return Array.from(new Set(keys)).sort((a, b) => rank(a) - rank(b)); -} - -/** - * Convert a parsed MF1 message into an MF2 message. - * - * With no selectors this is a straight token-by-token walk. With any, the - * message becomes a `select` whose variants are every combination of every - * selector's keys, and each token is written into every variant the enclosing - * cases admit: a token inside `one` lands in every variant keyed `one`, and a - * token outside every select lands in all of them. - */ -export function toMessage(ast: Token[]): Model.Message { - const selectors = findSelectors(ast); - - if (selectors.length === 0) { - return { type: 'message', declarations: [], pattern: ast.map((t) => toPart(t, null)) }; - } - - // Build the key tuples first, then fill in their patterns below - let tuples: (string | number)[][] = [[]]; - for (const selector of selectors) { - const keys = sortKeys(selector.keys); - tuples = tuples.flatMap((tuple) => keys.map((key) => [...tuple, key])); - } - - const variants: Model.Variant[] = tuples.map((tuple) => ({ - keys: tuple.map((key) => - key === 'other' ? { type: '*' } : { type: 'literal', quoted: false, value: String(key) }, - ), - value: [], - })); - - /** - * Walk the tokens, appending each to the variants still in play. - * - * @param plural The argument a `#` in scope refers to, if any - * @param filter The case chosen so far in each select already entered - */ - function fill( - tokens: Token[], - plural: string | null, - filter: { index: number; key: string | number }[], - ) { - for (const token of tokens) { - if (isSelect(token)) { - const index = selectors.findIndex( - sameSelector({ arg: token.arg, type: token.type, offset: token.pluralOffset ?? 0 }), - ); - // A `#` inside a `select` still refers to the plural enclosing it; - // inside a plural it refers to that plural's own name, which is not the - // argument's when one argument is selected twice - const inner = token.type === 'select' ? plural : selectors[index]!.name; - for (const c of token.cases) { - const key = token.type === 'select' ? c.key : asKey(c.key); - fill(c.tokens, inner, [...filter, { index, key }]); - } - continue; - } - - for (const variant of variants) { - const matches = filter.every(({ index, key }) => { - const vk = variant.keys[index]!; - return vk.type === '*' ? key === 'other' : String(key) === vk.value; - }); - if (!matches) continue; - - const part = toPart(token, plural); - const last = variant.value.length - 1; - // Adjacent text merges, so a variant reads as one string rather than a - // run of fragments split where its cases began and ended - if (typeof part === 'string' && typeof variant.value[last] === 'string') { - variant.value[last] += part; - } else { - variant.value.push(part); - } - } - } - } - - fill(ast, null, []); - - return { - type: 'select', - declarations: selectors.map(declaration), - selectors: selectors.map((s) => ({ type: 'variable', name: s.name })), - variants, - }; -} diff --git a/packages/integration/src/messageformat/functions.ts b/packages/integration/src/messageformat/functions.ts deleted file mode 100644 index c8614336..00000000 --- a/packages/integration/src/messageformat/functions.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { MessageFunction } from 'messageformat/functions'; -import { options, type NumberOptions, type PluralOptions } from './options.js'; -import { duration, datetime, number, numeric, temporal } from './values.js'; - -/** - * The handlers a compiled message resolves against. - * - * Every placeholder the conversion emits names one of these, and each takes its - * configuration as a single already-resolved bag. A handler is only ever the - * last step: coerce the operand, apply the scale, call `Intl`. Reading a style - * stays in `styles.ts`, once per message rather than once per format. - */ -export const functions = { - 'say:number': (ctx, opt, operand) => { - const { scale, ...nf } = options(opt.options); - const value = numeric(operand); - return number(ctx.locales as string[], scale ? Number(value) * scale : value, nf); - }, - - 'say:datetime': (ctx, opt, operand) => - datetime(ctx.locales as string[], temporal(operand), options(opt.options)), - - 'say:duration': (_ctx, _opt, operand) => { - const value = Number(numeric(operand)); - const str = duration(value); - return { - type: 'say:duration', - toParts: () => [{ type: 'say:duration', value: str }], - toString: () => str, - valueOf: () => value, - }; - }, - - /** - * The selector behind `plural` and `selectordinal`. - * - * An exact `=n` case is matched against the number as written, before the - * offset, so `=1` still means one. The category is chosen from the offset - * number, which lets "You and 2 others" branch on a total of three, and that - * same number is what a `#` in the branch prints. - */ - 'say:plural': (ctx, opt, operand) => { - const { offset = 0, ordinal = false } = options(opt.options); - const value = numeric(operand); - const shifted = typeof value === 'bigint' ? value - BigInt(offset) : value - offset; - - const result = number(ctx.locales as string[], shifted, {}); - // The offset number is what `#` prints, but the original is what the value - // *is*, so a second selector offsets from the number the message was given - result.valueOf = () => value; - - // Built once and kept, like the formatters in `values.ts`: constructing an - // `Intl` object is the expensive half of selecting - let rules: Intl.PluralRules | undefined; - result.selectKey = (keys) => { - const exact = String(value); - if (keys.has(exact)) return exact; - rules ??= new Intl.PluralRules(ctx.locales as string[], { - localeMatcher: ctx.localeMatcher, - type: ordinal ? 'ordinal' : 'cardinal', - }); - // `Intl.PluralRules` takes a number, never a bigint - const category = rules.select(Number(shifted)); - return keys.has(category) ? category : null; - }; - return result; - }, - - /** The selector behind `select`, and the fallback for an argument type we do not know. */ - 'say:string': (_ctx, _opt, operand) => { - const str = operand === undefined ? '' : String(operand); - return { - type: 'string', - selectKey: (keys) => (keys.has(str) ? str : null), - toParts: () => [{ type: 'string', value: str }], - toString: () => str, - valueOf: () => str, - }; - }, -} satisfies Record>; diff --git a/packages/integration/src/messageformat/index.test.ts b/packages/integration/src/messageformat/index.test.ts deleted file mode 100644 index d3a2f036..00000000 --- a/packages/integration/src/messageformat/index.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { functions } from './functions.js'; -import { options } from './options.js'; -import { duration, numeric, temporal } from './values.js'; -import { compile } from './index.js'; - -/** - * `Say.call` only ever asks a message for a string, so the parts side of every - * value, and the operand coercions a hand-written catalogue can reach that the - * macros cannot author, are exercised here against `compile` directly. - */ - -const when = new Date(2020, 0, 2, 12, 4, 5); - -/** Format, failing on the errors `MessageFormat` would otherwise only warn about. */ -function format(message: string, values: Record = {}) { - const errors: unknown[] = []; - const result = compile('en-US', message).format(values, (e) => errors.push(e)); - if (errors.length > 0) throw errors[0]; - return result.replaceAll(/[⁨⁩]/g, ''); -} - -function parts(message: string, values: Record = {}) { - return compile('en-US', message).formatToParts(values, (e) => { - throw e; - }); -} - -describe('operands', () => { - it('reads a number written as a string or a wrapper', () => { - expect(numeric('42')).toBe(42); - expect(numeric({ valueOf: () => 42 })).toBe(42); - }); - - // A bigint is passed through rather than narrowed, since the precision is the - // whole point of writing one - it('keeps a bigint a bigint', () => { - expect(numeric(10n ** 25n)).toBe(10n ** 25n); - expect(format('{n, number}', { n: 10n ** 25n })).toBe('10,000,000,000,000,000,000,000,000'); - }); - - it('throws for a value that is not a number', () => { - expect(() => numeric('nope')).toThrow('Input is not numeric'); - expect(() => format('{n, number}', { n: 'nope' })).toThrow('Input is not numeric'); - }); - - // `undefined` is a missing value rather than a bad one, and MF2 reports the - // missing variable itself - it('treats a missing number as NaN rather than an error', () => { - expect(numeric(undefined)).toBeNaN(); - }); - - it.each([ - ['a Date', when], - ['an epoch offset', when.getTime()], - ['a parseable string', when.toISOString()], - ['a wrapper', { valueOf: () => when.getTime() }], - ])('reads a date written as %s', (_form, value) => { - expect(temporal(value).getTime()).toBe(when.getTime()); - expect(format('{d, date, short}', { d: value })).toBe('1/2/2020'); - }); - - it('throws for a value that is not a date', () => { - expect(() => temporal('nope')).toThrow('Input is not a valid date'); - expect(() => temporal(null)).toThrow('Input is not a valid date'); - expect(() => format('{d, date}', { d: 'nope' })).toThrow('Input is not a valid date'); - }); -}); - -describe('formatted parts', () => { - it.each([ - ['{n, number, ::currency/EUR}', { n: 1234.5 }, 'number'], - ['{d, date, ::yyyyMMdd}', { d: when }, 'datetime'], - ])('reports %s as %s parts', (message, values, type) => { - const [part] = parts(message, values) as [ - { type: string; locale: string; dir?: string; parts: unknown[] }, - ]; - expect(part.type).toBe(type); - expect(part.locale).toBe('en-US'); - // The direction is a lazy getter off the resolved locale, and bidi - // isolation is what asks for it - expect(part.dir).toBe('ltr'); - expect(part.parts.length).toBeGreaterThan(0); - }); - - it('reports a duration as one part', () => { - expect(parts('{n, duration}', { n: 61 })).toContainEqual( - expect.objectContaining({ type: 'say:duration', value: '1:01' }), - ); - }); - - it('reports an unknown argument type as a string part', () => { - expect(parts('{n, spellout}', { n: 42 })).toContainEqual( - expect.objectContaining({ type: 'string', value: '42' }), - ); - }); - - it('reports a selector as a number part', () => { - expect(parts('{n, plural, other {#}}', { n: 3 })).toContainEqual( - expect.objectContaining({ type: 'number' }), - ); - }); -}); - -describe('selectors', () => { - // Two selects asking the same question of the same argument are one selector. - // Were they two, the variants would square rather than merge - it('merges repeated selects on the same argument', () => { - const message = - '{n, plural, one {{g, select, f {her} other {their}} item}' + - ' other {{n, plural, one {x} other {# items}}}}'; - expect(format(message, { n: 1, g: 'f' })).toBe('her item'); - expect(format(message, { n: 3, g: 'm' })).toBe('3 items'); - }); - - // Same argument, different offset, is a different question and so stays a - // separate selector - it('keeps selects with different offsets apart', () => { - const message = '{n, plural, offset:1 one {A#} other {{n, plural, one {B#} other {C#}}}}'; - expect(format(message, { n: 2 })).toBe('A1'); - expect(format(message, { n: 5 })).toBe('C5'); - }); - - it('orders exact numbers before categories and other last', () => { - const message = '{n, plural, other {many} one {a} =1 {exactly one} =0 {none}}'; - expect(format(message, { n: 0 })).toBe('none'); - expect(format(message, { n: 1 })).toBe('exactly one'); - expect(format(message, { n: 7 })).toBe('many'); - }); - - it('selects an ordinal category', () => { - const message = '{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}'; - expect(format(message, { n: 1 })).toBe('1st'); - expect(format(message, { n: 22 })).toBe('22nd'); - expect(format(message, { n: 11 })).toBe('11th'); - }); - - it('offsets a bigint selector', () => { - const message = '{n, plural, offset:1 one {# other} other {# others}}'; - expect(format(message, { n: 3n })).toBe('2 others'); - }); - - it('falls through to other when no category matches', () => { - expect(format('{g, select, f {her} other {their}}', { g: 'nope' })).toBe('their'); - }); - - // A `#` inside a nested `select` still counts the plural enclosing it - it('resolves a hash inside a select to the enclosing plural', () => { - const message = '{n, plural, other {{g, select, other {# of them}}}}'; - expect(format(message, { n: 4, g: 'x' })).toBe('4 of them'); - }); - - // Outside a plural there is nothing to substitute - it('writes a hash outside a plural as text', () => { - expect(format('a # b')).toBe('a # b'); - expect(format('{g, select, other {#}}', { g: 'x' })).toBe('#'); - }); - - // Two placeholders running together leave no text between them to merge into - it('keeps adjacent placeholders separate within a variant', () => { - expect(format('{n, plural, other {{a}{b}}}', { n: 1, a: 'x', b: 'y' })).toBe('xy'); - }); -}); - -describe('styles', () => { - it('rejects a style containing a placeholder', () => { - // Nothing can resolve `{x}` while the message is being compiled, and MF2 - // has no way to defer it - expect(() => compile('en-US', '{d, date, {x}}')).toThrow('Unsupported style part: argument'); - }); - - // `XXX` is ISO 4217's "no currency", which is what a pattern naming a - // currency it has no code for should format as. `Intl` gives it the two - // fraction digits a currency carries - it('reads a currency out of a literal pattern', () => { - // `Intl` separates a currency code from its amount with a non-breaking - // space, which is a detail of the locale rather than of the conversion - expect(format('{n, number, ¤¤#0}', { n: 12 }).replace(' ', ' ')).toBe('XXX 12.00'); - }); - - it('falls back for a pattern asking for something Intl cannot express', () => { - expect(format('{n, number, #E0}', { n: 1234.5 })).toBe('1,234.5'); - }); - - // Every token parsed, there were simply none of them. There is no error to - // report, so the skeleton is described rather than quoted back - it('falls back for an empty skeleton', () => { - expect(format('{d, date, ::}', { d: when })).toBe('Jan 2, 2020'); - }); - - // `qqqq` is valid ICU, a stand-alone quarter, that `Intl` cannot show. - // Keeping the fields that did resolve would render a date the message never - // asked for, so one unshowable field fails the whole skeleton - it('falls back rather than dropping a field it cannot show', () => { - expect(format('{d, date, ::yMMMdqqqq}', { d: when })).toBe('Jan 2, 2020'); - expect(format("{d, date, ::yMMMd'x'}", { d: when })).toBe('Jan 2, 2020'); - }); - - // The styles come from a plain object, so a key every object inherits must - // not pass for one an author asked for - it('rejects an inherited property name as a style', () => { - expect(format('{d, date, toString}', { d: when })).toBe('Jan 2, 2020'); - expect(format('{d, date, constructor}', { d: when })).toBe('Jan 2, 2020'); - }); -}); - -/** - * A value's `valueOf` is only read when it becomes the operand of another - * function, which no MF1 message can ask for, a duration or a string is always - * the end of the line. They are called here directly so the contract they - * publish is the one they keep. - */ -describe('message values', () => { - const ctx = { locales: ['en-US'], localeMatcher: 'best fit' } as never; - - it('reports a duration as the seconds it was given', () => { - expect(functions['say:duration'](ctx, {}, 61).valueOf()).toBe(61); - }); - - it('reports a number as the number it formatted', () => { - expect(functions['say:number'](ctx, {}, 1234.5).valueOf!()).toBe(1234.5); - // The scale is part of the value, not of the way it is written - expect(functions['say:number'](ctx, { options: { scale: 1000 } }, 1.5).valueOf!()).toBe(1500); - }); - - it('reports a date as a Date', () => { - expect(functions['say:datetime'](ctx, {}, when).valueOf!()).toStrictEqual(when); - }); - - // A plural reports the number the message was given, not the offset one it - // prints, which is what lets a second selector offset from the right place - it('reports a plural as the number before its offset', () => { - const value = functions['say:plural'](ctx, { options: { offset: 1 } }, 3); - expect(value.valueOf!()).toBe(3); - expect(value.toString!()).toBe('2'); - }); - - it('reports a string as itself', () => { - expect(functions['say:string'](ctx, {}, 'x').valueOf()).toBe('x'); - }); - - // A `select` on a value the caller never passed still has to choose a branch, - // and `other` is the branch that takes it - it('reads a missing string as empty', () => { - const value = functions['say:string'](ctx, {}, undefined); - expect(value.toString()).toBe(''); - expect(value.selectKey(new Set(['a']))).toBeNull(); - expect(value.selectKey(new Set(['']))).toBe(''); - }); -}); - -describe('duration', () => { - it.each([ - [Infinity, 'Infinity'], - [Number.NaN, 'NaN'], - [3600, '1:00:00'], - [359999, '99:59:59'], - ])('formats %o', (seconds, expected) => { - expect(duration(seconds)).toBe(expected); - }); - - // Seconds are rounded to the millisecond, and a value that rounds up to a - // whole minute has to carry rather than be written as `:60` - it.each([ - [59.9999, '1:00'], - [119.9999, '2:00'], - [3599.9999, '1:00:00'], - [59.5, '0:59.500'], - [59.4994, '0:59.499'], - ])('carries a rounded second in %o', (seconds, expected) => { - expect(duration(seconds)).toBe(expected); - }); -}); - -describe('options', () => { - // A placeholder with no style carries no bag, and the functions read one - // either way - it('reads an absent bag as empty', () => { - expect(options(undefined)).toEqual({}); - expect(options(null)).toEqual({}); - expect(options({ style: 'percent' })).toEqual({ style: 'percent' }); - }); -}); diff --git a/packages/integration/src/messageformat/index.ts b/packages/integration/src/messageformat/index.ts deleted file mode 100644 index 50c2e594..00000000 --- a/packages/integration/src/messageformat/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { parse } from '@messageformat/parser'; -import { MessageFormat } from 'messageformat'; -import { toMessage } from './convert.js'; -import { functions } from './functions.js'; - -/** - * ICU MessageFormat 1, compiled in-tree. - * - * `@messageformat/icu-messageformat-1` does this already, and this folder owes - * it the select-flattening in `convert.ts`. What it cannot do is a skeleton on - * a date: it renders a style into MF2's own option vocabulary, which has no - * room for one, so `{d, date, ::yyyyMMdd}` arrives as an unrecognised - * `mf1:argStyle` and renders as a fallback. - * - * Owning the conversion lets a style skip that vocabulary. A skeleton resolves - * to an `Intl` options bag while the message is compiled and is carried whole - * to format time, so `::yyyyMMdd` arrives by the same route as `short`. - * - * The pipeline reads in one direction: - * - * - `styles.ts`: an argument style becomes `Intl` options - * - `options.ts`: the seam those options travel through - * - `convert.ts`: the MF1 tree becomes an MF2 message - * - `values.ts` / `functions.ts`: what the formatter runs - */ - -/** - * Compile an ICU MessageFormat 1 message for a locale. - * - * @param locale Locale to format in - * @param source The message, in ICU MessageFormat 1 syntax - * @returns A formatter for the message - * @throws If the message is not valid ICU MessageFormat 1 - */ -export function compile(locale: string, source: string) { - return new MessageFormat(locale, toMessage(parse(source)), { functions }); -} diff --git a/packages/integration/src/messageformat/options.ts b/packages/integration/src/messageformat/options.ts deleted file mode 100644 index 8e443ae4..00000000 --- a/packages/integration/src/messageformat/options.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Model } from 'messageformat'; - -/** - * Carry an `Intl` options bag whole, from conversion to formatting. - * - * MF2 declares an option value as a literal string, and nothing in between - * inspects it: the resolver hands a literal to the function verbatim. So an - * already-parsed bag travels the same path unchanged, and a style is parsed - * once at compile rather than on every format. - * - * This is the one seam between the two halves of this folder: `styles.ts` fills - * a bag, `functions.ts` empties it, and nothing else looks inside. - */ -export const literal = (value: T) => ({ type: 'literal', value }) as unknown as Model.Literal; - -/** - * Read back an options bag placed by {@link literal}. - * - * A message reaching the formatter came through `compile`, so the bag is the - * one put there. The guard covers an absent value, which is how a placeholder - * with no style is written. - */ -export function options(value: unknown): T { - return typeof value === 'object' && value !== null ? (value as T) : ({} as T); -} - -/** The extra key a number's bag may carry, which `Intl` has no option for. */ -export type NumberOptions = Intl.NumberFormatOptions & { scale?: number }; - -/** How a `plural` or `selectordinal` selector was written. */ -export type PluralOptions = { offset?: number; ordinal?: boolean }; diff --git a/packages/integration/src/messageformat/values.ts b/packages/integration/src/messageformat/values.ts deleted file mode 100644 index 2c6e8811..00000000 --- a/packages/integration/src/messageformat/values.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { getLocaleDir, type MessageValue } from 'messageformat/functions'; - -/** - * A formatted part carries its direction only when it is known to be one of - * the two a reader can be isolated from. `auto` means undetermined, which the - * absence of the key says. - */ -function part(type: T, locale: string, parts: P[]) { - const dir = getLocaleDir(locale); - // The locale came back from `Intl`, so its direction is one of the two - /* v8 ignore next 3 */ - return dir === 'ltr' || dir === 'rtl' ? { type, dir, locale, parts } : { type, locale, parts }; -} - -/** - * The resolved values a placeholder formats to. - * - * Each is a thin wrapper over one `Intl` formatter, built lazily and kept: - * `formatToParts` and `format` are often both asked of the same value, and - * constructing the formatter is the expensive half. - */ - -// ===== Operands ===== // - -/** - * Read a numeric operand. - * - * A `bigint` is passed through rather than narrowed to `number`, since the - * point of one is the precision that conversion would lose. - * - * @throws If the value is not a number - */ -export function numeric(operand: unknown): number | bigint { - const value = typeof operand === 'object' && operand !== null ? operand.valueOf() : operand; - if (typeof value === 'bigint') return value; - const number = Number(value); - if (Number.isNaN(number) && value !== undefined) throw new Error('Input is not numeric'); - return number; -} - -/** - * Read a date operand, which may be written as a `Date`, an epoch offset, or - * anything `Date` can parse. - * - * @throws If the value is not a date - */ -export function temporal(operand: unknown): Date { - let value = operand; - if (typeof value === 'object' && value !== null && !(value instanceof Date)) { - value = value.valueOf(); - } - if (typeof value === 'number' || typeof value === 'string') value = new Date(value); - if (!(value instanceof Date) || Number.isNaN(value.getTime())) { - throw new Error('Input is not a valid date'); - } - return value; -} - -// ===== Values ===== // - -/** - * A number formatted by an `Intl.NumberFormat` options bag. - * - * Any scaling has already been applied to `value`, a scale is a multiplier on - * the number, not a way of writing it, so it belongs to the caller. - */ -export function number( - locales: string[], - value: number | bigint, - opt: Intl.NumberFormatOptions, -): MessageValue<'number'> { - let nf: Intl.NumberFormat | undefined; - let locale: string | undefined; - const format = () => (nf ??= new Intl.NumberFormat(locales, opt)); - const resolved = () => (locale ??= format().resolvedOptions().locale); - - return { - type: 'number', - get dir() { - return getLocaleDir(resolved()); - }, - get options() { - return { ...opt }; - }, - toParts: () => [part('number', resolved(), format().formatToParts(value))], - toString: () => format().format(value), - valueOf: () => value, - }; -} - -/** - * A date or time formatted by an `Intl.DateTimeFormat` options bag. - * - * `date` and `time` share this: by the time a style has resolved, the two - * differ only in which fields their bag names. - */ -export function datetime( - locales: string[], - value: Date, - opt: Intl.DateTimeFormatOptions, -): MessageValue<'datetime'> { - let dtf: Intl.DateTimeFormat | undefined; - let locale: string | undefined; - const format = () => (dtf ??= new Intl.DateTimeFormat(locales, opt)); - const resolved = () => (locale ??= format().resolvedOptions().locale); - - return { - type: 'datetime', - get dir() { - return getLocaleDir(resolved()); - }, - get options() { - return { ...opt }; - }, - toParts: () => [part('datetime', resolved(), format().formatToParts(value))], - toString: () => format().format(value), - valueOf: () => value, - }; -} - -/** - * `hhhh:mm:ss`, MF1's `duration` argument type. - * - * `Intl` has no equivalent, since `DurationFormat` writes "1 hr, 2 min" rather - * than a clock reading, so this is the one format written out by hand. - */ -export function duration(seconds: number): string { - if (!Number.isFinite(seconds)) return String(seconds); - - const sign = seconds < 0 ? '-' : ''; - - // Round to the millisecond *before* splitting the value up: a seconds field - // of 59.9999 rounds to `60`, which has to carry into the minutes - const total = Math.round(Math.abs(seconds) * 1000) / 1000; - - const secs = total % 60; - const minutes = Math.floor(total / 60); - const hours = Math.floor(minutes / 60); - - // A fractional second stays a string from here on, since `Number('1.500')` - // would undo the rounding - const written = Math.round(secs) === secs ? String(secs) : secs.toFixed(3); - - // One `:` is always written, so a sub-minute duration still reads as one - const parts: (string | number)[] = - hours > 0 ? [hours, minutes % 60, written] : [minutes, written]; - - // Every part but the first is padded to two digits: `1:01:01`, never - // `01:01:01`. Judged from the value, so `1.500` pads like the 1 it is - const first = parts.shift()!; - const pad = (n: string | number) => (Number(n) < 10 ? `0${n}` : String(n)); - return sign + [first, ...parts.map(pad)].join(':'); -} diff --git a/packages/integration/src/runtime.test.ts b/packages/integration/src/runtime.test.ts new file mode 100644 index 00000000..c552da9c --- /dev/null +++ b/packages/integration/src/runtime.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { datetime, duration, number, plural } from './runtime.js'; + +describe('number', () => { + it('formats for the locale it is given', () => { + expect(number('en-US', 1234.5)).toBe('1,234.5'); + expect(number('fr-FR', 1234.5)).toBe('1 234,5'); + }); + + it('takes an Intl options bag', () => { + expect(number('en-US', 0.25, { style: 'percent' })).toBe('25%'); + }); + + it('formats a bigint without narrowing it', () => { + expect(number('en-US', 9007199254740993n)).toBe('9,007,199,254,740,993'); + }); + + it('keeps one formatter per locale and options bag', () => { + // Constructing an `Intl` object is the expensive half, so the second call + // has to reach the same one + expect(number('en-US', 1)).toBe(number('en-US', 1)); + expect(number('en-US', 1, { style: 'percent' })).toBe('100%'); + expect(number('en-US', 1)).toBe('1'); + }); +}); + +describe('datetime', () => { + const when = new Date(2020, 0, 2, 12, 4, 5); + + it('formats a Date', () => { + expect(datetime('en-US', when, { year: 'numeric', month: 'long' })).toBe('January 2020'); + }); + + it('formats an epoch offset', () => { + expect(datetime('en-US', when.getTime(), { year: 'numeric' })).toBe('2020'); + }); +}); + +describe('plural', () => { + it('selects a cardinal category by default', () => { + expect(plural('en-US', 1)).toBe('one'); + expect(plural('en-US', 5)).toBe('other'); + }); + + it('selects an ordinal category when asked', () => { + expect(plural('en-US', 1, 'ordinal')).toBe('one'); + expect(plural('en-US', 2, 'ordinal')).toBe('two'); + expect(plural('en-US', 4, 'ordinal')).toBe('other'); + }); + + it('keeps the cardinal and ordinal rules apart', () => { + expect(plural('en-US', 2)).toBe('other'); + expect(plural('en-US', 2, 'ordinal')).toBe('two'); + }); +}); + +describe('duration', () => { + it.each([ + [0, '0:00'], + [5, '0:05'], + [61, '1:01'], + [3661, '1:01:01'], + [-61, '-1:01'], + [1.5, '0:01.500'], + ])('writes %d as a clock reading', (seconds, expected) => { + expect(duration(seconds)).toBe(expected); + }); + + it('writes a value that is not a number as itself', () => { + expect(duration(Number.POSITIVE_INFINITY)).toBe('Infinity'); + expect(duration(Number.NaN)).toBe('NaN'); + }); +}); diff --git a/packages/integration/src/runtime.ts b/packages/integration/src/runtime.ts new file mode 100644 index 00000000..82e78ee6 --- /dev/null +++ b/packages/integration/src/runtime.ts @@ -0,0 +1,85 @@ +/** + * What a compiled message calls. + * + * An `'f'` node names one of these, and every style it uses was resolved when + * it was compiled, so these are the last step and nothing else: hand an + * already-built `Intl` object a value. + * + * Nothing here parses anything, which is the point of the whole arrangement. + */ + +/** + * `Intl` objects, kept by locale and options. + * + * Constructing one is the expensive half of formatting, and a message formats + * the same way every time it is called, so the object outlives the call. + */ +const formatters = new Map(); + +function memoise(key: string, make: () => T) { + let formatter = formatters.get(key) as T | undefined; + if (formatter === undefined) formatters.set(key, (formatter = make())); + return formatter; +} + +/** Format a number the way `locale` writes one. */ +export function number(locale: string, value: number | bigint, options?: Intl.NumberFormatOptions) { + const key = `n${locale}${JSON.stringify(options ?? null)}`; + return memoise(key, () => new Intl.NumberFormat(locale, options)).format(value); +} + +/** Format a date or a time the way `locale` writes one. */ +export function datetime( + locale: string, + value: Date | number, + options?: Intl.DateTimeFormatOptions, +) { + const key = `d${locale}${JSON.stringify(options ?? null)}`; + return memoise(key, () => new Intl.DateTimeFormat(locale, options)).format(value); +} + +/** The CLDR plural category `value` falls in, for `locale`. */ +export function plural( + locale: string, + value: number | bigint, + type: Intl.PluralRuleType = 'cardinal', +) { + const key = `p${locale}${type}`; + // `Intl.PluralRules` takes a number, never a bigint + return memoise(key, () => new Intl.PluralRules(locale, { type })).select(Number(value)); +} + +/** + * `hhhh:mm:ss`, MF1's `duration` argument type. + * + * `Intl` has no equivalent, since `DurationFormat` writes "1 hr, 2 min" rather + * than a clock reading, so this is the one format written out by hand. + */ +export function duration(value: number) { + const seconds = Number(value); + if (!Number.isFinite(seconds)) return String(seconds); + + const sign = seconds < 0 ? '-' : ''; + + // Round to the millisecond *before* splitting the value up: a seconds field + // of 59.9999 rounds to `60`, which has to carry into the minutes + const total = Math.round(Math.abs(seconds) * 1000) / 1000; + + const secs = total % 60; + const minutes = Math.floor(total / 60); + const hours = Math.floor(minutes / 60); + + // A fractional second stays a string from here on, since `Number('1.500')` + // would undo the rounding + const written = Math.round(secs) === secs ? String(secs) : secs.toFixed(3); + + // One `:` is always written, so a sub-minute duration still reads as one + const parts: (string | number)[] = + hours > 0 ? [hours, minutes % 60, written] : [minutes, written]; + + // Every part but the first is padded to two digits: `1:01:01`, never + // `01:01:01`. Judged from the value, so `1.500` pads like the 1 it is + const first = parts.shift()!; + const pad = (n: string | number) => (Number(n) < 10 ? `0${n}` : String(n)); + return sign + [first, ...parts.map(pad)].join(':'); +} diff --git a/packages/integration/src/store.test.ts b/packages/integration/src/store.test.ts index 6ef49427..621fc64d 100644 --- a/packages/integration/src/store.test.ts +++ b/packages/integration/src/store.test.ts @@ -7,12 +7,15 @@ type Locale = 'en' | 'fr' | 'de'; const messages = { en: { greeting: 'Hello', - items: '{count, plural, one {# item} other {# items}}', - named: 'Hello, {name}', - identified: 'Order {id}', - underscored: 'Total {_total}', + items: ['?', ['=', ['v', '_count'], 1], '1 item', ['c', ['v', '_count'], ' items']], + named: ['c', 'Hello, ', ['v', '_name']], + identified: ['c', 'Order ', ['v', '_id']], + underscored: ['c', 'Total ', ['v', '__total']], + }, + fr: { + greeting: 'Bonjour', + items: ['?', ['=', ['v', '_count'], 1], '1 article', ['c', ['v', '_count'], ' articles']], }, - fr: { greeting: 'Bonjour', items: '{count, plural, one {# article} other {# articles}}' }, } satisfies Partial>; /** diff --git a/packages/integration/src/view.test.ts b/packages/integration/src/view.test.ts index fbe04368..2eadf322 100644 --- a/packages/integration/src/view.test.ts +++ b/packages/integration/src/view.test.ts @@ -4,25 +4,27 @@ import { createCatalogue, createView, type View } from './index.js'; type Locale = 'en' | 'fr'; +/** + * Messages in the shape the CLI compiles them into: one node per id, reading + * each value from behind the underscore the transform writes it with. + * + * What those nodes are compiled from is the CLI's business, and how they are + * evaluated is `compileMessage`'s, each covered by its own tests. + */ const messages = { en: { greeting: 'Hello', - items: '{count, plural, one {# item} other {# items}}', - named: 'Hello, {name}', - identified: 'Order {id}', - underscored: 'Total {_total}', + items: ['?', ['=', ['v', '_count'], 1], '1 item', ['c', ['v', '_count'], ' items']], + named: ['c', 'Hello, ', ['v', '_name']], + identified: ['c', 'Order ', ['v', '_id']], + underscored: ['c', 'Total ', ['v', '__total']], + }, + fr: { + greeting: 'Bonjour', + items: ['?', ['=', ['v', '_count'], 1], '1 article', ['c', ['v', '_count'], ' articles']], }, - fr: { greeting: 'Bonjour', items: '{count, plural, one {# article} other {# articles}}' }, } satisfies Record; -/** - * Drop the bidi isolation marks the formatter wraps substituted values in, so - * an assertion can read as the sentence a user sees. - */ -function plain(formatted: string) { - return formatted.replaceAll(/[⁨⁩]/g, ''); -} - function make() { return createCatalogue(messages); } @@ -46,66 +48,34 @@ describe('View#call', () => { it('formats a message for the locale the view is bound to', () => { expect(say.call({ id: 'greeting' })).toBe('Hello'); + expect(make().locale('fr').call({ id: 'greeting' })).toBe('Bonjour'); }); it('formats a message with placeholders', () => { - expect(say.call({ id: 'items', count: 1 })).toBe('1 item'); - expect(say.call({ id: 'items', count: 5 })).toBe('5 items'); + expect(say.call({ id: 'items', _count: 1 })).toBe('1 item'); + expect(say.call({ id: 'items', _count: 5 })).toBe('5 items'); }); - it('caches the compiled format across calls', () => { - const fr = make().locale('fr'); - expect(fr.call({ id: 'items', count: 1 })).toBe('1 article'); - // Second call hits the cached format - expect(fr.call({ id: 'items', count: 2 })).toBe('2 articles'); + it('names the locale when the message id is not found', () => { + expect(() => say.call({ id: 'missing' })).toThrow("No message for missing in locale 'en'"); }); it('formats a message carried in place of an id', () => { - expect(say.call({ message: '{n, number}', _n: 1234.5 })).toBe('1,234.5'); + expect(say.call({ message: ['f', 'number', ['v', '_n']], _n: 1234.5 })).toBe('1,234.5'); }); - it('throws when the message id is not found', () => { - expect(() => say.call({ id: 'missing' })).toThrow('Message for missing is not a string'); - }); - - it('strips the underscore the transform compiles values behind', () => { - expect(plain(say.call({ id: 'named', _name: 'Ada' }))).toBe('Hello, Ada'); - }); - - it('formats keys written without one, so a hand-written call still works', () => { - expect(plain(say.call({ id: 'named', name: 'Ada' }))).toBe('Hello, Ada'); + it('hands the descriptor over whole, values still behind their underscore', () => { + expect(say.call({ id: 'named', _name: 'Ada' })).toBe('Hello, Ada'); + expect(say.call({ id: 'underscored', __total: '9' })).toBe('Total 9'); }); it('formats a value named after the descriptor id', () => { - // The lookup still uses the id; the value only fills `{id}` in the message - expect(plain(say.call({ id: 'identified', _id: '42' }))).toBe('Order 42'); + // The lookup uses the id; `{id}` in the message is filled from `_id` + expect(say.call({ id: 'identified', _id: '42' })).toBe('Order 42'); }); it('does not expose the message id as a value', () => { - // `{id}` is left unresolved rather than filled with the message's own id - expect(plain(say.call({ id: 'identified' }))).not.toContain('identified'); - }); - - it('strips exactly one underscore, so a name that starts with one survives', () => { - expect(plain(say.call({ id: 'underscored', __total: '9' }))).toBe('Total 9'); - }); - - it('treats a value named `__proto__` as a value, not as a prototype', () => { - // Assigning the stripped key would write through to `Object.prototype` - // rather than naming a placeholder, so the values are built from own - // entries instead - const descriptor = { id: 'named', _name: 'Ada' }; - Object.defineProperty(descriptor, '___proto__', { - value: { polluted: true }, - enumerable: true, - }); - expect(plain(say.call(descriptor))).toBe('Hello, Ada'); - expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); - }); - - it('ignores keys a descriptor only inherits', () => { - const descriptor = Object.assign(Object.create({ _name: 'Ghost' }), { id: 'named' }); - expect(plain(say.call(descriptor))).not.toContain('Ghost'); + expect(say.call({ id: 'identified' })).not.toContain('identified'); }); }); @@ -115,16 +85,14 @@ describe('View immutability', () => { }); it('copies the messages it was given, so the caller cannot change them later', () => { - const source = { greeting: 'Hello' }; + const source: View.Messages = { greeting: 'Hello' }; const say = createView('en', source); source.greeting = 'Goodbye'; - expect(say.messages).toEqual({ greeting: 'Hello' }); expect(say.call({ id: 'greeting' })).toBe('Hello'); }); - it('freezes its messages, so a compiled format cannot go stale', () => { - const say = createView('en', { greeting: 'Hello' }); - expect(Object.isFrozen(say.messages)).toBe(true); + it('freezes its messages', () => { + expect(Object.isFrozen(createView('en', { greeting: 'Hello' }).messages)).toBe(true); }); }); @@ -156,140 +124,3 @@ describe('View macros', () => { ); }); }); - -// The macros exist to author these strings, so what matters is that the ICU -// they extract to is ICU the runtime formatter actually honours. Every style -// the parser accepts is exercised here, against the formatter we ship -describe('formatted arguments', () => { - // Built in local time, at midday, so the calendar date is the same one in - // every timezone the suite might run in - const when = new Date(2020, 0, 2, 12, 4, 5); - - function format(message: string, values: Record) { - return createCatalogue({ 'en-US': { m: message } }) - .locale('en-US') - .call({ id: 'm', ...values }); - } - - it.each([ - ['{n, number}', { n: 1234.5 }, '1,234.5'], - ['{n, number, integer}', { n: 1234.5 }, '1,235'], - ['{n, number, percent}', { n: 0.25 }, '25%'], - ['{n, number, #,##0.00}', { n: 1234.5 }, '1,234.50'], - ])('formats %s', (message, values, expected) => { - expect(format(message, values)).toBe(expected); - }); - - // Skeletons are why the MF1 conversion is ours rather than the upstream - // package's: it renders a style into MF2's option vocabulary, which has no - // word for a currency on a number or for any of these fields on a date - it.each([ - ['{n, number, ::.00}', { n: 1234.5 }, '1,234.50'], - ['{n, number, ::group-off}', { n: 1234.5 }, '1234.5'], - ['{n, number, ::compact-short}', { n: 12345 }, '12K'], - ['{n, number, ::scale/1000}', { n: 1.5 }, '1,500'], - // A skeleton's `percent` only writes the sign. The named MF1 style scales - // as well, which is `percent scale/100` spelled out - ['{n, number, ::percent}', { n: 25 }, '25%'], - ['{n, number, ::percent scale/100}', { n: 0.25 }, '25%'], - ])('formats the number skeleton %s', (message, values, expected) => { - expect(format(message, values)).toBe(expected); - }); - - // MF1 has nowhere to write a currency code, so `{n, number, currency}` cannot - // name one and falls back to a plain number. A skeleton carries the code - it('formats a currency, which only a skeleton can ask for', () => { - expect(format('{n, number, ::currency/EUR}', { n: 1234.5 })).toBe('€1,234.50'); - expect(format('{n, number, currency}', { n: 1234.5 })).toBe('1,234.5'); - }); - - it.each([ - ['{d, date, ::yyyyMMdd}', '01/02/2020'], - ['{d, date, ::yMMMM}', 'January 2020'], - ['{d, date, ::MMMd}', 'Jan 2'], - ['{d, date, ::EEEE}', 'Thursday'], - ['{d, time, ::Hm}', '12:04'], - ])('formats the date skeleton %s', (message, expected) => { - expect(format(message, { d: when })).toBe(expected); - }); - - // A style is authored once and read by everyone. A message that renders in a - // slightly wrong shape is recoverable; one that renders `{$d}` is not - it.each([ - ['{d, date, bogus}', 'Jan 2, 2020'], - ['{d, date, ::qqqq}', 'Jan 2, 2020'], - ])('falls back to the default format for %s', (message, expected) => { - expect(format(message, { d: when })).toBe(expected); - }); - - it('falls back to a plain number for an unreadable number style', () => { - expect(format('{n, number, ::bogus}', { n: 1234.5 })).toBe('1,234.5'); - }); - - // No macro authors a `duration`, so this only ever arrives from a catalogue - // written by hand. `Intl` has no clock-reading format, so we write it out - it.each([ - [0, '0:00'], - [5, '0:05'], - [61, '1:01'], - [3661, '1:01:01'], - [-61, '-1:01'], - [1.5, '0:01.500'], - ])('formats {n, duration} of %d', (n, expected) => { - expect(plain(format('{n, duration}', { n }))).toBe(expected); - }); - - // An argument type with no formatter still writes its value, rather than - // taking the rest of the message down with it - it('falls back to the plain value for an unknown argument type', () => { - expect(plain(format('{n, spellout}', { n: 42 }))).toBe('42'); - }); - - it.each([ - ['{d, date}', 'Jan 2, 2020'], - ['{d, date, short}', '1/2/2020'], - ['{d, date, medium}', 'Jan 2, 2020'], - ['{d, date, long}', 'January 2, 2020'], - ['{d, date, full}', 'Thursday, January 2, 2020'], - ])('formats %s', (message, expected) => { - expect(format(message, { d: when })).toBe(expected); - }); - - it.each(['{d, time}', '{d, time, short}', '{d, time, medium}', '{d, time, long}'])( - 'formats %s', - (message) => { - expect(format(message, { d: when })).toMatch(/\d{1,2}:\d{2}/); - }, - ); - - // ICU `select` has no exact-value syntax: `=0` there is a parse error, while - // a bare `0` matches the number and the string alike - it.each([ - [0, 'Free'], - ['0', 'Free'], - [1, 'Pro'], - ['enterprise', 'Custom'], - ])('selects the bare numeric case for %o', (tier, expected) => { - const message = '{tier, select, 0 {Free} 1 {Pro} other {Custom}}'; - expect(format(message, { tier })).toBe(expected); - }); - - it('applies a plural offset', () => { - const message = '{n, plural, offset:1 one {you and # other} other {you and # others}}'; - expect(format(message, { n: 3 })).toBe('you and 2 others'); - expect(format(message, { n: 2 })).toBe('you and 1 other'); - }); - - // ICU tests an exact value against the *original* number, before the offset - // is applied; the offset only reaches the CLDR category and `#`. So in the - // "you and N others" idiom, where the selector counts everyone including you, - // the branch meaning "nobody else" is `=1`, not `=0` - it('matches an exact branch before applying the offset', () => { - const correct = '{n, plural, offset:1 =1 {nobody else} other {you and # others}}'; - expect(format(correct, { n: 1 })).toBe('nobody else'); - expect(format(correct, { n: 3 })).toBe('you and 2 others'); - - const wrong = '{n, plural, offset:1 =0 {nobody else} other {you and # others}}'; - expect(format(wrong, { n: 1 })).toBe('you and 0 others'); - }); -}); diff --git a/packages/integration/src/view.ts b/packages/integration/src/view.ts index b6185634..1b7d7ef8 100644 --- a/packages/integration/src/view.ts +++ b/packages/integration/src/view.ts @@ -1,4 +1,4 @@ -import { compile } from './messageformat/index.js'; +import { compileMessage, type Message } from './message.js'; import type { DateTimeOptions, Disallow, @@ -8,36 +8,26 @@ import type { SelectOptions, } from './types.js'; -/** - * Map a descriptor's keys back to the placeholders the message names. The - * transform prefixes every value with one underscore, so stripping exactly one - * is the inverse: `_0` is `0`, `__total` is `_total`. Keys without one pass - * through, so a hand-written `call({ id, name })` still formats `{name}`. - * - * Built from own entries, so a value named `__proto__` stays a placeholder. - */ -function resolveDescriptorValues(descriptor: View.Descriptor) { - return Object.fromEntries( - Object.entries(descriptor) - // The id or the message names the message, it is not one of its values - .filter(([key]) => key !== 'id' && key !== 'message') - .map(([key, value]) => [key.startsWith('_') ? key.slice(1) : key, value]), - ); -} - function macro(name: string): never { throw new Error(`'say.${name}' is a macro and must be used with the relevant saykit plugin`); } export namespace View { - export type Messages = { [key: string]: string }; + /** + * One locale's messages, compiled. + * + * Each is a {@link Message} the CLI compiled from the catalogue, with every + * style already resolved. Being data rather than code, a locale's messages + * serialise, which is what lets a server hand them to a client tree. + */ + export type Messages = { [key: string]: Message }; /** * What the transform compiles a message down to: the id it was extracted - * under, or the ICU message itself when there was nothing to extract, with - * its values behind one underscore each. + * under, or the compiled message itself when there was nothing to extract, + * with its values behind one underscore each. */ - export type Descriptor = ({ id: string } | { message: string }) & { + export type Descriptor = ({ id: string } | { message: Message }) & { [match: string | number]: unknown; }; } @@ -93,8 +83,8 @@ export interface View { * Format the message a descriptor names, or the message it carries. * * A message with nothing to translate, a lone `say.date(x)` with no text - * around it, is never extracted: the transform writes the ICU into the call - * instead of an id. + * around it, is never extracted: the transform writes the compiled message + * into the call instead of an id. * * @param descriptor Descriptor to format * @returns The formatted message @@ -236,9 +226,7 @@ export interface View { * Create a view over one locale and the messages it formats against. * * A catalogue memoises one per locale, which is how application code usually - * reaches one; a single-locale app can build one directly. The format cache - * belongs to the view, so a view built over one set of messages can never be - * served a format compiled from another. + * reaches one; a single-locale app can build one directly. * * @param locale The locale to bind to * @param messages The messages this view formats against @@ -248,27 +236,35 @@ export function createView( locale: Locale, messages: View.Messages, ): View { - // Copied and frozen: formats are compiled once and kept, so a record that - // could change afterwards would leave `call` formatting from the old string - // while `view.messages` shows the new one + // Copied and frozen, so nothing can swap a message out from under the view + // that is already handing its result to callers const own: View.Messages = Object.freeze({ ...messages }); - const formats = new Map>(); - const say = (() => { throw new Error("'say' is a macro and must be used with the relevant saykit plugin"); }) as unknown as View; + // One compile per message, on the first call that needs it. A message nobody + // renders is never walked, and one rendered a thousand times is walked once + const compiled = new Map string>(); + function call(descriptor: View.Descriptor) { - // An inline message is its own cache key: two lone dates with the same - // style share one format - const { id, message = own[id] } = descriptor as { id: string; message?: string }; - if (typeof message !== 'string') throw new Error(`Message for ${id} is not a string`); + // An inline message is one helper over one value, and the transform writes + // a fresh literal into every call, so it is walked rather than cached + // The index signature keeps `in` from narrowing, so the shape is asserted + const { id, message } = descriptor as { id: string; message?: Message }; + if (message !== undefined) return compileMessage(message, locale)(descriptor); + + let format = compiled.get(id); + + if (!format) { + const message = own[id]; + if (message === undefined) throw new Error(`No message for ${id} in locale '${locale}'`); - let format = formats.get(message); - if (!format) formats.set(message, (format = compile(locale, message))); + compiled.set(id, (format = compileMessage(message, locale))); + } - return String(format.format(resolveDescriptorValues(descriptor))); + return format(descriptor); } return Object.freeze( diff --git a/packages/plugin-babel/package.json b/packages/plugin-babel/package.json index afb5dfa4..eff333ee 100644 --- a/packages/plugin-babel/package.json +++ b/packages/plugin-babel/package.json @@ -29,18 +29,6 @@ ".": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" - }, - "./metro": { - "types": "./dist/metro/index.d.cts", - "default": "./dist/metro/index.cjs" - }, - "./next": { - "types": "./dist/next/index.d.cts", - "default": "./dist/next/index.cjs" - }, - "./next/loader": { - "types": "./dist/next/loader.d.cts", - "default": "./dist/next/loader.cjs" } }, "publishConfig": { diff --git a/packages/plugin-babel/src/catalogue.ts b/packages/plugin-babel/src/catalogue.ts deleted file mode 100644 index 9abc7a38..00000000 --- a/packages/plugin-babel/src/catalogue.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; -import type { Bucket, Config } from '@saykit/config'; -import { - assembleCatalogueRecord, - resolveCatalogueSources, -} from '@saykit/config/features/catalogue'; - -/** Normalise an absolute path into the form bucket globs are written against. */ -const toId = (path: string) => relative(process.cwd(), path).replaceAll('\\', '/').split('?')[0]!; - -/** - * The glob a bucket's catalogues match, e.g. `src/locales/*.po`, the `output` - * template with its placeholders filled in. Bundlers that select files by glob - * rather than by predicate need this to target exactly the catalogues, and - * nothing else sharing their extension. - */ -export const catalogueGlob = (bucket: Bucket) => - String(bucket.output) - .replace('{locale}', '*') - .replace('{extension}', bucket.formatter.extension.slice(1)) - // Globs are matched against posix-separated paths on every platform - .replaceAll('\\', '/') - .replace(/^\.\//, ''); - -/** Whether `path` is one of the config's catalogue outputs. */ -export const isCatalogue = (config: Config, path: string) => - config.buckets.some((bucket) => bucket.output.match(toId(path))); - -/** - * Assemble the catalogue at `path` into a `{ id: string }` record, merging in - * the fallback chain (configured fallbacks + the source locale) so an - * untranslated key resolves to a fallback string rather than going missing. - * - * Returns `undefined` when the path is not a catalogue, and otherwise reports - * the files that fed the record alongside it, a bundler that can track them - * gets invalidation for free when a fallback locale is edited. - * - * Reads are synchronous so every caller can share one implementation: Babel's - * visitor cannot await, and the handful of files in a fallback chain are not - * worth an async path in a bundler that is blocked on the result anyway. - */ -export function loadCatalogue(config: Config, path: string) { - const id = toId(path); - const bucket = config.buckets.find((b) => b.output.match(id)); - if (!bucket) return; - - const { sources } = resolveCatalogueSources(config, bucket, id); - const contents = sources.map((source) => { - try { - return readFileSync(source, 'utf8'); - } catch { - return ''; - } - }); - - return { record: assembleCatalogueRecord(bucket, contents), sources }; -} diff --git a/packages/plugin-babel/src/index.test.ts b/packages/plugin-babel/src/index.test.ts index be7ab890..6e8d8662 100644 --- a/packages/plugin-babel/src/index.test.ts +++ b/packages/plugin-babel/src/index.test.ts @@ -1,8 +1,7 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { transformSync } from '@babel/core'; -import { generateHash } from '@saykit/config/features/messages'; import { afterAll, describe, expect, it, vi } from 'vitest'; const dir = mkdtempSync(join(tmpdir(), 'saykit-babel-')); @@ -28,15 +27,13 @@ const config = { vi.mock('@saykit/config/features/loader', () => ({ resolveConfig: () => config })); const { default: plugin } = await import('./index.js'); -const { loadCatalogue } = await import('./catalogue.js'); -const { withSayKit } = await import('./next/index.js'); afterAll(() => rmSync(dir, { recursive: true, force: true })); -const run = (code: string, filename: string, options: { catalogues?: 'inline' | 'module' } = {}) => +const run = (code: string, filename: string) => transformSync(code, { filename, - plugins: [[plugin, options]], + plugins: [plugin], babelrc: false, configFile: false, })!.code!; @@ -57,121 +54,3 @@ describe('plugin-babel parserOverride', () => { expect(out).toContain('MARK'); }); }); - -describe('plugin-babel catalogue imports', () => { - const source = `import m from './messages.json';\nexport default m;`; - - it('inlines the assembled record by default, so Babel alone is enough', () => { - writeFileSync(join(dir, 'messages.json'), JSON.stringify([{ message: 'Hello' }])); - const out = run(source, join(dir, 'app.ts')); - expect(out).not.toContain('./messages.json'); - expect(out).toContain('Hello'); - }); - - // With a bundler integration wired up (see `next/loader.ts` and - // `metro/transformer.ts`) the import has to survive, inlining it strips the - // dependency edge the bundler invalidates on, which is what broke hot reload - // in https://github.com/k0d13/saykit/issues/71 - it('leaves the import intact under `catalogues: "module"`', () => { - writeFileSync(join(dir, 'messages.json'), JSON.stringify([{ message: 'Hello' }])); - const out = run(source, join(dir, 'app.ts'), { catalogues: 'module' }); - expect(out).toContain('./messages.json'); - }); - - it('requires a default import when inlining', () => { - writeFileSync(join(dir, 'messages.json'), JSON.stringify([{ message: 'Hello' }])); - expect(() => run(`import { m } from './messages.json';`, join(dir, 'app.ts'))).toThrow( - 'require a single default import', - ); - }); - - // Inlining replaces the whole declaration with one binding, so a named - // specifier alongside the default has to be rejected rather than dropped - it('rejects a default import mixed with named specifiers', () => { - writeFileSync(join(dir, 'messages.json'), JSON.stringify([{ message: 'Hello' }])); - expect(() => run(`import m, { extra } from './messages.json';`, join(dir, 'app.ts'))).toThrow( - 'require a single default import', - ); - }); -}); - -describe('loadCatalogue', () => { - it('assembles a catalogue into a record', () => { - writeFileSync( - join(dir, 'en.json'), - JSON.stringify([ - { message: 'Hello', translation: 'Hello', id: 'greeting' }, - { message: 'Bye', translation: 'Bye', id: 'farewell' }, - ]), - ); - - const catalogue = loadCatalogue(config as never, join(dir, 'en.json')); - expect(catalogue?.record).toMatchObject({ greeting: 'Hello', farewell: 'Bye' }); - }); - - it('falls back to the source locale for keys untranslated in another locale', () => { - writeFileSync( - join(dir, 'fr.json'), - JSON.stringify([{ message: 'Hello', translation: 'Bonjour', id: 'greeting' }]), - ); - - const catalogue = loadCatalogue(config as never, join(dir, 'fr.json')); - expect(catalogue?.record).toMatchObject({ greeting: 'Bonjour', farewell: 'Bye' }); - // Every file in the chain is reported so callers can register it for - // invalidation, not just the locale's own file - expect(catalogue?.sources).toHaveLength(2); - }); - - it('hashes a key when the message carries no id', () => { - writeFileSync(join(dir, 'm2.json'), JSON.stringify([{ message: 'Bye' }])); - const catalogue = loadCatalogue(config as never, join(dir, 'm2.json')); - expect(catalogue?.record).toHaveProperty(generateHash('Bye', undefined)); - }); - - it('ignores a path that is not a catalogue', () => { - expect(loadCatalogue(config as never, join(dir, 'helper.ts'))).toBeUndefined(); - }); -}); - -describe('next withSayKit', () => { - it('derives a Turbopack rule from the bucket output', () => { - const out = withSayKit({}); - const [glob, rule] = Object.entries(out.turbopack.rules!)[0]!; - - // The glob is the bucket's own output template, so nothing else sharing the - // extension is routed through the loader - expect(glob.endsWith('/*.json')).toBe(true); - expect(rule).toMatchObject({ loaders: ['babel-plugin-saykit/next/loader'], as: '*.js' }); - }); - - it('adds a webpack rule that matches catalogues and nothing else', () => { - const webpackConfig = { module: { rules: [] as unknown[] } }; - withSayKit({}).webpack(webpackConfig, {}); - - const rule = webpackConfig.module.rules[0] as { - test: (path: string) => boolean; - type: string; - }; - expect(rule.test(join(dir, 'fr.json'))).toBe(true); - expect(rule.test(join(dir, 'helper.ts'))).toBe(false); - // The loader emits JavaScript, so a `.json` catalogue must not be handed to - // webpack's JSON parser - expect(rule.type).toBe('javascript/auto'); - }); - - it('preserves the caller’s own turbopack rules and webpack function', () => { - const out = withSayKit({ - turbopack: { rules: { '*.svg': { loaders: ['svg'] } } }, - webpack: (c) => { - (c.module ??= {}).rules = ['mine']; - return c; - }, - }); - - expect(out.turbopack.rules).toHaveProperty('*.svg'); - - const result = out.webpack({}, {}); - expect(result.module!.rules![0]).toBe('mine'); - expect(result.module!.rules).toHaveLength(2); - }); -}); diff --git a/packages/plugin-babel/src/index.ts b/packages/plugin-babel/src/index.ts index 87f48cc3..b7d45c8d 100644 --- a/packages/plugin-babel/src/index.ts +++ b/packages/plugin-babel/src/index.ts @@ -1,7 +1,6 @@ -import { dirname, relative, resolve } from 'node:path'; -import type { ConfigAPI, PluginObj, parse as Parse, types } from '@babel/core'; +import { relative } from 'node:path'; +import type { ConfigAPI, PluginObj, parse as Parse } from '@babel/core'; import { resolveConfig } from '@saykit/config/features/loader'; -import { loadCatalogue } from './catalogue.js'; declare module '@babel/core' { interface PluginObj { @@ -9,68 +8,20 @@ declare module '@babel/core' { } } -export interface Options { - /** - * How a catalogue import is resolved. - * - * `'inline'` (the default) replaces the import with the assembled record, so - * the Babel plugin is enough on its own. The cost is that the record lands in - * the importing module, which a bundler only re-reads when that module's own - * bytes change, editing a catalogue will not hot-reload. - * - * `'module'` leaves the import for a bundler integration to serve, either - * `babel-plugin-saykit/next` or `babel-plugin-saykit/metro`. Set this - * whenever one of those is wired up, or the import gets inlined before the - * integration is ever asked for the module. - */ - catalogues?: 'inline' | 'module'; -} - -export default ( - { types: t }: ConfigAPI & { types: typeof types }, - { catalogues = 'inline' }: Options = {}, -): PluginObj => { +/** + * Rewrite `say` call sites into the descriptors the runtime formats. + * + * Catalogues are not this plugin's business: the CLI compiles each locale into + * an ordinary `.js` module, so Next, Metro and everything else load it the way + * they load any other module. + */ +export default (_api: ConfigAPI): PluginObj => { const config = resolveConfig(); return { name: 'saykit', - visitor: - catalogues === 'module' - ? {} - : { - // TODO: This is fragile, it does not work with dynamic imports, document this - ImportDeclaration(path, state) { - const importee = path.node.source.value; - if (!importee.startsWith('.')) return; - const importer = state.filename ?? state.file.opts.filename; - if (!importer) return; - - const catalogue = loadCatalogue(config, resolve(dirname(importer), importee)); - if (!catalogue) return; - - // The whole declaration is replaced by one binding, so anything - // bound alongside the default would be dropped silently - const [specifier, ...rest] = path.node.specifiers; - if (specifier?.type !== 'ImportDefaultSpecifier' || rest.length > 0) - throw path.buildCodeFrameError( - 'SayKit inline imports require a single default import', - ); - - path.replaceWith( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(specifier.local.name), - t.objectExpression( - Object.entries(catalogue.record).map(([key, value]) => - t.objectProperty(t.stringLiteral(key), t.stringLiteral(value)), - ), - ), - ), - ]), - ); - }, - }, + visitor: {}, parserOverride(code, opts, parse) { const id_ = opts.sourceFileName; diff --git a/packages/plugin-babel/src/metro/index.ts b/packages/plugin-babel/src/metro/index.ts deleted file mode 100644 index 0038d64c..00000000 --- a/packages/plugin-babel/src/metro/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createRequire } from 'node:module'; -import { isAbsolute, join } from 'node:path'; -import { resolveConfig } from '@saykit/config/features/loader'; - -interface MetroConfig { - projectRoot?: string; - resolver?: { sourceExts?: string[] }; - transformer?: Record; - transformerPath?: string; -} - -/** - * Resolve Metro's own transformer from the project rather than from this - * package, since it is the project that depends on Metro. - * - * The base is the config's `projectRoot`, `process.cwd()` is wrong whenever - * Metro is started from elsewhere, which in a monorepo is the normal case. - */ -const transformerPath = join(__dirname, 'transformer.cjs'); - -function resolveUpstream(specifier: string, projectRoot: string) { - if (isAbsolute(specifier)) return specifier; - - try { - return createRequire(join(projectRoot, 'metro.config.js')).resolve(specifier); - } catch { - // A hoisted install can still satisfy it from here - return require.resolve(specifier); - } -} - -/** - * Wrap a Metro config so SayKit catalogues load as real modules. - * - * ```js - * const { withSayKit } = require('babel-plugin-saykit/metro'); - * module.exports = withSayKit(getDefaultConfig(__dirname)); - * ``` - * - * Metro's transform cache is keyed on each file's own bytes, so a catalogue has - * to stay a module of its own to be invalidated at all; see - * `./transformer.ts`. - * - * Apply this outermost. Anything wrapped around it that also sets - * `transformerPath` replaces this one, and catalogues stop being assembled. - */ -export function withSayKit(metroConfig: T): T { - // Wrapping our own transformer would make it its own upstream, and every - // transform would recurse until the worker died - if (metroConfig.transformerPath === transformerPath) return metroConfig; - - const config = resolveConfig(); - - // Metro resolves `.json` itself but knows nothing of catalogue formats like - // `.po`, and a file it cannot resolve is not a module it can invalidate - const extensions = config.buckets - .map((bucket) => bucket.formatter.extension.slice(1)) - .filter((extension) => extension !== 'json'); - - const sourceExts = [...(metroConfig.resolver?.sourceExts ?? [])]; - for (const extension of extensions) - if (!sourceExts.includes(extension)) sourceExts.push(extension); - - // Metro loads a worker standalone, so the wrapped transformer reaches its - // upstream through the transformer config rather than a closure - const upstream = metroConfig.transformerPath ?? 'metro-transform-worker'; - - return { - ...metroConfig, - resolver: { ...metroConfig.resolver, sourceExts }, - transformerPath, - transformer: { - ...metroConfig.transformer, - saykitTransformerPath: resolveUpstream(upstream, metroConfig.projectRoot ?? process.cwd()), - }, - }; -} diff --git a/packages/plugin-babel/src/metro/transformer.ts b/packages/plugin-babel/src/metro/transformer.ts deleted file mode 100644 index ae5bd014..00000000 --- a/packages/plugin-babel/src/metro/transformer.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { resolveConfig, resolveConfigFile } from '@saykit/config/features/loader'; -import { loadCatalogue } from '../catalogue.js'; - -/** - * The transformer config Metro hands each worker call. `saykitTransformerPath` - * is the upstream worker this one wraps, stashed here by {@link withSayKit} - * because a worker is loaded standalone and has no other way to reach it. - */ -interface TransformerConfig { - saykitTransformerPath: string; -} - -interface Worker { - transform( - config: TransformerConfig, - projectRoot: string, - filename: string, - data: Buffer, - options: unknown, - ): Promise; - getCacheKey(config: TransformerConfig, options?: unknown): string; -} - -const config = resolveConfig(); - -const upstream = (transformerConfig: TransformerConfig): Worker => - require(transformerConfig.saykitTransformerPath) as Worker; - -/** - * What a catalogue transforms into depends on the SayKit config, the fallback - * chain it resolves, the formatter that parses it, and on the version of this - * package doing the assembling. Neither is a byte of the file Metro hashes, so - * without them in the cache key, editing `saykit.config.*` or upgrading leaves - * every catalogue serving the record it was cached with. - */ -const salt = createHash('sha1') - .update(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')) - .update(readFileSync(resolveConfigFile(), 'utf8')) - .digest('hex'); - -/** - * Metro reads `.json` straight through `transformJSON` and never runs Babel over - * it, so a Babel plugin cannot reach a catalogue at all. Wrapping the transform - * worker is the one place that can: it substitutes the assembled record for the - * catalogue's own source, leaving the file a real module whose sha1, and - * therefore Metro's transform cache entry, moves whenever it is edited. - */ -export function transform( - transformerConfig: TransformerConfig, - projectRoot: string, - filename: string, - data: Buffer, - options: unknown, -) { - const worker = upstream(transformerConfig); - const catalogue = loadCatalogue(config, filename); - if (!catalogue) return worker.transform(transformerConfig, projectRoot, filename, data, options); - - // Metro reads a `.json` module as its body verbatim and everything else as - // JavaScript, so each needs the record in the shape it expects - const record = JSON.stringify(catalogue.record); - const code = filename.endsWith('.json') ? record : `module.exports = ${record};`; - - return worker.transform(transformerConfig, projectRoot, filename, Buffer.from(code), options); -} - -export function getCacheKey(transformerConfig: TransformerConfig, options?: unknown) { - return `${upstream(transformerConfig).getCacheKey(transformerConfig, options)}-saykit-${salt}`; -} diff --git a/packages/plugin-babel/src/next/index.ts b/packages/plugin-babel/src/next/index.ts deleted file mode 100644 index 80569c37..00000000 --- a/packages/plugin-babel/src/next/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { resolveConfig } from '@saykit/config/features/loader'; -import { catalogueGlob, isCatalogue } from '../catalogue.js'; - -/** - * The slice of Next's config this touches. Typed here rather than imported so - * the package does not depend on `next`. - */ -interface NextConfig { - turbopack?: { - rules?: Record; - [key: string]: unknown; - }; - webpack?: (config: WebpackConfig, context: unknown) => WebpackConfig; - [key: string]: unknown; -} - -interface WebpackConfig { - module?: { rules?: unknown[] }; - [key: string]: unknown; -} - -// Turbopack resolves loaders by module specifier, so this has to be a published -// export even though it is not a supported entry point on its own -const loader = 'babel-plugin-saykit/next/loader'; - -/** - * Wrap a Next config so SayKit catalogues load as real modules. - * - * ```js - * import { withSayKit } from 'babel-plugin-saykit/next'; - * export default withSayKit({}); - * ``` - * - * Pair it with `catalogues: 'module'` on the Babel plugin, which has to - * leave the import alone for the loader to ever be asked for the module. - * - * Rules are derived from the SayKit config, so both bundlers get one per bucket - * targeting exactly that bucket's `output`, nothing else sharing the extension - * goes through the loader, which matters most for a `.json` bucket, where the - * alternative would be routing every JSON import in the app through it. - */ -export function withSayKit( - nextConfig: T = {} as T, -): T & Required> { - const config = resolveConfig(); - - // Turbopack selects by glob and has no predicate form, so the bucket's output - // template is filled in and matched wherever it sits under the project root. - // `as: '*.js'` because the loader emits JavaScript for every extension - const rules = Object.fromEntries( - config.buckets.map((bucket) => [ - `**/${catalogueGlob(bucket)}`, - { loaders: [loader], as: '*.js' }, - ]), - ); - - return { - ...nextConfig, - - turbopack: { - ...nextConfig.turbopack, - rules: { ...nextConfig.turbopack?.rules, ...rules }, - }, - - // Turbopack is the default, but `next --webpack` needs the same wiring - webpack: (webpackConfig: WebpackConfig, context: unknown) => { - const result = nextConfig.webpack?.(webpackConfig, context) ?? webpackConfig; - - result.module ??= {}; - result.module.rules ??= []; - result.module.rules.push({ - // A predicate, so the rule covers exactly the catalogues however the - // buckets are laid out - test: (path: string) => isCatalogue(config, path), - use: loader, - // The loader emits JavaScript, which webpack would not assume for a - // `.json` catalogue, it would hand the output to its JSON parser - type: 'javascript/auto', - }); - - return result; - }, - }; -} diff --git a/packages/plugin-babel/src/next/loader.ts b/packages/plugin-babel/src/next/loader.ts deleted file mode 100644 index 2435c37b..00000000 --- a/packages/plugin-babel/src/next/loader.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { resolveConfig } from '@saykit/config/features/loader'; -import { loadCatalogue } from '../catalogue.js'; - -/** - * The slice of webpack's loader context this needs. Typing it here rather than - * depending on `webpack` keeps the package free of a bundler dependency, and - * Turbopack implements the same surface for the loaders it runs. - */ -interface LoaderContext { - resourcePath: string; - addDependency(file: string): void; -} - -const config = resolveConfig(); - -/** - * Replaces a catalogue file with its assembled record. - * - * This is a *loader* rather than a plugin because Turbopack runs loaders and - * not webpack plugins, and it is published only so `withSayKit` can name it in - * the rules it generates. A plain webpack, Vite or Rollup build should reach for - * `unplugin-saykit` instead, which does the same job through each bundler's own - * plugin API. - * - * The catalogue stays a real module, which is the whole point: the importer - * keeps a dependency edge to it, so editing a catalogue invalidates exactly the - * modules that read it. Inlining the record into the importer instead leaves the - * importer's own bytes unchanged, and no bundler can invalidate on that. - */ -export default function saykitLoader(this: LoaderContext, source: string) { - const catalogue = loadCatalogue(config, this.resourcePath); - if (!catalogue) return source; - - // Fallback files feed this module, so editing them must invalidate it too - for (const file of catalogue.sources) this.addDependency(file); - - // Always JavaScript, whatever the catalogue's extension, which is why the - // generated rules carry `type: 'javascript/auto'` and `as: '*.js'` - return `export default ${JSON.stringify(catalogue.record)}`; -} diff --git a/packages/plugin-babel/tsdown.config.ts b/packages/plugin-babel/tsdown.config.ts index b764c73e..05510f7c 100644 --- a/packages/plugin-babel/tsdown.config.ts +++ b/packages/plugin-babel/tsdown.config.ts @@ -1,13 +1,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: [ - 'src/index.ts', - 'src/metro/index.ts', - 'src/metro/transformer.ts', - 'src/next/index.ts', - 'src/next/loader.ts', - ], + entry: ['src/index.ts'], format: 'cjs', outputOptions: { comments: { jsdoc: false } }, }); diff --git a/packages/plugin-unplugin/src/index.test.ts b/packages/plugin-unplugin/src/index.test.ts index 1d7825d9..298c20b2 100644 --- a/packages/plugin-unplugin/src/index.test.ts +++ b/packages/plugin-unplugin/src/index.test.ts @@ -1,14 +1,12 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { generateHash } from '@saykit/config/features/messages'; +import { join } from 'node:path'; import { afterAll, describe, expect, it, vi } from 'vitest'; const dir = mkdtempSync(join(tmpdir(), 'saykit-unplugin-')); const config = { - locales: ['en', 'en-GB', 'en-NZ', 'fr'], - fallbackLocales: { 'en-NZ': ['en-GB'] }, + locales: ['en', 'fr'], buckets: [ { match: (id: string) => id.endsWith('.ts'), @@ -27,19 +25,6 @@ const config = { }[], }, }, - // A non-JSON output, to cover the ESM-wrapper branch of `load`. The - // formatter still parses JSON; only the extension matters here - { - match: (id: string) => id.endsWith('.ts'), - output: Object.assign(join(dir, '{locale}', 'messages.{extension}'), { - match: (id: string) => id.endsWith('messages.po'), - }), - transformer: { transform: (code: string) => code }, - formatter: { - extension: '.po', - parse: (content: string) => (content ? JSON.parse(content) : []), - }, - }, ], }; @@ -48,20 +33,10 @@ vi.mock('@saykit/config/features/loader', () => ({ resolveConfig: () => config } const { default: unplugin } = await import('./index.js'); const plugin = unplugin.raw(undefined, { framework: 'rollup' } as never) as { transform: { handler: (code: string, id: string) => string }; - load: { handler: (id: string) => Promise }; }; afterAll(() => rmSync(dir, { recursive: true, force: true })); -const write = (locale: string, content: unknown, extension = 'json') => { - const file = join(dir, locale, `messages.${extension}`); - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, typeof content === 'string' ? content : JSON.stringify(content)); - return file; -}; - -const record = (output: string | undefined) => JSON.parse(output!.replace('export default ', '')); - describe('unplugin transform', () => { it('transforms code for a matching bucket', () => { expect(plugin.transform.handler('say`Hi`', join(process.cwd(), 'src/app.ts'))).toBe('SAY`Hi`'); @@ -71,70 +46,3 @@ describe('unplugin transform', () => { expect(plugin.transform.handler('say`Hi`', join(process.cwd(), 'src/app.css'))).toBe('say`Hi`'); }); }); - -describe('unplugin load', () => { - it('loads the source locale into a record', async () => { - const file = write('en', [ - { message: 'Hello', translation: '', id: 'greeting' }, - { message: 'Bye', translation: '' }, - ]); - - const output = await plugin.load.handler(file); - // A `.json` id is left as bare JSON for the bundler's own JSON handling - expect(output).not.toContain('export default'); - const messages = record(output); - expect(messages.greeting).toBe('Hello'); - // No id and empty translation -> hashed key, source text as value - expect(messages[generateHash('Bye', undefined)]).toBe('Bye'); - }); - - it('falls back to the source string for keys untranslated in a non-source locale', async () => { - write('en', [ - { message: 'Hello', id: 'greeting' }, - { message: 'Bye', id: 'farewell' }, - ]); - const file = write('fr', [{ message: 'Hello', translation: 'Bonjour', id: 'greeting' }]); - - const messages = record(await plugin.load.handler(file)); - expect(messages.greeting).toBe('Bonjour'); // real translation wins - expect(messages.farewell).toBe('Bye'); // untranslated -> source fallback - }); - - it('resolves an empty non-source locale entirely to source strings', async () => { - write('en', [{ message: 'Hello', id: 'greeting' }]); - const file = write('fr', ''); - - const messages = record(await plugin.load.handler(file)); - expect(messages.greeting).toBe('Hello'); - }); - - it('resolves a configured fallback chain before the source locale', async () => { - write('en', [ - { message: 'A', id: 'a' }, - { message: 'B', id: 'b' }, - { message: 'C', id: 'c' }, - ]); - write('en-GB', [ - { message: 'B', translation: 'B-GB', id: 'b' }, - { message: 'C', translation: 'C-GB', id: 'c' }, - ]); - const file = write('en-NZ', [{ message: 'C', translation: 'C-NZ', id: 'c' }]); - - const messages = record(await plugin.load.handler(file)); - expect(messages.a).toBe('A'); // only in the source - expect(messages.b).toBe('B-GB'); // from the en-GB fallback - expect(messages.c).toBe('C-NZ'); // en-NZ wins over en-GB and the source - }); - - it('wraps a non-JSON catalogue in a default export', async () => { - const file = write('en', [{ message: 'Hello', id: 'greeting' }], 'po'); - - const output = await plugin.load.handler(file); - expect(output).toContain('export default'); - expect(record(output).greeting).toBe('Hello'); - }); - - it('returns undefined when the id does not match a bucket output', async () => { - expect(await plugin.load.handler(join(dir, 'other.txt'))).toBeUndefined(); - }); -}); diff --git a/packages/plugin-unplugin/src/index.ts b/packages/plugin-unplugin/src/index.ts index 8cbe0981..1120f4f1 100644 --- a/packages/plugin-unplugin/src/index.ts +++ b/packages/plugin-unplugin/src/index.ts @@ -1,12 +1,14 @@ -import { readFile } from 'node:fs/promises'; import { relative } from 'node:path'; -import { - assembleCatalogueRecord, - resolveCatalogueSources, -} from '@saykit/config/features/catalogue'; import { resolveConfig } from '@saykit/config/features/loader'; -import { createUnplugin, type UnpluginBuildContext } from 'unplugin'; - +import { createUnplugin } from 'unplugin'; + +/** + * Rewrite `say` call sites into the descriptors the runtime formats. + * + * Transforming source is the whole of the job. A locale is imported as the + * `.js` module the CLI compiled it into, which every bundler already knows how + * to load, watch and hot-update, so there is nothing to intercept. + */ export default createUnplugin((_options?: never) => { const config = resolveConfig(); @@ -22,34 +24,5 @@ export default createUnplugin((_options?: never) => { return bucket?.transformer.transform(code, id) ?? code; }, }, - - load: { - // TODO: Can bucket output be used in this filter? - filter: { id: { exclude: /node_modules/ } }, - handler: async function (this: UnpluginBuildContext, id_: string) { - const id = relative(process.cwd(), id_).replaceAll('\\', '/').split('?')[0]!; - const bucket = config.buckets.find((b) => b.output.match(id)); - if (!bucket) return; - - // The fallback chain (configured fallbacks + the source locale) is - // merged in here at load time so an untranslated key resolves to a - // fallback string while the runtime still loads a single locale module - const { sources } = resolveCatalogueSources(config, bucket, id); - const contents = await Promise.all( - sources.map((source) => readFile(source, 'utf8').catch(() => '')), - ); - - // Fallback files feed this module, so editing them should invalidate it - for (const source of sources) this.addWatchFile?.(source); - - const record = assembleCatalogueRecord(bucket, contents); - - // A `.json` id is interpreted as JSON by whatever runs next (Rollup's - // json plugin, webpack's `json` module type, esbuild's extension-picked - // loader), so the ESM wrapper would be a syntax error there - if (id.endsWith('.json')) return JSON.stringify(record); - return `export default ${JSON.stringify(record)}`; - }, - }, }; }); diff --git a/packages/transform-js/src/generator.ts b/packages/transform-js/src/generator.ts index 321dbdeb..2a1804e0 100644 --- a/packages/transform-js/src/generator.ts +++ b/packages/transform-js/src/generator.ts @@ -2,6 +2,7 @@ import * as t from '@babel/types'; import { ArgumentMessage, ChoiceMessage, + compileMessage, CompositeMessage, ElementMessage, type Message, @@ -21,11 +22,15 @@ export function isLoneArgument(message: CompositeMessage) { /** * The property a call looks its message up by: an id into the catalogue, or - * the ICU message itself when nothing was extracted. + * the message itself when nothing was extracted, compiled the way the CLI + * compiles a catalogue's, so the runtime formats both from the same data. */ export function generateDescriptorProperty(message: CompositeMessage) { if (isLoneArgument(message)) - return t.objectProperty(t.identifier('message'), t.stringLiteral(message.toICUString())); + return t.objectProperty( + t.identifier('message'), + t.valueToNode(compileMessage(message.toICUString())), + ); const id = message.descriptor.id ?? message.toHashString(); return t.objectProperty(t.identifier('id'), t.stringLiteral(id)); } diff --git a/packages/transform-js/src/index.test.ts b/packages/transform-js/src/index.test.ts index 8a766ade..f1dc3043 100644 --- a/packages/transform-js/src/index.test.ts +++ b/packages/transform-js/src/index.test.ts @@ -220,7 +220,7 @@ describe('createJsTransformer.transform', () => { const code = 'const d = say.date(at, { style: "::yMMM" });'; expect(transformer.extract(code, 'file.ts')).toEqual([]); const output = transformer.transform(code, 'file.ts'); - expect(output).toContain('message: "{at, date, ::yMMM}"'); + expect(output).toContain('message: ["f", "datetime", ["v", "_at"]'); expect(output).toContain('_at: at'); expect(output).not.toMatch(/\bid:/); }); diff --git a/packages/transform-jsx/src/generator.ts b/packages/transform-jsx/src/generator.ts index 452b7bd1..049901e5 100644 --- a/packages/transform-jsx/src/generator.ts +++ b/packages/transform-jsx/src/generator.ts @@ -11,13 +11,15 @@ import { generateDescriptorProperty } from '@saykit/transform-js/generator'; export function generateSayJSXElement(message: CompositeMessage) { const children = generateChildExpressions(message.children); - // The same lookup a call makes, written as a prop: an id, or the message - // itself when there was nothing to extract + // The same lookup a call makes, written as a prop: an id, or the compiled + // message itself when there was nothing to extract const descriptor = generateDescriptorProperty(message); const attributes = [ t.jsxAttribute( t.jsxIdentifier((descriptor.key as t.Identifier).name), - descriptor.value as t.StringLiteral, + t.isStringLiteral(descriptor.value) + ? descriptor.value + : t.jsxExpressionContainer(descriptor.value as t.Expression), ), ...(message.whitespace === undefined ? [] diff --git a/packages/transform-jsx/src/index.test.ts b/packages/transform-jsx/src/index.test.ts index 34bad23f..4c396ff3 100644 --- a/packages/transform-jsx/src/index.test.ts +++ b/packages/transform-jsx/src/index.test.ts @@ -274,7 +274,7 @@ describe('createJsxTransformer.transform', () => { const code = 'const x = ;'; expect(transformer.extract(code, 'file.tsx')).toEqual([]); const output = transformer.transform(code, 'file.tsx'); - expect(output).toContain('message="{scheduled_at, date, ::d}"'); + expect(output).toContain('message={["f", "datetime", ["v", "_scheduled_at"]'); expect(output).toContain('_scheduled_at={at}'); expect(output).not.toContain('id='); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dde4a90..9776f307 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -413,6 +413,9 @@ importers: '@messageformat/number-skeleton': specifier: 2.0.0-0 version: 2.0.0-0 + '@messageformat/parser': + specifier: ^5.1.1 + version: 5.1.1 commander: specifier: ^15.0.0 version: 15.0.0 @@ -429,6 +432,9 @@ importers: '@types/picomatch': specifier: ^4.0.3 version: 4.0.3 + saykit: + specifier: workspace:* + version: link:../integration packages/format-json: devDependencies: @@ -446,20 +452,7 @@ importers: specifier: workspace:^ version: link:../config - packages/integration: - dependencies: - '@messageformat/date-skeleton': - specifier: 2.0.0-0 - version: 2.0.0-0 - '@messageformat/number-skeleton': - specifier: 2.0.0-0 - version: 2.0.0-0 - '@messageformat/parser': - specifier: ^5.1.1 - version: 5.1.1 - messageformat: - specifier: ^4.0.0 - version: 4.0.0 + packages/integration: {} packages/integration-carbon: devDependencies: @@ -4439,10 +4432,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuku-codegen/binding-darwin-arm64@0.8.0': resolution: {integrity: sha512-7cSJH6PaKLRBdCfiB4pM6EukvgOk5xV4tyuLOIOEqrHsbnV7brtyff7CjhZbeGozdIHoOnKOi5R7rrmCWN3QSw==} @@ -6436,10 +6431,6 @@ packages: mermaid@11.16.0: resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} - messageformat@4.0.0: - resolution: {integrity: sha512-XKmJ/ffTWToWOlHJzt85ZChQgVGC0LHzNWuNK8zuYpNySsB0nIEmytOdSAOW9ETKtkajAUJf520m5gFHHnrTYg==} - engines: {node: ^20.19 || ^22.12 || >=24} - metro-babel-transformer@0.84.4: resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -14184,8 +14175,6 @@ snapshots: ts-dedent: 2.3.0 uuid: 14.0.1 - messageformat@4.0.0: {} - metro-babel-transformer@0.84.4(supports-color@10.2.2): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) diff --git a/website/content/core-concepts/architecture.mdx b/website/content/core-concepts/architecture.mdx index 6ca24b9b..0e30401a 100644 --- a/website/content/core-concepts/architecture.mdx +++ b/website/content/core-concepts/architecture.mdx @@ -161,10 +161,12 @@ You can override this with a [descriptor](/core-concepts/messages#descriptors) i Aside from the transformer-emitted `say.call(...)` invocations, only two things end up in your bundle: -- the `saykit` runtime (small, dependency-light apart from ICU MessageFormat) +- the `saykit` runtime, which has no dependencies - whichever integration you're using (`@saykit/react`, `@saykit/carbon`, …) -Translation catalogues are emitted as plain JS modules, tree-shakable, code-split-able, dynamically importable. Locales you never load never end up in your bundle. +Catalogues are compiled to plain JS modules, one entry per message, with every number and date format already resolved to the options bag it stands for. So no message parser reaches your bundle, and the only formatting code that ships is four small wrappers over `Intl`. A message is data rather than a function, which is what lets a server hand a locale to a client tree as JSON. + +Those modules are ordinary modules: tree-shakable, code-split-able, dynamically importable. Locales you never load never end up in your bundle. ## Where to next? diff --git a/website/content/core-concepts/extraction.mdx b/website/content/core-concepts/extraction.mdx index 6c9b8afe..efc2420b 100644 --- a/website/content/core-concepts/extraction.mdx +++ b/website/content/core-concepts/extraction.mdx @@ -23,17 +23,17 @@ The end result is a set of files like: ``` src/locales/ - en.po # source locale, freshly generated from your source - en.d.po.ts # auto-generated TS declaration for *.po imports - fr.po # other locales, left untouched (or created empty if missing) - fr.d.po.ts + en.po # source locale, freshly generated from your source + en.js # the compiled module your app imports + en.d.ts # its declaration + fr.po # other locales, left untouched (or created empty if missing) + fr.js + fr.d.ts ``` - The `.d.po.ts` files are emitted next to each catalogue so that `import en from './locales/en.po'` - type-checks even without a build plugin running. The `.d.{extension}.ts` name is the one - TypeScript actually looks for, and non-JSON extensions need `allowArbitraryExtensions`; see [typed - messages](/guides/typed-messages). + The `.po` is what a translator edits; the `.js` beside it is what your app imports, compiled by + [`saykit compile`](/reference/cli#saykit-compile), which extraction runs as its last step. ## Source-only writes @@ -46,9 +46,9 @@ Extraction writes **only** the source locale (the first entry in `locales`). It This keeps diffs small (changing one string touches one file) and treats translated content as owned by your translation management system, not by extraction. You can run `saykit extract` as often as you like; your translators' work is never touched. -### Fallback at load time +### Fallback at compile time -Because non-source files no longer carry the source strings, an untranslated (or missing) key is resolved through a **fallback chain** when the build plugin loads a catalogue, not at runtime. By default the chain ends at the source locale, so an untranslated key renders the source string. You can insert intermediate locales with [`fallbackLocales`](/core-concepts/configuration#fallback-locales): +Because non-source files no longer carry the source strings, an untranslated (or missing) key is resolved through a **fallback chain** when the catalogue is compiled, not at runtime. By default the chain ends at the source locale, so an untranslated key renders the source string. You can insert intermediate locales with [`fallbackLocales`](/core-concepts/configuration#fallback-locales): ```ts title="saykit.config.ts" fallbackLocales: { @@ -57,7 +57,7 @@ fallbackLocales: { }, ``` -The chain is baked into the emitted JS module at build time, so the runtime still loads a single locale. See [Vite](/integrations/vite) / [Babel](/integrations/babel) for how loading works. +The chain is resolved into the compiled module, so your app still imports a single module per locale. ### Pruning other locales yourself @@ -134,12 +134,13 @@ This fails the build if anyone forgot to run extraction, useful for keeping tran For each bucket output: -| File | Purpose | -| ------------------ | ------------------------------------------------------------------ | -| `{locale}.po` | The catalogue, in whatever format the bucket's formatter produces | -| `{locale}.d.po.ts` | Generic TS declaration so `import en from './locales/en.po'` types | +| File | Purpose | +| --------------- | ----------------------------------------------------------------- | +| `{locale}.po` | The catalogue, in whatever format the bucket's formatter produces | +| `{locale}.js` | The compiled module your app imports, one entry per message | +| `{locale}.d.ts` | Its declaration, so `import en from './locales/en.js'` types | -SayKit doesn't write a `.gitignore`: it's up to you which generated files to commit or ignore. The `.po` catalogues are canonical and should be committed; the `.d.po.ts` declarations are regenerated on every extraction, so you can either commit them (handy for CI type-checking) or ignore them via your project's `.gitignore`. +SayKit doesn't write a `.gitignore`: it's up to you which generated files to commit or ignore. The `.po` catalogues are canonical and should be committed. The `.js` is build output, so most projects ignore it and run `saykit compile` before anything that reads it. The `.d.ts` is two lines and never changes, so committing that keeps `tsc` passing on a fresh clone before anything has been generated. ## Next diff --git a/website/content/core-concepts/messages.mdx b/website/content/core-concepts/messages.mdx index 5100bca5..38744e27 100644 --- a/website/content/core-concepts/messages.mdx +++ b/website/content/core-concepts/messages.mdx @@ -305,7 +305,7 @@ say.time(opensAt, { style: 'short' }); // → "19:30" Written on its own, with no text around it, a fragment is still formatted for the locale but is not extracted: it has nothing a translator could change, so it never reaches the catalogue. The -transform writes the ICU into the call instead of an id. +transform writes the compiled message into the call instead of an id. `style` is optional, and omitting it still gives locale-aware output, `{n, number}` applies the right grouping separators and decimal mark. An unrecognised style fails the build. diff --git a/website/content/core-concepts/runtime.mdx b/website/content/core-concepts/runtime.mdx index 371344da..c0d9855e 100644 --- a/website/content/core-concepts/runtime.mdx +++ b/website/content/core-concepts/runtime.mdx @@ -21,8 +21,8 @@ You usually create one catalogue per app. Framework integrations wrap it, `SayPr ```ts import { createCatalogue } from 'saykit'; -import en from './locales/en.po'; -import fr from './locales/fr.po'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; const catalogue = createCatalogue({ en, fr }); ``` @@ -36,8 +36,8 @@ The keys are your locales, so there is no separate list to keep in step. Each en ```ts const catalogue = createCatalogue({ en, - fr: () => import('./locales/fr.po'), - ja: () => import('./locales/ja.po'), + fr: () => import('./locales/fr.js'), + ja: () => import('./locales/ja.js'), }); ``` @@ -228,8 +228,8 @@ A typical client-side bootstrap: ```ts title="src/i18n.ts" import { createCatalogue, createStore } from 'saykit'; -import en from './locales/en.po'; -import fr from './locales/fr.po'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; export const catalogue = createCatalogue({ en, fr }); @@ -244,8 +244,8 @@ A typical per-request server-side bootstrap: import { createCatalogue } from 'saykit'; const catalogue = createCatalogue({ - en: () => import('./locales/en.po'), - fr: () => import('./locales/fr.po'), + en: () => import('./locales/en.js'), + fr: () => import('./locales/fr.js'), }); export function getSayFor(headers: Headers) { diff --git a/website/content/getting-started/introduction.mdx b/website/content/getting-started/introduction.mdx index 1eecfdc6..7c9251e7 100644 --- a/website/content/getting-started/introduction.mdx +++ b/website/content/getting-started/introduction.mdx @@ -57,7 +57,7 @@ SayKit wires up like this: 1. You **author** messages with `` say`...` `` or `...` in source files. 2. The `saykit` CLI **extracts** them into per-locale files (`src/locales/en.po`, `fr.po`, …). 3. Translators (or you) fill in the translations in those files. -4. At build time a SayKit plugin **transforms** your source, macros become small runtime calls, and `import en from './locales/en.po'` becomes a plain JS object. +4. At build time a SayKit plugin **transforms** your source, macros become small runtime calls, and `import en from './locales/en.js'` becomes a plain JS object. 5. At runtime, `catalogue.locale(...)` returns the locale-bound `View` that **formats** messages on demand. There's no SaaS, no proxy, no extra service. Translation files live in your repo, next to the code they describe. diff --git a/website/content/getting-started/quickstart.mdx b/website/content/getting-started/quickstart.mdx index 575cd907..0074c399 100644 --- a/website/content/getting-started/quickstart.mdx +++ b/website/content/getting-started/quickstart.mdx @@ -121,10 +121,12 @@ SayKit walks your source, finds every macro, and writes the catalogue files: ``` src/locales/ - en.po # source locale, generated from your source - en.d.po.ts # auto-generated TS declaration - fr.po # other locales, created empty (header only) - fr.d.po.ts + en.po # source locale, generated from your source + en.js # the compiled module your app imports + en.d.ts # its declaration + fr.po # other locales, created empty (header only) + fr.js + fr.d.ts ``` Extraction only writes the **source** locale (`en`). Other locales are created empty the first time and then left untouched, translated content is owned by your translation management system, and untranslated keys fall back to the source string automatically. See [extraction](/core-concepts/extraction) for the full picture. @@ -174,8 +176,8 @@ Create one catalogue for your app: ```tsx title="src/i18n.ts" import { createCatalogue } from 'saykit'; -import en from './locales/en.po'; -import fr from './locales/fr.po'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; export const catalogue = createCatalogue({ en, fr }); ``` diff --git a/website/content/guides/custom-formatter.mdx b/website/content/guides/custom-formatter.mdx index d43b81b4..2f3feb8b 100644 --- a/website/content/guides/custom-formatter.mdx +++ b/website/content/guides/custom-formatter.mdx @@ -144,9 +144,11 @@ Resulting files: ```text src/locales/ en.json - en.d.json.ts + en.js + en.d.ts fr.json - fr.d.json.ts + fr.js + fr.d.ts ``` ## Preserving existing content diff --git a/website/content/guides/dynamic-loading.mdx b/website/content/guides/dynamic-loading.mdx index 431c3414..61006054 100644 --- a/website/content/guides/dynamic-loading.mdx +++ b/website/content/guides/dynamic-loading.mdx @@ -38,8 +38,8 @@ The simplest setup: ```ts title="src/i18n.ts" import { createCatalogue } from 'saykit'; -import en from './locales/en.po'; -import fr from './locales/fr.po'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; export const catalogue = createCatalogue({ en, fr }); ``` @@ -52,10 +52,10 @@ Both catalogues are part of the bundle. `catalogue.locale('fr')` is synchronous, import { createCatalogue } from 'saykit'; export const catalogue = createCatalogue({ - en: () => import('./locales/en.po'), - fr: () => import('./locales/fr.po'), - ja: () => import('./locales/ja.po'), - de: () => import('./locales/de.po'), + en: () => import('./locales/en.js'), + fr: () => import('./locales/fr.js'), + ja: () => import('./locales/ja.js'), + de: () => import('./locales/de.js'), }); ``` @@ -68,7 +68,7 @@ const say = await catalogue.load('fr'); Writing one thunk per locale rather than one function keyed by locale is deliberate: every import is a literal a bundler can see, so the set of shipped locales is decided at build time, and there is no locale the catalogue lists but cannot produce. - `babel-plugin-saykit` currently rewrites **static** `import en from './locales/en.po'` only. For + `babel-plugin-saykit` currently rewrites **static** `import en from './locales/en.js'` only. For dynamic imports, the bundler does the work, the formatter still parses the file, but through the bundler's loader pipeline rather than SayKit's. This works fine for `unplugin-saykit` (Vite, Rolldown, Rollup, Webpack, …). @@ -80,13 +80,13 @@ Inline the source locale, lazy-load everything else: ```ts title="src/i18n.ts" import { createCatalogue } from 'saykit'; -import en from './locales/en.po'; +import en from './locales/en.js'; export const catalogue = createCatalogue({ en, - fr: () => import('./locales/fr.po'), - ja: () => import('./locales/ja.po'), - de: () => import('./locales/de.po'), + fr: () => import('./locales/fr.js'), + ja: () => import('./locales/ja.js'), + de: () => import('./locales/de.js'), }); ``` @@ -122,10 +122,10 @@ On the server, you usually load only the locale this request needs: import { createCatalogue } from 'saykit'; export const catalogue = createCatalogue({ - en: () => import('./locales/en.po'), - fr: () => import('./locales/fr.po'), - ja: () => import('./locales/ja.po'), - de: () => import('./locales/de.po'), + en: () => import('./locales/en.js'), + fr: () => import('./locales/fr.js'), + ja: () => import('./locales/ja.js'), + de: () => import('./locales/de.js'), }); export function getSayFor(locale: string) { diff --git a/website/content/guides/typed-messages.mdx b/website/content/guides/typed-messages.mdx index 86021e23..4210693d 100644 --- a/website/content/guides/typed-messages.mdx +++ b/website/content/guides/typed-messages.mdx @@ -3,49 +3,35 @@ title: Typed messages description: How translation file imports are typed, and how to push typing further --- -SayKit emits a declaration file alongside every translation file so that importing it from your source compiles without complaint: +SayKit writes a declaration beside every compiled locale module, so importing one from your source compiles without complaint: ```text src/locales/ en.po - en.d.po.ts + en.js + en.d.ts fr.po - fr.d.po.ts + fr.js + fr.d.ts ``` ```ts -import en from './locales/en.po'; -// en is typed as: Record +import en from './locales/en.js'; +// en is typed as: Record string> ``` -The auto-generated declaration is intentionally simple: +The declaration is intentionally simple: -```ts title="src/locales/en.d.po.ts" -declare const translations: Record; -export default translations; +```ts title="src/locales/en.d.ts" +declare const messages: Record string>; +export default messages; ``` -That's enough to make the import type-check, even before your build plugin has run, and it's the only declaration shape your tooling needs to handle. - -## The `.d.{extension}.ts` name - -The name isn't arbitrary. To type an import of `./locales/en.po`, TypeScript strips the extension it's given and looks for `en.d.po.ts`, the sibling-declaration convention for non-TypeScript files. A file named `en.po.d.ts` is _not_ consulted for a specifier TypeScript recognises, so it would silently do nothing. - -For any extension other than `.json`, this also requires a compiler flag: - -```json title="tsconfig.json" -{ - "compilerOptions": { - "allowArbitraryExtensions": true - } -} -``` - -Without it, `import en from './locales/en.po'` fails to resolve no matter what the declaration is called. JSON catalogues need `resolveJsonModule` instead, which `moduleResolution: "bundler"` already implies. +That's enough to make the import type-check before anything has been compiled, and it's the only declaration shape your tooling needs to handle. Since the module is a `.js` file, no compiler flag is needed for it: `allowArbitraryExtensions` and `resolveJsonModule` are not involved. ## Why generic? -You'll notice the declaration is `Record` rather than something narrow like `{ "abc123": string; "def456": string }`. That's deliberate: +You'll notice the declaration is a plain `Record` rather than something narrow like `{ "abc123": (values: { name: string }) => string }`. That's deliberate: - Identifiers are content hashes by default. They're stable, but not meant to be consumed by hand. - The macros (`` say`...` ``, ``) are what your application code writes. The transform inserts the right id at build time, so your code never needs to know about hashes. @@ -58,29 +44,30 @@ In short: **you never reference an id from application code**. The build does th SayKit doesn't manage a `.gitignore` for you: it's up to you which generated files to commit or ignore. A common setup: - **Commit** the `.po` files. They're the canonical translations. -- Either commit or ignore the `.d.po.ts` files. SayKit regenerates them on every extraction, so ignoring them keeps diffs quiet; committing them lets CI type-check without an extra extraction step. +- **Ignore** the `.js` modules. They're build output, regenerated from the `.po` by `saykit compile`. +- **Commit** the `.d.ts` files. Each is two lines and never changes, so they add no diff noise, and they keep `tsc` passing on a fresh clone before anything has been compiled. -If you want to ignore the declarations, add a line like `*.d.po.ts` to your project's `.gitignore`. +A `.gitignore` line like `src/locales/*.js` covers the middle one. ## Importing in different bundlers -The actual JS module is produced by the SayKit build-tool plugin (or the formatter, at extraction time, indirectly). All of these work: +The module is produced by [`saykit compile`](/reference/cli#saykit-compile) and is an ordinary ES module, so every bundler handles it with no SayKit involvement. All of these work: ```ts // Bundled at build time, synchronous, in the initial chunk -import en from './locales/en.po'; +import en from './locales/en.js'; ``` ```ts // Code-split, dynamic import per locale -const en = await import('./locales/en.po').then((m) => m.default); +const en = await import('./locales/en.js').then((m) => m.default); ``` ```ts // A thunk per locale (recommended for many locales) createCatalogue({ - en: () => import('./locales/en.po'), - fr: () => import('./locales/fr.po'), + en: () => import('./locales/en.js'), + fr: () => import('./locales/fr.js'), }); ``` @@ -103,4 +90,4 @@ The runtime `say.call({ id: 'greeting.hello' })` works exactly the same way as f ## Next - [Extraction](/core-concepts/extraction): how files are generated -- [Custom formatter](/guides/custom-formatter): if you want different generated declarations +- [CLI](/reference/cli#saykit-compile): what compilation writes diff --git a/website/content/integrations/babel.mdx b/website/content/integrations/babel.mdx index ab920624..ea404f93 100644 --- a/website/content/integrations/babel.mdx +++ b/website/content/integrations/babel.mdx @@ -24,23 +24,13 @@ pnpm add -D @saykit/config @saykit/format-po @saykit/transform-js @saykit/transf ```json title=".babelrc" { "presets": ["next/babel"], - "plugins": [["saykit", { "catalogues": "module" }]] + "plugins": ["saykit"] } ``` Adding a `.babelrc` switches Next.js from SWC to Babel for compilation. SayKit doesn't have a SWC plugin, so this is the supported way to use SayKit with Next.js today. -`catalogues: 'module'` hands catalogues to a loader instead of inlining them; see [Catalogues](#catalogues). Wrap your Next config to register it: - -```js title="next.config.mjs" -import { withSayKit } from 'babel-plugin-saykit/next'; - -export default withSayKit({ - // your config -}); -``` - -`withSayKit` derives the rules from your `saykit.config.*`, one per bucket, for both Turbopack and `next --webpack`. Each targets that bucket's `output` exactly, so changing a bucket's path or format needs no change here, and no other file of the same extension is routed through the loader. +Nothing else to register: `next.config.mjs` needs no SayKit wrapper. ### Generic Babel @@ -65,7 +55,7 @@ React Native uses Babel via Metro. Add the plugin to your Babel config: ```js title="babel.config.js" module.exports = { presets: ['module:metro-react-native-babel-preset'], - plugins: [['saykit', { catalogues: 'module' }]], + plugins: ['saykit'], }; ``` @@ -78,66 +68,25 @@ module.exports = function (api) { api.cache(true); return { presets: ['babel-preset-expo'], - plugins: [['saykit', { catalogues: 'module' }]], + plugins: ['saykit'], }; }; ``` -Metro never runs Babel over `.json`, so catalogues are assembled by a transform worker instead. Wrap your Metro config: - -```js title="metro.config.js" -const { getDefaultConfig } = require('expo/metro-config'); -const { withSayKit } = require('babel-plugin-saykit/metro'); - -module.exports = withSayKit(getDefaultConfig(__dirname)); -``` - -`withSayKit` also registers non-JSON catalogue extensions (e.g. `.po`) with Metro's resolver, since a file Metro cannot resolve is not a module it can reload. +`metro.config.js` needs no SayKit wrapper either. ## What it does -The Babel plugin runs the matching SayKit transformer over every non-`node_modules` source file, rewriting macros to `say.call(...)` invocations. - -Assembling a catalogue means walking the locale's [fallback chain](/core-concepts/configuration#fallback-locales), parsing every file in it with the bucket's formatter, and merging the results into one record so untranslated keys fall back through the chain to the source string. Under the default `'inline'` mode the plugin does that itself, at the import; under `'module'` it leaves the import alone and the Next.js loader or Metro transformer assembles the record instead. - -The result: no `.po` parser at runtime, no SayKit extractor in the bundle, just small `say.call()` calls and one plain JS object per locale. - -## Catalogues +The Babel plugin runs the matching SayKit transformer over every non-`node_modules` source file, rewriting macros to `say.call(...)` invocations. That is the whole of its job. -Where that record is assembled is controlled by the `catalogues` option, and the choice decides whether editing a catalogue hot-reloads. +Catalogues are not its business. [`saykit compile`](/reference/cli#saykit-compile) turns each one into an ordinary `{locale}.js` module, walking the locale's [fallback chain](/core-concepts/configuration#fallback-locales) so untranslated keys fall back to the source string. Your app imports that module, and every bundler already knows how to load, watch and hot-update a module. -### `'inline'` (default) - -The import is replaced with the record, in the module that imported it. Babel alone is enough, with no bundler configuration at all. - -The cost is hot reload. A bundler re-reads a module when that module's own bytes change, and a record baked into an importer lives in a file that does not change when you edit a catalogue. So new and edited strings only appear after a cache-clearing restart (`expo start --clear`, deleting `.next`). - -That is usually fine for a plain Babel build or a library, and painful in a dev server. - -### `'module'` - -The import is left alone, and the catalogue is served by a bundler integration: `babel-plugin-saykit/next` for Next.js, `babel-plugin-saykit/metro` for Metro. Catalogues stay **real modules**, which is exactly what makes them hot-reloadable. - -Set it whenever one of those is wired up. The two are mutually exclusive: if the plugin inlines the import, the integration is never asked for the module and nothing hot-reloads. - -Those two are the whole list, because they are the two bundlers that cannot be reached any other way: Metro never runs Babel over `.json`, and Turbopack runs loaders but not plugins. On webpack, Vite, Rollup or esbuild proper, use [`unplugin-saykit`](/integrations/unplugin) and skip the Babel plugin's catalogue handling entirely. +The result: no message parser at runtime, no SayKit extractor in the bundle, just small `say.call()` calls and one plain module per locale. - Under webpack and Turbopack the fallback files are registered as dependencies too, so editing a - source locale updates every locale that falls back to it. - - - - Under **Metro** only the locale's own file is tracked. Metro keys its transform cache on each - file's bytes and offers no equivalent of `addDependency`, so editing a *fallback* locale (e.g. - `en.json` while viewing `fr`) does not refresh the locale that falls back to it. Editing the - locale you are viewing works normally. - - - - Only **static** imports of catalogues are handled. `import('./locales/en.po')` (dynamic import) is - not. For dynamic locale loading, write a thunk per locale in `messages` and let your bundler - handle the imports. + Run `saykit compile` before anything that reads those modules. A `predev` and `prebuild` script + is the usual way; `saykit extract` also compiles as its last step, and `saykit extract --watch` + recompiles when a translation changes. ## `unplugin` vs Babel: which one? diff --git a/website/content/integrations/carbon.mdx b/website/content/integrations/carbon.mdx index d96ecee3..1d55fb96 100644 --- a/website/content/integrations/carbon.mdx +++ b/website/content/integrations/carbon.mdx @@ -64,8 +64,8 @@ Eagerly load locales so the bot can answer immediately on cold start: import { createCatalogue } from 'saykit'; export const catalogue = createCatalogue({ - en: await import('./locales/en.po').then((m) => m.default), - fr: await import('./locales/fr.po').then((m) => m.default), + en: await import('./locales/en.js').then((m) => m.default), + fr: await import('./locales/fr.js').then((m) => m.default), }); ``` diff --git a/website/content/integrations/react.mdx b/website/content/integrations/react.mdx index 53121168..241d6bbf 100644 --- a/website/content/integrations/react.mdx +++ b/website/content/integrations/react.mdx @@ -152,7 +152,7 @@ import { SayProvider, useSay } from '@saykit/react/client'; ### `` -Render `SayProvider` once near the top of your client tree. It takes either a [store](/core-concepts/runtime), or a locale and its messages. +Render `SayProvider` once near the top of your client tree. It takes either a [store](/core-concepts/runtime), or a catalogue and a locale. A store owns the catalogue, so it can switch locale at runtime. That is what a browser app wants: every locale is loadable, and switching re-renders the tree. @@ -160,8 +160,8 @@ Build the store in your `i18n` module and export it, so the provider and everyth ```ts title="src/i18n.ts" import { createCatalogue, createStore } from 'saykit'; -import en from './locales/en.po'; -import fr from './locales/fr.po'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; export const catalogue = createCatalogue({ en, fr }); @@ -177,11 +177,11 @@ import { store } from './i18n'; ; ``` -A store is a live object and cannot cross the server/client boundary, so a server-rendered app hands over the locale and its messages instead: +A server-rendered app has the server choose the locale instead. A store is a live object and cannot cross the server/client boundary, but a locale and its messages can: messages compile to data rather than to functions, so they serialise like any other JSON. ```tsx - + {children} ``` @@ -250,13 +250,14 @@ export const withSay = createWithSay(catalogue); ```tsx title="src/app/[locale]/layout.tsx" import { SayProvider } from '@saykit/react/client'; -import { catalogue, withSay } from '../../i18n'; +import { getSay } from '@saykit/react/server'; +import { withSay } from '../../i18n'; async function RootLayout({ params, children }) { const { locale } = await params; return ( - + {children} @@ -267,7 +268,7 @@ async function RootLayout({ params, children }) { export default withSay(RootLayout, (props) => props.params.then((params) => params.locale)); ``` -`` takes no props here. In a server component it resolves to the `react-server` build of `@saykit/react/client`, which reads the established view and serialises that locale and its messages across the boundary, which is all a client component can be given. +`` takes no props here. In a server component it resolves to the `react-server` build of `@saykit/react/client`, which reads the established view and hands that locale and its messages across the boundary. Wrap every segment that renders messages, not just the outermost one. A framework is free to @@ -323,7 +324,7 @@ A simple way to think about the integration: - render translated content with `` in any component, server or client - on the server, `withSay` binds one view per request, and `getSay()` reads it -- on the client, `SayProvider` supplies a store to follow, or the single locale the server sent, so the tree hydrates consistently +- on the client, `SayProvider` supplies a store to follow, or a catalogue bound to the locale the server sent, so the tree hydrates consistently ## Next diff --git a/website/content/integrations/vite.mdx b/website/content/integrations/vite.mdx index 766ee6d9..1a1f6dc2 100644 --- a/website/content/integrations/vite.mdx +++ b/website/content/integrations/vite.mdx @@ -122,7 +122,7 @@ The plugin takes no options, everything it needs lives in your `saykit.config.ts For every file in your bucket's `include` globs, the plugin asks the configured transformers to rewrite the source. Macros like `` say`Hello, ${name}!` `` become small `say.call({ id: '...', _name: name })` runtime invocations. -For every import that matches a bucket's `output` template, e.g. `import fr from './locales/fr.po'`, the plugin resolves that locale's [fallback chain](/core-concepts/configuration#fallback-locales), parses each file in it with the bucket's formatter, and emits a single JS module exporting the merged catalogue, real translations overlaying their fallbacks, untranslated keys resolving to the source string. There is no `.po` file at runtime; just plain JS objects, and still only one locale per module. Each source file in the chain is registered as a watch dependency, so editing the source locale re-runs the load for dependent locales in dev. +For every import that matches a bucket's `output` template, e.g. `import fr from './locales/fr.js'`, the plugin resolves that locale's [fallback chain](/core-concepts/configuration#fallback-locales), parses each file in it with the bucket's formatter, and emits a single JS module exporting the merged catalogue, real translations overlaying their fallbacks, untranslated keys resolving to the source string. There is no `.po` file at runtime; just plain JS objects, and still only one locale per module. Each source file in the chain is registered as a watch dependency, so editing the source locale re-runs the load for dependent locales in dev. ## Plugin ordering diff --git a/website/content/reference/api/react.mdx b/website/content/reference/api/react.mdx index 4b6f9496..7c28e058 100644 --- a/website/content/reference/api/react.mdx +++ b/website/content/reference/api/react.mdx @@ -176,7 +176,7 @@ Formats the time portion of a `Date` or timestamp. Same props as ``; s Wraps a client tree with the view its descendants resolve against. Required before any client-side use of `` or `useSay()`. -It takes either a `Store`, or a locale and its messages. A store owns a catalogue and can switch locale; it is a live object, so it belongs to an application that holds its catalogue on the client. A locale and its messages are plain data and are what a server can send across the boundary; the provider builds a single-locale store over them, which has nothing to switch to. +It takes either a `Store`, or a locale and its messages. A store owns a catalogue and can switch locale, which is what a client that chooses its own locale wants. A locale and its messages are the serialisable form: messages compile to data rather than to functions, so a server can hand them straight across the boundary. ```tsx {children} @@ -196,11 +196,12 @@ Props: type: 'Store', }, locale: { - description: 'The active locale string. Use with `messages`, instead of `store`.', + description: 'The locale to bind. Use with `messages`, instead of `store`.', type: 'string', }, messages: { - description: 'The message catalogue for the locale. Use with `locale`, instead of `store`.', + description: + 'The compiled messages for that locale. Should be referentially stable rather than a fresh object literal per render.', type: 'View.Messages', }, children: { @@ -225,7 +226,7 @@ function Title({ name }: { name: string }) { } ``` -There is no hook for the store behind it. A store is a module-scope value you already hold, so switching is `store.set('fr')` on the one you built; a provider given a locale and its messages has no catalogue to switch through anyway. +There is no hook for the store behind it. A store is a module-scope value you already hold, so switching is `store.set('fr')` on the one you built. ## `@saykit/react/server` diff --git a/website/content/reference/api/saykit.mdx b/website/content/reference/api/saykit.mdx index d4be0111..f5e76ab7 100644 --- a/website/content/reference/api/saykit.mdx +++ b/website/content/reference/api/saykit.mdx @@ -25,8 +25,8 @@ createCatalogue(messages: Record): Catalogue import('./locales/fr.po'), - pl: () => import('./locales/pl.po'), + fr: () => import('./locales/fr.js'), + pl: () => import('./locales/pl.js'), }); ``` diff --git a/website/content/reference/cli.mdx b/website/content/reference/cli.mdx index 75f811d3..1f09ee21 100644 --- a/website/content/reference/cli.mdx +++ b/website/content/reference/cli.mdx @@ -3,7 +3,7 @@ title: CLI description: The saykit command-line interface, every command and flag --- -The `saykit` CLI extracts messages from your source files. It's installed with `@saykit/config` and exposes two commands: `extract` and `clean`. +The `saykit` CLI extracts messages from your source files and compiles catalogues into the locale modules your app imports. It's installed with `@saykit/config` and exposes three commands: `extract`, `compile` and `clean`. ```sh saykit --help @@ -55,10 +55,61 @@ For each bucket: 3. Merges entries with the same text + context, unioning their references. 4. Hashes ids for messages without a custom id. 5. Writes the **source** locale catalogue via the bucket's formatter, and creates an empty placeholder for any locale that has no file yet. Existing non-source files are left untouched. -6. Writes a `{locale}.d.{extension}.ts` declaration next to each catalogue. +6. Compiles every locale, exactly as [`saykit compile`](#saykit-compile) does. The CLI throws on unrecoverable errors (missing config, invalid config, formatter parse failure). Use `--verbose` to see the full stack. +## `saykit compile` + +Compile every locale's catalogue into the `{locale}.js` module your app imports, next to the catalogue it came from. `extract` finishes by doing this, so you only need `compile` on its own when nothing was extracted: a CI build from a fresh clone, or a pull from your TMS that changed translations without touching any source. + +```sh +saykit compile +saykit compile --verbose +saykit compile --quiet +``` + +For each bucket, and each configured locale, `compile`: + +1. Reads the locale's catalogue and its [fallback chain](/core-concepts/runtime), most specific first, so an untranslated key resolves to a fallback string. +2. Compiles each ICU message into a JavaScript function, with its locale and every number and date format already resolved. +3. Writes `{locale}.js`, and a `{locale}.d.ts` beside it. + +The output is meant to be read, with the message each entry came from written above it: + +```js title="src/locales/fr.js" +// Generated by saykit. Do not edit. +export default { + // {count, plural, one {# article} other {# articles}} + "c4Ef_2": ["?",["=",["f","plural",["v","_count"]],"one"],["c",["f","number",["v","_count"]]," article"],["c",["f","number",["v","_count"]]," articles"]], +}; +``` + +Because every format is resolved here, nothing parses a message at runtime and no message parser reaches your bundle. The module is data rather than code, so a server can hand a locale's messages to a client tree the way it hands any other JSON, and the message it was compiled from sits above it for review. + +### Options + + + + + The generated `.js` is build output, so keep it out of version control. The `.d.ts` beside it is + two lines and never changes, so committing that leaves `tsc` passing on a fresh clone before + anything has been generated. + + ## `saykit clean` Remove dead entries from every non-source locale file. `clean` only ever subtracts, it never writes new keys into a locale, so running it can only shrink your locale files. @@ -113,7 +164,10 @@ A typical setup wires the CLI into npm scripts so you don't have to remember the { "scripts": { "extract": "saykit extract", - "extract:watch": "saykit extract --watch" + "extract:watch": "saykit extract --watch", + "compile": "saykit compile", + "predev": "saykit compile", + "prebuild": "saykit compile" } } ``` @@ -123,6 +177,8 @@ pnpm extract pnpm extract:watch ``` +The `pre` scripts matter when the generated modules are not committed: anything that reads them makes them first. + ## CI usage The most common CI check is: extraction has been run, and translations are up-to-date with code: @@ -130,11 +186,11 @@ The most common CI check is: extraction has been run, and translations are up-to ```yaml - run: pnpm install - run: pnpm saykit extract -- run: git diff --exit-code -- 'src/locales/*' +- run: git diff --exit-code -- 'src/locales/*.po' ``` This fails the build if anyone forgot to run `extract` after changing or adding messages. ## Future commands -The CLI is intentionally small today. New commands (compile, lint, stats) may land before 1.0. Anything published outside `extract` and `clean` will go through the same `saykit --help` discovery. +The CLI is intentionally small today. New commands (lint, stats) may land before 1.0. Anything published outside `extract`, `compile` and `clean` will go through the same `saykit --help` discovery.