Two translation systems can run on the same site, and this skill covers both:
| Part | What it holds | Section |
|---|---|---|
Rosey locale JSON (rosey/locales/{code}.json) |
Shared UI and any page text tagged with data-rosey — nav, footer, headings, buttons, breadcrumbs, and (for non-split pages) whole page bodies |
Part 1 |
Content collection files (blog_fr/, blog_de/ …) |
Split-by-directory body content + per-post frontmatter (title, description, alt text) that the SSG renders natively per locale | Part 2 |
Most sites only need Part 1. Only reach for Part 2 if the project actually has per-locale content directories (split-by-directory, set up in the make-site-multilingual skill, Phase 8). A split-by-directory page needs both: its body comes from a content collection file (Part 2), its shared UI from the locale JSON (Part 1).
This skill is written for AI coding agents, but the process works with any AI tool that reads and writes JSON.
Why Rosey files are ideal for AI
Rosey locale files are flat JSON with a predictable three-field structure per entry. That gives an agent three properties that make translation efficient and safe:
- Incremental — new entries from
write-localeshavevalueset to the source original. Comparingvaluetooriginalinstantly identifies what's untranslated; already-translated entries are left untouched. No diffing, no external state, no tracking database. - Deterministic / idempotent — running the same pass twice produces the same output. No re-translation of existing work, reviewable
git diff. - Context-rich — keys encode where text appears (
nav:about,index:hero:title,blog:recent-posts), which disambiguates short strings ("More", "Back", "Home") without a screenshot.
The data format is the state management: read a JSON file, find entries where value === original, translate them, write the file.
Part 1: Translate Rosey Locale JSON Files
Prerequisites
- Locale files exist at
rosey/locales/{code}.json(generated bywrite-locales). - The user has said which locale(s) to translate and into which language(s).
base.jsonis up to date — runnpx rosey generate+npx rosey-cloudcannon-connector write-localesif unsure.- Source-language copy edits are finished. Changing source text after translating marks every translation of it out of date — correct behaviour, but pure churn. Settle the English copy first, in both directions.
Locale file format
Each file is a flat JSON object keyed by Rosey translation keys:
{
"hero:title": {
"original": "Welcome to Sendit",
"value": "Welcome to Sendit",
"_base_original": "Welcome to Sendit"
},
"hero:subtitle": {
"original": "The best email platform",
"value": "La meilleure plateforme email",
"_base_original": "The best email platform"
}
}
| Field | Description |
|---|---|
original |
Source text when the translation was last acknowledged |
value |
The translation (or the source original if untranslated) |
_base_original |
Current source text from base.json, updated by write-locales each build |
Classification follows from these three fields:
- Untranslated —
value === originalandoriginal === _base_original. Needs full translation. - Stale —
original !== _base_original. Source changed since the translation was acknowledged. Translate from_base_original, then setoriginal = _base_original. - Current —
original === _base_originalandvalue !== original. Already translated; leave it alone.
Workflow overview
This skill ships helper scripts that do the mechanical work (classification, translation memory, merging, HTML validation), leaving only the actual translation to the AI. Three phases:
- Prepare — a script classifies entries and writes a slim task file.
- Translate — the AI reads the task file and fills in translations.
- Merge — a script merges translations back into the full locale file.
Locate the scripts before running them. The commands below assume the skills were copied to skills/ in the project root. Depending on the install route they may be under .agents/skills/, .cursor/skills/, or a plugin directory outside the project — adjust the path to wherever this skill's scripts/ sits. If they aren't present at all, use the Manual Fallback at the end of Part 1.
Phase 1.1: Prepare
node skills/translate-multilingual/scripts/prepare-translation.mjs --locale fr
The script:
- Classifies every entry as untranslated / stale / current (skip)
- Builds a translation memory from already-translated entries and auto-applies exact matches (e.g. "Recent Posts" appearing 4×, translated once, applied everywhere) — writing those straight back to the locale file
- Picks tone/register examples from existing translations
- Writes a slim task file to
rosey/locales/.translation-task-{code}.json - Reports counts (total, auto-applied, needs AI, stale)
Flags: --locale <code> (required), --source <dir> (default rosey), --output <path>, --examples <n> (default 5).
Phase 1.2: Translate
Read the task file. It contains:
_meta.tone_examples— already-translated entries showing the project's tone/register. Match this style.untranslated— entries needing full translation. Each hasoriginal; add avalue.stale— entries where the source changed. Each hasold_original,new_original,old_value; add avaluebased onnew_original.
Translation rules
Use key namespaces for context. nav:about = an About nav link; index:hero:title = a hero page title; footer:copyright = footer copyright. For short/ambiguous strings ("More", "Back", "Home") the namespace tells you how it should read.
Preserve HTML structure. Locale values are always HTML (the SSG converted Markdown to HTML at build time). Keep every tag, attribute, and nesting; translate only the text nodes.
{
"original": "<p>A starting point for <strong>developers</strong> looking to build a website.</p>",
"value": "<p>Un point de départ pour les <strong>développeurs</strong> cherchant à créer un site web.</p>"
}
- Keep all tags in place (
<p>,<strong>,<a href="...">,<br>). - Preserve attributes exactly (especially
href,class,id). - Keep
and other entities where they appear.
Translate in namespace-grouped batches, not one entry at a time in isolation — group all nav:* together, all index:hero:* together, etc. This gives natural context and consistent results.
Match tone and register using _meta.tone_examples: formal vs informal address (vous/tu, Sie/du, usted/tú), and terminology consistency (if "Sign up" is already "S'inscrire", don't switch to "Créer un compte").
Intentional non-translations. Brand names ("CloudCannon"), product names ("Rosey"), proper nouns, technical terms, URLs, email addresses, and code snippets stay identical — set value equal to original. Signals: the key contains brand/name/logo/copyright, or the value is a single proper noun/trademark/URL/code. When in doubt, translate — a reviewer can revert one entry more easily than spot a missing translation.
Whitespace. write-locales preserves whitespace from the HTML (entries may be " Blog "). Preserve the same leading/trailing whitespace — Rosey substitutes the value directly into HTML and altered whitespace can break rendering.
After translating, write the task file back to the same path with value fields added.
Phase 1.3: Merge
node skills/translate-multilingual/scripts/merge-translation.mjs --locale fr
The script:
- Merges
valuefields from the task file into the full locale file - For stale entries, sets
original = _base_original(clears the stale flag) - Validates HTML — checks tag names/counts match and
<a>href values are preserved; logs warnings for mismatches but still writes - Sorts keys alphabetically, 2-space indent, trailing newline
- Deletes the task file after a successful merge
Flags: --locale <code> (required), --source <dir> (default rosey), --input <path>, --dry-run.
If the script reports HTML warnings, review those entries and fix broken tags before committing.
Part 1 checklist
- Run
prepare-translation.mjs; review the counts - Read the task file; check tone examples
- Translate all
untranslatedentries (addvalue) - Translate all
staleentries (addvaluebased onnew_original) - Preserve HTML tags, attributes, whitespace
- Match tone/register
- Write the task file back; run
merge-translation.mjs; check warnings - Review the
git diffof the locale file
Manual fallback (Part 1)
If the helper scripts aren't available, the AI reads and writes the full locale file directly:
- Classify every entry as untranslated / stale / current using the three-field rules above. Report counts (e.g. "42 untranslated, 3 stale, 107 current — translating 45 entries to French").
- Translate applying the same rules as Phase 1.2. Match tone/register of existing translations; translate in namespace-grouped batches. For stale entries, set both
value(new translation) andoriginal = _base_original. - Write back:
JSON.stringify(data, null, 2)(2-space indent), keys sorted alphabetically (matchingwrite-localesoutput)- Preserve all three fields on every entry — never drop
_base_original - Don't add or remove keys — only modify
value(andoriginalfor stale entries) - End with a trailing newline
Part 2: Translate Split-by-Directory Content Collections
Only needed if the project has per-locale content directories (e.g. src/content/blog_fr/, src/content/blog_de/). These are MDX/MD files with YAML frontmatter that the SSG renders natively per locale.
The clean boundary with Part 1
On a split-by-directory page, the two systems never touch the same element:
- Content collection files (Part 2): body content and post-specific frontmatter (title, heading, description, image alt). Rendered natively from the locale file. These have no
data-roseyattributes — addingdata-roseyto content already translated via a collection would create two systems fighting over one element. - Rosey locale JSON (Part 1): shared UI in layout/component templates — header, footer, nav, breadcrumbs, sidebar headings like "Recent Posts". These use
data-roseybecause they're shared across pages and aren't in any post's frontmatter.
Rule of thumb: frontmatter or body → content collection (Part 2); shared layout/component UI → Rosey (Part 1). Fully translating a split-by-directory page needs both parts.
Prerequisites
- Per-locale content directories exist (
src/content/blog_fr/,src/content/blog_de/). - A source-language directory with the same slugs exists (
src/content/blog/). - The user has said which locale(s) to translate.
Workflow overview
Same prepare → translate → merge shape as Part 1, with content-specific scripts. If the scripts aren't available, use the Manual Fallback at the end of Part 2.
Phase 2.1: Prepare
First identify the source and locale directories: look for {collection}_{locale}/ dirs in the content dir (usually src/content/), and check cloudcannon.config.yml for per-locale collections or a locale config file (often src/lib/locales.ts).
Then, per locale collection:
node skills/translate-multilingual/scripts/prepare-content-translation.mjs \
--source-dir src/content/blog \
--locale-dir src/content/blog_fr \
--locale fr
The script:
- Compares each locale file against the source file with the same filename
- Marks files untranslated (frontmatter text + body still match source) vs already translated (skip)
MUST finish source-post edits before translating. This classification is binary — there is no stale state and no equivalent of Part 1's
_base_original. Edit a source post after its locale copy is translated and the copy still differs from source, so it stays classified "already translated" and is silently skipped on every later run, with nothing flagging it. Remedy: re-translate that file explicitly, or diff it against its source file to find what changed. See make-site-multilingual/troubleshooting.md.
- Extracts translatable frontmatter fields (title, headings, descriptions, alt text) by dot-notation path
- Skips structural fields (dates, image paths, tags, booleans, CMS metadata, URLs)
- Writes a task manifest to
src/content/.translation-task-{code}-content.json
Flags: --source-dir <dir> (required), --locale-dir <dir> (required), --locale <code> (required), --output <path>.
Phase 2.2: Translate
For each file with "status": "untranslated" in the manifest:
Frontmatter — mirror translatable_frontmatter into a translated_frontmatter object with the same keys and translated values:
{
"translatable_frontmatter": {
"title": "Visual Translation Editing with the RCC",
"post_hero.heading": "Visual Translation Editing with the RCC",
"seo.page_description": "How the Rosey CloudCannon Connector enables..."
},
"translated_frontmatter": {
"title": "Édition visuelle des traductions avec le RCC",
"post_hero.heading": "Édition visuelle des traductions avec le RCC",
"seo.page_description": "Comment le Rosey CloudCannon Connector permet..."
}
}
Translate: title, headings (*.heading, *.subheading), descriptions (*.description, *.page_description), image alt text (*_alt, *.image_alt, *.featured_image_alt), and any other human-readable text. The script already excluded CMS metadata (_schema, _name, _uuid), dates, image paths, tags, author names, URLs, booleans.
Body — if the manifest includes body, add translated_body with the translated markdown:
{
"body": "The Rosey CloudCannon Connector (RCC) is a client-side script...",
"translated_body": "Le Rosey CloudCannon Connector (RCC) est un script côté client..."
}
Body rules:
- Preserve markdown formatting (bold, italic, links, lists, blockquotes)
- Keep link URLs unchanged — translate only link text, not
href - Keep code blocks in the source language (code examples, CLI commands, HTML snippets)
- Preserve MDX components — keep component syntax, translate only text inside
- Keep technical terms and product names (Rosey, CloudCannon, Bookshop)
Match tone/register with existing translations (Rosey locale JSON or other translated content) — same formal/informal and terminology consistency as Part 1.
Write the manifest back with translated_frontmatter / translated_body added.
Phase 2.3: Merge
node skills/translate-multilingual/scripts/merge-content-translation.mjs \
--input src/content/.translation-task-fr-content.json
The script patches translated frontmatter into the YAML (preserving structural fields and formatting), replaces body content, validates frontmatter integrity, and deletes the manifest after a successful merge. Flags: --input <path> (required), --dry-run. Review any fields it warns it couldn't patch.
Part 2 checklist
- Identify source and locale content directories
- Run
prepare-content-translation.mjsper locale collection; review counts - Translate all
translatable_frontmatter(addtranslated_frontmatter) - Translate body (add
translated_body) - Preserve markdown formatting, code blocks, link URLs, MDX components
- Match tone/register
- Write the manifest back; run
merge-content-translation.mjs; check warnings - Review the
git diff
Manual fallback (Part 2)
- Detect collections — find
{collection}_{locale}/dirs, a locale config file, or per-locale entries incloudcannon.config.yml; the source collection is the one with no locale suffix. - Classify files — compare each locale file against the source file with the same slug. Untranslated = frontmatter text + body identical to source. Report counts.
- Translate each untranslated file. Frontmatter: translate title/headings/descriptions/alt text; leave
_schema/_name/_uuid, dates, image paths, tags, author names, URLs, booleans. Body: translate prose/headings/list items; preserve formatting, code blocks, link URLs, MDX components. - Write back in place — preserve exact YAML structure (field order, nesting, indentation), the file extension (
.mdx/.md), any MDX imports, and a trailing newline.
Edge cases
- Tags/categories as slugs (
rosey,visual-editing) — used for URLs/filtering; do not translate. - Code blocks (fenced
```) — stay in the source language. - Brand names in alt text — translate the alt text but keep brand names.
- Files with no source equivalent — skip.
- Partially translated files — translate only the untranslated parts.
Learnings and Gotchas
This section is a living document. When you discover new patterns, issues, or improvements while translating, ask the user before appending them here. See the repo README's Key conventions → Living documents.