Skip to content

fix(i18n): support hyphenated locale codes - #1434

Open
yeeway0609 wants to merge 1 commit into
nuxt-content:mainfrom
yeeway0609:fix/locale-codes-hyphen
Open

fix(i18n): support hyphenated locale codes#1434
yeeway0609 wants to merge 1 commit into
nuxt-content:mainfrom
yeeway0609:fix/locale-codes-hyphen

Conversation

@yeeway0609

Copy link
Copy Markdown
Contributor

Resolves #1233, resolves #1280.

Any locale whose code contains a hyphen — zh-CN, zh-TW, pt-BR, en-GB, de-CH — 404s on every route, and the build still exits 0. Docus ships zh-CN.json, zh-TW.json and pt-BR.json, so those locales look supported but have never worked.

Why it happens

The collection is created, but it is empty:

docs_en          2 rows
docs_zh_CN       0 rows      <-- created, but nothing was ever inserted

One locale code is used for four things with conflicting constraints:

Usage Constraint Needed for zh-CN
Collection name must be a valid JS identifier — resolveCollection() rejects - and drops the collection docs_zh_cn
Content folder / include glob must match what is on disk content/zh-CN/
Page path / URL prefix Nuxt Content slugifies every segment with lower: true /zh-cn/...
html lang, hreflang canonical BCP 47 tag zh-CN

#1246 addressed only the first row. One code, four call sites, four spellings:

// content.config.ts — #1246 replaced the hyphen in the shared variable
const code = locale.code.replace('-', '_')            // 'zh_CN'
collections[`docs_${code}`] = defineCollection({      // 'docs_zh_CN'  valid
  source: {
    include: `${code}/**/*`,                          // 'zh_CN/**/*'  no such folder
    prefix: `/${code}`,                               // '/zh_CN'
  },
})

// modules/config.ts — no replace, so the locale filter looks elsewhere
existsSync(join(rootDir, 'content', locale.code))     // 'content/zh-CN'

// app.vue, error.vue, AppSearch.vue, landing.vue, [...slug].vue,
// server/utils/content.ts, sitemap.xml.ts — no replace either
`docs_${locale.value}`                                // 'docs_zh-CN'  no such collection

// app.vue, error.vue — @nuxt/ui export names are zh_cn / zh_tw
nuxtUiLocales[locale.value]                           // undefined -> English

Before #1246 the collection name was invalid and Nuxt Content warned about it; after #1246 the name is valid and the failure is silent. Both states 404.

Reproduction on main (v5.13.0)
pnpx create-docus i18n-test -t i18n
cd i18n-test

In nuxt.config.ts, replace fr with any hyphenated code:

i18n: {
  defaultLocale: 'en',
  locales: [
    { code: 'en', name: 'English' },
    { code: 'zh-CN', name: '简体中文' },
  ],
}
mv content/fr content/zh-CN
pnpm build && node .output/server/index.mjs

The build exits 0, but:

ERROR  [request error] [fatal] [GET] http://localhost/zh-CN
Page not found
  [cause]: { statusCode: 404, statusMessage: 'Page not found', fatal: true }

[nitro]   ├─ /zh-CN (214ms)
  │ └── [404] Page not found

/en returns 200, every /zh-CN route returns 404. Prerender 404s are non-fatal, which is why CI (lint + typecheck + build) stays green on a completely broken locale.

How people work around it today

Every workaround avoids the hyphen, at the cost of a non-standard locale code.

Rename the bundled locale file — suggested in #1280 ("it no longer conforms to geographic identifier codes such as zh-cn, zh-tw, zh-hk"):

mv i18n/locales/zh-CN.json i18n/locales/cn.json
mv content/zh-CN content/cn

Use a bare language code. This is what we ship today. It routes, but @nuxt/ui/locale has no zh export, so component strings stay English and <html lang> is wrong — and it needs a hook to undo Docus's own locale filter:

const locales = [
  { code: 'en', name: 'English' },
  { code: 'zh', name: '繁體中文', language: 'zh-Hant', file: 'zh.json' },
]

export default defineNuxtConfig({
  extends: ['docus'],
  modules: [
    '@nuxtjs/i18n',
    // Docus only keeps locales that have a bundled locale file, so `zh` is
    // dropped from `filteredLocales`, `locales.length > 1` is false and the
    // language switcher never renders. Put it back after modules are done.
    (_options, nuxt) => {
      nuxt.hook('modules:done', () => {
        const docus = nuxt.options.runtimeConfig.public.docus as {
          filteredLocales?: Array<{ code: string }>
        }
        const filtered = docus.filteredLocales ?? []

        docus.filteredLocales = [...filtered, ...locales.filter(locale =>
          !filtered.some(item => item.code === locale.code),
        )]
      })
    },
  ],
  i18n: { defaultLocale: 'en', locales },
})

zh cannot distinguish Simplified from Traditional, so sites needing both end up dropping one.

The fix

Derive the two forms the code actually needs instead of overloading one variable — the same layering the rest of the ecosystem uses, where @nuxt/ui keeps code: "zh-TW" in its data and normalizes only its export names:

// layer/utils/locale.ts
normalizeLocale('zh-TW')   // 'zh-tw'  URL prefix, i18n code
getLocaleKey('zh-TW')      // 'zh_tw'  collection name, @nuxt/ui export name
  • Normalize codes to lowercase in modules/config.ts, since Nuxt Content always generates lowercase page paths. The original tag is kept as the locale's language, so <html lang> and hreflang still emit zh-TW. defaultLocale is normalized the same way.
  • Look the content folder up on disk rather than guessing its name, so content/zh-TW/ and content/zh-tw/ both resolve and include gets the real folder name — which is what Fix i18n collection name strategy #1246 broke.
  • Route every collection name through getLocaleKey() — in content.config.ts and the nine runtime call sites that build one.
  • Fix the @nuxt/ui locale lookup, which used the raw code and silently fell back to English.
  • Match bundled locale files case-insensitively, so zh-TW.json serves the zh-tw locale, in both the i18n registration and the single-language plugin.

One part is less obvious: @nuxtjs/i18n collects its locales from each layer's raw config, not from the merged nuxt.options.i18n. Normalizing only the merged options registers a second, empty locale, so modules/config.ts normalizes the layer configs too.

Users keep writing standard BCP 47 codes, and name the content folder however they like:

i18n: {
  defaultLocale: 'en',
  locales: [
    { code: 'en', name: 'English' },
    { code: 'zh-TW', name: '繁體中文' },
  ],
}

What is accepted, before and after

code in nuxt.config URL before URL after
en, fr, ja /en /en — unchanged
zh-TW, zh-CN, pt-BR, en-GB 404 on every route /zh-tw, /zh-cn, /pt-br, /en-gb
zh-tw (already lowercase) 404 on every route /zh-tw
Content folder for code: 'zh-TW' before after
content/zh-TW/ not found resolved
content/zh-tw/ not found resolved
before after
<html lang> en@nuxt/ui lookup missed and fell back zh-TW — the locale's language
hreflang zh-tw, matching the URL

Breaking changes

None in practice.

  • Locales without a hyphen (en, fr, ja, …) — every transform is a no-op. Verified by rebuilding the en + fr docs site: identical routes, no 404s.
  • Locales with a hyphen — they 404 on every route today, so no working setup can regress, and no workaround relies on the current behavior. Existing content/zh-CN/ folders keep working; the folder is matched case-insensitively rather than renamed.
Verification

A project with three locales (en, zh-CN, zh-TW), built on main and on this branch:

  1. pnpm build — on main the prerender log shows [404] Page not found for /zh-CN and /zh-TW; on this branch it does not.

  2. node .output/server/index.mjs, then open the locale routes:

    Route main this branch
    /en, /en/getting-started/introduction 200 200
    /zh-cn, /zh-cn/getting-started/introduction 404 200
    /zh-tw, /zh-tw/getting-started/introduction 404 200
  3. View source on a zh-TW page — <html lang="zh-TW" dir="ltr">, the canonical tag, not the lowercased URL code.

  4. Docus UI strings come from the bundled zh-CN.json / zh-TW.json, and the Nuxt UI component strings from @nuxt/ui/locale's zh_cn / zh_tw.

  5. The language switcher lists all three locales, and its links, the sidebar and sitemap.xml all point at /zh-cn/... and /zh-tw/....

  6. Rebuilding the en + fr docs site gives identical routes and no 404s.

  7. A locale with no bundled locale file still warns and is skipped, as before.

Content folders work in either case — content/zh-TW/ and content/zh-tw/ both resolve, on case-sensitive filesystems too.

pnpm lint, pnpm typecheck and pnpm docs:build all pass.

Related

Everything above is a proposal, let me know if you would rather solve this differently. 😊

@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

@yeeway0609 is attempting to deploy a commit to the NuxtLabs Team on Vercel.

A member of the Team first needs to authorize it.

@pkg-pr-new

pkg-pr-new Bot commented Aug 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/create-docus@1434
npm i https://pkg.pr.new/docus@1434

commit: b810c1a

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n zh-CN show 404 not found The language code with hyphens can't generate the collection correctly

1 participant