Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions layer/app/app.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
import * as nuxtUiLocales from '@nuxt/ui/locale'
import { getLocaleKey } from '../utils/locale'
import { transformNavigation } from './utils/navigation'
import { useDocusShortcuts } from './composables/useDocusShortcuts'
import { useSubNavigation } from './composables/useSubNavigation'
Expand All @@ -12,10 +13,10 @@ const site = useSiteConfig()
const { locale, locales, isEnabled, switchLocalePath } = useDocusI18n()
const { isEnabled: isAssistantEnabled } = useAssistant()

const nuxtUiLocale = computed(() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales] || nuxtUiLocales.en)
const nuxtUiLocale = computed(() => nuxtUiLocales[getLocaleKey(locale.value) as keyof typeof nuxtUiLocales] || nuxtUiLocales.en)
const lang = computed(() => nuxtUiLocale.value.code)
const dir = computed(() => nuxtUiLocale.value.dir)
const collectionName = computed(() => isEnabled.value ? `docs_${locale.value}` : 'docs')
const collectionName = computed(() => isEnabled.value ? `docs_${getLocaleKey(locale.value)}` : 'docs')

useHead({
meta: [
Expand Down
3 changes: 2 additions & 1 deletion layer/app/components/app/AppSearch.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
import { getLocaleKey } from '../../../utils/locale'

const props = defineProps<{
navigation?: ContentNavigationItem[]
Expand All @@ -9,7 +10,7 @@ const appConfig = useAppConfig()
const { forced: forcedColorMode } = useDocusColorMode()
const { locale, isEnabled } = useDocusI18n()

const collectionName = computed(() => (isEnabled.value ? `docs_${locale.value}` : 'docs') as keyof PageCollections)
const collectionName = computed(() => (isEnabled.value ? `docs_${getLocaleKey(locale.value)}` : 'docs') as keyof PageCollections)
const useFts = appConfig.search.fts

const { data: files } = useFts
Expand Down
5 changes: 3 additions & 2 deletions layer/app/error.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { NuxtError } from '#app'
import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
import * as nuxtUiLocales from '@nuxt/ui/locale'
import { getLocaleKey } from '../utils/locale'
import { transformNavigation } from './utils/navigation'

const props = defineProps<{
Expand All @@ -10,7 +11,7 @@ const props = defineProps<{

const { locale, locales, isEnabled, t, switchLocalePath } = useDocusI18n()

const nuxtUiLocale = computed(() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales] || nuxtUiLocales.en)
const nuxtUiLocale = computed(() => nuxtUiLocales[getLocaleKey(locale.value) as keyof typeof nuxtUiLocales] || nuxtUiLocales.en)
const lang = computed(() => nuxtUiLocale.value.code)
const dir = computed(() => nuxtUiLocale.value.dir)

Expand Down Expand Up @@ -45,7 +46,7 @@ if (isEnabled.value) {
})
}

const collectionName = computed(() => isEnabled.value ? `docs_${locale.value}` : 'docs')
const collectionName = computed(() => isEnabled.value ? `docs_${getLocaleKey(locale.value)}` : 'docs')

const { data: navigation } = await useAsyncData(`navigation_${collectionName.value}`, () => queryCollectionNavigation(collectionName.value as keyof PageCollections), {
transform: (data: ContentNavigationItem[]) => transformNavigation(data, isEnabled.value, locale.value),
Expand Down
3 changes: 2 additions & 1 deletion layer/app/pages/[[lang]]/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { kebabCase } from 'scule'
import type { ContentNavigationItem, Collections, DocsCollectionItem } from '@nuxt/content'
import { findPageHeadline } from '@nuxt/content/utils'
import { getLocaleKey } from '../../../utils/locale'

definePageMeta({
layout: 'docs',
Expand All @@ -12,7 +13,7 @@ const { locale, isEnabled, t } = useDocusI18n()
const { isOpen } = useAssistant()
const appConfig = useAppConfig()
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const collectionName = computed(() => isEnabled.value ? `docs_${locale.value}` : 'docs')
const collectionName = computed(() => isEnabled.value ? `docs_${getLocaleKey(locale.value)}` : 'docs')

const [{ data: page }, { data: surround }] = await Promise.all([
useAsyncData(kebabCase(route.path), () => queryCollection(collectionName.value as keyof Collections).path(route.path).first() as Promise<DocsCollectionItem>),
Expand Down
6 changes: 3 additions & 3 deletions layer/app/plugins/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { RouteLocationNormalized } from 'vue-router'
import { consola } from 'consola'
import { findLocaleFile } from '../../utils/locale'

const log = consola.withTag('docus')

Expand All @@ -19,9 +20,8 @@ export default defineNuxtPlugin(async () => {
let locale = configuredLocale
let resolvedMessages: Record<string, unknown>

// Try to load the requested locale file
const localeKey = `../../i18n/locales/${configuredLocale}.json`
const localeLoader = localeFiles[localeKey]
const fileName = findLocaleFile(configuredLocale, Object.keys(localeFiles).map(key => key.split('/').pop()!))
const localeLoader = fileName ? localeFiles[`../../i18n/locales/${fileName}`] : undefined

if (localeLoader) {
const localeModule = await localeLoader()
Expand Down
3 changes: 2 additions & 1 deletion layer/app/templates/landing.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script setup lang="ts">
import type { Collections } from '@nuxt/content'
import { getLocaleKey } from '../../utils/locale'

const route = useRoute()
const { locale, isEnabled } = useDocusI18n()

// Dynamic collection name based on i18n status
const collectionName = computed(() => isEnabled.value ? `landing_${locale.value}` : 'landing')
const collectionName = computed(() => isEnabled.value ? `landing_${getLocaleKey(locale.value)}` : 'landing')

const { data: page } = await useAsyncData(collectionName.value, () => queryCollection(collectionName.value as keyof Collections).path(route.path).first())
if (!page.value) {
Expand Down
19 changes: 11 additions & 8 deletions layer/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { DefinedCollection } from '@nuxt/content'
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
import { useNuxt } from '@nuxt/kit'
import { joinURL } from 'ufo'
import { landingPageExists, docsFolderExists } from './utils/pages'
import { landingPageExists, docsFolderExists, findLocaleFolder } from './utils/pages'
import { getLocaleKey, normalizeLocale } from './utils/locale'

const { options } = useNuxt()
const cwd = joinURL(options.rootDir, 'content')
Expand All @@ -25,26 +26,28 @@ let collections: Record<string, DefinedCollection>
if (locales && Array.isArray(locales)) {
collections = {}
for (const locale of locales) {
const code = (typeof locale === 'string' ? locale : locale.code).replace('-', '_')
const hasLocaleDocs = docsFolderExists(options.rootDir, code)
const code = normalizeLocale(typeof locale === 'string' ? locale : locale.code)
const collectionKey = getLocaleKey(code)
const dir = findLocaleFolder(options.rootDir, code) || code
const hasLocaleDocs = docsFolderExists(options.rootDir, dir)

if (!hasLandingPage) {
collections[`landing_${code}`] = defineCollection({
collections[`landing_${collectionKey}`] = defineCollection({
type: 'page',
source: {
cwd,
include: `${code}/index.md`,
include: `${dir}/index.md`,
},
})
}

collections[`docs_${code}`] = defineCollection({
collections[`docs_${collectionKey}`] = defineCollection({
type: 'page',
source: {
cwd,
include: hasLocaleDocs ? `${code}/docs/**` : `${code}/**/*`,
include: hasLocaleDocs ? `${dir}/docs/**` : `${dir}/**/*`,
prefix: hasLocaleDocs ? `/${code}/docs` : `/${code}`,
exclude: [`${code}/index.md`],
exclude: [`${dir}/index.md`],
},
schema: createDocsSchema(),
})
Expand Down
74 changes: 46 additions & 28 deletions layer/modules/config.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { createResolver, defineNuxtModule, logger } from '@nuxt/kit'
import { defu } from 'defu'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { readdirSync } from 'node:fs'
import { findLocaleFile, normalizeLocale } from '../utils/locale'
import { findLocaleFolder } from '../utils/pages'
import { inferSiteURL, getPackageJsonMetadata } from '../utils/meta'
import { getGitBranch, getGitEnv, getLocalGitInfo } from '../utils/git'

const log = logger.withTag('docus')

type I18nLocale = string | { code: string, name?: string }
type DocusI18nOptions = { locales?: I18nLocale[], strategy?: string }
type I18nLocale = string | { code: string, name?: string, language?: string }
type DocusI18nOptions = { locales?: I18nLocale[], defaultLocale?: string, strategy?: string }
type DocusMcpOptions = { route?: string, enabled?: boolean }
type RegisterModuleOptions = {
langDir: string
locales: Array<{ code: string, name: string, file: string }>
locales: Array<{ code: string, name: string, language: string, file: string }>
}

export default defineNuxtModule({
Expand Down Expand Up @@ -82,36 +83,61 @@ export default defineNuxtModule({

if (i18nOptions && typeof i18nOptions === 'object' && i18nOptions.locales) {
const { resolve } = createResolver(import.meta.url)
const langDir = resolve('../i18n/locales')
const localeFileNames = readdirSync(langDir)

// Keep the original tag as `language` for `html lang` and `hreflang`
const normalizeLocaleEntry = (locale: I18nLocale) => {
const code = typeof locale === 'string' ? locale : locale.code
const base = typeof locale === 'string' ? { name: code, language: code } : locale

return { ...base, code: normalizeLocale(code), language: base.language || code }
}

const normalizedLocales = i18nOptions.locales.map(normalizeLocaleEntry)

// Filter locales to only include existing ones
const filteredLocales = i18nOptions.locales.filter((locale: I18nLocale) => {
const localeCode = typeof locale === 'string' ? locale : locale.code
const filteredLocales = normalizedLocales.filter((locale) => {
const localeCode = locale.code

// Check for JSON locale file
const localeFilePath = resolve('../i18n/locales', `${localeCode}.json`)
const hasLocaleFile = existsSync(localeFilePath)
const localeFile = findLocaleFile(localeCode, localeFileNames)

// Check for content folder
const contentPath = join(nuxt.options.rootDir, 'content', localeCode)
const hasContentFolder = existsSync(contentPath)
const contentFolder = findLocaleFolder(nuxt.options.rootDir, localeCode)

if (!hasLocaleFile) {
if (!localeFile) {
log.warn(`Locale file not found: ${localeCode}.json - skipping locale "${localeCode}"`)
}

if (!hasContentFolder) {
if (!contentFolder) {
log.warn(`Content folder not found: content/${localeCode}/ - skipping locale "${localeCode}"`)
}

return hasLocaleFile && hasContentFolder
return !!localeFile && !!contentFolder
})

// Override strategy to prefix
typedNuxtOptions.i18n = {
...i18nOptions,
locales: normalizedLocales,
defaultLocale: i18nOptions.defaultLocale && normalizeLocale(i18nOptions.defaultLocale),
strategy: 'prefix',
}

// @nuxtjs/i18n reads locales from each layer config, not from the merged options
for (const layer of nuxt.options._layers) {
const layerI18n = (layer.config as { i18n?: DocusI18nOptions }).i18n

if (layerI18n?.locales) {
layerI18n.locales = layerI18n.locales.map(normalizeLocaleEntry)

if (layerI18n.defaultLocale) {
layerI18n.defaultLocale = normalizeLocale(layerI18n.defaultLocale)
}
}
}

// Expose filtered locales
nuxt.options.runtimeConfig.public.docus = {
filteredLocales,
Expand All @@ -120,20 +146,12 @@ export default defineNuxtModule({
const registerI18nModule = nuxt.hook as unknown as (name: string, callback: (register: (options: RegisterModuleOptions) => void) => void) => void

registerI18nModule('i18n:registerModule', (register) => {
const langDir = resolve('../i18n/locales')

const locales = filteredLocales.map((locale: I18nLocale) => {
return typeof locale === 'string'
? {
code: locale,
name: locale,
file: `${locale}.json`,
}
: {
code: locale.code,
name: locale.name || locale.code,
file: `${locale.code}.json`,
}
const locales = filteredLocales.flatMap((locale) => {
const file = findLocaleFile(locale.code, localeFileNames)

return file
? [{ code: locale.code, name: locale.name || locale.code, language: locale.language, file }]
: []
})

register({
Expand Down
4 changes: 2 additions & 2 deletions layer/server/mcp/tools/list-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { queryCollection } from '@nuxt/content/server'
import { joinURL } from 'ufo'
import { z } from 'zod'
import { inferSiteURL } from '../../../utils/meta'
import { getAvailableLocales, getCollectionsToQuery } from '../../utils/content'
import { getAvailableLocales, getCollectionsToQuery, getLocaleFromCollection } from '../../utils/content'

export default defineMcpTool({
description: `Lists all available documentation pages with their categories and basic information.
Expand Down Expand Up @@ -60,7 +60,7 @@ OUTPUT: Returns a structured list with:
title: page.title,
path: page.path,
description: page.description,
locale: collectionName.replace('docs_', ''),
locale: getLocaleFromCollection(collectionName, availableLocales),
url: siteUrl ? joinURL(siteUrl, baseURL, page.path) : joinURL(baseURL, page.path),
}))
}),
Expand Down
3 changes: 2 additions & 1 deletion layer/server/routes/sitemap.xml.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { queryCollection } from '@nuxt/content/server'
import { inferSiteURL } from '../../utils/meta'
import { getLocaleKey } from '../../utils/locale'
import { getAvailableLocales, getCollectionsToQuery, isNavigationPath } from '../utils/content'

interface SitemapUrl {
Expand All @@ -16,7 +17,7 @@ export default defineEventHandler(async (event) => {

if (availableLocales.length > 0) {
for (const locale of availableLocales) {
collections.push(`landing_${locale}`)
collections.push(`landing_${getLocaleKey(locale)}`)
}
}
else {
Expand Down
14 changes: 11 additions & 3 deletions layer/server/utils/content.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { LocaleObject } from '@nuxtjs/i18n'
import { getLocaleKey } from '../../utils/locale'

type ConfigWithLocales = {
i18n?: { locales?: Array<string | LocaleObject> }
Expand All @@ -17,11 +18,11 @@ export function getAvailableLocales(config: ConfigWithLocales): string[] {

export function getCollectionsToQuery(locale: string | undefined, availableLocales: string[]): string[] {
if (locale && availableLocales.includes(locale)) {
return [`docs_${locale}`]
return [`docs_${getLocaleKey(locale)}`]
}

return availableLocales.length > 0
? availableLocales.map(l => `docs_${l}`)
? availableLocales.map(l => `docs_${getLocaleKey(l)}`)
: ['docs']
}

Expand All @@ -34,8 +35,15 @@ export function getCollectionFromPath(path: string, availableLocales: string[]):
const firstSegment = pathSegments[0]

if (firstSegment && availableLocales.includes(firstSegment)) {
return `docs_${firstSegment}`
return `docs_${getLocaleKey(firstSegment)}`
}

return 'docs'
}

/** Maps a `docs_*` collection name back to the locale code it was built from. */
export function getLocaleFromCollection(collectionName: string, availableLocales: string[]): string {
const key = collectionName.replace('docs_', '')

return availableLocales.find(locale => getLocaleKey(locale) === key) || key
}
20 changes: 20 additions & 0 deletions layer/utils/locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Nuxt Content lowercases every path it generates, so an uppercase code would
* never match its own pages. The original tag is kept as the locale `language`.
*/
export function normalizeLocale(code: string): string {
return code.toLowerCase()
}

/**
* Collection names must be valid identifiers, so hyphens become underscores.
* `@nuxt/ui/locale` export names use the same form.
*/
export function getLocaleKey(code: string): string {
return normalizeLocale(code).replace(/-/g, '_')
}

/** Locale files keep their BCP 47 name, so match them on the normalized code. */
export function findLocaleFile(code: string, fileNames: string[]): string | undefined {
return fileNames.find(fileName => fileName.toLowerCase() === `${normalizeLocale(code)}.json`)
}
13 changes: 12 additions & 1 deletion layer/utils/pages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync } from 'node:fs'
import { existsSync, readdirSync } from 'node:fs'
import { joinURL } from 'ufo'

/**
Expand All @@ -20,3 +20,14 @@ export function docsFolderExists(rootDir: string, locale?: string): boolean {
: joinURL(rootDir, 'content', 'docs')
return existsSync(docsPath)
}

/**
* Finds the content folder for a locale, ignoring case so `content/zh-TW/` and
* `content/zh-tw/` both work. Returns the name as it is on disk.
*/
export function findLocaleFolder(rootDir: string, locale: string): string | undefined {
const contentDir = joinURL(rootDir, 'content')
if (!existsSync(contentDir)) return undefined

return readdirSync(contentDir).find(dir => dir.toLowerCase() === locale)
}
Loading