diff --git a/README.md b/README.md index eea5dfa..7fdc4c7 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ rating encoding, and the bugs this rebuild fixes. ## Requirements -- Node 20 or newer (uses `--experimental-strip-types`, so no build step for the API) +- Node 22.18 or newer (native TypeScript type stripping, so no build step for the API) - pnpm 9 or newer ## Setup @@ -375,8 +375,31 @@ Both gate a deploy. See [Languages](#languages) for what they look for. ## Languages -English, Spanish and Dutch. English is the site as it was; the other two are the same -site, prerendered again. +Twenty-four, listed in `web/src/i18n/config.ts`. English is the site as it was; the other +twenty-three are the same site, prerendered again, 84 pages each. + +English, Spanish and Dutch are hand-written. The other twenty-one began as bulk machine +translation, and they are in two states. + +**German, Danish, Swedish, Indonesian, Vietnamese and Turkish** have had a full +terminology pass: every string that names a mint, and every label, title and meta +description on the home page, the mint list and a mint page. The machine had translated +the site's central noun into the local word for a coin factory (`Münzprägeanstalt`, +`mincovna`, `darphane`) or, in the Germanic languages, into the *sweet*: Danish and +Swedish shipped `Cashu-pastiller`, "Cashu lozenges", and Swedish offered to sort your +`minttabletter`. Indonesian had `permen`, Vietnamese `cây mint`, the mint plant. Those +six now say `mint`, and `sats`, `ecash` and `Lightning` survive untranslated in them as +the glossary requires. + +**The other fifteen** still carry that damage in their body copy, in the same shapes: +Czech `mincovny`, Polish `mennice`, Romanian `monetării`, Greek `νομισματοκοπείο`. Their +chrome, titles and meta descriptions are repaired, so what a reader meets first is right, +but the prose underneath is not. Roughly 400 strings, concentrated in the four inflected +languages where a word-level fix needs case-correct edits inside running sentences, which +is a native speaker's job rather than a careful search and replace. + +`web/src/i18n/GLOSSARY.md` is where a pass starts, and the house rules at the top of it +hold in every language, particularly the first row of the table. ### Routing @@ -404,9 +427,9 @@ opened the English page is not overruled by a browser setting they configured ye The language control in the header is how you change language, and it lands on the page you were already reading. -One file per route emits all three: pages live under `web/src/pages/[...locale]/`, and +One file per route emits every locale: pages live under `web/src/pages/[...locale]/`, and `localePaths()` in `web/src/i18n/paths.ts` is their `getStaticPaths`. There is one copy -of each page's markup, translated by `t()`, rather than three copies to keep in step. +of each page's markup, translated by `t()`, rather than twenty-four to keep in step. ### What is and is not translated @@ -425,9 +448,10 @@ Never translated, because it is data rather than copy: A mint's own `description` is the first sentence of its page's meta description, verbatim. Only when a mint has published none does the site write that sentence itself. -### Adding a fourth language +### Adding a language -Four steps, and the build tells you if you miss one. +Five steps. Four of them the build tells you about; the fifth it cannot, which is why it +has a test of its own. 1. **`web/src/i18n/GLOSSARY.md`** — add the column and decide the terms first. This is the step people skip, and it is the one that costs later: a reader who meets two words @@ -453,6 +477,15 @@ Four steps, and the build tells you if you miss one. TypeScript exists. `check-i18n` compares the two lists and fails if they disagree, so forgetting this is a build error rather than a mystery. +5. **`web/src/i18n/index.ts`** — `import pt from './pt.json'` and add `pt: pt as Catalog` + to `CATALOGS`. This is the step to get right, because it is the only one nothing + downstream complains about: `catalogFor()` falls back to English by design, so a + locale listed in `LOCALES` with no catalog behind it does not fail the build. It + prerenders the whole English site under the new `lang`, the new URL prefix and a full + reciprocal hreflang set, and tells every crawler those pages are a different language. + Twenty-one locales shipped in exactly that state once. `pnpm test` now fails on it: + see `web/test/i18n-wiring.test.mjs`. + That is the whole change. The Astro i18n config, the routes, the switcher, the hreflang sets, the `og:locale:alternate` list and the sitemap all read from `LOCALES` and pick the new language up on the next build. @@ -505,7 +538,7 @@ English. Islands read it through `useI18n()` in `web/src/i18n/client.ts`. The alternative, importing `es.json` from an island, does not work here: island chunks are shared across every locale, so anything imported into one is downloaded by all of them. A dynamic `import()` keyed on locale would avoid that but costs a round trip on the -critical path of every island and leaves all three catalogs sitting in `dist/_astro/`. +critical path of every island and leaves every catalog sitting in `dist/_astro/`. Inlining travels in HTML the page was sending anyway. It is in the body rather than the head because the view transition router replaces the @@ -658,10 +691,11 @@ below rather than by a test for Cashu. reviews panel work unchanged), a route in `targetPath` (`web/src/lib/feed-resolve.ts`), nav entries in `Topbar.astro` and `Footer.astro`, and sitemap entries in `src/pages/sitemap.xml.ts`. -5. **Copy.** A namespace in `en.json`, `es.json` and `nl.json`, the namespace added to +5. **Copy.** A namespace in `en.json` and in every other catalog, the namespace added to `CLIENT_NAMESPACES` if an island renders any of it, and a row in `web/src/i18n/GLOSSARY.md` for every term the ecosystem introduces. `pnpm check:i18n` - fails the build until all three catalogs have the keys. + warns per locale until every catalog has the keys; until then they fall back to + English. Not in scope, deliberately: **LNURL**. Nothing in the routes, event kinds, schema values or copy refers to it, and it is planned as a later stage rather than half-built now. @@ -759,7 +793,7 @@ Wants=network-online.target Type=simple User=cashumints WorkingDirectory=/srv/cashumints/api -ExecStart=/usr/bin/node --experimental-strip-types src/index.ts +ExecStart=/usr/bin/node src/index.ts Environment=NODE_ENV=production Environment=PORT=8787 Environment=DB_PATH=/var/lib/cashumints/cashumints.db diff --git a/api/package.json b/api/package.json index 908f6da..f61f2f4 100644 --- a/api/package.json +++ b/api/package.json @@ -4,16 +4,16 @@ "private": true, "type": "module", "scripts": { - "dev": "node --env-file-if-exists=../.env --experimental-strip-types --watch src/index.ts", - "start": "node --env-file-if-exists=../.env --experimental-strip-types src/index.ts", - "seed": "node --env-file-if-exists=../.env --experimental-strip-types src/seed.ts", - "migrate": "node --env-file-if-exists=../.env --experimental-strip-types src/migrate.ts", + "dev": "node --env-file-if-exists=../.env --watch src/index.ts", + "start": "node --env-file-if-exists=../.env src/index.ts", + "seed": "node --env-file-if-exists=../.env src/seed.ts", + "migrate": "node --env-file-if-exists=../.env src/migrate.ts", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --env-file-if-exists=../.env --experimental-strip-types src/check.ts", - "test:warnings": "node --env-file-if-exists=../.env --experimental-strip-types src/check-warnings.ts", - "test:offline": "node --env-file-if-exists=../.env --experimental-strip-types src/check-offline.ts", - "test:lnurl": "node --env-file-if-exists=../.env --experimental-strip-types src/check-lnurl.ts", - "test:index": "node --experimental-strip-types src/check-index.ts" + "test": "node --env-file-if-exists=../.env src/check.ts", + "test:warnings": "node --env-file-if-exists=../.env src/check-warnings.ts", + "test:offline": "node --env-file-if-exists=../.env src/check-offline.ts", + "test:lnurl": "node --env-file-if-exists=../.env src/check-lnurl.ts", + "test:index": "node src/check-index.ts" }, "dependencies": { "@cashumints/shared": "workspace:*", diff --git a/package.json b/package.json index aa61a39..45efc09 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "2.0.0", "type": "module", "engines": { - "node": ">=20" + "node": ">=22.18" }, "scripts": { "dev": "pnpm --parallel --filter ./api --filter ./web dev", diff --git a/web/package.json b/web/package.json index aa595ca..329ad06 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,7 @@ "bones": "node --env-file-if-exists=../.env scripts/bones.mjs", "check:links": "node scripts/check-links.mjs dist", "check:i18n": "node scripts/check-i18n.mjs", - "test": "node --experimental-strip-types --test test/*.test.mjs", + "test": "node --test test/*.test.mjs", "check:hreflang": "node scripts/check-hreflang.mjs dist" }, "dependencies": { diff --git a/web/scripts/check-i18n.mjs b/web/scripts/check-i18n.mjs index b6ec3f7..c4700ab 100644 --- a/web/scripts/check-i18n.mjs +++ b/web/scripts/check-i18n.mjs @@ -242,6 +242,8 @@ const DYNAMIC_PREFIXES = [ 'fedimint.network.', // localNetworkName(network) 'feed.ecosystem.', // the /reviews filter, one option per ECOSYSTEMS entry 'reviews.dialog.bodyPlaceholder.', // one prompt per ecosystem, keyed by subject.type + 'reviews.dialog.lede.', // the dialog's invitation, one per ecosystem + 'reviews.rate.', // the reviews panel's inline "Rate this mint" row ]; const isDynamic = (key) => DYNAMIC_PREFIXES.some((prefix) => key.startsWith(prefix)); diff --git a/web/src/components/ReviewByUrl.astro b/web/src/components/ReviewByUrl.astro index 6e28da2..c1e8020 100644 --- a/web/src/components/ReviewByUrl.astro +++ b/web/src/components/ReviewByUrl.astro @@ -37,16 +37,19 @@ const { locale } = pageLocale(Astro) as { locale: Locale }; const t = useI18n(locale); /* - * The label, the placeholder and the review prompt all differ per ecosystem, and all - * three fall back to the Cashu wording for a type whose own string has not been written - * — the same rule the reviews panel already applies to its placeholder. + * The label, the invitation and the two review prompts all differ per ecosystem, and + * all of them fall back to the Cashu wording for a type whose own string has not been + * written — the same rule the reviews panel already applies to its placeholder. */ -const wordsFor = (kind: IndexType): { label: string; placeholder: string; body: string } => { +const wordsFor = ( + kind: IndexType, +): { label: string; placeholder: string; lede: string; body: string } => { const key = (base: string): string => t.has(`${base}.${kind}`) ? `${base}.${kind}` : `${base}.cashu`; return { label: t(key('reviews.byUrl.label')), placeholder: t(key('reviews.byUrl.placeholder')), + lede: t(key('reviews.dialog.lede')), body: t(key('reviews.dialog.bodyPlaceholder')), }; }; @@ -120,7 +123,7 @@ const initial = words[type] ?? words.cashu; + {/* + The way in, where the reviews are. + + The header button at the top of the page is a long way from here, and by the time + somebody has read what other people wrote they are at the other end of the panel. + This is the same flow, opened the same way — one `[data-write-open]` among several + — with one addition: pressing a star opens the dialog with that star already + chosen, so a rating takes exactly one press. + + One button rather than five, because five buttons cannot live inside one and a row + that is only reachable star by star is a row a keyboard cannot use. The stars are + spans; which one was pressed is read off the click. + */} + + {/* One line of consequence when the toggle above is on: how much is showing. */} @@ -206,7 +229,7 @@ function escapeText(value: string): string { every page load. --> - +