Publishing in several languages
The URL shape, the two modes for an untranslated page, how translations are kept honest, and what every output surface owes a multilingual build.
This is not a sketch. It has been built twice on forks of this repository — once to three configured languages, once all the way to six across 198 pages — and a number of the rules below are here because the first version of them was wrong in a way only a real build showed. Those places say so, and say what the symptom looked like, because that is the part that is hard to recognise from the cause.
The routing half is already done, and it was free.
A page’s URL is its path under content/, resolved in exactly one place
(hrefFor in src/lib/navigation.ts). So
content/fr/guides/authoring.mdx is served at /fr/guides/authoring without a
line of new code, and the same is true of its Markdown twin, its share card and
its entry in the sitemap. There is no router to teach about locales.
What has to be built is everything around the route: a sidebar that knows which tree it is in, a canonical tag that does not publish the same English page six times, a freshness signal that makes translation debt visible, and a set of machine outputs that do not hand a French reader English answers.
The settings
One object, added to src/docs.config.ts. It is present there already, as a
commented block, so that this page and that file cannot drift apart.
export interface I18nLocale {
/** Shown in the language picker. */
name: string;
/** Two or three letters, for the picker's collapsed state. */
short: string;
/** `lang` attribute and JSON-LD `inLanguage`. */
language: string;
/** BCP 47 with region, for `og:locale`. */
locale: string;
/** Only for Arabic, Hebrew, Persian and Urdu. */
dir?: 'ltr' | 'rtl';
}
export interface I18nConfig {
/** Key order sets the language picker's order. */
locales: Record<string, I18nLocale>;
/** Served unprefixed. Must be a key of `locales`. */
defaultLocale: string;
/** What a locale does with a page it has not translated. */
missing: 'fallback' | 'hide';
/** What a locale does with a translation older than its source. */
stale: 'warn' | 'fallback';
/**
* Group and tab labels, per locale. These are the only strings the sidebar
* cannot get from a page's own frontmatter, because they live in the config.
*/
navigationLabels: Record<string, Record<string, string>>;
}
export const i18n: I18nConfig = {
locales: {
en: { name: 'English', short: 'EN', language: 'en', locale: 'en_US' },
fr: { name: 'Français', short: 'FR', language: 'fr', locale: 'fr_FR' },
de: { name: 'Deutsch', short: 'DE', language: 'de', locale: 'de_DE' },
},
defaultLocale: 'en',
missing: 'fallback',
stale: 'warn',
navigationLabels: {
fr: { 'Start here': 'Commencer', 'Writing content': 'Rédaction' },
de: { 'Start here': 'Erste Schritte', 'Writing content': 'Inhalte' },
},
};One record keyed by locale, rather than a list of codes plus three parallel maps of labels. The parallel-map version is the one this problem keeps producing, and it drifts: a locale added to the list and forgotten in one of the maps is a missing label at runtime, not a build failure.
seo.locale and seo.language become this object’s default entry. Delete them
from seo when you add it, rather than leaving two places that answer “what
language is this site”.
language and locale are separate fields because the region is a decision, not
a formality. pt is not a language: it is a choice between pt-BR and pt-PT,
and the two differ in vocabulary a reader notices immediately. If the product
being documented has its own language catalogue, match it — documentation that
disagrees with the product about what “Portuguese” means is worse than either
answer.
The file tree
Translations mirror the default tree, one directory per non-default locale.
content
guides
fr
guides
de
Under missing: 'fallback', that tree produces exactly these routes — nine, not
six, because a locale answers for every page whether or not it has translated it:
/getting-started /guides/authoring /guides/deployment
/fr/getting-started /fr/guides/authoring /fr/guides/deployment ← English body
/de/getting-started /de/guides/authoring /de/guides/deployment ← English body
↑ English bodyUnder missing: 'hide' there are five, and the other four are 404s. Nothing
else about the two modes differs.
Two rules, and the second is the load-bearing one.
The default locale is not prefixed. /getting-started stays where it is;
French is at /fr/getting-started. This is what makes adopting languages later
a non-event: no existing URL moves, so there is nothing to redirect. Prefixing
every locale, including the default, is defensible on a new site and only there.
The path after the locale segment is the translation key. fr/guides/authoring
is the French guides/authoring because the paths match, and for no other
reason. There is no mapping file.
That second rule is worth defending, because the temptation to break it is real:
a localized slug — fr/guides/redaction — reads better and does marginally
better in search. The cost is that “which page is this a translation of” stops
being a string operation. A mapping table appears, and the language picker, the
hreflang set, the freshness check and the coverage report all start reading
from it. Every one of them is then one stale row away from being wrong. Identical
paths keep all four as pure path arithmetic. Take the slugs only if you are
prepared to own the table.
The one-line change to make before any of this
Astro’s glob loader slugifies each path segment and then strips a trailing
/index, which is right for one language and fatal with locale directories:
// astro/dist/content/utils.js
const slug = rawSlugSegments.map(githubSlug).join('/').replace(/\/index$/, '');content/fr/index.mdx therefore gets the id fr — indistinguishable from the
locale directory itself. Stripping the locale prefix leaves an empty source id,
so the translated homepage is reported as a stray file whose source does not
exist, and the build stops on a message that points nowhere near the cause:
These translated files do not match any page in docs.config.ts navigation:
content/fr.mdx (no source at content/.mdx)The trap is the timing. Nothing is wrong until a locale translates its homepage, so a fork can configure three languages, pass a hundred tests, and meet this on the day it ships its first complete locale.
Override generateId in src/content.config.ts to keep the literal path — and
keep the slugification, or the first file named Getting Started.mdx is the next
bug:
loader: glob({
base: './content',
pattern: '**/*.{md,mdx}',
generateId: ({ entry }) =>
entry.replace(/\.[^./]+$/, '').split('/').map(githubSlug).join('/'),
}),No URL changes: hrefFor already maps a homepage id to its locale root, so
fr/index still serves at /fr.
A locale exists from its first translated file
The rule to get right before any other, and the one the first implementation of this specification got wrong.
missing decides what a locale does about the pages it has not translated. It
does not decide whether a locale nobody has started is a locale. Naming de in
the config buys the machinery; writing content/de/anything.mdx is what
publishes it. Until then German has no routes, no sitemap, no corpus file, no
entry in the language picker, and the build’s output is byte-for-byte what it was
before the config changed.
Without that rule, missing: 'fallback' reads the config, finds German, and
dutifully publishes the entire site in German — every page, in English, at URLs
nothing links to and the picker does not offer, all of them crawlable. On a
33-page site that measured 67 ghost pages, from adding one line to a config file.
With it, both modes agree, and a fork can name every language it intends to support without publishing a word.
A page that is not translated
Within a locale that exists, this is the decision that shapes everything else, and it has exactly two honest answers.
missing: 'fallback' | missing: 'hide' | |
|---|---|---|
/de/guides/authoring | exists, serves the English body | 404 |
| Sidebar in German | listed, marked untranslated | absent |
| Canonical | /guides/authoring | — |
hreflang for de | not advertised | not advertised |
| German sitemap | absent | absent |
| German search index | absent | absent |
| Language picker on that page | switches, shows a notice | points into German elsewhere |
Start on hide, move to fallback. The two are right at different sizes, and
the switch is one word.
hide is right while a locale is thin. At three translated pages, fallback
publishes thirty English duplicates for every real translation — each one
canonicalised and excluded from the sitemap, so not harmful, but not useful to
anybody either.
fallback is right once a locale is substantial, and it is the correct long-run
setting. A documentation set is permanently half-translated — that is the steady
state, not a transitional one — and a reader arriving from a search result or a
colleague’s link should not meet a 404 for a page that exists. It is also what
Starlight does, so it is the
behaviour readers of Astro documentation already expect.
Stay on hide when a locale is a deliberate subset: a translated quick-start for
a market you are testing, or content whose partial translation would be a legal
problem rather than an inconvenience.
The sidebar is derived, never duplicated
The scalability of the whole approach rests here.
navigation in src/docs.config.ts stays a single tree. A locale’s sidebar is
that same tree with its page ids prefixed, and nothing else:
const localizedId = (locale: string, id: string) =>
locale === i18n.defaultLocale ? id : `${locale}/${id}`;Labels need no translation, because they never lived in the config: the sidebar
reads sidebarTitle, title, icon and badge from each page’s own
frontmatter, so a translated file carries its own translated label. The
exceptions are group and tab labels, which the config does own — hence
navigationLabels, and nothing more.
Adding a fourth language is therefore one directory plus a handful of group labels. It is never a second sidebar to keep in step, which is the failure this shape exists to prevent.
Three things in src/lib/navigation.ts need adjusting:
getNavigation()takes a locale, caches per locale, and resolves each locale’s tree separately — dropping ids with no file underhide, reusing the logic that already drops drafts.- The orphan check currently fails the build for any published page the config does not name. Every translated file is such a page, so it has to skip them.
- Which opens a hole, and closing it is the part that is easy to miss. That check
existed to catch a file nothing references. A translation is never named in
navigation, so once it is skipped,content/fr/guides/authorng.mdx— with the typo — is a file nothing serves, nothing lists and nothing reports. It sits in the repository looking like work that shipped. Translations need their own version of the same rule: every locale-prefixed file must have a source at its path that the config knows about, and the error should name the source it should have matched.
One more consequence, in the route files rather than here: on a fallback page the
id and the entry come apart. /fr/guides/authoring is rendered from
content/guides/authoring.mdx, so “where it is served” and “what renders it” are
two different values. Three routes need that pairing — the page, its Markdown twin
and its share card — and each one deriving it separately is three chances to
derive it differently. Resolve it once, in a function that returns both.
Links inside a translated page
This is the defect that actually reaches readers, and the one nothing else catches. The page renders. The link works. It is simply in the wrong language.
A translated page carries its source’s links verbatim, and that is correct — a
translator handed /api/errors has no business inventing a path. But rendered
inside /fr/…, every one of those links drops the reader out of French
mid-sentence, and lands them at the top of a page they were partway through. At
six languages this measured 75 links across 40 of 165 translated pages.
The fix belongs in the build, not in the content. Rewriting the files would have to be redone after every future translation, and it would freeze an answer that changes over time — whether the target is translated yet.
The rule is two lines:
| The target has a translation in this locale | Point at it |
|---|---|
| It does not | Leave the link alone |
The second case is not a fallback-as-failure. An untranslated page in English beats a localized URL that 404s, and it is self-correcting: translate the target later and the link starts resolving to it, with no edit to the page holding it.
if (node.type === 'element' && node.tagName === 'a') {
// node.properties.href
}
if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
// node.attributes.find(attribute => attribute.name === 'href').value
}Split the fragment off before rewriting, and put it back after.
/api/errors#insufficient_scope losing its anchor is the least visible way to
break a link: it still resolves, it just stops landing where it promised.
This belongs in astro.config.ts as a rehype plugin, beside the ones that already
rewrite images and tables — it needs the tree, and it needs to run for every page
in every locale without an author thinking about it.
Canonical, hreflang, and the picker
In src/components/DocumentHead.astro, for every page:
<link rel="alternate" hreflang="en" href="https://docs.example.com/getting-started">
<link rel="alternate" hreflang="fr" href="https://docs.example.com/fr/getting-started">
<link rel="alternate" hreflang="x-default" href="https://docs.example.com/getting-started">Four rules that are easy to get wrong and cheap to assert at build time:
- Only translated locales are listed. An alternate pointing at a fallback page is a lie about that page’s language.
x-defaultnames the default locale. It is what a reader whose language you do not serve should be sent to.- Alternates are reciprocal. If the French page lists English, the English page must list French. Google discards non-reciprocal sets silently, which is the worst possible failure mode: no error, no effect.
- The set includes the page itself. A self-referencing alternate is required, and its absence is the second most common mistake after reciprocity.
Put them in the <head> and not in the sitemap. Both locations are valid and
Google treats them as equivalent — doing both means maintaining the same matrix
twice for nothing.
The language picker belongs in the topbar, beside the theme toggle, and it has two rules that both came out of getting it wrong.
It lists locales that have any content, not locales that have this page. The
per-page rule sounds tighter and is worse: under hide it hides the picker on
every untranslated page — most of them, early on — so a reader who lands on the
English page has no way to discover that a French section exists at all.
For a page a locale lacks, it links to that locale’s landing page — and that is
not /fr. /fr exists only once content/fr/index.mdx does. Using it as the
fallback is how the picker becomes a menu of 404s, which is exactly what happened:
a locale with one translated page and no homepage offered every other page a link
straight into nothing. Link to the locale’s homepage if it has one, otherwise to
the first page in its sidebar.
Keeping translations honest
An untracked translation set does not stay half-translated. It rots, quietly, because nothing in the build has an opinion about a French page whose English source moved on six months ago.
Duvlify already reads git for a page’s real modification date
(src/lib/last-modified.ts), which makes the rule almost free:
A translation is stale when its last content commit is older than its source’s.
This is how Lunaria — the tracker behind Astro’s own documentation — decides the same question, so it is a shape worth matching. Lunaria itself is the sensible upgrade once you want a dashboard and a GitHub Action rather than a build-time list.
The two modes:
stale: 'warn'prints the list at the end of the build and serves the translation with a notice on the page. Translation debt stays visible without blocking a deploy.stale: 'fallback'treats stale as untranslated: the page reverts to the source language, and the locale stops advertising it. Correct when a wrong translation is worse than an English one — pricing, limits, security procedures.
A npm run i18n:status script prints coverage per locale and exits non-zero
when a threshold is missed, which is what makes it usable in CI.
What each output surface owes
Seventeen surfaces. This table is the checklist: work down it and you are done.
One rule covers most of the mistakes in it: a per-locale output must take the
locale as an argument all the way down. Wherever the chain stops and a function
reaches for a module-level constant instead, that constant holds the default
locale’s value — so the bug is invisible in the default locale, which is the one
the author is testing. It reached production once as a /fr/llms.txt whose
“Documentation home” link was built from the configured site root and sent every
agent that followed it back to English.
| Surface | What changes | Where |
|---|---|---|
| Routes | Nothing. content/fr/x already serves /fr/x. | — |
| Sidebar, tabs, prev/next | Ids prefixed; group labels from navigationLabels | src/lib/navigation.ts |
| Internal links in prose | Retargeted to the locale when the target is translated | astro.config.ts rehype plugin |
| Language picker | New component; locales with content, never a link into nothing | src/components/ |
<html lang> and dir | Per locale, from i18n.locales | src/components/DocsLayout.astro |
| Canonical | Fallback pages point at the default locale | src/pages/[...slug].astro |
hreflang + x-default | Translated locales only, reciprocal | src/components/DocumentHead.astro |
og:locale, JSON-LD inLanguage | Per locale instead of one constant | DocumentHead.astro, src/lib/structured-data.ts |
| Sitemaps | Flat while monolingual, an index plus one per locale from the second language on | src/pages/sitemap.xml.ts |
robots.txt | Sitemap: names the index only | src/pages/robots.txt.ts |
llms.txt, llms-full.txt | One pair per locale; the root pair lists them | src/pages/llms.txt.ts, src/pages/llms-full.txt.ts |
| Search index | One per locale; each page names its own for the shared bundle to read | src/pages/search-index.json.ts, src/scripts/search.ts |
| Share cards | /og/fr/… for translated pages; fallbacks share the source’s | src/pages/og/[...slug].png.ts, astro.config.ts |
| Updates feed | Default locale only, unless the changelog is translated | src/pages/updates.xml.ts |
| Agent manifest | A real per-page locale, not seo.language | src/lib/agent-manifest.ts |
MCP search, list_pages | A locale parameter, strictly applied | worker/agent/tools.ts, worker/agent/retrieval.ts |
| 404 | A German URL gets the German 404 | worker/index.ts, src/pages/404.astro |
Sitemaps
One sitemap per locale, and a sitemap index at /sitemap.xml listing them —
from the second language on. While the site serves one, /sitemap.xml stays
the flat <urlset> it is today, because /sitemap-en.xml under an index would be
a second URL for one list and a reviewer wondering which one Search Console is
reading. The switch follows the content, so nothing has to be remembered.
Not because the limits demand it — an index is only required past 50,000 URLs or 50 MB, which a documentation site does not reach — but because Search Console reports coverage per submitted sitemap. One file per language is what tells you German is indexed at 40 %. In a single combined sitemap that fact does not exist.
Every sitemap must be on the same host, and robots.txt should name
/sitemap.xml alone — index or not; the children are discovered through it.
One rule worth asserting rather than trusting: a sitemap must not submit a URL
that canonicalises somewhere else. Under fallback that is the difference
between a locale’s sitemap listing what it has translated and asking Google to
index thirty copies of the English site. It is two lines in a test and it is the
kind of mistake that produces no visible symptom at all.
llms.txt and the corpus
One pair per locale: /llms.txt and /llms-full.txt for the default, then
/fr/llms.txt and /fr/llms-full.txt. The root llms.txt links to the others,
since nothing else would make them discoverable.
Not one combined file, for the same reason the search index is split:
llms-full.txt is already the largest thing the build emits, and concatenating
six languages produces a file six times the size whose only use is to fill a
context window with five languages the reader did not ask for.
What splitting them does not fix: the header of /fr/llms.txt — the site
description and seo.agentInstructions — is still English, because both are
single strings in seo rather than per-locale. The page list under it is French.
Worth making those two fields per-locale before a language ships in full;
worth knowing about either way, because it looks like a bug in the split.
The search contract for agents
Half of this is already in place, which is easy to miss. locale is uploaded as
a metadata field on every item (scripts/index-sync.mjs) and is a field of the
agent manifest (src/lib/agent-manifest.ts). What is missing is that it comes
from seo.language, a single site-wide value — so today every item carries the
same locale — and that nothing reads it: the search tool takes query and
limit, and aiSearch() passes no filter.
The contract to implement is deliberately blunt:
- No
localeargument: the default locale only. Never a mixed result set. An agent that receives French and English passages for one query will quote across both in a single answer. - A
localeargument: that locale only. Not “that locale, then the default” — a filter that widens on its own cannot be reasoned about from the outside. - An unknown locale is an error, not a quiet fall back to the default. An
agent sending
fr-FRwherefrwas configured needs to be told. list_pagestakeslocaletoo, so an agent can observe coverage instead of inferring it from failures.fetchneeds nothing: a translated page’s id is alreadyfr/guides/authoring.
Strictness has one consequence that must be handled rather than accepted. Under
missing: 'fallback', a French search for an untranslated topic matches
nothing, even though the answer exists in English. The fix is not to widen the
filter and not to index the English text again under fr — that duplicates
every fallback page’s chunks, which costs money and reintroduces exactly the
mixing the rule prevents. The fix is for the empty result to say so:
No passages in
frmatched. This documentation is partially translated — retry withlocale: "en", or calllist_pageswithlocale: "fr"to see what is available.
The search tool already treats an empty result as the moment an agent is most
likely to answer from memory, and already spends its response steering it
somewhere useful. This is the same move, one step further.
The filter itself is a metadata comparison the index already supports —
{ key: 'locale', type: 'eq', value: locale } on the field index-sync.mjs
uploads — so it belongs at the index and not after it. A post-filter would ask for
ten passages, discard the seven in another language, and hand back three, which
makes limit mean nothing.
Then check it again on the way out, against the manifest. Not because the index filter is expected to fail, but because the two retrieval backends filter by different mechanisms — a metadata filter for the semantic pass, a separate index file for the lexical one — and a contract this strict should not depend on both being right. The manifest is the one place a page’s language is stated as a fact, so it gets the last word.
Budget for the index: items are pages × locales, and items are billed.
UI strings
The chrome has its own English in it — “On this page”, “Copy page”, the search placeholder, the previous/next labels, the notices this page introduces. That needs a flat, typed dictionary:
const en = {
onThisPage: 'On this page',
copyPage: 'Copy page',
} as const;
/* The keys of `en`, with plain `string` values. */
export type UiStrings = { [K in keyof typeof en]: string };
export const ui: Record<string, UiStrings> = {
en,
fr: { onThisPage: 'Sur cette page', copyPage: 'Copier la page' },
};Type it so a missing key fails astro check rather than rendering undefined in
a topbar.
The mapped type is not decoration, and the obvious version does not work.
as const on the English block narrows every value to its own literal type, so
under typeof en the only valid onThisPage is the string 'On this page' — and
every translated line is a type error. The keys are what must match; the words are
the entire point of the file.
Add a runtime check beside it: a locale configured in i18n.locales with no entry
here should throw at import time, not render an English topbar to French readers.
This is the only genuinely new code a multilingual fork owns; the rest is adjustments to files that already exist.
Getting the translations written
The mechanical part is worth scripting rather than doing by hand: an
i18n:export that writes one CSV row per page — id, target path, whole source
file, empty target column — and an i18n:import that reads the translated CSV
back into content/<locale>/. Whole files, not fields: a title translated apart
from the prose it heads is how a page ends up with two voices.
Two things that sound optional and are not. Write a real RFC 4180 parser, or
rather about forty lines of one: every cell is quoted because page bodies contain
commas, newlines and quotation marks, and a split on , corrupts every row. And
sandbox the path column to content/ before writing it. A CSV is an outside
input — it has been through a spreadsheet and possibly a third-party service — and
one that can name any path on disk is a way to overwrite src/docs.config.ts from
a translation sheet.
Better still, do not read the destination from the sheet at all. Derive it from the locale and the page id, which you already have. Validating an untrusted path is good; not having one is better, and the two cost the same to write.
CSV because it is what translation tooling accepts natively, human or machine — AI Glot is one such tool, built for exactly this shape of file.
Whatever produces the text, review it before merging. A machine translation of an API reference will translate an identifier eventually, and a translated identifier is a support ticket.
Acceptance criteria
Written as assertions, because that is what they should become. npm test
already builds the site and reads its outputs (test/publication.test.mjs), so
there is somewhere for each of these to live.
With locales: ['en', 'fr'], defaultLocale: 'en', and a guides/authoring
page translated into French while guides/deployment is not:
/guides/authoringand/fr/guides/authoringboth exist. No URL gained an/enprefix./fr/guides/deploymentexists underfallbackand 404s underhide.- That page’s canonical is
/guides/deploymentunderfallback. - It is not listed as an
hreflang="fr"alternate anywhere. /guides/authoringlistsfr,/fr/guides/authoringlistsen, and both list themselves and anx-default./sitemap.xmlis an index naming/sitemap-en.xmland/sitemap-fr.xml. No fallback URL appears in the French one.robots.txtnames/sitemap.xmland nothing else./fr/llms.txtexists, lists French pages only, and/llms.txtlinks to it./fr/search-index.jsoncontains no English page.searchwith nolocalereturnsenpassages only; withlocale: 'fr',frpassages only; withlocale: 'fr-FR', an error.- Every French page’s manifest entry reports
locale: 'fr'. - A French file whose last commit predates its source’s appears in the build’s stale list, and only that file.
draft: trueonguides/authoringremoves/fr/guides/authoringtoo.- Every asset every localized page references exists in
dist/— the check that already catches thebase/basePathmismatch.
And with de configured but not a single German file, in either mode:
- No route, sitemap, corpus file, search index or share card exists for
de, and the whole build is byte-identical to the monolingual one.
One more, and it is mechanical enough to belong in i18n:check rather than in a
test file:
- No page under
/fr/links to a URL outside/fr/that has a French translation. Nothing else catches cross-locale link leakage — the page renders and the link resolves, so it survives review, CI and a read-through. It was found by grepping built HTML.
Tests written before i18n will pass, then fail on the first translation
Expect this, and do not treat it as a regression. A suite that asserts, for every
page in content/, that its id appears in dist/sitemap.xml, dist/llms.txt
and dist/search-index.json breaks the moment a translation exists — correctly,
because a translated page belongs to its own locale’s outputs. sitemap.xml
also stops being a list of pages and becomes an index as soon as a second locale
has content, so even the default locale’s URLs move into sitemap-en.xml.
The tempting repair is to make the test’s file walk skip locale directories. That is the wrong one: it removes coverage from the newest and least-exercised code on the site. Resolve each page against its own locale’s artifacts instead.
Three further traps in the same suites, all of which read like product bugs and are not:
- A locale’s homepage is its root.
startsWith('/fr/')excludes/fr, which is the one page that proves the locale is live. list_pagesanswers for one locale while the manifest carries every language. Comparing their counts directly fails by exactly the number of translations.- Byte length is not character length. A shell
${#var}over adescription:counts bytes, so every accented language over-reports; Zod’s.max(170)counts characters. One sweep “fixed” 31 files before noticing 11 were never over the limit.
/sitemap.xmlis still a flat<urlset>, not an index.- No page advertises an alternate at all — a lone self-reference is not a set.
- The language picker is not rendered.
Four more that hold whatever the state, and are the ones worth writing first because they keep holding as content arrives:
- Every
hreflangset is reciprocal and includes its own page. - Every canonical, every alternate, every sitemap
<loc>and every picker link resolves to a file the build produced. - No sitemap lists a URL whose page canonicalises elsewhere.
- Every manifest entry’s
localematches the locale in its own id.
Written this way they iterate over an empty set on a monolingual site — so have
them report that they did, the way the draft rules already do in
test/publication.test.mjs, rather than pass in silence. That is what makes them
start biting the day content/fr/ gains its first file, with nobody having to
remember to come back.
Pitfalls, by symptom
These are the ones that cost an afternoon, listed by what you see rather than by what causes it.
Every non-Latin character renders as a box, or in a system font.
astro.config.ts self-hosts the webfont with subsets: ['latin']. Russian,
Greek, Japanese and Arabic all need their subset added — and the file size that
comes with it. Share cards are affected in the same way and often noticed later,
since nobody looks at them.
The build fails on a translated page’s frontmatter, after the round trip.
description is capped at 170 characters (src/content.config.ts), and German
and Spanish routinely run 15–25 % over English. A description written comfortably
at 160 comes back at 200 and fails validation at the least convenient moment —
after translation, when the fix belongs in the source file rather than the one
the error names.
Aim for ~140 characters in source descriptions on a site that intends to translate. The field’s own comment says 150–160, which is right for one language and already too close to the ceiling for six. Raising the cap for non-default locales is the other option, and worse: search snippets truncate anyway, so the limit is doing real work.
Every translation reports as stale on the first CI run. A shallow clone
gives every file the checkout date. actions/checkout needs fetch-depth: 0,
the same requirement the existing dateModified already has.
hreflang has no effect at all. Almost always a non-reciprocal set, or a
missing self-reference. Both are assertable at build time; assert them.
A translated page appears for a source page that does not. draft: true on
a source must suppress its translations too, in every locale. Drafting a page
and leaving five translated copies live is the inverse of what the flag means.
Search sometimes answers in the wrong language, under load only. The Worker caches the lexical index in a module-level slot, and one slot serves whichever language asked first to every language that asks after it, for the life of the isolate. One cache entry per locale. This is invisible in local testing, where one request arrives at a time.
A CSV column lookup fails on exactly one column, the first. Translation
services and Excel both write a UTF-8 BOM, which fuses onto the first header
cell — kind rather than kind — so that field alone silently misses. One
line at the top of the parser:
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);A new locale cannot be added because its UI strings do not exist yet. The
dictionary throws at import time for a configured locale with no entry, which is
the right design and also a chicken-and-egg. Write the strings first. If you must
seed them, write the English values out in full rather than spreading ...en —
a per-key check reads the file as text and a spread satisfies nothing — and do
not deploy the locale until the real strings land.
A locale’s search finds less than its sidebar shows. Under fallback, only
translated pages are indexed, so a reader can browse to a page search cannot
find. The alternative — indexing the English body under the French index — is
worse and much harder to notice, so this is the trade to take knowingly rather
than a defect to fix.
Deciding later costs nothing
Because the default locale is unprefixed and a page’s id is its path, adding languages never moves an existing URL. There is no redirect map, no link rot, and no reason to make this decision before you have a translator.
Start monolingual. The tree accepts content/fr/ the day it exists.