Definition
A locale suffix is a storage convention that appends a language code to the end of a markdown filename — for example .en.md or .ja.md — so documents that share the same slug but differ only by locale live side by side. Files without a suffix are treated as the default locale (e.g. ko).
When my-article.md (ko), my-article.en.md, and my-article.ja.md coexist under the same my-article slug, href, canonical, and hreflang can share the slug axis and keep multilingual sitemaps simple.
Why it matters
Splitting content into locale-specific folders like content/en/ and content/ja/ forks category paths, internal links, and build scripts, which raises maintenance cost. The suffix approach keeps the directory layout unchanged and adds locale as a separate data axis.
However, if locale lives only in filenames while DB primary keys and search index ids stay as type:slug, you get silent row collisions, zero-result search hydration, and MiniSearch duplicate-id exceptions. Locale must be threaded consistently from filename → types → SQLite id → search-index id → API queries.
How it works
- Loader: When globbing, parse
.{locale}.mdand fillContentDocument.locale. Default tokowhen absent. - SQLite id:
type:slug:locale(e.g.knowledge:webpack-splitchunks:en). List/detail APIs filter withWHERE locale = requested locale. - Search index: MiniSearch document ids use the same format as the DB. Filter via
SearchScope.locale. - Untranslated fallback: On detail, if the requested locale document is missing, redirect to
/ko(explicit choice instead of 404 or empty fallback render). - Translation metadata: Machine-translated en/ja files set frontmatter
translated: machine. Hiding them withstatus: draftmakes that locale's list appear empty. - SEO: Sitemap hreflang outputs only locales where a file actually exists. Keep canonical,
og:locale, and JSON-LDinLanguagein sync.
Practical application
- Extend
build-content-db.ts,schema.ts,loader.ts,queries.ts, andsearch-index.tsto be locale-aware in one PR. - Before changing PK/id format,
grepall consumers (search hydration, list API, relations). - Replace copy-pasted redirects in four detail routes with one helper like
getLocalizedDocumentOrRedirect. - Pass the active locale explicitly to relations, load-more, and infinite-scroll APIs.
- When delegating bulk translation, instruct agents: "Never reconstruct body from compressed Read output; re-read with
limitpagination."
Trade-offs
| Choice | Pros | Cons |
|---|---|---|
| Locale suffix | Shared slug/href, simple hreflang | Changing id format requires updating the whole pipeline at once |
Locale subtree (/en/...) | Easy to see language from folder alone | Duplicated slug, link, and build rules |
| Single file + frontmatter locale | Fewer files | Hard to edit and diff multiple languages for the same slug |
| Untranslated 404 | Strict | Rough UX |
| Untranslated → ko redirect | Readable fallback | URL changes to ko (intentional) |
When not to use
- When each locale needs a completely different slug and structure (separate content, not translation).
- When you plan to add locale to ids without aligning search and DB consumers first (partial rollout).
- When machine translations are hidden as draft and unintentionally empty that language's catalog.
Common mistakes
- Changing DB id to
type:slug:localebut leaving search-index id astype:slug→ duplicate throw or zero hydration. - Scoping list pages to locale but hard-coding relations/load-more to ko.
- Setting
translated: machinebut hiding with draft status so en/ja nav looks "empty." - Subagents reconstructing body from compressed Read output so translation files diverge from source.
Related concepts
- json-ld-structured-data —
inLanguageand canonical metadata - inverted-index-full-text-search — search document id consistency
- single-source-of-truth-content-metadata — frontmatter as SSOT