Commit Graph
3 Commits
Author SHA1 Message Date
jcoffey-dev d282813bc5 Give the i18n scripts a parser again
TypeScript 7 is the native port: the package ships a `tsc` shim over a Go
binary, and `typescript` now exports `version` and `versionMajorMinor` and no
compiler API. Every `ts.createSourceFile` in scripts/ has been throwing
"Cannot read properties of undefined (reading 'Latest')" since the 5.9.3 → 7.0.2
bump -- four of the five i18n scripts dead, only i18n-extract still running.

Nothing noticed because no workflow runs them. The catalogue gate for nine
languages has been dark, and the only signal was running it by hand.

There is no official TS7 API package (@typescript/ast and @typescript/api are
both 404), and the alternative was rewriting 493 lines and 25 distinct AST
calls, including the JSX guards, against a different tree -- in tooling with no
tests of its own. So `typescript-ast` is an npm alias for the last TypeScript
carrying the JS API. It parses; `typescript` still type-checks and builds. Two
entries, two jobs, said so in each script so the next reader does not delete
one as a leftover.

What the gate says now it can speak: catalogues are green and coverage is 100%.
The "16 falling back to English" it reports in every locale are placeholders,
example domains, a product name, a licence id and the quote glyph -- strings
that should stay English. The 41 stale keys per locale are real dead weight and
are left for their own change.
2026-09-10 14:21:42 -07:00
jcoffey-dev 3f4b33cb51 Finish extraction: 100%, and a coverage number worth believing
The 143 the codemod refused turned out to be two different things, and only
one of them needed a person.

A third were phrases sitting next to an icon -- `<Plus /> New rule`. The
refusal rule was "has siblings", which is broader than the danger: what breaks
a translation is a sibling that renders *text*, splitting a sentence into
fragments no one can reorder. An element beside a phrase does not. Narrowing
the rule to text-producing siblings let the codemod take 73 more.

The rest were real sentences with values in the middle, rebuilt by hand as
named placeholders -- "Your active script “{name}” was written by hand",
"Waiting on the server — goes out {when}." Named rather than positional
because a translator moves the parts around; counted things go through
plural() so Russian and Ukrainian get their three forms rather than English's
two.

Sentences with an element inside them needed something new. `Open <code>mailto:
</code> links in ihasmail` has two obvious treatments and both are wrong:
splitting it into two t() calls hands over fragments that cannot be reordered,
and dropping the <code> keeps the sentence whole but loses the monospace that
said "this is a literal". tNode() keeps the sentence whole and makes the
element a named hole in it, so a translator sees one sentence and can put the
hole where their language wants it. The German test asserts exactly that: the
same call renders the code first when the catalogue says so.

The coverage number was also lying, and it is worth saying how. It counted
text inside <code> and inside translate="no" as untranslated work, and
placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone
format. None of those will ever be translated, so the report sat at 21 with 6
real items left. A number with an unreachable floor is something to argue with
rather than act on, so the tool now applies the same rules the codemod does.

596 wrapped, nothing remaining. Verified in the browser across 15 views, which
is where the last bulk pass hid a bug the tests could not see: no entities, no
unfilled placeholders, no raw t( in rendered text, and the toggle switches that
looked like emptied labels are text-free by design.
2026-08-31 10:41:24 -07:00
jcoffey-dev 95dcb96086 Start extraction: an i18n core, and a way to see how far it has got
The groundwork in #145 gave the app a language to serve. This gives it
something to serve, and a way to measure the distance to the languages
actually planned.

The English text is the key. `t("Archive")` looks "Archive" up and returns the
English when it is not there, which buys three things worth more than tidy
symbolic keys: no English catalogue to keep in step with the code, a missing
translation that degrades to readable English rather than to
`mail.list.archive`, and an extraction step that is wrapping a string rather
than inventing a name for it. Names are where extraction stalls, and 55
components is a lot of small naming arguments. The cost is that editing English
copy orphans its translations, which is the right way round: the copy is the
product, and a stale German sentence should fall back to the new English.

`plural()` takes forms rather than (one, other), because two forms is an
English assumption that does not survive phase two of the plan. Russian and
Ukrainian need three, and choosing between them is not a question about the
number 1. Intl.PluralRules knows the rule for every language the browser knows,
so the catalogue supplies the forms and the runtime picks; a category the
catalogue does not carry falls back to `other` rather than rendering undefined.
Interpolation is named rather than positional for the same reason -- German
moves the parts of a sentence around and means the same thing.

Catalogues are dynamically imported, so a reader who never leaves English never
downloads one, and English needs no fetch at all. `applyLang` sets the lang
attribute before kicking the load, deliberately: lang is what stops Chrome
offering to translate and should not wait on a network request to say something
it already knows.

`t()` is a plain function, not a hook, so the tree is keyed on a language
version at the root and thrown away when the catalogue changes. Making every
call site a subscriber would turn extracting a string from "wrap it" into "wrap
it and add a hook", for an event that happens about once per account.

NotificationsSettings is extracted end to end as the reference -- it covers all
four shapes, being JSX text, translated attributes, a toast, and a sentence
with a value interpolated into it.

scripts/i18n-coverage.mjs counts what is left, because ~1,000 strings across 56
files is too many to eyeball in review or carry in anyone's head. It reports 20
wrapped and 925 remaining, and it deliberately does not count punctuation and
separators as untranslated -- a floor no amount of work could reach would make
the number useless. A progress report rather than a gate: --check exits
non-zero, for once the number is low enough for that to mean something.

ROADMAP.md said translations were "English-only for now" on a page whose stated
purpose is things the answer is "no" to. It now says what is actually happening,
carries the phase order, and says why Arabic, Hebrew and Persian are on neither
list: RTL is a layout and bidi problem rather than a longer catalogue, and
shipping it as though it were the same kind of work is how an RTL build ends up
unusable with nobody saying so.
2026-08-31 09:44:49 -07:00