translate
GitHub管理SurfSense桌面应用界面文本的添加、变更与多语言翻译。规范ICU MessageFormat键值命名、格式化处理及翻译流程,确保本地化一致性与质量。
Trigger Scenarios
Install
npx skills add MODSetter/SurfSense --skill translate -g -y
SKILL.md
Frontmatter
{
"name": "translate",
"description": "Adding, changing or translating interface strings in the SurfSense desktop app (surfsense_local). Use when adding a message to the code, moving hard-coded UI text into messages, translating into every language in LOCALES, or adding a language. Owns the key shape, the tone per language and the glossary."
}
Translate
The desktop app's interface text lives in surfsense_local/frontend/translations/, one ICU MessageFormat file per language. English is the source; every other language in LOCALES is translated from it. How it works: docs/architecture/localization.md.
| File | Holds |
|---|---|
translations/en.json |
generated by formatjs extract from every defaultMessage in the code; never edited by hand |
translations/<code>.json (ja, de, …) |
{"key": "ICU string"}, flat, sorted, two-space indent, written with the steps below |
glossary.md |
terms that stay English, and the chosen term per language |
Only fixed text in the app's code is translated. Never user content, model output, model names or descriptions, file paths, or logs.
Adding English
- Key:
<feature>_<surface>_<purpose>, snake_case.<feature>is a folder underfrontend/src/features/, orappfor the shell.<purpose>is one of_title,_body,_label,_placeholder,_button,_tooltip,_empty,_error,_toast,_aria,_link,_status. - Name the meaning, not the words:
sources_delete_confirm_title, notare_you_sure. - A backend error code maps to
<feature>_error_<code>. - One key per place. Never reuse another feature's "Cancel"; never add a
commonkey. - A whole sentence per value. No joining keys, no leading or trailing space. A link that is an action becomes its own key and its own element.
- Visible apostrophes are
’, never': ICU uses'as its escape character. - Pass numbers raw, never preformatted text, and format them in the message with an ICU skeleton:
{size, number, ::unit/gigabyte .#},{percent, number, ::percent}(a fraction),{downloads, number, ::compact-short}. ICU cannot pick a unit, so megabytes and gigabytes are two messages. - A count or position is
{count, number}, never a plain{count}, which prints raw digits. Ids and HTTP status codes stay plain: they are not amounts. - A date goes in as a
Date, formatted in the message:{date, date, ::yyyyMMMd}. - Named placeholders only:
{name},{count}. Plurals are ICU:{count, plural, one {# document} other {# documents}}; FormatJS prints#with the language's digit grouping. Never(s). - Write the English in the call, as FormatJS recommends:
intl.formatMessage({ id: "sources_list_empty", defaultMessage: "No sources yet" }), orintl.formatMessage({ id, defaultMessage }, { name })with values. The id is literal and explicit. Importintlfrom@/i18n/intl. Then runpnpm translationsinsurfsense_local/frontend(or onlypnpm translations:extract), which regeneratesen.json; commit it with the code. - A styled part of a sentence (a bold name, an accent) is an ICU tag, FormatJS's native rich text:
"Using <b>{name}</b> via {source}", rendered withintl.formatMessage({ id, defaultMessage }, { b: (chunks) => <span …>{chunks}</span> }). An element can also be a value:{ time: <RelativeTime … /> }. - Outside a message, numbers, dates, relative times, lists and language names go through
intl.formatNumber,formatDate,formatRelativeTime,formatList,formatDisplayName, never a bareIntl.*,toLocale*or a raw{count}in JSX.
New UI text, step by step:
- Write each piece of fixed text as
intl.formatMessage({ id, defaultMessage }), following the rules above. - Run
pnpm translationsinsurfsense_local/frontendto regenerateen.json. - Translate the new keys into every other language in
LOCALESwith the steps below. - Run the checks, then commit the code,
en.jsonand every translated file together.
Translating
-
Find the keys. A key needs work in a language when it is missing there, or when its English changed since that language was last translated:
cd surfsense_local/frontend base=$(git log -1 --format=%H -- translations/ja.json) git diff "$base" -- translations/en.jsonRepeat for each language. Every added or changed line in that diff is a key to translate.
-
Read the context. The component that calls it (
grep -rn '"<key>"' src). Translate the meaning in that place, not the English words. -
Translate. Follow the tone below and
glossary.md. Keep every placeholder name, tag name andselectbranch exactly. Write the plural categories the language has, not the English ones: for example, Japanese onlyother, Germanoneandother. -
Check. From
surfsense_local/frontend:pnpm translations:verify(formatjs verifyfor missing and extra keys and matching placeholders), thennode scripts/check_translations.mjsfrom the repo root. Theformatjs-verifyandcheck-translationspre-commit hooks run the same. Stop and fix on any failure; do not commit around it.
Adding a language
- Create
translations/<code>.jsonby translating every key inen.jsonwith the steps above. Do this first: a code inLOCALESwithout a catalog fails the checks. - Add the code to
LOCALESinfrontend/src/i18n/locales.tsandelectron/src/main/i18n/locales.ts. Nothing else holds a list:intl.tsglobs the catalogs, andplural-categories.test.tsandcheck_translations.mjsreadLOCALES. - A language whose catalog is regional, or whose script decides which catalog fits, needs a case in
catalogFor()inresolve-locale.ts, which otherwise matches the base language: any Portuguese takespt-BR, and Chinese takeszh-CNunless the tag asks for Traditional. - Add a column to
glossary.mdand a line under Tone. - Run the checks in Translating step 4, then
pnpm test. - Open the app in that language and shorten any text that overflows. Never shrink the UI to fit.
A right-to-left language needs more than a catalog: dir on the document, the left and right utility classes replaced with their logical forms, and the directional icons mirrored. None of that is built.
To check layout before any translation exists, pick English (Pseudo-Accents) (en-XA) under Settings › General in pnpm dev. It is longer, accented English; text that stays plain is not in a message.
Tone
- Japanese. です/ます for sentences, the default in Microsoft's Japanese style guide. Plain form for short labels and buttons (
保存,削除). 〜してください for instructions. Full-width punctuation (。,、), no space between Japanese and Latin text unless the product name needs it. - Korean. 합니다체 for sentences, the register Korean software ships in. ~하세요 for instructions (
다시 시도하세요). Buttons and labels are nouns (저장,삭제). Standard 띄어쓰기; a particle attaches straight to a Latin name (SurfSense를) and is chosen by how the name is pronounced, but a placeholder that hides the ending takes을(를). - Simplified Chinese. 你, never 您. Simplified characters and mainland terms (
设置,下载,文件,模型). Bare verbs for buttons (保存,删除); 请 only where the English asks the user to act. Full-width punctuation (,,。,“”), a half-width space between Chinese and Latin or digits (在 SurfSense 中,4 GB), none next to full-width punctuation. - Hindi. आप, never तुम. Buttons and menu items are the polite imperative (
सहेजें,हटाएं). Sentences end in।; labels and fragments do not. Everyday technical terms in Devanagari with their nuqta (चैट,मॉडल,सेटिंग्स,फ़ाइल), never a coined Sanskrit equivalent. - German. du, as macOS has used since Sierra. Never Sie, never mixed. Capitalize du only at the start of a sentence. Buttons are infinitives (
Speichern,Löschen). - Spanish. tú, never usted, never vos. Neutral across Spain and Latin America:
videonotvídeo,este equiponotordenadororcomputadora. Buttons are infinitives (Guardar,Eliminar). Open every question and exclamation (¿,¡), and quote with«». - French. vous, as macOS and Windows both use. Never tu, never mixed. Buttons are infinitives (
Enregistrer,Supprimer). A no-break space before:,;,?,!and inside« ». - Brazilian Portuguese. você, never tu, and never a mixed verb form. Brazilian vocabulary throughout (
arquivo,baixar,tela,excluir), never European. Buttons are infinitives (Salvar,Excluir). - Russian. вы, lowercase except at the start of a sentence; never ты, never a capitalised Вы. Buttons are infinitives (
Сохранить,Удалить). Guillemets«»around a quoted interface label. Every plural writes all four categories, each with its own noun form. - Every language: as short as the English allows. A
_buttonor_labelhas the room its English has. Write’for a visible apostrophe, never', which ICU reads as its escape character.
Limits
- Never reword English while translating. A problem with the English is a separate change.
- Never translate a key name, a placeholder name or a tag name.
- Never guess. When a key's meaning is unclear from its component, stop and say which key and why.
- Never send strings to an outside translation service or CLI. Nothing leaves without a decision (ADR 0017).
- A new term that will recur goes into
glossary.mdin the same change.
Version History
- a51f9a3 Current 2026-09-28 18:43


