sun, 22:35 at the minemoon, waxing crescent, 36% lit - 22:35 at the mine(spicemine)αβ金γ

サインインEN日本

mélangej · ABCL on Jooby and Netty

Localization

No page holds a sentence. Every word a visitor can read lives in one catalog, keyed, in every language supported, and spice-ui.lisp is structure plus (tr :key) and no prose at all.

That is a stronger rule than "strings are externalised", stated that way because the weaker version always decays: one page formats English inline, the catalog stops being the list of what the software says, and nobody finds out until a translator asks for the list and gets an incomplete one.

The catalog

(:msg-password-short
 "passwords need at least 12 characters"
 "パスワードは12文字以上にしてください"
 "The only password rule spicemine enforces: length, not a character zoo.")

Four fields: key, English, Japanese, and context for the translator. The context is not a comment — it goes to the reviewer, who cannot see the screen the string appears on. A translator without context guesses, and a guess in a button label is a wrong button.

(tr :btn-sign-in)                    ; "sign in" / "サインイン"
(trf :clone-label clone-url)         ; a format string with ~a in it

tr falls back to English per string rather than to emptiness: a half-translated catalog should read as a half-translated page, not a page with holes in it. A key that does not exist renders [[like-this]], because a missing string that renders as nothing is a missing string nobody reports.

Which language

?hl= beats the cookie beats Accept-Language beats English. The query parameter wins because it is the switcher, and a switcher that loses to a remembered choice looks broken. The cookie is a preference, not a credential: nothing that decides a right ever reads it.

The switcher sits in the top right, two items, each written in its own script — EN and 日本 — because a Japanese reader looks for 日本, not for "JA". Each links to the current path with ?hl= set, so switching keeps your place.

What the tests enforce

A localization bug is silent by nature: the page renders, nothing errors, and only a reader of the other language finds out. So the build asserts what a human cannot see:

  • every string has a translation (missing-keys is empty);
  • every string has a context line longer than ten characters;
  • every pair takes the same number of ~a. This is the one that eats data: a Japanese line that drops a placeholder loses a token, a clone URL or an invite link, silently, on a page nobody here reads.
  • and the browser suite loads every page in both languages and fails on a [[missing-key]]. The catalog test proves the strings exist; this proves the pages ask for the ones that do.

Getting a real translation

./gradlew -q l10nExtract            # writes l10n/strings.json

reads the catalog and emits every string as {key, section, en, ja, context, chars, slots}, grouped by the banner comments in the source — the author's own division of the interface, which is a better section list than anything inferred from key prefixes. That file is published next to a review page where a native speaker corrects strings in place.

The extractor is Lisp, and that is the point of mentioning it: the catalog is a Lisp file, so it is loaded and the variable is read. The first version was a Python script that found the catalog with a regex, tracked paren depth by hand, and had to know what \" means inside a Lisp string literal — a parser for a language that was already running in the next process. Reading the data from the image cannot disagree with the data the server renders, and it took the extractor from 164 lines to about 120 of which half are comments.

Three things that sheet does which a spreadsheet does not: it carries the context; it counts the placeholders and warns live; and it separates "I corrected this" from "I read this and it is fine", which look identical in a spreadsheet and are not the same thing at all.

Corrections come back as JSON and go into the catalog by hand, key by key. A script that writes Lisp source from a web form is a script that will one day write a quotation mark into a string literal and take the server down.

Adding a language

  1. Add it to +languages+: keyword, ISO code, a two-character label in its own script, the endonym for the tooltip.
  2. Extend each catalog entry.
  3. Add a font stack for its script in +i18n-style+ if it needs one — system-ui alone picks a Latin face and falls back per glyph, which mixes two type designs inside one word.

No page changes, no route changes, no new files.

The glossary, which is the same idea

mélangej's host is for people who version code and for people who version everything else, and those groups do not share a vocabulary. So every term carries an (i): hover or focus it and the explanation appears, with no script at all. The icon is a real link to the full glossary entry, so a tap goes somewhere; the tooltip opens on :hover and :focus, which covers the keyboard and gives a touch device a first tap that explains before a second tap navigates.

The tooltip is positioned against the page column, not the word — absolutely positioned with left/right set and top left at auto, so it keeps the static vertical position of its own line while spanning the column. That is the one arrangement that cannot overflow the viewport whatever the word's position in the line.

← Fragments and DatastarSkins →