astro-i18n-setup Skill
把新网站脚手架变成开箱即用的多语言站点:Astro i18n 路由(默认语言无前缀 + 其他语言带前缀)、Base 布局中的自引用 hreflang + x-default、i18n sitemap 备用链接、语言切换器,以及让测试从首次提交就跨语言保持通过的测试工具改动。在新网站 Q4 = "上线时支持两种及以上语言" 时于脚手架阶段运行,或稍后为默认语言无前缀的单语言站点添加第二种语言(分阶段上线)。触发语句:"多语言站点"、"添加 i18n"、"德语+英语站点"、"国际化"、"hreflang 设置"
安装方式:把技能目录放入 ~/.claude/skills/(Claude Code)或在 claude.ai 设置中启用;也可复制右侧安装命令一键添加。
技能指令原文(SKILL.md)
Astro i18n setup (scaffold-time, opt-in)
Wires turnkey internationalization into the new-website Astro overlay. Opt-in:
the default scaffold stays single-locale. Run it either at scaffold time for a
2+-language launch (new-website §1 Q4), or later when you add a second language to
an existing single-locale site — see Phased rollout below.
Safe vs unsafe: because the default locale stays unprefixed (/, not /en/), adding
a secondary prefixed locale (/de/…) is purely additive — it never moves an existing
URL. What DOES break links/SEO is moving the default locale itself off / (e.g.
retrofitting / → /en/); this skill never does that, so keep `prefixDefaultLocale:
false`. A full re-prefixing of the default is a migration, not this skill.
Routing model (house default): clean default locale, prefixed others —
/ and /about are the default language (marked x-default); other languages get
a prefix (/de/, /de/about). prefixDefaultLocale: false. This keeps the
default-locale URLs identical to a single-locale build, so nothing regresses.
Deep SEO rules (reciprocity, x-default, canonical-vs-hreflang, sitemap structure,
"translate the whole page not just chrome") live in
seo-audit/references/international-seo.md — read it, don't re-derive it.
Phased rollout (primary language first, translate later)
You don't have to translate everything before launch. Because the default locale is
unprefixed, you can build and ship the primary language first, then add the second
locale when the translations are ready — without moving a single existing URL:
- Phase 0 — vendor this skill NOW. A declared-multilingual-phased site gets
astro-i18n-setup copied into its skills dir at scaffold time (new-website §3's
conditional copy list) even while running single-locale — Phase 2 below is
executed months later by whoever holds the handoff, and the instruction can't
resolve if the skill isn't in the project.
- Phase 1 — primary only. Build the site in the default language and ship it. Keep
LOCALES = [DEFAULT_LOCALE] (one entry). tests/i18n.spec.ts self-skips while
there's a single locale (LOCALES.length < 2), so the full QA gate is green with no
half-built hreflang. You can skip this skill entirely for now, or apply it with a
single-locale LOCALES to pre-wire the layout.
- Phase 2 — add the locale. When translating, apply this skill's changes: add the new
locale to astro.config i18n.locales, the sitemap({ i18n }) map, and LOCALES;
create the src/pages//… pages. hreflang, x-default, sitemap alternates and
i18n.spec.ts now enforce both locales — and the default-language URLs are exactly what
they were in Phase 1.
The one rule: commit to an unprefixed default from day 1 (`prefixDefaultLocale:
false`). That's the only decision that's expensive to change later; the translations
themselves are always additive.
Localized 404
The starter's src/pages/404.astro serves every unmatched URL, whatever its
locale prefix. Translate its strings to the DEFAULT locale's language; a
per-locale 404 (src/pages/de/404.astro) is optional and needs no routing
changes — Cloudflare Pages serves /de/404 for unmatched /de/* paths when
it exists. Keep any 404 noindex and out of PAGES (@astrojs/sitemap already
excludes status-code pages from the sitemap by itself).
Partial translation (some routes are not translated)
Not every route needs every locale. A German company running a DE+EN site whose blog
stays German-only, or an English-first site adding one German landing page, does not
have to translate everything to keep the i18n gate on.
Mark such a route in src/config.ts's ROUTES registry (§2) with an explicit
locales list; a route with no locales field exists in every locale (unchanged
default):
export const ROUTES: readonly RouteSpec[] = [
{ path: '/' },
{ path: '/privacy' },
{ path: '/blog/some-post', locales: ['de'] }, // German-only, no EN variant
];
One registry drives everything: tests/_helpers.ts PAGES (the EN variant is never
expected to exist), Base.astro's head hreflang (the route emits one de alternate +
a self-referencing x-default — never a link to a never-built page), the sitemap
serialize hook (§1 — no advertised-but-missing alternate), and
tests/i18n.spec.ts's completeness/x-default/reciprocity/thin-translation checks
(all per-route via routeLocales(); a route without a default-locale variant gets a
deterministic x-default — its first listed locale — on every variant). Google's
guidance is explicit that hreflang belongs only on pages that actually have variants
(international-seo.md) — this is that rule, made enforceable.
Light path: twin pages (no prefixed routing)
For a handful of translated pages on an otherwise single-language site, the full
routing setup above is more machinery than the job needs. The default kit
Base.astro ships an optional alternates prop instead — no astro.config changes,
no locale prefixes, no LOCALES:
Give each paired page its own localized slug (see "Localized slugs" in
seo-audit/references/international-seo.md) — e.g. /ai-events-munich (EN) and
/ai-treffen-muenchen (DE) — and pass BOTH pages the full cluster, including a
self-referencing entry and x-default:
<!-- /ai-events-munich (EN page) -->
<Base title="…" description="…" alternates={[
{ hreflang: 'en', href: '/ai-events-munich' },
{ hreflang: 'de', href: '/ai-treffen-muenchen' },
{ hreflang: 'x-default', href: '/ai-events-munich' },
]}>
<!-- /ai-treffen-muenchen (DE page) -->
<Base lang="de" title="…" description="…" alternates={[
{ hreflang: 'de', href: '/ai-treffen-muenchen' },
{ hreflang: 'en', href: '/ai-events-munich' },
{ hreflang: 'x-default', href: '/ai-events-munich' },
]}>
tests/seo.spec.ts's twin-page reciprocity check (in the default suite) enforces
that both halves agree: a self-referencing entry equal to the canonical, no dead
same-site targets, and A→B implies B→A. Forgetting the cluster on one twin — the
exact bug that shipped live on a real site built from this kit (an English event
page with no alternates, so its German twin's hreflang went unreciprocated,
which makes Google ignore the pair) — now fails the build. Also add a visible
Deutsch near the
nav/footer: alternates only talks to crawlers, not visitors.
Pick the heavy path (everything above this section) when most of the site is
translated and you want prefixed routing + a language switcher; pick this light
path for a few one-off translated pages. Don't mix both on the same SITE: a
localized-slug twin can't be expressed in ROUTES (non-default locales map to
/prefix paths there), so on a heavy site a light-path page fails i18n.spec's
completeness check by design.
What it changes
1. astro.config.mjs — i18n routing + sitemap alternates
Add the i18n block (default + launch locales, prefixDefaultLocale: false)
and give sitemap() the matching i18n map so it emits
hreflang alternates — plus a serialize hook that filters each entry's
alternates down to the locales the route actually exists in (sparse routes:
routes given an explicit locales list in §2's ROUTES registry — see
"Partial translation" above). The hook imports the shared registry from
src/config.ts (Vite loads the Astro config, so the TS import works).
Verbatim config: references/heavy-path-code.md §1.
Fully-translated sites (no locales overrides in ROUTES): the hook is a no-op.
2. src/config.ts — replace the single locale, add the route registry
Replace SITE.locale with LOCALES / DEFAULT_LOCALE / LOCALE_LABELS, and
add the per-route ROUTES registry — THE single registry that Base.astro
(head hreflang), astro.config.mjs (sitemap alternates) and the tests all read,
so they can't drift apart; a route with NO locales field exists in every
locale. The block also carries fail-loud import-time validation of the
registry (duplicate path, unknown or repeated locale) and the pathLocale() /
neutralPath() / routeLocales() helpers everything else imports — apply it
whole. Verbatim code: references/heavy-path-code.md §2.
Remove SITE.locale — and the matching lang = SITE.locale default prop in
Base.astro (the lang = SITE.locale default in the Props destructure): the layout derives the locale from Astro.currentLocale now
(step 3), so leaving that default referencing the deleted export breaks the build.
Everything reads DEFAULT_LOCALE / Astro.currentLocale.
3. src/layouts/Base.astro — locale-aware (VERIFIED build output)
Add the imports + derive locale/alternates, set from the current
locale, and emit self-referencing hreflang + x-default. Verbatim code, with
the hard-won caveats preserved as inline comments:
references/heavy-path-code.md §3. Build and confirm the output (reciprocal
hreflang, and clean trailing slashes under trailingSlash: 'never').
The starter's CHROME chrome-strings lookup keys on SITE.locale, which §2
removed — rekey it on Astro.currentLocale ?? DEFAULT_LOCALE (per-page chrome
language IS correct on a locale-routed site, unlike the single-locale starter).
The template's LIGHT-path pieces are superseded on a heavy site: **delete the
{alternates.map(…)} render line** (the hreflang cluster from
references/heavy-path-code.md §3 replaces it) — the
alternates prop and altHref helper then sit unused; remove them too or leave
them, but never feed both emission paths on one page.
The starter's Astro.locals.lang = lang; line becomes Astro.locals.lang = currentLocale;
(in the §3 code), so a contact form on the page speaks the page's language.
Keep the ogLocale line (config.ts's shared ogLocaleFor maps lang → og:locale);
optionally add og:locale:alternate for the non-current locales. inLanguage in the
WebPage/WebSite schema should use currentLocale.
4. src/components/LanguageSwitcher.astro (new)
Copy the ready component from references/LanguageSwitcher.astro into
src/components/ and render it in the site's nav/header on every page. It links
the current page in each locale the route exists in — routeLocales(neutral)
(§2), NOT LOCALES, so a sparse route's switcher never links a never-built
variant — labelled from LOCALE_LABELS, active locale aria-current="true".
tests/i18n.spec.ts asserts every multi-locale page carries it and that each
switcher's links point at exactly the route's real siblings (hreflang AND
href). Rendering it twice (header + footer) is fine — the check runs per
switcher — but give the second one a distinct ariaLabel (axe's
landmark-unique rule).
5. Page/content structure
- Default-locale pages stay at
src/pages/*(e.g.src/pages/about.astro→/about). - Each other locale lives under
src/pages//*(e.g.src/pages/de/about.astro→/de/about). - With Content Collections, add a
localefield (or per-locale entry dirs) and render
locale routes from it. Translate the whole page, not just the chrome — partial
translation reads as thin/duplicate (international-seo.md).
6. public/llms.txt — keep ONE unified file
List the key pages across all locales in a single /llms.txt (do not split per
locale) — each entry's description written in THAT page's language (the German
entries in German), per website-seo-geo's GEO rule.
Test-harness changes (the part that keeps the gate honest)
tests/_helpers.ts — generate PAGES from the route registry
import { DEFAULT_LOCALE, ROUTES, routeLocales } from '../src/config';
export const PAGES = ROUTES.flatMap((r) =>
routeLocales(r.path).map((loc) =>
loc === DEFAULT_LOCALE ? r.path : (r.path === '/' ? `/${loc}` : `/${loc}${r.path}`),
),
) as readonly string[];
Only the PAGES export changes — KEEP the rest of the file (THEMES,
germanFunctionWordDensity, GERMAN_FUNCTION_WORDS, and toneViolations with the
tone rules it reads): the tone and i18n specs import
them, and replacing the whole file with just this snippet breaks the suite at compile
time.
With no locales overrides this yields the identical set as before — /,
/privacy, /de, /de/privacy (route-major order instead of locale-major; every
consumer sorts or iterates, so nothing observes the order). A sparse route appears
only under its own locales, so navigation.spec never 404-checks a variant that was
deliberately not built. seo.spec.ts's canonical check (`canonical === SITE.url +
path`) and the sitemap-drift test pass unchanged, because the i18n sitemap still
emits one per page (alternates are , not extra s).
Adding a locale also brings the new /de* pages under seo.spec.ts's own-OG-card
guard. Give each real CONTENT page a translated card — an entry with German
title/subtitle text in generate_og_cards.py PAGES plus image= on the page.
Do NOT just exempt content routes: an exempted /de page shares the ENGLISH
default card into German chats, the same silent-wrong-language failure the
German gates exist to stop. Utility twins are the exception — make the
exemption locale-neutral with one line in seo.spec.ts (both places that
consult it), so /de/privacy inherits /privacy's exemption. The HOME route
must NOT inherit: / is exempt only because the default card IS the home
card — a rationale that does not transfer to /de, which needs its own
translated card in generate_og_cards.py like any content page:
const isCardExempt = (p: string) =>
OWN_CARD_EXEMPT.has(p) || (neutralPath(p) !== '/' && OWN_CARD_EXEMPT.has(neutralPath(p)));
(import neutralPath from ../src/config; replace the two OWN_CARD_EXEMPT.has(path)
call sites with isCardExempt(path).)
tests/i18n.spec.ts (new) — hreflang contract
Drop in the ready spec references/i18n.spec.ts (copy it to the project's tests/).
Like every tests/*.spec.ts, that copy is frozen — after a make refresh reports
astro-i18n-setup stale, diff references/i18n.spec.ts against the project's
tests/i18n.spec.ts by hand (and treat it + tests/_helpers.ts as a pair: the spec
imports helpers, so refresh both together or the import breaks loudly).
For every page in PAGES it asserts: exactly one per locale THE ROUTE
EXISTS IN (routeLocales(), §2) plus x-default; every href is the absolute
production URL; the page's canonical equals its OWN-locale alternate; x-default → the
default-locale variant (or the route's first listed locale when it has no default-locale
variant); and cross-page reciprocity (A→B implies B→A — the #1 hreflang mistake). It also machine-checks the
"translate the whole page" rule below, in two parts (word-count thinness and wrong-language
body are different failure modes — one check can't catch both):
- Stub/thin translation: for every non-default-locale page it follows its own
default-locale hreflang alternate back to the original, counts words in both via
document.body.innerText, and fails if the translated page has under 40% as many words —
catching a dramatically shorter placeholder page. Word-splitting assumes a whitespace-
delimited language (this skill's documented use case, DE+EN) — it does not work for
scripts without whitespace word boundaries (Japanese, Chinese, Thai, …); see the code
comment in i18n.spec.ts if you need one of those.
- Wrong-language body (German only): word count can't tell a same-length body that's
still in the original language from a real translation — nav/footer translated, body left
untranslated, and the ratio check above passes clean since nothing got shorter. For pages
whose locale's PRIMARY subtag is de (de, de-DE, de-AT, de-CH, …), a second check
strips nav/header/footer first (their own translated chrome can otherwise mask an
untranslated body on a short page — verified empirically) and counts common German
function words (der/die/das/und/ist/…) in what's left, failing if their density is near
zero — real German body prose runs ~15–20%, a non-German body runs ~0%.
All checks self-skip on a single-locale site (the loop has nothing to iterate). The
matching sitemap alternates come from the sitemap({ i18n }) config
above — i18n.spec.ts machine-checks them against routeLocales() per entry (no
manual dist/sitemap-0.xml grep needed).
tests/positioning.spec.ts — key per locale
Add a POSITIONING row per locale path ('/de/about': { term: '…DE term…' }); the
positioning term is translated, so each locale owns its own phrase.
tests/tone.spec.ts already branches on (through toneViolations in
tests/_helpers.ts): universal rules (the em-dash ban)
always apply, and English-specific rules layer on top for lang starting en, German-
specific rules layer on top for lang starting de — other languages get only the
universal rules. No change needed.
tests/placeholders.spec.ts iterates PAGES, so it covers each locale's pages with no
change. One edit, before launch only: a second locale's legal page built from one of the kit's drafts
(/de/privacy from _datenschutz.astro) carries [BRACKET] slots until the owner
fills them, so add its route to UNFILLED_UNTIL_LAUNCH next to /privacy. That is
the one case in which the list may grow; on a launched site, fill the slots instead.
Regional German (DACH)
Default to bare de for one German variant — it targets every German-speaking
market at once, and de-DE/de-AT/de-CH variants whose content barely differs
are a near-duplicate risk, not a win (international-seo.md, "Near-Duplicate
Regional Variants"). Go regional only when content genuinely differs per market
(prices in CHF, different legal entities, Swiss ß→ss spelling).
For real multi-region German, the Astro-level mechanism is { path, codes }
locale objects — NOT LOCALES = ['de-DE', 'de-CH'] with plain string routing,
which yields ugly /de-DE/… URL prefixes:
// astro.config.mjs — Astro syntax reference ONLY, see the warning below
i18n: {
defaultLocale: 'de',
locales: [{ path: 'de', codes: ['de', 'de-DE'] }, { path: 'ch', codes: ['de-CH'] }],
routing: { prefixDefaultLocale: false },
}
The kit's harness does NOT yet support path≠code locales — config.ts
LOCALES holds plain strings that must equal the URL prefixes: applying the
config above to a kit-built site crashes the build (getRelativeLocaleUrl
throws for unknown locales), and mapping regioned codes into
sitemap({ i18n }) makes the sitemap say de-DE where routeLocales() says
de — failing the sitemap-consistency test with a genuine head/sitemap
contradiction. Treat multi-region as a harness EXTENSION: LOCALES,
pathLocale(), PAGES generation and the serialize hook all need the
path↔codes mapping first. Two Astro behaviors to know when you build that:
the sitemap i18n map takes ONE code per path key (pick the canonical one),
and Astro.currentLocale returns codes[0] — order matters, and `
lang>` (which drives the German tone/density checks via the primary subtag)
follows it. Until then: bare de is the supported answer.
Wiring (done once in the suite)
new-website/SKILL.md§1 Q4 → "if 2+ languages: runastro-i18n-setup"; add the
skill to the §3 bundled-skills copy list (so multilingual handoffs are self-contained).
references/WEBSITE_ARCHITECTURE.mdPart 7 → point at this skill.
Verify (scaffold a throwaway 2-locale site in /tmp)
npm run build
# dist/ has /index.html + /de.html (served /de) + nested /de/<page>.html
grep -o 'hreflang="[^"]*"' dist/index.html # en, de, x-default present & reciprocal
grep -c '<xhtml:link' dist/sitemap-0.xml # alternates present
npm test # green, INCLUDING tests/i18n.spec.ts
Confirm: every page self-references + lists every locale its route exists in +
x-default; default-locale URLs are unchanged from a single-locale build; trailing
slashes are consistent (/ keeps its slash, /de and sub-pages have none).
Testing a sparse route? Add { path: '/nur-de', locales: ['de'] } to ROUTES,
create only src/pages/de/nur-de.astro, rebuild, and confirm: /de/nur-de emits
exactly one locale alternate + a self-referencing x-default; npm test stays
green with no EN variant anywhere; and dist/sitemap-0.xml carries no
pointing at a never-built /nur-de (the §1 serialize hook).