Languages
Solid Memo speaks English (en), Swedish (sv), Korean (ko),
German (de), Spanish (es) and French (fr). Every text the app
itself shows comes from a message file; text that comes with the data —
deck titles and descriptions, card text — is shown as the data has it,
in the language the user says it is in (see
Text in the user's languages).
Which language
pickLocale (locale.ts) decides, in
order:
- the language the user chose — with the language list (a globe and
the language's name in itself) at the top of every screen, or under Language in the study preferences — kept on
this device by the
LanguagePreferenceport (localStorageLanguagePreference.ts, keysolid-memo:language) — not in the pod, since the sign-in screens need it before any pod is reached; - else the first of the browser's preferred languages
(
navigator.languages) the app speaks —sv-SEcounts assv; - else English.
AppShell, the shell of Solid Memo and of the Studio, sets <html lang> to it. Deck and card text is shown in the
same language, else the browser's, else English, else the first
language it has (shown in
langText.ts); text in another
language than the page's is marked with its own lang (readerLang,
ReaderText), none for untagged text or text in no language (zxx).
A deck's keywords, several per language, are shown only in the chosen
language: every keyword under a tag with its primary subtag (sv,
sv-se, sv-fi for Swedish), with those in no stated language
(untagged and zxx), each once (keywordsIn in
keywords.ts). There is no
fallback: a deck with none of those shows no keywords. Library decks
carry their title, description and keywords in every language the app
speaks, so a reader of any of them sees keywords on every library deck.
The library's search still looks through the keywords of every
language, as it does titles and descriptions.
Text in the user's languages
Text the user writes — a deck's name, description and keywords, a card's sides, notes, labels and picture descriptions — is saved language-tagged, one value per language (keywords: a comma-separated list per language), in whatever language the user says it is in (deck format 6, card format 6: shapes.md). The page's language says nothing about it: an English speaker on a Swedish page types English. So:
Every field has a language picker (
LangTextFieldwithLanguagePickerin packages/ui/src/ui): a button saying the language ("Language: Swedish", or "not stated") that opens in place onto radios — the text's own language, what the deck uses, the languages chosen recently on this device, the page's language, English, Swedish, the browser's languages, "No language (codes, numbers, symbols)" (zxx) and any other by its code, checked bycanonicalTag(languageTag.ts). The button shares one box with its text, at the text's end (under a textarea, in a bar of its own), and shows the language's name alone ("Swedish", or "Language?" while none is chosen); a screen reader still reads its full name. A translation is a row of its own, with its own picker; changing the language moves the text without retyping it.Nothing is chosen from the page. A language is preselected only from evidence: the text's own tag; on a card, the language the last card added in the same form had (sticky), else the one the deck's cards use most for that part (
usualTag, throughdeckLanguagesin deckLanguages.ts); for a note, label or picture description, else the one chosen last on this device; for a deck's name, the one chosen last on this device. The recent choices are kept in the browser, not the pod (remembered.ts, keyssolid-memo:recentLanguages.deckandsolid-memo:recentLanguages.own). Choosing a language for an empty field fills the untouched empty fields of the same kind in the form.Text the user writes or changes always has its language. Saving text whose language is not chosen is refused (
textNeedsLanguage), and focus goes to that field's picker. The app never writes untagged text anew, nor copies text under English on its own.Tags are stored in lower case (
pt-br), as the pod reads them; they are named throughIntl.DisplayNames(languageLabel: "Swedish — svenska (sv)"), a tag Intl cannot name shown as itself.Text over several lines keeps its breaks. A card's text saved with a line break (a line feed or a carriage return), in any language, is edited in a textarea, and stays one in that editor even once a save drops the breaks, so focus stays where it is. That holds for every card text, the labels and the picture descriptions too, though they are meant as single lines: editing never drops a break. A card in Markdown has its sides and notes in textareas whatever they hold (markdown.md). A deck's description is always a textarea. In a textarea Enter starts a new line and Ctrl+Enter or ⌘+Enter submits the form, as the textarea states (
aria-keyshortcuts).An input method's keys are its own. While one composes text (Korean, Japanese or Chinese, say), the Enter or Escape that commits or cancels it does nothing in the app (
composing: the event'sisComposing, or keyCode 229, which is all Safari gives once the composition has ended). That holds for every Enter or Escape handled while a field may have the focus: Ctrl+Enter in a textarea, Enter and Escape in a language picker, Escape in a name edited in place, Escape on an enlarged picture. A form's own submit on Enter is left to the browser, which commits the syllable first.Text written right to left keeps its own direction. Text in a language written right to left — Arabic, Persian, Urdu, Hebrew and the like — runs right to left wherever the page marks it with its language (
lang="ar", asreaderLangand the editor's fields do), isolated from the text around it, by the style sheet's:lang()rule rather than adiron each element; a block of it starts at the right (text-align: start). Where such text is set into a sentence as plain text (a card's name in "Remove the card "{card}"?", a title, a picture's description), the message isolates it between U+2068 and U+2069 (isolated), so the sentence's own punctuation stays put. The pages themselves stay left to right: no language the app speaks is written right to left yet, so nothing mirrors.
Older data may not say its language: card sides saved untagged, as the
app wrote them before card format 5, and keywords saved before deck
format 6. Their language is not guessed. Untagged keywords are kept, or
removed, as they are; a keyword added or changed needs its language.
The deck's page says so while any is left (DeckLanguagesNotice),
linking to the Languages section of the deck's preferences
(DeckLanguagesSection), where the untagged fronts or backs are given
one language at once (stateCardLanguages); the Browser marks the
cards with a side in no stated language and can list only those
(languages=unstated in its URL). Text still as its library release
has it is left alone, so a later release can update it.
The same words under several languages are text in each of them, and are accepted as they are: "Stockholm"@en with "Stockholm"@sv, like "1969"@sv with "1969"@it. That includes the identical English copies the app wrote while the formats required English: the app cannot tell them from a real translation, so they are left in the data untouched, neither flagged nor offered for removal. Each language is an entry of its own in the editor, edited, retagged or removed like any other translation.
The deck's page weighs its text itself (deckLanguageIssues over
deckLanguages, pure domain functions) from the
cards it has read already and the deck's library release
(UseCases.deckRelease), rather than through a use case of its own
that would read the cards a second time. When the release cannot be
read, the page cannot tell the library's text from the user's: if the
deck's text holds none to settle even counting the library's, nothing
is said; otherwise the notice says the check could not be made, rather
than counts, and still links to the Languages section, which shows why.
Formatted text
Text in Markdown (markdown.md) changes nothing of the
above: each language's value is its own Markdown document, under its own
tag, and every rule here holds of it as of plain text — shownTag,
textNeedsLanguage, one text per language, a distractor in the back's
languages. A code block inside @en text keeps @en, and a code-only
back may be zxx. The card editor hints at each language's text under
that text, and its preview shows the main one, the one being written.
The app's own words about Markdown are messages like any other:
cardContentFields.markdown* and .preview* for the editor,
markdownHelp.* for its cheat sheet (the syntax itself is code, the
same in every language), markdownProblem.* for each problem
markdownProblems names, and markdown.* for the names of a code block
and a table region and a link's host.
Messages
packages/ui/src/i18n/en.json and the
other languages' files beside it (sv.json, ko.json, de.json,
es.json, fr.json) hold one namespace per
component (deckList, instancePicker, …), a few shared ones
(common, language, footer), and studio for the
Studio's own screens, in apps/studio, and the page's
while it loads them. Components read them through
useI18n() (i18n.tsx):
const { t, tx, readerText, formatDate, directionLabel } = useI18n();
t("deckList.heading"); // "Decks"
t("common.cardCount", { count: 3 }); // "3 cards" — a plural message
tx("app.loggedInAs", { name: <ExternalLink … /> }); // markup inside a sentence
- A message's
{name}placeholders are filled from the values given; a number is written as the language writes one ("1,000", "1 000"), so a value that must keep its digits as they are (a year) is given as text. - A plural message gives a form for each plural category of its
language —
{ "one": …, "other": … }in English, Swedish and German,{ "one": …, "many": …, "other": … }in Spanish and French (many for a million and more: "1 000 000 de cartes"), only{ "other": … }in Korean — picked bycountthrough the language's plural rules (Intl.PluralRules), which are the Unicode CLDR plural rules (by language) as the browser ships them, so their CLDR version is the browser's (policy.md). A message without a count takes other. - A sentence with a link in it stays one message (
tx), so a language can put the link where its word order needs it. - Keys are string literals, typed from
en.json: a misspelled key fails the type check.
Tests in i18n.test.tsx hold
every other message file to exactly the keys and placeholders of
en.json, and every key the source asks for to one en.json has.
Components outside a provider speak English, so component tests query English text without any setup.
Translations
English is the source; Swedish is the maintainer's own. Korean, German, Spanish and French were translated with the help of AI: a draft by one model following a glossary per language (register, typography, the words for deck, card, review…), then an independent check that translates each message back into English and fixes what drifted. No native speaker has reviewed them yet, so corrections from one are welcome. German says du, Spanish tú (neutral, international), French vous, Korean the polite 해요 style.
The About page, a static page for
search engines and for people deciding whether to use Solid Memo, is in
English only for now. It is outside the app and its catalogs, so it has
no translations. A page per language, linked to each other with
hreflang, can come later.
These docs, which the site also serves at /docs/
(deployment.md), are in
English only, and their pages say so (lang="en"). They are written
for contributors and those who run a copy as much as for learners, and
change with the code, so they are not translated. The app's footer links
to them with hreflang="en", and its label says "in English" in every
other language (footer.docs).
Adding a language
- Add its code to
LOCALESinpackages/domain/src/locale.ts. - Add
packages/ui/src/i18n/<code>.jsonwith every key ofen.json, and register it inCATALOGSini18n.tsx. - Name it in itself in
LANGUAGE_NAMESinLanguageSelector.tsx. - Give each plural message exactly the forms its plural rules name
(
new Intl.PluralRules("<code>").resolvedOptions().pluralCategories): only other in Korean, Japanese and Chinese; one, many and other in Spanish and French (many for a million and more); one, few, many and other in Polish and Ukrainian. - Register the file in the journeys' harness too:
LocaleandCATALOGSin e2e/journeys/harness/strings.ts, and the language's name inswitchLanguagein e2e/journeys/pages/Chrome.ts. - Extend the tests that name every language:
locale.test.ts,i18n.test.tsx(its catalogs, a plural, a date, a language's name),LanguageSelector.test.tsx,PreferencesScreen.test.tsx. - Name and define every topic in it in
ns/vocab/topics.ttl, which holds its
topics in the app's languages (vocab.md): the scheme's
dcterms:titleandskos:definition, and each concept'sskos:prefLabelandskos:definition, withowl:versionInfobumped and askos:changeNotesaying so. Then runnpm run format:turtle,npm run generateandnpm run crosscheck, and update the tests that compare topic labels (concepts.test.ts,library.test.ts). - If its script needs its own fonts, line breaking or heading
treatment, add
:lang(<code>)rules topackages/ui/src/style.css, with tests instyle.test.ts, as Korean has (theme.md). The Studio's headings are in the body's type and weight whatever the language (apps/studio/src/ui/studio.css), so look at them there too. - A language written right to left, as the app's own, needs more than
its text's direction (which the style sheet already sets): the page
itself mirrored (
dir="rtl"on<html>) and every layout rule that names a side made logical. No language the app speaks needs that yet.
The shapes' sh:messages stay in English and Swedish: a new language
needs none, and its readers see a shape's own message in English, marked
so, and the validator's messages in their language
(shapes.md).