astro-i18n-setup
Skill9 starsChanged 30 days agoMITView on GitHub
Turn a new-website scaffold into a turnkey multi-language site: Astro i18n routing (clean default locale + prefixed others), self-referencing hreflang + x-default in the Base layout, i18n sitemap alternates, a language switcher, and the test-harness changes that keep the green-from-commit-1 gate honest across locales. Run at scaffold time when new-website Q4 = "two+ languages at launch", OR later to add a second language to a single-locale site whose default stays unprefixed (phased rollout). Trigger phrases: "multi-language site", "add i18n", "DE+EN site", "internationalization", "hreflang setup", "two languages at launch".
1 other project uses a version of this.
Adopted by
Most recently taken up by
- karero/website-builderword for word, first seen Oct 14, 2026
Other versions1 project uses a version of this.
Word for word · 1
- karero/website-builder★ 9100% same
Edited · 0
None found.
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository41 other files it gives its agents.
- skills/new-website/templates/AGENTS.mdAGENTS.md
- skills/new-website/templates/CLAUDE.mdCLAUDE.md
- ai-seoSkill
- business-listings-setupSkill
- copywritingSkill
- customer-researchSkill
- double-knuthSkill
- facts-checkSkill
- imageSkill
- independent-reviewSkill
- internal-link-auditSkill
- keystatic-setupSkill
- new-websiteSkill
- og-imagesSkill
- outgoing-link-auditSkill
- schema-markupSkill
- search-console-insightsSkill
- search-console-setupSkill
- seo-auditSkill
- seo-repositionSkill
- site-architectureSkill
- website-brand-kitSkill
- website-contact-formSkill
- website-content-guideSkill
- website-design-systemSkill
- website-hero-carouselSkill
- website-launch-checkSkill
- website-motionSkill
- website-new-pageSkill
- website-permissionsSkill
- website-positioning-checkSkill
- website-positioningSkill
- website-qaSkill
- website-reviewSkill
- website-scorecardSkill
- website-seo-geoSkill
- website-storySkill
- website-taglineSkill
- website-team-setupSkill
- website-testimonialsSkill
- wordpress-inspectorSkill
The file
We split this skill into the rules it gives an agent
12 rules. About 0 are common, 1 is distinctive, and 11 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
11
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 52
Phase 2 — add the locale: When translating, apply this skill's changes: add the new locale to astro.config i18n.locales, the sitemap({ i18n …
- Distinctive: line 73
Not every route needs every locale.
- Very specific: line 146
Add the i18n block (default + launch locales, prefixDefaultLocale: false) and give sitemap() the matching i18n map so it emits <xhtml:link> …
- Very specific: line 166
Remove SITE.locale — and the matching lang = SITE.locale default prop in Base.astro (the lang = SITE.locale default in the Props …
- Very specific: line 172
Add the imports + derive locale/alternates, set <html lang> from the current locale, and emit self-referencing hreflang + x-default.
- Very specific: line 188
Keep the ogLocale line (config.ts's shared ogLocaleFor maps lang → og:locale).
- Very specific: line 205
Default-locale pages stay at src/pages/* (e.g.
- Very specific: line 227
Only the PAGES export changes — KEEP the rest of the file (THEMES, germanFunctionWordDensity, GERMAN_FUNCTION_WORDS, and toneViolations …
- Very specific: line 243
Do NOT just exempt content routes: an exempted /de page shares the ENGLISH default card into German chats, the same silent-wrong-language …
- Very specific: line 287
All checks self-skip on a single-locale site (the loop has nothing to iterate).
- Very specific: line 293
Add a POSITIONING row per locale path ('/de/about': { term: '…DE term…' }).
- Very specific: line 313
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 …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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-setupcopied 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.tsself-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-localeLOCALESto pre-wire the layout. - Phase 2 — add the locale. When translating, apply this skill's changes: add the new
locale to
astro.configi18n.locales, thesitemap({ i18n })map, andLOCALES; create thesrc/pages/<locale>/…pages. hreflang,x-default, sitemap alternates andi18n.spec.tsnow 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
<a href="/ai-treffen-muenchen" lang="de" hreflang="de">Deutsch</a> 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 <xhtml:link>
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 <html lang> 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/<locale>/*(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 <loc> per page (alternates are <xhtml:link>, not extra <loc>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 <link hreflang> 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 ini18n.spec.tsif 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 stripsnav/header/footerfirst (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 <xhtml:link> 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 <html lang> (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.
That page also carries the draft notice, cleared as AGENTS.md §5 says. A notice you
translate for a page in another language must be added to the list of notice texts in
tests/placeholders.spec.ts, or the test does not see it.
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 <html
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.tsConfirm: 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
<xhtml:link> pointing at a never-built /nur-de (the §1 serialize hook).
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

