CtrlK
BlogDocsLog inGet started
Tessl Logo

translate

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.

75

Quality

94%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

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.

FileHolds
translations/en.jsongenerated 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.mdterms 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 under frontend/src/features/, or app for 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, not are_you_sure.
  • A backend error code maps to <feature>_error_<code>.
  • One key per place. Never reuse another feature's "Cancel"; never add a common key.
  • 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" }), or intl.formatMessage({ id, defaultMessage }, { name }) with values. The id is literal and explicit. Import intl from @/i18n/intl. Then run pnpm translations in surfsense_local/frontend (or only pnpm translations:extract), which regenerates en.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 with intl.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 bare Intl.*, toLocale* or a raw {count} in JSX.

New UI text, step by step:

  1. Write each piece of fixed text as intl.formatMessage({ id, defaultMessage }), following the rules above.
  2. Run pnpm translations in surfsense_local/frontend to regenerate en.json.
  3. Translate the new keys into every other language in LOCALES with the steps below.
  4. Run the checks, then commit the code, en.json and every translated file together.

Translating

  1. 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.json

    Repeat for each language. Every added or changed line in that diff is a key to translate.

  2. Read the context. The component that calls it (grep -rn '"<key>"' src). Translate the meaning in that place, not the English words.

  3. Translate. Follow the tone below and glossary.md. Keep every placeholder name, tag name and select branch exactly. Write the plural categories the language has, not the English ones: for example, Japanese only other, German one and other.

  4. Check. From surfsense_local/frontend: pnpm translations:verify (formatjs verify for missing and extra keys and matching placeholders), then node scripts/check_translations.mjs from the repo root. The formatjs-verify and check-translations pre-commit hooks run the same. Stop and fix on any failure; do not commit around it.

Adding a language

  1. Create translations/<code>.json by translating every key in en.json with the steps above. Do this first: a code in LOCALES without a catalog fails the checks.
  2. Add the code to LOCALES in frontend/src/i18n/locales.ts and electron/src/main/i18n/locales.ts. Nothing else holds a list: intl.ts globs the catalogs, and plural-categories.test.ts and check_translations.mjs read LOCALES.
  3. A language whose catalog is regional, or whose script decides which catalog fits, needs a case in catalogFor() in resolve-locale.ts, which otherwise matches the base language: any Portuguese takes pt-BR, and Chinese takes zh-CN unless the tag asks for Traditional.
  4. Add a column to glossary.md and a line under Tone.
  5. Run the checks in Translating step 4, then pnpm test.
  6. 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: video not vídeo, este equipo not ordenador or computadora. 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 _button or _label has 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.md in the same change.
Repository
MODSetter/SurfSense
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.