diff --git a/docs/content/docs/2.collections/1.define.md b/docs/content/docs/2.collections/1.define.md
index eaa840f22..60e4e07bd 100644
--- a/docs/content/docs/2.collections/1.define.md
+++ b/docs/content/docs/2.collections/1.define.md
@@ -138,6 +138,38 @@ Indexes are created automatically when the database schema is generated. They wo
- **`unique`** (optional): Set to `true` to create a unique index (default: `false`)
- **`name`** (optional): Custom index name. If omitted, auto-generates as `idx_{collection}_{column1}_{column2}`
+### i18n Support
+
+Enable multi-language content for a collection by adding the `i18n` option. Pass `true` to auto-detect locales from `@nuxtjs/i18n`, or provide an explicit config:
+
+```ts [content.config.ts]
+import { defineCollection, defineContentConfig } from '@nuxt/content'
+import { z } from 'zod'
+
+export default defineContentConfig({
+ collections: {
+ // Auto-detect from @nuxtjs/i18n
+ docs: defineCollection({
+ type: 'page',
+ source: '*/docs/**',
+ i18n: true,
+ }),
+ // Explicit config
+ team: defineCollection({
+ type: 'data',
+ source: 'data/*.yml',
+ schema: z.object({ name: z.string(), role: z.string() }),
+ i18n: {
+ locales: ['en', 'fr', 'de'],
+ defaultLocale: 'en',
+ },
+ }),
+ },
+})
+```
+
+When `i18n` is configured, a `locale` column and a composite `(locale, stem)` index are automatically added to the collection. See the [i18n integration guide](/docs/integrations/i18n) for full documentation.
+
**Performance Tips:**
- Index columns used in `where()` queries for faster filtering
diff --git a/docs/content/docs/3.files/2.yaml.md b/docs/content/docs/3.files/2.yaml.md
index 654dce9a9..80d68ba08 100644
--- a/docs/content/docs/3.files/2.yaml.md
+++ b/docs/content/docs/3.files/2.yaml.md
@@ -43,6 +43,26 @@ url: https://github.com/larbish
```
::
+## Inline i18n
+
+YAML files in i18n-enabled collections can include an `i18n` section for inline translations. Untranslated fields are preserved from the default locale automatically:
+
+```yaml [jane.yml]
+name: Jane Doe
+role: Developer
+country: Switzerland
+
+i18n:
+ fr:
+ role: Développeuse
+ country: Suisse
+ de:
+ role: Entwicklerin
+ country: Schweiz
+```
+
+See the [i18n integration guide](/docs/integrations/i18n) for full documentation.
+
## Query Data
Now we can query authors:
diff --git a/docs/content/docs/3.files/3.json.md b/docs/content/docs/3.files/3.json.md
index 17017dcde..562baa46a 100644
--- a/docs/content/docs/3.files/3.json.md
+++ b/docs/content/docs/3.files/3.json.md
@@ -51,6 +51,24 @@ Create authors files in `content/authors/` directory.
Each file in `data` collection should contain only one object, therefore having top level array in a JSON file will cause invalid result in query time.
::
+## Inline i18n
+
+JSON files in i18n-enabled collections can include an `i18n` key for inline translations. Untranslated fields are preserved from the default locale automatically:
+
+```json [jane.json]
+{
+ "name": "Jane Doe",
+ "role": "Developer",
+ "country": "Switzerland",
+ "i18n": {
+ "fr": { "role": "Développeuse", "country": "Suisse" },
+ "de": { "role": "Entwicklerin", "country": "Schweiz" }
+ }
+}
+```
+
+See the [i18n integration guide](/docs/integrations/i18n) for full documentation.
+
## Query Data
Now we can query authors:
diff --git a/docs/content/docs/4.utils/1.query-collection.md b/docs/content/docs/4.utils/1.query-collection.md
index 4f0a52bcb..0eceed427 100644
--- a/docs/content/docs/4.utils/1.query-collection.md
+++ b/docs/content/docs/4.utils/1.query-collection.md
@@ -223,6 +223,38 @@ const { data } = await useAsyncData(route.path, () => {
})
```
+### `locale(locale: string, opts?: { fallback?: string })`
+
+Filter results by locale. Only applicable to collections with `i18n` configured.
+
+- Parameters:
+ - `locale`: The locale code to filter by (e.g. `'fr'`)
+ - `opts.fallback`: Optional fallback locale code. When set, items missing in the requested locale will be filled from the fallback locale.
+
+```ts
+// Filter by French locale
+queryCollection('docs').locale('fr').all()
+
+// With fallback to English for missing items
+queryCollection('docs').locale('fr', { fallback: 'en' }).all()
+```
+
+::tip
+When `@nuxtjs/i18n` is installed and the collection has `i18n` configured, locale filtering is applied automatically based on the current locale. You only need `.locale()` for explicit control.
+::
+
+### `stem(stem: string)`
+
+Filter by stem (filename without extension). Automatically resolves the full stem path including the collection's source prefix. Useful for querying data collections by filename.
+
+- Parameter:
+ - `stem`: The stem to match (e.g. `'navbar'` for `content/navigation/navbar.yml`)
+
+```ts
+// Matches content/navigation/navbar.yml when source is 'navigation/*.yml'
+queryCollection('navigation').stem('navbar').first()
+```
+
### `count()`
Count the number of matched collection entries based on the query.
diff --git a/docs/content/docs/4.utils/5.use-query-collection.md b/docs/content/docs/4.utils/5.use-query-collection.md
new file mode 100644
index 000000000..4615da9ef
--- /dev/null
+++ b/docs/content/docs/4.utils/5.use-query-collection.md
@@ -0,0 +1,115 @@
+---
+title: useQueryCollection
+description: The useQueryCollection composable wraps queryCollection with useAsyncData for automatic caching and locale reactivity.
+---
+
+## Usage
+
+`useQueryCollection` provides the same chainable API as `queryCollection`, but wraps execution in `useAsyncData` with automatic cache key generation and locale-reactive re-fetching.
+
+```vue [pages/technologies.vue]
+
+```
+
+::warning
+`useQueryCollection` must be called in a Vue component setup context, just like `useAsyncData` and `useFetch`. It cannot be called in event handlers, watchers, or lifecycle hooks.
+::
+
+## API
+
+### Type
+
+```ts
+function useQueryCollection(
+ collection: T
+): UseQueryCollectionBuilder
+```
+
+The optional generic `R` overrides the return type. When omitted, the collection's generated type is used.
+
+### Methods
+
+`useQueryCollection` supports all the same chainable methods as `queryCollection`:
+
+- `.where(field, operator, value)`
+- `.andWhere(groupFactory)`
+- `.orWhere(groupFactory)`
+- `.order(field, direction)`
+- `.select(...fields)`
+- `.skip(n)`
+- `.limit(n)`
+- `.path(path)`
+- `.stem(stem)`
+- `.locale(locale, opts?)`
+
+### Terminal Methods
+
+Terminal methods execute the query and return `AsyncData`:
+
+- `.all()` — returns `AsyncData`
+- `.first()` — returns `AsyncData`
+- `.count(field?, distinct?)` — returns `AsyncData`
+
+## Locale Reactivity
+
+When `@nuxtjs/i18n` is installed and the collection has `i18n` configured, `useQueryCollection` automatically:
+
+1. Detects the current locale
+2. Includes it in the cache key
+3. Watches the locale ref for changes
+4. Re-fetches content when the locale changes (no page reload needed)
+
+```vue [app/layouts/default.vue]
+
+```
+
+## Type Override
+
+Use the generic parameter to override the return type when the collection's generated type doesn't match your component's expected interface:
+
+```vue [pages/technologies.vue]
+
+```
+
+## Examples
+
+### Single Item by Stem
+
+```vue
+
+```
+
+### Filtered and Ordered
+
+```vue
+
+```
+
+### With Explicit Locale
+
+```vue
+
+```
diff --git a/docs/content/docs/4.utils/6.query-collection-locales.md b/docs/content/docs/4.utils/6.query-collection-locales.md
new file mode 100644
index 000000000..63502905a
--- /dev/null
+++ b/docs/content/docs/4.utils/6.query-collection-locales.md
@@ -0,0 +1,100 @@
+---
+title: queryCollectionLocales
+description: Query all locale variants of a content item for language switchers and hreflang tags.
+---
+
+## Usage
+
+`queryCollectionLocales` returns all locale variants for a given content stem. This is useful for building language switchers, generating hreflang SEO tags, and implementing `defineI18nRoute()` with `@nuxtjs/i18n`.
+
+```vue [app/components/LanguageSwitcher.vue]
+
+
+
+
+
+```
+
+::tip
+`queryCollectionLocales` bypasses automatic locale filtering — it always returns all locale variants regardless of the current locale.
+::
+
+## API
+
+### Type
+
+```ts
+// Client-side (auto-imported)
+function queryCollectionLocales(
+ collection: T,
+ stem: string
+): Promise
+
+// Server-side
+function queryCollectionLocales(
+ event: H3Event,
+ collection: T,
+ stem: string
+): Promise
+
+interface ContentLocaleEntry {
+ locale: string
+ stem: string
+ path?: string // Only for page collections
+ title?: string // Only for page collections
+}
+```
+
+### Parameters
+
+- `collection`: The collection name
+- `stem`: The content stem (e.g. `'docs/getting-started'`)
+
+## Server Usage
+
+```ts [server/api/locales.ts]
+export default eventHandler(async (event) => {
+ const stem = getQuery(event).stem as string
+ return await queryCollectionLocales(event, 'docs', stem)
+})
+```
+
+## Use Cases
+
+### Language Switcher
+
+```vue
+
+```
+
+### Hreflang Meta Tags
+
+```vue
+
+```
diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md
index 9ba6c4d4c..a918fc9e5 100644
--- a/docs/content/docs/7.integrations/01.i18n.md
+++ b/docs/content/docs/7.integrations/01.i18n.md
@@ -9,12 +9,12 @@ seo:
description: Learn how to create multi-language websites using Nuxt Content with the @nuxtjs/i18n module.
---
-Nuxt Content integrates with `@nuxtjs/i18n` to create multi-language websites. When both modules are configured together, you can organize content by language and automatically serve the correct content based on the user's locale.
+Nuxt Content integrates with `@nuxtjs/i18n` to create multi-language websites. Content can be organized by locale directories (path-based) or with inline translations in a single file. Locale detection is automatic when `@nuxtjs/i18n` is installed.
## Setup
::prose-steps
-### Install the required module
+### Install the required modules
```bash [terminal]
npm install @nuxtjs/i18n
@@ -27,9 +27,9 @@ export default defineNuxtConfig({
modules: ['@nuxt/content', '@nuxtjs/i18n'],
i18n: {
locales: [
- { code: 'en', name: 'English', language: 'en-US', dir: 'ltr' },
+ { code: 'en', name: 'English', language: 'en-US' },
{ code: 'fr', name: 'French', language: 'fr-FR' },
- { code: 'fa', name: 'Farsi', language: 'fa-IR', dir: 'rtl' },
+ { code: 'de', name: 'German', language: 'de-DE' },
],
strategy: 'prefix_except_default',
defaultLocale: 'en',
@@ -37,131 +37,209 @@ export default defineNuxtConfig({
})
```
-### Define collections for each language
+### Define collections with i18n
-Create separate collections for each language in your `content.config.ts`:
+Add `i18n: true` to auto-detect locales from `@nuxtjs/i18n`, or provide an explicit config:
```ts [content.config.ts]
-const commonSchema = ...;
+import { defineCollection, defineContentConfig } from '@nuxt/content'
+import { z } from 'zod'
export default defineContentConfig({
collections: {
- // English content collection
- content_en: defineCollection({
+ // Auto-detect locales from @nuxtjs/i18n
+ docs: defineCollection({
type: 'page',
- source: {
- include: 'en/**',
- prefix: '',
- },
- schema: commonSchema,
+ source: '*/docs/**',
+ i18n: true,
}),
- // French content collection
- content_fr: defineCollection({
- type: 'page',
- source: {
- include: 'fr/**',
- prefix: '',
+ // Or explicit config
+ team: defineCollection({
+ type: 'data',
+ source: 'data/team.yml',
+ schema: z.object({
+ name: z.string(),
+ role: z.string(),
+ }),
+ i18n: {
+ locales: ['en', 'fr', 'de'],
+ defaultLocale: 'en',
},
- schema: commonSchema,
- }),
- // Farsi content collection
- content_fa: defineCollection({
- type: 'page',
- source: {
- include: 'fa/**',
- prefix: '',
- },
- schema: commonSchema,
}),
},
})
```
-### Create dynamic pages
+When `i18n` is configured, a `locale` column is automatically added to the collection schema and an index on `(locale, stem)` is created.
+::
-Create a catch-all page that fetches content based on the current locale:
+## Content Approaches
-```vue [pages/[...slug\\].vue]
-
+```
+
+::tip
+Content paths are stored **without** the locale prefix (e.g., `/docs/getting-started` not `/en/docs/getting-started`). When using `@nuxtjs/i18n` with `prefix_except_default` strategy, strip the locale prefix from `route.path` before querying.
+::
+
+For the default locale, a single `WHERE locale = ?` query is issued. For non-default locales, content is fetched with automatic fallback to the default locale for missing items.
-
-
-
-
Page not found
-
This page doesn't exist in {{ locale }} language.
-
-
+### useQueryCollection
+
+The `useQueryCollection` composable wraps `queryCollection` with `useAsyncData`, providing automatic cache key generation and locale-reactive re-fetching:
+
+```vue [pages/technologies.vue]
+
```
+
+::warning
+`useQueryCollection` must be called in a Vue component setup context (like `useAsyncData` and `useFetch`).
::
-That's it! 🚀 Your multi-language content site is ready.
+### Explicit Locale Control
-## Content Structure
+Use `.locale()` to override the auto-detected locale:
-Organize your content files in language-specific folders to match your collections:
+```ts
+// Filter by a specific locale
+queryCollection('docs').locale('fr').all()
-```text
-content/
- en/
- index.md
- about.md
- blog/
- post-1.md
- fr/
- index.md
- about.md
- blog/
- post-1.md
- fa/
- index.md
- about.md
+// With fallback to default locale for missing items
+queryCollection('docs').locale('fr', { fallback: 'en' }).all()
```
-Each language folder should contain the same structure to ensure content parity across locales.
+### Language Switcher (All Locale Variants)
-## Fallback Strategy
+Use `queryCollectionLocales` to get all locale variants for a given content item — useful for building language switchers and hreflang tags:
-You can implement a fallback strategy to show content from the default locale when content is missing in the current locale:
+```ts
+const locales = await queryCollectionLocales('docs', 'docs/getting-started')
+// Returns: [{ locale: 'en', path: '/docs/getting-started', stem: '...', title: '...' }, ...]
+```
-```ts [pages/[...slug\\].vue]
-const { data: page } = await useAsyncData('page-' + slug.value, async () => {
- const collection = ('content_' + locale.value) as keyof Collections
- let content = await queryCollection(collection).path(slug.value).first()
+### Stem Queries
- // Fallback to default locale if content is missing
- if (!content && locale.value !== 'en') {
- content = await queryCollection('content_en').path(slug.value).first()
- }
+Use `.stem()` to query data collections by filename. The source directory prefix is resolved automatically:
+
+```ts
+// Matches content/navigation/navbar.yml
+queryCollection('navigation').stem('navbar').first()
+```
- return content
+## Translator Change Tracking
+
+For inline i18n, each non-default locale item stores a `_i18nSourceHash` in its `meta`. This hash is computed from the default locale's translated fields. When the default content changes, the hash changes — allowing Studio or custom tooling to detect potentially outdated translations.
+
+```ts
+const item = await queryCollection('team').locale('fr').first()
+console.log(item.meta._i18nSourceHash) // Hash of the default locale's translated fields
+```
+
+## CSP Configuration
+
+If you use `nuxt-security` with Content Security Policy, add `'wasm-unsafe-eval'` to your `script-src` directive. Nuxt Content uses WebAssembly SQLite for client-side queries during locale switching:
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ security: {
+ headers: {
+ contentSecurityPolicy: {
+ 'script-src': ["'self'", "'wasm-unsafe-eval'", /* ... */],
+ },
+ },
+ },
+ routeRules: {
+ '/__nuxt_content/**': {
+ csurf: false, // Exempt content API from CSRF
+ },
+ },
})
```
-::prose-warning
-Make sure to handle missing content gracefully and provide clear feedback to users when content is not available in their preferred language.
-::
+## Known Limitations
+
+**Translatable slugs with different filenames**: When locale versions use different filenames (e.g., `en/products.md` vs `de/produkte.md`), `queryCollectionLocales` cannot automatically link them because the stems differ after locale-prefix stripping. Content with the same filename across locale directories works correctly. This limitation requires coordination with `@nuxtjs/i18n` and is tracked in [nuxt-modules/i18n#3028](https://github.com/nuxt-modules/i18n/discussions/3028).
## Complete Examples
diff --git a/src/module.ts b/src/module.ts
index 99603aae6..66d01158c 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -20,6 +20,7 @@ import { join } from 'pathe'
import htmlTags from '@nuxtjs/mdc/runtime/parser/utils/html-tags-list'
import { kebabCase, pascalCase } from 'scule'
import defu from 'defu'
+import { expandI18nData } from './utils/i18n'
import { version } from '../package.json'
import { generateCollectionInsert, generateCollectionTableDefinition } from './utils/collection'
import { componentsManifestTemplate, contentTypesTemplate, fullDatabaseRawDumpTemplate, manifestTemplate, moduleTemplates } from './utils/templates'
@@ -131,15 +132,18 @@ export default defineNuxtModule({
// Helpers are designed to be enviroment agnostic
addImports([
{ name: 'queryCollection', from: resolver.resolve('./runtime/client') },
+ { name: 'useQueryCollection', from: resolver.resolve('./runtime/client') },
{ name: 'queryCollectionSearchSections', from: resolver.resolve('./runtime/client') },
{ name: 'queryCollectionNavigation', from: resolver.resolve('./runtime/client') },
{ name: 'queryCollectionItemSurroundings', from: resolver.resolve('./runtime/client') },
+ { name: 'queryCollectionLocales', from: resolver.resolve('./runtime/client') },
])
addServerImports([
{ name: 'queryCollection', from: resolver.resolve('./runtime/nitro') },
{ name: 'queryCollectionSearchSections', from: resolver.resolve('./runtime/nitro') },
{ name: 'queryCollectionNavigation', from: resolver.resolve('./runtime/nitro') },
{ name: 'queryCollectionItemSurroundings', from: resolver.resolve('./runtime/nitro') },
+ { name: 'queryCollectionLocales', from: resolver.resolve('./runtime/nitro') },
])
addComponent({ name: 'ContentRenderer', filePath: resolver.resolve('./runtime/components/ContentRenderer.vue') })
@@ -375,8 +379,19 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio
usedComponents.push(...parsedContent.__metadata.components)
}
- const { queries, hash } = generateCollectionInsert(collection, parsedContent)
- list.push([key, queries, hash])
+ // i18n: expand inline translations to per-locale rows
+ if (collection.i18n && (parsedContent?.meta as Record)?.i18n) {
+ const expandedItems = expandI18nData(parsedContent, collection.i18n, collection.type)
+ for (const item of expandedItems) {
+ const itemKey = item.locale ? `${keyInCollection}#${item.locale}` : keyInCollection
+ const { queries: itemQueries, hash: itemHash } = generateCollectionInsert(collection, item)
+ list.push([itemKey, itemQueries, itemHash])
+ }
+ }
+ else {
+ const { queries, hash } = generateCollectionInsert(collection, parsedContent)
+ list.push([keyInCollection, queries, hash])
+ }
}
catch (e: unknown) {
logger.warn(`"${keyInCollection}" is ignored because parsing is failed. Error: ${e instanceof Error ? e.message : 'Unknown error'}`)
diff --git a/src/runtime/client.ts b/src/runtime/client.ts
index 6f9708005..c187bd890 100644
--- a/src/runtime/client.ts
+++ b/src/runtime/client.ts
@@ -1,11 +1,14 @@
import type { H3Event } from 'h3'
-import { collectionQueryBuilder } from './internal/query'
+import { collectionQueryBuilder, collectionQueryGroup } from './internal/query'
import { generateNavigationTree } from './internal/navigation'
import { generateItemSurround } from './internal/surround'
import { type GenerateSearchSectionsOptions, generateSearchSections } from './internal/search'
+import { generateCollectionLocales } from './internal/locales'
import { fetchQuery } from './internal/api'
-import type { Collections, PageCollections, CollectionQueryBuilder, SurroundOptions, SQLOperator, QueryGroupFunction, ContentNavigationItem } from '@nuxt/content'
-import { tryUseNuxtApp } from '#imports'
+import type { Collections, PageCollections, CollectionQueryBuilder, ContentLocaleEntry, SurroundOptions, SQLOperator, QueryGroupFunction, ContentNavigationItem } from '@nuxt/content'
+import type { AsyncData, NuxtError } from '#app'
+import type { Ref } from 'vue'
+import { tryUseNuxtApp, useAsyncData, computed } from '#imports'
interface ChainablePromise extends Promise {
where(field: keyof PageCollections[T] | string, operator: SQLOperator, value?: unknown): ChainablePromise
@@ -15,8 +18,13 @@ interface ChainablePromise extends Promise(collection: T): CollectionQueryBuilder => {
- const event = tryUseNuxtApp()?.ssrContext?.event
- return collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql))
+ const nuxtApp = tryUseNuxtApp()
+ const event = nuxtApp?.ssrContext?.event
+ // Auto-detect locale from @nuxtjs/i18n (client: $i18n.locale, SSR: event.context.nuxtI18n)
+ const detectedLocale = (nuxtApp?.$i18n as { locale?: { value?: string } })?.locale?.value
+ || (event?.context?.nuxtI18n as { vueI18nOptions?: { locale?: string } })?.vueI18nOptions?.locale
+ || (event?.context?.nuxtI18n as { locale?: string })?.locale
+ return collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql), detectedLocale)
}
export function queryCollectionNavigation(collection: T, fields?: Array): ChainablePromise {
@@ -31,6 +39,150 @@ export function queryCollectionSearchSections(c
return chainablePromise(collection, qb => generateSearchSections(qb, opts))
}
+export function queryCollectionLocales(collection: T, stem: string): Promise {
+ // Skip auto-locale: this helper needs ALL locale variants, not just the current one
+ const event = tryUseNuxtApp()?.ssrContext?.event
+ const qb = collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql))
+ return generateCollectionLocales(qb, stem)
+}
+
+/**
+ * useAsyncData wrapper for queryCollection.
+ * Provides a chainable API that auto-wraps execution in useAsyncData
+ * with an auto-generated cache key. Locale is auto-detected from @nuxtjs/i18n
+ * and content automatically re-fetches when the locale changes.
+ *
+ * Must be called in a Vue component setup context (like useAsyncData, useFetch).
+ *
+ * @example
+ * const { data } = await useQueryCollection('technologies').all()
+ * const { data } = await useQueryCollection('navigation').stem('navbar').first()
+ */
+export function useQueryCollection(collection: T) {
+ const nuxtApp = tryUseNuxtApp()
+ const i18nLocaleRef = (nuxtApp?.$i18n as { locale?: Ref })?.locale
+ // Reactive locale for cache key and watch
+ const localeValue = computed(() => i18nLocaleRef?.value || '')
+
+ type Item = Collections[T]
+ // Use the consumer's type override if provided, otherwise the collection type
+ type Result = [R] extends [never] ? Item : R
+
+ // Collect query chain operations to replay on each execution
+ const ops: Array<(qb: CollectionQueryBuilder) => void> = []
+ let explicitLocale = false
+
+ // Track key-relevant params directly — avoids creating a full query builder in buildKey
+ const keyParts = {
+ conditions: [] as string[],
+ orderBy: [] as string[],
+ offset: 0,
+ limit: 0,
+ selectedFields: [] as string[],
+ localeFallback: undefined as { locale: string, fallback: string } | undefined,
+ }
+
+ const builder = {
+ where(field: string, operator: SQLOperator, value?: unknown) {
+ keyParts.conditions.push(`${field}${operator}${value}`)
+ ops.push(qb => qb.where(field, operator, value))
+ return builder
+ },
+ andWhere(groupFactory: QueryGroupFunction) {
+ const group = groupFactory(collectionQueryGroup(collection))
+ const cond = (group as unknown as { _conditions: string[] })._conditions.join(' AND ')
+ keyParts.conditions.push(`and(${cond})`)
+ ops.push(qb => qb.andWhere(groupFactory))
+ return builder
+ },
+ orWhere(groupFactory: QueryGroupFunction) {
+ const group = groupFactory(collectionQueryGroup(collection))
+ const cond = (group as unknown as { _conditions: string[] })._conditions.join(' OR ')
+ keyParts.conditions.push(`or(${cond})`)
+ ops.push(qb => qb.orWhere(groupFactory))
+ return builder
+ },
+ order(field: keyof Item, direction: 'ASC' | 'DESC') {
+ keyParts.orderBy.push(`${String(field)}:${direction}`)
+ ops.push(qb => qb.order(field, direction))
+ return builder
+ },
+ select(...fields: K[]) {
+ keyParts.selectedFields.push(...fields.map(String))
+ ops.push(qb => qb.select(...fields))
+ return builder
+ },
+ skip(skip: number) {
+ keyParts.offset = skip
+ ops.push(qb => qb.skip(skip))
+ return builder
+ },
+ limit(limit: number) {
+ keyParts.limit = limit
+ ops.push(qb => qb.limit(limit))
+ return builder
+ },
+ path(path: string) {
+ keyParts.conditions.push(`path=${path}`)
+ ops.push(qb => qb.path(path))
+ return builder
+ },
+ stem(stem: string) {
+ keyParts.conditions.push(`stem=${stem}`)
+ ops.push(qb => qb.stem(stem))
+ return builder
+ },
+ locale(locale: string, opts?: { fallback?: string }) {
+ explicitLocale = true
+ if (opts?.fallback) {
+ keyParts.localeFallback = { locale, fallback: opts.fallback }
+ }
+ else {
+ keyParts.conditions.push(`locale=${locale}`)
+ }
+ ops.push(qb => qb.locale(locale, opts))
+ return builder
+ },
+ all(): AsyncData {
+ return useAsyncData(() => buildKey('all'), () => buildQuery().all(), { watch: watchSources() }) as AsyncData
+ },
+ first(): AsyncData {
+ return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) as AsyncData
+ },
+ count(field?: keyof Item | '*', distinct?: boolean): AsyncData {
+ const countKey = `count:${String(field ?? '*')}:${distinct ? 'd' : ''}`
+ return useAsyncData(() => buildKey(countKey), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData
+ },
+ }
+
+ function watchSources() {
+ return !explicitLocale && i18nLocaleRef ? [i18nLocaleRef] : undefined
+ }
+
+ /** Rebuild a fresh query builder with all chained ops replayed. */
+ function buildQuery(): CollectionQueryBuilder {
+ const qb = queryCollection(collection)
+ for (const op of ops) op(qb)
+ return qb
+ }
+
+ /** Build cache key from tracked params — no query builder instantiation needed. */
+ function buildKey(method: string): string {
+ const parts = [String(collection)]
+ if (keyParts.conditions.length) parts.push(...keyParts.conditions)
+ if (keyParts.localeFallback) parts.push(`l:${keyParts.localeFallback.locale}:fb:${keyParts.localeFallback.fallback}`)
+ else if (localeValue.value && !explicitLocale) parts.push(`l:${localeValue.value}`)
+ if (keyParts.orderBy.length) parts.push(`o:${keyParts.orderBy.join(',')}`)
+ if (keyParts.offset) parts.push(`s:${keyParts.offset}`)
+ if (keyParts.limit) parts.push(`n:${keyParts.limit}`)
+ if (keyParts.selectedFields.length) parts.push(`f:${keyParts.selectedFields.join(',')}`)
+ parts.push(method)
+ return `content:${JSON.stringify(parts)}`
+ }
+
+ return builder
+}
+
async function executeContentQuery(event: H3Event | undefined, collection: T, sql: string) {
if (import.meta.client && window.WebAssembly) {
return queryContentSqlClientWasm(collection, sql) as Promise
diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts
new file mode 100644
index 000000000..4a9ff481a
--- /dev/null
+++ b/src/runtime/internal/locales.ts
@@ -0,0 +1,26 @@
+import type { CollectionQueryBuilder, ContentLocaleEntry } from '@nuxt/content'
+
+/**
+ * Query all locale variants for a given content stem within an i18n-enabled collection.
+ * Returns one entry per locale, useful for building language switchers and hreflang tags.
+ */
+export async function generateCollectionLocales>(
+ queryBuilder: CollectionQueryBuilder,
+ stem: string,
+): Promise {
+ // No .select() — data collections lack path/title columns; SELECT * is safe here
+ // because ContentLocaleEntry marks path? and title? as optional.
+ const items = await queryBuilder
+ .stem(stem)
+ .all()
+
+ return items.map((item) => {
+ const row = item as Record
+ return {
+ locale: row.locale as string,
+ stem: row.stem as string,
+ path: row.path as string | undefined,
+ title: row.title as string | undefined,
+ }
+ })
+}
diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts
index 6b617d225..a98ac4d8c 100644
--- a/src/runtime/internal/query.ts
+++ b/src/runtime/internal/query.ts
@@ -1,6 +1,6 @@
import { withoutTrailingSlash } from 'ufo'
import type { Collections, CollectionQueryBuilder, CollectionQueryGroup, QueryGroupFunction, SQLOperator } from '@nuxt/content'
-import { tables } from '#content/manifest'
+import manifestMeta, { tables } from '#content/manifest'
const buildGroup = (group: CollectionQueryGroup, type: 'AND' | 'OR') => {
const conditions = (group as unknown as { _conditions: Array })._conditions
@@ -69,7 +69,11 @@ export const collectionQueryGroup = (collection: T)
return query
}
-export const collectionQueryBuilder = (collection: T, fetch: (collection: T, sql: string) => Promise): CollectionQueryBuilder => {
+export const collectionQueryBuilder = (collection: T, fetch: (collection: T, sql: string) => Promise, detectedLocale?: string): CollectionQueryBuilder => {
+ // Read collection metadata from manifest
+ const collectionMeta = (manifestMeta as Record)[String(collection)]
+ const i18nConfig = collectionMeta?.i18n
+ const stemPrefix = collectionMeta?.stemPrefix || ''
const params = {
conditions: [] as Array,
selectedFields: [] as Array,
@@ -81,6 +85,10 @@ export const collectionQueryBuilder = (collection:
field: '' as keyof Collections[T] | '*',
distinct: false,
},
+ // Locale fallback (handled via two queries + JS merge)
+ localeFallback: undefined as { locale: string, fallback: string } | undefined,
+ // Track whether .locale() was called explicitly (exposed for cache key generation)
+ localeExplicitlySet: false,
}
const query: CollectionQueryBuilder = {
@@ -99,6 +107,24 @@ export const collectionQueryBuilder = (collection:
path(path: string) {
return query.where('path', '=', withoutTrailingSlash(path))
},
+ stem(stem: string) {
+ // Resolve full stem by prepending the collection's source prefix if not already present.
+ // Check segment boundary to avoid false matches (e.g. prefix "navigation" matching "navigation2/foo").
+ const fullStem = stemPrefix && !(stem === stemPrefix || stem.startsWith(stemPrefix + '/'))
+ ? `${stemPrefix}/${stem}`
+ : stem
+ return query.where('stem', '=', fullStem)
+ },
+ locale(locale: string, opts?: { fallback?: string }) {
+ params.localeExplicitlySet = true
+ if (opts?.fallback) {
+ params.localeFallback = { locale, fallback: opts.fallback }
+ }
+ else {
+ query.where('locale', '=', locale)
+ }
+ return query
+ },
skip(skip: number) {
params.offset = skip
return query
@@ -122,22 +148,144 @@ export const collectionQueryBuilder = (collection:
return query
},
async all(): Promise {
+ applyAutoLocale()
+ if (params.localeFallback) {
+ return fetchWithLocaleFallback()
+ }
return fetch(collection, buildQuery()).then(res => (res || []) as Collections[T][])
},
async first(): Promise {
+ applyAutoLocale()
+ if (params.localeFallback) {
+ return fetchWithLocaleFallback({ limit: 1 }).then(res => res[0] || null)
+ }
return fetch(collection, buildQuery({ limit: 1 })).then(res => res[0] || null)
},
async count(field: keyof Collections[T] | '*' = '*', distinct: boolean = false) {
+ applyAutoLocale()
+ if (params.localeFallback) {
+ // Ensure the counted field is fetched and bypass pagination for accurate counts
+ const countField = field !== '*' ? String(field) : undefined
+ const savedFields = params.selectedFields
+ const savedOffset = params.offset
+ const savedLimit = params.limit
+ if (countField && savedFields.length > 0 && !savedFields.includes(field as keyof Collections[T])) {
+ params.selectedFields = [...savedFields, field as keyof Collections[T]]
+ }
+ params.offset = 0
+ params.limit = 0
+ return fetchWithLocaleFallback({ preserveField: countField }).then((res) => {
+ params.selectedFields = savedFields
+ params.offset = savedOffset
+ params.limit = savedLimit
+ if (field === '*') return res.length
+ const values = res
+ .map(r => (r as unknown as Record)[String(field)])
+ .filter(v => v !== null && v !== undefined)
+ return distinct ? new Set(values).size : values.length
+ })
+ }
return fetch(collection, buildQuery({
count: { field: String(field), distinct },
})).then(m => (m[0] as { count: number }).count)
},
}
- function buildQuery(opts: { count?: { field: string, distinct: boolean }, limit?: number } = {}) {
+ /**
+ * Auto-apply locale filter when:
+ * 1. The collection has i18n configured (in manifest)
+ * 2. No explicit .locale() call was made
+ * 3. A locale was detected from @nuxtjs/i18n
+ * Runs once before query execution (all/first/count).
+ */
+ let autoLocaleApplied = false
+ function applyAutoLocale() {
+ if (autoLocaleApplied || params.localeExplicitlySet || !i18nConfig || !detectedLocale) return
+ if (!i18nConfig.locales.includes(detectedLocale)) return
+ autoLocaleApplied = true
+ if (detectedLocale === i18nConfig.defaultLocale) {
+ // Default locale: single query, no fallback needed
+ params.conditions.push(`("locale" = ${singleQuote(detectedLocale)})`)
+ }
+ else {
+ // Non-default locale: query with fallback to default
+ params.localeFallback = { locale: detectedLocale, fallback: i18nConfig.defaultLocale }
+ }
+ }
+
+ /**
+ * Two-query locale fallback: fetches locale-specific rows and default-locale rows,
+ * then merges by stem (locale items take priority, fallback fills gaps).
+ * Internally injects 'stem' into selectedFields for merge-key deduplication,
+ * stripping it from results when the caller didn't explicitly select it.
+ * Accepts an optional limit override and a preserveField to keep for count operations.
+ */
+ async function fetchWithLocaleFallback(opts: { limit?: number, preserveField?: string } = {}): Promise {
+ const { locale, fallback } = params.localeFallback!
+
+ // Ensure `stem` is always fetched — needed for merge-key deduplication.
+ // Track whether we injected it so we can strip it from results later.
+ const savedFields = params.selectedFields
+ const stemInjected = savedFields.length > 0 && !savedFields.includes('stem' as keyof Collections[T])
+ if (stemInjected) {
+ params.selectedFields = [...savedFields, 'stem' as keyof Collections[T]]
+ }
+
+ // Sub-queries fetch ALL matching rows (no limit/offset) — we apply those JS-side on the merged result
+ const localeCondition = `("locale" = ${singleQuote(locale)})`
+ const localeQuery = buildQuery({ extraCondition: localeCondition, noLimitOffset: true })
+ const localeResults = await fetch(collection, localeQuery).then(res => res || [])
+
+ const fallbackCondition = `("locale" = ${singleQuote(fallback)})`
+ const fallbackQuery = buildQuery({ extraCondition: fallbackCondition, noLimitOffset: true })
+ const fallbackResults = await fetch(collection, fallbackQuery).then(res => res || [])
+
+ // Restore original selectedFields to avoid side-effects on repeated calls
+ params.selectedFields = savedFields
+
+ // Merge: prefer locale results, fill gaps from fallback
+ const getStem = (r: Collections[T]) => (r as unknown as { stem: string }).stem
+ const localeStemSet = new Set(localeResults.map(getStem))
+ const fallbackOnly = fallbackResults.filter(item => !localeStemSet.has(getStem(item)))
+
+ // When using the default ORDER BY (stem ASC), we can do a proper sorted merge
+ // because mergeSortedArrays compares by stem.
+ // LIMITATION: when a custom ORDER BY is specified, we cannot interleave the
+ // two result sets correctly because that would require parsing the SQL ORDER BY
+ // clause and re-implementing the comparison in JS. Instead we concatenate
+ // locale items first, then fallback items — each group retains its DB order
+ // but the overall sequence may not match a single-query ORDER BY.
+ const merged = params.orderBy.length === 0
+ ? mergeSortedArrays(localeResults, fallbackOnly, getStem)
+ : [...localeResults, ...fallbackOnly]
+
+ // Apply offset then limit on the merged result
+ let result = merged
+ if (params.offset > 0) {
+ result = result.slice(params.offset)
+ }
+ const limit = opts.limit ?? (params.limit > 0 ? params.limit : 0)
+ if (limit > 0) {
+ result = result.slice(0, limit)
+ }
+
+ // Strip internally-injected 'stem' if the caller didn't select it
+ // (unless it's needed by a count() call targeting that field)
+ if (stemInjected && opts.preserveField !== 'stem') {
+ return result.map((item) => {
+ const { stem: _, ...rest } = item as unknown as Record
+ return rest as Collections[T]
+ })
+ }
+
+ return result as Collections[T][]
+ }
+
+ function buildQuery(opts: { count?: { field: string, distinct: boolean }, limit?: number, extraCondition?: string, noLimitOffset?: boolean } = {}) {
let query = 'SELECT '
if (opts?.count) {
- query += `COUNT(${opts.count.distinct ? 'DISTINCT ' : ''}${opts.count.field}) as count`
+ const countField = opts.count.field === '*' ? '*' : `"${opts.count.field.replace(/"/g, '')}"`
+ query += `COUNT(${opts.count.distinct ? 'DISTINCT ' : ''}${countField}) as count`
}
else {
const fields = Array.from(new Set(params.selectedFields))
@@ -145,19 +293,27 @@ export const collectionQueryBuilder = (collection:
}
query += ` FROM ${tables[String(collection)]}`
- if (params.conditions.length > 0) {
- query += ` WHERE ${params.conditions.join(' AND ')}`
+ const conditions = [...params.conditions]
+ if (opts.extraCondition) {
+ conditions.push(opts.extraCondition)
}
- if (params.orderBy.length > 0) {
- query += ` ORDER BY ${params.orderBy.join(', ')}`
+ if (conditions.length > 0) {
+ query += ` WHERE ${conditions.join(' AND ')}`
}
- else {
- query += ` ORDER BY stem ASC`
+
+ // Skip ORDER BY for COUNT queries (PostgreSQL rejects ORDER BY on aggregate without GROUP BY)
+ if (!opts?.count) {
+ if (params.orderBy.length > 0) {
+ query += ` ORDER BY ${params.orderBy.join(', ')}`
+ }
+ else {
+ query += ` ORDER BY stem ASC`
+ }
}
const limit = opts?.limit || params.limit
- if (limit > 0) {
+ if (!opts?.noLimitOffset && limit > 0) {
if (params.offset > 0) {
query += ` LIMIT ${limit} OFFSET ${params.offset}`
}
@@ -172,6 +328,40 @@ export const collectionQueryBuilder = (collection:
return query
}
+/**
+ * Merge two arrays that are both sorted by `stem` ASC (the default ORDER BY).
+ * Interleaves items using lexicographic comparison of their `stem` values.
+ * Precondition: both arrays must be sorted by stem ASC — this function does NOT
+ * handle arbitrary ORDER BY clauses (use concatenation for custom sorts).
+ */
+function mergeSortedArrays(a: T[], b: T[], getStem: (r: T) => string): T[] {
+ // Both arrays come from the DB with the same ORDER BY.
+ // Build a position map from array `a` stems to interleave `b` items correctly.
+ // Items in `b` whose stem falls between two `a` items get inserted at that position.
+ const result: T[] = []
+ let ai = 0
+ let bi = 0
+ while (ai < a.length && bi < b.length) {
+ if (getStem(a[ai]!).localeCompare(getStem(b[bi]!)) <= 0) {
+ result.push(a[ai]!)
+ ai++
+ }
+ else {
+ result.push(b[bi]!)
+ bi++
+ }
+ }
+ while (ai < a.length) {
+ result.push(a[ai]!)
+ ai++
+ }
+ while (bi < b.length) {
+ result.push(b[bi]!)
+ bi++
+ }
+ return result
+}
+
function singleQuote(value: unknown) {
return `'${String(value).replace(/'/g, '\'\'')}'`
}
diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts
index 7304b8da0..8c2070bbf 100644
--- a/src/runtime/internal/security.ts
+++ b/src/runtime/internal/security.ts
@@ -1,6 +1,6 @@
const SQL_COMMANDS = /SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|\$/i
-const SQL_COUNT_REGEX = /COUNT\((DISTINCT )?([a-z_]\w+|\*)\)/i
-const SQL_SELECT_REGEX = /^SELECT (.*) FROM (\w+)( WHERE .*)? ORDER BY (["\w,\s]+) (ASC|DESC)( LIMIT \d+)?( OFFSET \d+)?$/
+const SQL_COUNT_REGEX = /COUNT\((DISTINCT )?("[a-z_]\w+"|[a-z_]\w+|\*)\)/i
+const SQL_SELECT_REGEX = /^SELECT (.*?) FROM (\w+)( WHERE .*?)?( ORDER BY (["\w,\s]+) (ASC|DESC))?( LIMIT \d+)?( OFFSET \d+)?$/
/**
* Assert that the query is safe
@@ -16,6 +16,11 @@ export function assertSafeQuery(sql: string, collection: string) {
throw new Error('Invalid query: Query cannot be empty')
}
+ // Reject newlines to prevent multi-statement injection
+ if (sql.includes('\n') || sql.includes('\r')) {
+ throw new Error('Invalid query: Newlines are not allowed in queries')
+ }
+
const cleanedupQuery = cleanupQuery(sql)
// Query is invalid if the cleaned up query is not the same as the original query (it contains comments)
@@ -28,7 +33,7 @@ export function assertSafeQuery(sql: string, collection: string) {
throw new Error('Invalid query: Query must be a valid SELECT statement with proper syntax')
}
- const [_, select, from, where, orderBy, order, limit, offset] = match
+ const [_, select, from, where, _orderByFull, orderBy, order, limit, offset] = match
// COLUMNS
const columns = select?.trim().split(', ') || []
@@ -47,8 +52,8 @@ export function assertSafeQuery(sql: string, collection: string) {
// FROM
if (from !== `_content_${collection}`) {
- const collection = String(from || '').replace(/^_content_/, '')
- throw new Error(`Invalid query: Collection '${collection}' does not exist`)
+ const invalidCollection = String(from || '').replace(/^_content_/, '')
+ throw new Error(`Invalid query: Collection '${invalidCollection}' does not exist`)
}
// WHERE
@@ -62,10 +67,12 @@ export function assertSafeQuery(sql: string, collection: string) {
}
}
- // ORDER BY
- const _order = (orderBy + ' ' + order).split(', ')
- if (!_order.every(column => column.match(/^("[a-zA-Z_]+"|[a-zA-Z_]+) (ASC|DESC)$/))) {
- throw new Error('Invalid query: ORDER BY clause must contain valid column names followed by ASC or DESC')
+ // ORDER BY (optional — COUNT queries omit it)
+ if (orderBy && order) {
+ const _order = (orderBy + ' ' + order).split(', ')
+ if (!_order.every(column => column.match(/^("[a-zA-Z_]+"|[a-zA-Z_]+) (ASC|DESC)$/))) {
+ throw new Error('Invalid query: ORDER BY clause must contain valid column names followed by ASC or DESC')
+ }
}
// LIMIT
@@ -88,49 +95,56 @@ function cleanupQuery(query: string, options: { removeString: boolean } = { remo
let result = ''
for (let i = 0; i < query.length; i++) {
const char = query[i]
- const prevChar = query[i - 1]
const nextChar = query[i + 1]
- if (char === '\'' || char === '"') {
- if (!options?.removeString) {
- result += char
- continue
- }
-
- if (inString) {
- if (char !== stringFence || nextChar === stringFence || prevChar === stringFence) {
- // skip character, it's part of a string
+ if (inString) {
+ if (char === stringFence) {
+ if (nextChar === stringFence) {
+ // Doubled quote escape (e.g., '' inside a string) — skip both, stay in string
+ if (!options?.removeString) {
+ result += char + char // preserve both quotes
+ }
+ i++
continue
}
-
- inString = false
- stringFence = ''
- continue
+ else {
+ // String closing quote
+ inString = false
+ stringFence = ''
+ }
}
- else {
- inString = true
- stringFence = char
- continue
+ // Inside string: keep character when not removing strings
+ if (!options?.removeString) {
+ result += char
}
+ continue
}
- if (!inString) {
- if (char === '-' && nextChar === '-') {
- // everything after this is a comment
- return result
+ // Not in string — opening quote starts string tracking regardless of removeString mode
+ if (char === '\'' || char === '"') {
+ inString = true
+ stringFence = char
+ if (!options?.removeString) {
+ result += char
}
+ continue
+ }
- if (char === '/' && nextChar === '*') {
- i += 2
- while (i < query.length && !(query[i] === '*' && query[i + 1] === '/')) {
- i += 1
- }
- i += 2
- continue
- }
+ if (char === '-' && nextChar === '-') {
+ // everything after this is a comment
+ return result
+ }
- result += char
+ if (char === '/' && nextChar === '*') {
+ i += 2
+ while (i < query.length && !(query[i] === '*' && query[i + 1] === '/')) {
+ i += 1
+ }
+ if (i < query.length) i += 2
+ continue
}
+
+ result += char
}
return result
}
diff --git a/src/runtime/nitro.ts b/src/runtime/nitro.ts
index 1ab6b7af4..13ec1b408 100644
--- a/src/runtime/nitro.ts
+++ b/src/runtime/nitro.ts
@@ -28,3 +28,8 @@ export const queryCollectionItemSurroundings = server.queryCollectionItemSurroun
* @deprecated Import from `@nuxt/content/server` instead
*/
export const queryCollectionSearchSections = server.queryCollectionSearchSections
+
+/**
+ * @deprecated Import from `@nuxt/content/server` instead
+ */
+export const queryCollectionLocales = server.queryCollectionLocales
diff --git a/src/runtime/server.ts b/src/runtime/server.ts
index 3e98af8ca..6f981233c 100644
--- a/src/runtime/server.ts
+++ b/src/runtime/server.ts
@@ -3,8 +3,9 @@ import { collectionQueryBuilder } from './internal/query'
import { generateNavigationTree } from './internal/navigation'
import { generateItemSurround } from './internal/surround'
import { type GenerateSearchSectionsOptions, generateSearchSections } from './internal/search'
+import { generateCollectionLocales } from './internal/locales'
import { fetchQuery } from './internal/api'
-import type { Collections, CollectionQueryBuilder, PageCollections, SurroundOptions, SQLOperator, QueryGroupFunction } from '@nuxt/content'
+import type { Collections, CollectionQueryBuilder, ContentLocaleEntry, PageCollections, SurroundOptions, SQLOperator, QueryGroupFunction } from '@nuxt/content'
interface ChainablePromise extends Promise {
where(field: keyof PageCollections[T] | string, operator: SQLOperator, value?: unknown): ChainablePromise
@@ -14,7 +15,12 @@ interface ChainablePromise extends Promise(event: H3Event, collection: T): CollectionQueryBuilder => {
- return collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql))
+ // Auto-detect locale from @nuxtjs/i18n server context (resilient to different i18n versions)
+ const i18nCtx = event.context?.nuxtI18n as Record | undefined
+ const detectedLocale = (i18nCtx?.vueI18nOptions as { locale?: string })?.locale
+ || (i18nCtx?.locale as string)
+ || undefined
+ return collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql), detectedLocale)
}
export function queryCollectionNavigation(event: H3Event, collection: T, fields?: Array) {
@@ -29,6 +35,12 @@ export function queryCollectionSearchSections(e
return chainablePromise(event, collection, qb => generateSearchSections(qb, opts))
}
+export function queryCollectionLocales(event: H3Event, collection: T, stem: string): Promise {
+ // Skip auto-locale: this helper needs ALL locale variants, not just the current one
+ const qb = collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql))
+ return generateCollectionLocales(qb, stem)
+}
+
function chainablePromise(event: H3Event, collection: T, fn: (qb: CollectionQueryBuilder) => Promise) {
const queryBuilder = queryCollection(event, collection)
diff --git a/src/types/collection.ts b/src/types/collection.ts
index 9e71e893c..cb88f8a89 100644
--- a/src/types/collection.ts
+++ b/src/types/collection.ts
@@ -8,6 +8,21 @@ export interface Collections {}
export type CollectionType = 'page' | 'data'
+/**
+ * Configuration for i18n support on a collection.
+ * When set, a `locale` column is automatically added to the collection schema.
+ */
+export interface CollectionI18nConfig {
+ /**
+ * List of supported locale codes (e.g. ['en', 'fr', 'de'])
+ */
+ locales: string[]
+ /**
+ * Default locale code used as fallback (e.g. 'en')
+ */
+ defaultLocale: string
+}
+
/**
* Defines an index on collection columns for optimizing database queries
*/
@@ -69,6 +84,12 @@ export interface PageCollection {
source?: string | CollectionSource | CollectionSource[] | ResolvedCustomCollectionSource
schema?: ContentStandardSchemaV1
indexes?: CollectionIndex[]
+ /**
+ * Enable i18n support for this collection.
+ * Pass `true` to auto-detect from `@nuxtjs/i18n` module config, or
+ * pass a `CollectionI18nConfig` object to configure manually.
+ */
+ i18n?: true | CollectionI18nConfig
}
export interface DataCollection {
@@ -76,6 +97,12 @@ export interface DataCollection {
source?: string | CollectionSource | CollectionSource[] | ResolvedCustomCollectionSource
schema: ContentStandardSchemaV1
indexes?: CollectionIndex[]
+ /**
+ * Enable i18n support for this collection.
+ * Pass `true` to auto-detect from `@nuxtjs/i18n` module config, or
+ * pass a `CollectionI18nConfig` object to configure manually.
+ */
+ i18n?: true | CollectionI18nConfig
}
export type Collection = PageCollection | DataCollection
@@ -87,9 +114,14 @@ export interface DefinedCollection {
extendedSchema: Draft07
fields: Record
indexes?: CollectionIndex[]
+ /**
+ * `true` is the shorthand resolved from `@nuxtjs/i18n` in config loading.
+ * After resolution, this is always `CollectionI18nConfig | undefined`.
+ */
+ i18n?: true | CollectionI18nConfig
}
-export interface ResolvedCollection extends DefinedCollection {
+export interface ResolvedCollection extends Omit {
name: string
tableName: string
/**
@@ -97,6 +129,10 @@ export interface ResolvedCollection extends DefinedCollection {
* Private collections will not be available in the runtime.
*/
private: boolean
+ /**
+ * Fully resolved i18n config (never `true` — that's resolved before this point).
+ */
+ i18n?: CollectionI18nConfig
}
export interface CollectionInfo {
@@ -115,6 +151,11 @@ export interface CollectionItemBase {
stem: string
extension: string
meta: Record
+ /**
+ * Locale code for this content item.
+ * Only present when the collection has i18n enabled.
+ */
+ locale?: string
}
export interface PageCollectionItemBase extends CollectionItemBase {
diff --git a/src/types/database.ts b/src/types/database.ts
index 7e827f2b1..2aff24185 100644
--- a/src/types/database.ts
+++ b/src/types/database.ts
@@ -17,7 +17,7 @@ export type DatabaseAdapterFactory = (otps?: Options) => DatabaseAdapte
export interface LocalDevelopmentDatabase {
fetchDevelopmentCache(): Promise>
fetchDevelopmentCacheForKey(key: string): Promise
- insertDevelopmentCache(id: string, checksum: string, parsedContent: string): void
+ insertDevelopmentCache(id: string, value: string, checksum: string): Promise
deleteDevelopmentCache(id: string): void
dropContentTables(): void
exec(sql: string): void
diff --git a/src/types/index.ts b/src/types/index.ts
index fa34160e5..4bb9814f3 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,5 +1,6 @@
export type * from './collection'
export type * from './hooks'
+export type * from './locales'
export type * from './module'
export type * from './navigation'
export type * from './surround'
diff --git a/src/types/locales.ts b/src/types/locales.ts
new file mode 100644
index 000000000..575a4af14
--- /dev/null
+++ b/src/types/locales.ts
@@ -0,0 +1,12 @@
+/**
+ * Represents a single locale variant of a content item.
+ * Returned by `queryCollectionLocales`, useful for language switchers and hreflang tags.
+ */
+export interface ContentLocaleEntry {
+ locale: string
+ stem: string
+ /** Only present for `page` collections. */
+ path?: string
+ /** Only present for `page` collections. */
+ title?: string
+}
diff --git a/src/types/query.ts b/src/types/query.ts
index 5b013feaa..0883d1ab8 100644
--- a/src/types/query.ts
+++ b/src/types/query.ts
@@ -4,6 +4,20 @@ export type QueryGroupFunction = (group: CollectionQueryGroup) => Collecti
export interface CollectionQueryBuilder {
path(path: string): CollectionQueryBuilder
+ /**
+ * Filter by stem (filename without extension).
+ * Automatically resolves the full stem path including the collection's source prefix.
+ * e.g., `.stem('navbar')` matches `content/navigation/navbar.yml` when the collection source is `navigation/*.yml`
+ */
+ stem(stem: string): CollectionQueryBuilder
+ /**
+ * Filter results by locale.
+ * @param locale - The locale code to filter by (e.g. 'fr')
+ * @param opts - Options for locale filtering
+ * @param opts.fallback - Fallback locale code. When set, items missing in the
+ * requested locale will be filled from the fallback locale.
+ */
+ locale(locale: string, opts?: { fallback?: string }): CollectionQueryBuilder
select(...fields: K[]): CollectionQueryBuilder>
order(field: keyof T, direction: 'ASC' | 'DESC'): CollectionQueryBuilder
skip(skip: number): CollectionQueryBuilder
diff --git a/src/utils/collection.ts b/src/utils/collection.ts
index 7663ea6f2..79c7af06e 100644
--- a/src/utils/collection.ts
+++ b/src/utils/collection.ts
@@ -3,7 +3,7 @@ import type { Collection, ResolvedCollection, CollectionSource, DefinedCollectio
import { getOrderedSchemaKeys, describeProperty, getCollectionFieldsTypes } from '../runtime/internal/schema'
import type { Draft07, ParsedContentFile } from '../types'
import { defineLocalSource, defineGitSource } from './source'
-import { emptyStandardSchema, mergeStandardSchema, metaStandardSchema, pageStandardSchema, infoStandardSchema, detectSchemaVendor, replaceComponentSchemas } from './schema'
+import { emptyStandardSchema, mergeStandardSchema, metaStandardSchema, pageStandardSchema, localeStandardSchema, infoStandardSchema, detectSchemaVendor, replaceComponentSchemas } from './schema'
import { logger } from './dev'
import nuxtContentContext from './context'
import { formatDate, formatDateTime } from './content/transformers/utils'
@@ -27,15 +27,29 @@ export function defineCollection(collection: Collection): DefinedCollectio
extendedSchema = mergeStandardSchema(pageStandardSchema, extendedSchema)
}
+ // Add locale field when i18n is fully configured (not `true` shorthand —
+ // that gets resolved later in loadContentConfig via resolveI18nConfig)
+ const hasI18nConfig = collection.i18n && collection.i18n !== true
+ if (hasI18nConfig) {
+ extendedSchema = mergeStandardSchema(localeStandardSchema, extendedSchema)
+ }
+
extendedSchema = mergeStandardSchema(metaStandardSchema, extendedSchema)
+ // Auto-add composite index on (locale, stem) for i18n collections
+ const indexes = collection.indexes ? [...collection.indexes] : []
+ if (hasI18nConfig) {
+ indexes.push({ columns: ['locale', 'stem'] })
+ }
+
return {
type: collection.type,
source: resolveSource(collection.source),
schema: standardSchema,
extendedSchema: extendedSchema,
fields: getCollectionFieldsTypes(extendedSchema),
- indexes: collection.indexes,
+ indexes,
+ i18n: collection.i18n,
}
}
@@ -67,6 +81,8 @@ export function resolveCollection(name: string, collection: DefinedCollection):
type: collection.type || 'page',
tableName: getTableName(name),
private: name === 'info',
+ // Ensure i18n: true is never passed through (should be resolved in config.ts)
+ i18n: collection.i18n === true ? undefined : collection.i18n,
}
}
diff --git a/src/utils/config.ts b/src/utils/config.ts
index c726b6d0a..ee6533014 100644
--- a/src/utils/config.ts
+++ b/src/utils/config.ts
@@ -2,8 +2,10 @@ import { loadConfig, watchConfig, createDefineConfig } from 'c12'
import { relative } from 'pathe'
import { hasNuxtModule, useNuxt } from '@nuxt/kit'
import type { Nuxt } from '@nuxt/schema'
-import type { DefinedCollection, ModuleOptions } from '../types'
+import type { CollectionI18nConfig, DefinedCollection, ModuleOptions } from '../types'
import { defineCollection, resolveCollections } from './collection'
+import { localeStandardSchema, mergeStandardSchema } from './schema'
+import { getCollectionFieldsTypes } from '../runtime/internal/schema'
import { logger } from './dev'
import { resolveStudioCollection } from './studio'
@@ -75,7 +77,59 @@ export async function loadContentConfig(nuxt: Nuxt, options?: ModuleOptions) {
resolveStudioCollection(nuxt, finalCollectionsConfig)
}
+ // Resolve `i18n: true` shorthand from @nuxtjs/i18n module config
+ resolveI18nConfig(nuxt, finalCollectionsConfig)
+
const collections = resolveCollections(finalCollectionsConfig)
return { collections }
}
+
+/**
+ * Resolve `i18n: true` shorthand on collections by reading locale config
+ * from the `@nuxtjs/i18n` module. If nuxt-i18n is not installed and a
+ * collection uses `i18n: true`, a warning is logged and i18n is disabled.
+ */
+function resolveI18nConfig(nuxt: Nuxt, collections: Record) {
+ // Check which collections need resolution
+ const needsResolution = Object.values(collections).some(c => c.i18n === true)
+ if (!needsResolution) return
+
+ let resolvedConfig: CollectionI18nConfig | undefined
+
+ if (hasNuxtModule('@nuxtjs/i18n', nuxt)) {
+ const i18nOptions = (nuxt.options as unknown as {
+ i18n?: {
+ locales?: Array
+ defaultLocale?: string
+ }
+ }).i18n
+
+ if (i18nOptions?.locales?.length && i18nOptions.defaultLocale) {
+ resolvedConfig = {
+ locales: i18nOptions.locales.map(l => typeof l === 'string' ? l : l.code),
+ defaultLocale: i18nOptions.defaultLocale,
+ }
+ }
+ }
+
+ for (const [name, collection] of Object.entries(collections)) {
+ if (collection.i18n !== true) continue
+
+ if (resolvedConfig) {
+ collection.i18n = resolvedConfig
+ // Merge locale schema + index now that we have the real config
+ // (defineCollection deferred this because i18n was `true`)
+ collection.extendedSchema = mergeStandardSchema(localeStandardSchema, collection.extendedSchema)
+ collection.fields = getCollectionFieldsTypes(collection.extendedSchema)
+ collection.indexes = [...(collection.indexes || []), { columns: ['locale', 'stem'] }]
+ }
+ else {
+ logger.warn(
+ `Collection "${name}" has \`i18n: true\` but @nuxtjs/i18n module is not installed or has no locales configured. `
+ + 'Provide an explicit `i18n: { locales, defaultLocale }` config or install @nuxtjs/i18n.',
+ )
+ collection.i18n = undefined
+ }
+ }
+}
diff --git a/src/utils/content/index.ts b/src/utils/content/index.ts
index 696e2c093..54f4a1dd8 100644
--- a/src/utils/content/index.ts
+++ b/src/utils/content/index.ts
@@ -6,6 +6,7 @@ import type { Nuxt } from '@nuxt/schema'
import { resolveAlias } from '@nuxt/kit'
import type { LanguageRegistration } from 'shiki'
import { defu } from 'defu'
+import { detectLocaleFromPath } from '../i18n'
import { createJiti } from 'jiti'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import { visit } from 'unist-util-visit'
@@ -216,6 +217,19 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt)
}
}
+ // i18n: detect locale from path prefix when collection has i18n configured
+ if (collection.i18n && collectionKeys.includes('locale')) {
+ const currentPath = String(result.path || pathMetaFields.path || '')
+ const currentStem = String(result.stem || pathMetaFields.stem || '')
+ const detected = detectLocaleFromPath(currentPath, currentStem, collection.i18n)
+
+ result.locale = result.locale ?? detected.locale
+ if (collectionKeys.includes('path')) {
+ result.path = detected.path
+ }
+ result.stem = detected.stem
+ }
+
const afterParseCtx: FileAfterParseHook = { file: hookedFile, content: result as ParsedContentFile, collection }
await nuxt?.callHook?.('content:file:afterParse', afterParseCtx)
return afterParseCtx.content
diff --git a/src/utils/dev.ts b/src/utils/dev.ts
index 3b78da1b6..a0c41ca68 100644
--- a/src/utils/dev.ts
+++ b/src/utils/dev.ts
@@ -3,13 +3,14 @@ import type { ViteDevServer } from 'vite'
import crypto from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'pathe'
+import { expandI18nData } from './i18n'
import type { Nuxt } from '@nuxt/schema'
import { isIgnored, updateTemplates, useLogger } from '@nuxt/kit'
import type { ConsolaInstance } from 'consola'
import chokidar from 'chokidar'
import micromatch from 'micromatch'
import { withTrailingSlash } from 'ufo'
-import type { ModuleOptions, ResolvedCollection } from '../types'
+import type { ModuleOptions, ParsedContentFile, ResolvedCollection } from '../types'
import type { Manifest } from '../types/manifest'
import { getLocalDatabase } from './database'
import { generateCollectionInsert } from './collection'
@@ -159,11 +160,43 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani
collectionType: collection.type,
}).then(result => JSON.stringify(result))
- db.insertDevelopmentCache(keyInCollection, checksum, parsedContent)
+ db.insertDevelopmentCache(keyInCollection, parsedContent, checksum)
}
- const { queries: insertQuery } = generateCollectionInsert(collection, JSON.parse(parsedContent))
- await broadcast(collection, keyInCollection, insertQuery)
+ const parsed: ParsedContentFile = JSON.parse(parsedContent)
+
+ // i18n: expand inline translations to per-locale DB rows
+ if (collection.i18n && (parsed?.meta as Record)?.i18n) {
+ const i18nData = (parsed.meta as Record).i18n as Record>
+ // Capture source locale before expandI18nData mutates parsed.locale
+ const sourceLocale = (parsed.locale as string | undefined) || collection.i18n.defaultLocale
+
+ const expandedItems = expandI18nData(parsed, collection.i18n, collection.type)
+ for (const item of expandedItems) {
+ const itemKey = item.locale ? `${keyInCollection}#${item.locale}` : keyInCollection
+ const { queries } = generateCollectionInsert(collection, item)
+ await broadcast(collection, itemKey, queries)
+ }
+
+ // Clean up the bare (un-suffixed) row in case i18n was just added to this file
+ await broadcast(collection, keyInCollection)
+
+ // Remove locale rows that are no longer in the i18n section
+ for (const locale of collection.i18n.locales) {
+ if (locale === sourceLocale || locale in i18nData) continue
+ await broadcast(collection, `${keyInCollection}#${locale}`)
+ }
+ }
+ else {
+ // Clean up stale locale variants if i18n was previously present but removed
+ if (collection.i18n) {
+ for (const locale of collection.i18n.locales) {
+ await broadcast(collection, `${keyInCollection}#${locale}`)
+ }
+ }
+ const { queries: insertQuery } = generateCollectionInsert(collection, parsed)
+ await broadcast(collection, keyInCollection, insertQuery)
+ }
}
}
@@ -193,7 +226,13 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani
await db.deleteDevelopmentCache(keyInCollection)
+ // Remove main row and all locale variant rows
await broadcast(collection, keyInCollection)
+ if (collection.i18n) {
+ for (const locale of collection.i18n.locales) {
+ await broadcast(collection, `${keyInCollection}#${locale}`)
+ }
+ }
}
}
@@ -206,9 +245,21 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani
}
const collectionDump = manifest.dump[collection.name]!
- const keyIndex = collectionDump.findIndex(item => item.includes(`'${key}'`))
+ // Use exact key match: look for the id as a complete SQL string literal ('key',) to avoid
+ // substring matches (e.g., 'team.yml' matching 'team.yml#fr')
+ const escapedKey = key.replace(/'/g, '\'\'')
+ const keyMatch = (item: string) => item.includes(`'${escapedKey}',`) || item.endsWith(`'${escapedKey}')`)
+ const keyIndex = collectionDump.findIndex(keyMatch)
const indexToUpdate = keyIndex !== -1 ? keyIndex : collectionDump.length
- const itemsToRemove = keyIndex === -1 ? 0 : 1
+
+ // Count all consecutive dump entries belonging to this key (large content splits
+ // into INSERT + UPDATE fragments that each reference the same key literal)
+ let itemsToRemove = 0
+ if (keyIndex !== -1) {
+ for (let i = keyIndex; i < collectionDump.length && keyMatch(collectionDump[i]!); i++) {
+ itemsToRemove++
+ }
+ }
if (insertQuery) {
collectionDump.splice(indexToUpdate, itemsToRemove, ...insertQuery)
diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts
new file mode 100644
index 000000000..698594bdb
--- /dev/null
+++ b/src/utils/i18n.ts
@@ -0,0 +1,141 @@
+import { createDefu } from 'defu'
+import { hash } from 'ohash'
+import type { CollectionI18nConfig } from '../types/collection'
+import type { ParsedContentFile } from '../types'
+
+/**
+ * Custom defu that merges arrays by index (item-by-item) instead of concatenating.
+ * Applied recursively to all nested arrays within merged objects.
+ * Used for inline i18n expansion: locale overrides merge with default locale items
+ * so untranslated fields (routes, IDs, icons, URLs) are preserved from the default.
+ *
+ * In createDefu's merger: obj[key] = defaults (second arg), value = overrides (first arg).
+ * Override items take priority; default items fill gaps for missing fields.
+ */
+export const defuByIndex = createDefu((obj, key, value) => {
+ if (Array.isArray(obj[key]) && Array.isArray(value)) {
+ const defaultArr = obj[key]
+ const overrideArr = value
+ const maxLen = Math.max(overrideArr.length, defaultArr.length)
+ const result = []
+ for (let i = 0; i < maxLen; i++) {
+ const overrideItem = overrideArr[i]
+ const defaultItem = defaultArr[i]
+ if (overrideItem !== undefined && defaultItem !== undefined
+ && typeof overrideItem === 'object' && overrideItem !== null
+ && typeof defaultItem === 'object' && defaultItem !== null) {
+ // Recursively merge with defuByIndex so nested arrays also merge by index
+ result.push(defuByIndex(overrideItem, defaultItem))
+ }
+ else {
+ result.push(overrideItem !== undefined ? overrideItem : defaultItem)
+ }
+ }
+ ;(obj as Record)[key as string] = result
+ return true
+ }
+})
+
+/**
+ * Expand inline i18n data from a parsed content file into per-locale items.
+ * The default locale keeps the original content; non-default locales get a deep-merged
+ * copy where only overridden fields differ. Non-default items include `_i18nSourceHash`
+ * for tracking whether the source content has changed since translation.
+ *
+ * For page collections (`collectionType: 'page'`), the body AST is replaced wholesale
+ * rather than deep-merged, since body is a parsed markdown tree that cannot be meaningfully merged.
+ *
+ * Note: this function mutates `parsedContent.meta` (removes the `i18n` key) and
+ * sets `parsedContent.locale` if not already set. This is acceptable because the
+ * source content is always consumed (inserted into DB) immediately after expansion.
+ */
+export function expandI18nData(
+ parsedContent: ParsedContentFile,
+ i18nConfig: CollectionI18nConfig,
+ collectionType?: 'page' | 'data',
+): ParsedContentFile[] {
+ const meta = parsedContent.meta as Record | undefined
+ const i18nData = meta?.i18n as Record> | undefined
+ if (!i18nData) {
+ if (!parsedContent.locale) {
+ parsedContent.locale = i18nConfig.defaultLocale
+ }
+ return [parsedContent]
+ }
+
+ const { i18n: _removed, ...cleanMeta } = meta!
+ parsedContent.meta = cleanMeta
+
+ if (!parsedContent.locale) {
+ parsedContent.locale = i18nConfig.defaultLocale
+ }
+
+ // Compute source hash from default locale's translatable fields
+ const translatedFields = new Set(Object.values(i18nData).flatMap(Object.keys))
+ const sourceFields: Record = {}
+ for (const field of translatedFields) {
+ sourceFields[field] = parsedContent[field]
+ }
+ const i18nSourceHash = hash(sourceFields)
+
+ const items: ParsedContentFile[] = [parsedContent]
+
+ for (const [locale, overrides] of Object.entries(i18nData)) {
+ if (locale === parsedContent.locale) continue
+
+ // Deep merge preserves untranslated fields (routes, IDs, icons).
+ // For page collections, body AST must not be deep-merged — replace it wholesale.
+ const merged = defuByIndex(overrides, parsedContent) as ParsedContentFile
+ if (collectionType === 'page' && overrides.body) {
+ merged.body = overrides.body
+ }
+
+ const localeItem: ParsedContentFile = {
+ ...merged,
+ id: `${parsedContent.id}#${locale}`,
+ locale,
+ meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash },
+ }
+
+ items.push(localeItem)
+ }
+
+ return items
+}
+
+/**
+ * Detect locale from the first path segment and strip the locale prefix
+ * from both path and stem. Returns default locale when no prefix matches.
+ */
+export function detectLocaleFromPath(
+ path: string,
+ stem: string,
+ i18nConfig: CollectionI18nConfig,
+): { locale: string, path: string, stem: string } {
+ const pathParts = path.split('/').filter(Boolean)
+ const firstPart = pathParts[0]
+
+ if (firstPart && i18nConfig.locales.includes(firstPart)) {
+ const pathWithoutLocale = '/' + pathParts.slice(1).join('/')
+
+ let newStem = stem
+ if (stem === firstPart) {
+ newStem = ''
+ }
+ else if (stem.startsWith(firstPart + '/')) {
+ newStem = stem.slice(firstPart.length + 1)
+ }
+
+ return {
+ locale: firstPart,
+ path: pathWithoutLocale === '/' ? '/' : pathWithoutLocale,
+ stem: newStem,
+ }
+ }
+
+ return {
+ locale: i18nConfig.defaultLocale,
+ path,
+ stem,
+ }
+}
diff --git a/src/utils/schema/definitions.ts b/src/utils/schema/definitions.ts
index 6b88eecff..ef965da80 100644
--- a/src/utils/schema/definitions.ts
+++ b/src/utils/schema/definitions.ts
@@ -84,6 +84,23 @@ export const metaStandardSchema: Draft07 = {
},
}
+export const localeStandardSchema: Draft07 = {
+ $schema: 'http://json-schema.org/draft-07/schema#',
+ $ref: '#/definitions/__SCHEMA__',
+ definitions: {
+ __SCHEMA__: {
+ type: 'object',
+ properties: {
+ locale: {
+ type: 'string',
+ },
+ },
+ required: [],
+ additionalProperties: false,
+ },
+ },
+}
+
export const pageStandardSchema: Draft07 = {
$schema: 'http://json-schema.org/draft-07/schema#',
$ref: '#/definitions/__SCHEMA__',
diff --git a/src/utils/templates.ts b/src/utils/templates.ts
index f9e0cc447..7e70afa0b 100644
--- a/src/utils/templates.ts
+++ b/src/utils/templates.ts
@@ -176,9 +176,14 @@ export const manifestTemplate = (manifest: Manifest) => ({
filename: moduleTemplates.manifest,
getContents: ({ options }: { options: { manifest: Manifest } }) => {
const collectionsMeta = options.manifest.collections.reduce((acc, collection) => {
+ // Stem prefix = source prefix only (collection name is stripped by describeId in path-meta.ts)
+ const sourcePrefix = collection.source?.[0]?.prefix || ''
+ const stemPrefix = sourcePrefix.replace(/^\/|\/$/g, '')
acc[collection.name] = {
type: collection.type,
fields: collection.fields,
+ ...(collection.i18n ? { i18n: collection.i18n } : {}),
+ ...(stemPrefix ? { stemPrefix } : {}),
}
return acc
}, {} as Record)
diff --git a/test/fixtures/i18n/content.config.ts b/test/fixtures/i18n/content.config.ts
new file mode 100644
index 000000000..676805d02
--- /dev/null
+++ b/test/fixtures/i18n/content.config.ts
@@ -0,0 +1,33 @@
+import { defineCollection, defineContentConfig } from '@nuxt/content'
+import { z } from 'zod'
+
+export default defineContentConfig({
+ collections: {
+ // Path-based i18n collection: content organized by locale directories
+ blog: defineCollection({
+ type: 'page',
+ source: '*/blog/**',
+ schema: z.object({
+ date: z.string().optional(),
+ }),
+ i18n: {
+ locales: ['en', 'fr'],
+ defaultLocale: 'en',
+ },
+ }),
+ // Inline i18n collection: translations embedded in the content file
+ team: defineCollection({
+ type: 'data',
+ source: 'data/team.yml',
+ schema: z.object({
+ name: z.string(),
+ role: z.string(),
+ country: z.string().optional(),
+ }),
+ i18n: {
+ locales: ['en', 'fr', 'de'],
+ defaultLocale: 'en',
+ },
+ }),
+ },
+})
diff --git a/test/fixtures/i18n/content/data/team.yml b/test/fixtures/i18n/content/data/team.yml
new file mode 100644
index 000000000..d1e615842
--- /dev/null
+++ b/test/fixtures/i18n/content/data/team.yml
@@ -0,0 +1,10 @@
+name: Jane Doe
+role: Developer
+country: Switzerland
+i18n:
+ fr:
+ role: Développeuse
+ country: Suisse
+ de:
+ role: Entwicklerin
+ country: Schweiz
diff --git a/test/fixtures/i18n/content/en/blog/hello.md b/test/fixtures/i18n/content/en/blog/hello.md
new file mode 100644
index 000000000..1e71ed450
--- /dev/null
+++ b/test/fixtures/i18n/content/en/blog/hello.md
@@ -0,0 +1,9 @@
+---
+title: Hello World
+description: An introductory post
+date: '2025-01-01'
+---
+
+# Hello World
+
+Welcome to the blog.
diff --git a/test/fixtures/i18n/content/en/blog/only-english.md b/test/fixtures/i18n/content/en/blog/only-english.md
new file mode 100644
index 000000000..53a74c0e1
--- /dev/null
+++ b/test/fixtures/i18n/content/en/blog/only-english.md
@@ -0,0 +1,9 @@
+---
+title: English Only Post
+description: This post only exists in English
+date: '2025-02-01'
+---
+
+# English Only
+
+This post has no French translation.
diff --git a/test/fixtures/i18n/content/fr/blog/hello.md b/test/fixtures/i18n/content/fr/blog/hello.md
new file mode 100644
index 000000000..589edbc96
--- /dev/null
+++ b/test/fixtures/i18n/content/fr/blog/hello.md
@@ -0,0 +1,9 @@
+---
+title: Bonjour le Monde
+description: Un article d'introduction
+date: '2025-01-01'
+---
+
+# Bonjour le Monde
+
+Bienvenue sur le blog.
diff --git a/test/fixtures/i18n/nuxt.config.ts b/test/fixtures/i18n/nuxt.config.ts
new file mode 100644
index 000000000..04d37b47f
--- /dev/null
+++ b/test/fixtures/i18n/nuxt.config.ts
@@ -0,0 +1,9 @@
+import { defineNuxtConfig } from 'nuxt/config'
+
+export default defineNuxtConfig({
+ modules: [
+ '@nuxt/content',
+ ],
+ devtools: { enabled: true },
+ compatibilityDate: '2025-09-03',
+})
diff --git a/test/fixtures/i18n/package.json b/test/fixtures/i18n/package.json
new file mode 100644
index 000000000..57a196b38
--- /dev/null
+++ b/test/fixtures/i18n/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "nuxt-content-test-i18n",
+ "private": true,
+ "scripts": {
+ "dev": "nuxi dev"
+ }
+}
diff --git a/test/fixtures/i18n/server/api/content/blog-first.get.ts b/test/fixtures/i18n/server/api/content/blog-first.get.ts
new file mode 100644
index 000000000..c0605a245
--- /dev/null
+++ b/test/fixtures/i18n/server/api/content/blog-first.get.ts
@@ -0,0 +1,17 @@
+import { eventHandler, getQuery } from 'h3'
+
+export default eventHandler(async (event) => {
+ const { path, locale, fallback } = getQuery(event) as { path?: string, locale?: string, fallback?: string }
+
+ let query = queryCollection(event, 'blog')
+
+ if (locale) {
+ query = query.locale(locale, fallback ? { fallback } : undefined)
+ }
+
+ if (path) {
+ query = query.path(path)
+ }
+
+ return await query.first()
+})
diff --git a/test/fixtures/i18n/server/api/content/blog.get.ts b/test/fixtures/i18n/server/api/content/blog.get.ts
new file mode 100644
index 000000000..084e695e1
--- /dev/null
+++ b/test/fixtures/i18n/server/api/content/blog.get.ts
@@ -0,0 +1,13 @@
+import { eventHandler, getQuery } from 'h3'
+
+export default eventHandler(async (event) => {
+ const { locale, fallback } = getQuery(event) as { locale?: string, fallback?: string }
+
+ let query = queryCollection(event, 'blog')
+
+ if (locale) {
+ query = query.locale(locale, fallback ? { fallback } : undefined)
+ }
+
+ return await query.all()
+})
diff --git a/test/fixtures/i18n/server/api/content/locales.get.ts b/test/fixtures/i18n/server/api/content/locales.get.ts
new file mode 100644
index 000000000..13b9bd12f
--- /dev/null
+++ b/test/fixtures/i18n/server/api/content/locales.get.ts
@@ -0,0 +1,11 @@
+import { eventHandler, getQuery } from 'h3'
+
+export default eventHandler(async (event) => {
+ const { collection, stem } = getQuery(event) as { collection?: string, stem?: string }
+
+ if (!collection || !stem) {
+ throw new Error('collection and stem are required')
+ }
+
+ return await queryCollectionLocales(event, collection as 'blog', stem)
+})
diff --git a/test/fixtures/i18n/server/api/content/team.get.ts b/test/fixtures/i18n/server/api/content/team.get.ts
new file mode 100644
index 000000000..256590e0a
--- /dev/null
+++ b/test/fixtures/i18n/server/api/content/team.get.ts
@@ -0,0 +1,13 @@
+import { eventHandler, getQuery } from 'h3'
+
+export default eventHandler(async (event) => {
+ const { locale, fallback } = getQuery(event) as { locale?: string, fallback?: string }
+
+ let query = queryCollection(event, 'team')
+
+ if (locale) {
+ query = query.locale(locale, fallback ? { fallback } : undefined)
+ }
+
+ return await query.all()
+})
diff --git a/test/i18n.test.ts b/test/i18n.test.ts
new file mode 100644
index 000000000..894f0ea9c
--- /dev/null
+++ b/test/i18n.test.ts
@@ -0,0 +1,220 @@
+import fs from 'node:fs/promises'
+import { createResolver } from '@nuxt/kit'
+import { setup, $fetch } from '@nuxt/test-utils'
+import { afterAll, describe, expect, test } from 'vitest'
+import { getLocalDatabase } from '../src/utils/database'
+import { getTableName } from '../src/utils/collection'
+import { initiateValidatorsContext } from '../src/utils/dependencies'
+import type { LocalDevelopmentDatabase } from '../src/module'
+
+const resolver = createResolver(import.meta.url)
+
+async function cleanup() {
+ await fs.rm(resolver.resolve('./fixtures/i18n/node_modules'), { recursive: true, force: true })
+ await fs.rm(resolver.resolve('./fixtures/i18n/.nuxt'), { recursive: true, force: true })
+ await fs.rm(resolver.resolve('./fixtures/i18n/.data'), { recursive: true, force: true })
+}
+
+describe('i18n', async () => {
+ await initiateValidatorsContext()
+
+ await cleanup()
+ afterAll(async () => {
+ await cleanup()
+ })
+
+ await setup({
+ rootDir: resolver.resolve('./fixtures/i18n'),
+ dev: true,
+ port: 0, // Let OS assign a free port to avoid EADDRINUSE on CI
+ })
+
+ describe('database', () => {
+ let db: LocalDevelopmentDatabase
+ afterAll(async () => {
+ if (db) {
+ await db.close()
+ }
+ })
+
+ test('local database is created', async () => {
+ const stat = await fs.stat(resolver.resolve('./fixtures/i18n/.data/content/contents.sqlite'))
+ expect(stat?.isFile()).toBe(true)
+ })
+
+ test('blog table exists with locale column', async () => {
+ db = await getLocalDatabase({ type: 'sqlite', filename: resolver.resolve('./fixtures/i18n/.data/content/contents.sqlite') }, { nativeSqlite: true })
+
+ const tableInfo = await db.database?.prepare(`PRAGMA table_info(${getTableName('blog')});`).all() as { name: string }[]
+ const columnNames = tableInfo.map(c => c.name)
+
+ expect(columnNames).toContain('locale')
+ expect(columnNames).toContain('path')
+ expect(columnNames).toContain('title')
+ })
+
+ test('team table exists with locale column', async () => {
+ const tableInfo = await db.database?.prepare(`PRAGMA table_info(${getTableName('team')});`).all() as { name: string }[]
+ const columnNames = tableInfo.map(c => c.name)
+
+ expect(columnNames).toContain('locale')
+ expect(columnNames).toContain('name')
+ expect(columnNames).toContain('role')
+ })
+ })
+
+ describe('path-based i18n (blog collection)', () => {
+ test('query English blog posts', async () => {
+ const posts = await $fetch[]>('/api/content/blog?locale=en')
+
+ expect(posts.length).toBeGreaterThanOrEqual(2)
+ const titles = posts.map(p => p.title)
+ expect(titles).toContain('Hello World')
+ expect(titles).toContain('English Only Post')
+
+ // All posts should have locale = 'en'
+ for (const post of posts) {
+ expect(post.locale).toBe('en')
+ }
+ })
+
+ test('query French blog posts', async () => {
+ const posts = await $fetch[]>('/api/content/blog?locale=fr')
+
+ expect(posts.length).toBeGreaterThanOrEqual(1)
+ const titles = posts.map(p => p.title)
+ expect(titles).toContain('Bonjour le Monde')
+
+ for (const post of posts) {
+ expect(post.locale).toBe('fr')
+ }
+ })
+
+ test('locale strips path prefix', async () => {
+ const posts = await $fetch[]>('/api/content/blog?locale=en')
+ const helloPost = posts.find(p => p.title === 'Hello World')
+
+ // Path should NOT contain the locale prefix
+ expect(helloPost?.path).toBe('/blog/hello')
+ expect((helloPost?.path as string)?.startsWith('/en/')).toBe(false)
+ })
+
+ test('same content has same path across locales', async () => {
+ const enPosts = await $fetch[]>('/api/content/blog?locale=en')
+ const frPosts = await $fetch[]>('/api/content/blog?locale=fr')
+
+ const enHello = enPosts.find(p => p.title === 'Hello World')
+ const frHello = frPosts.find(p => p.title === 'Bonjour le Monde')
+
+ // Both should have the same path (locale prefix stripped)
+ expect(enHello?.path).toBe('/blog/hello')
+ expect(frHello?.path).toBe('/blog/hello')
+ })
+
+ test('fallback returns default locale for missing translations', async () => {
+ const posts = await $fetch[]>('/api/content/blog?locale=fr&fallback=en')
+
+ const titles = posts.map(p => p.title)
+ // Should include the French translation
+ expect(titles).toContain('Bonjour le Monde')
+ // Should also include English-only post as fallback
+ expect(titles).toContain('English Only Post')
+ })
+
+ test('query specific post by path and locale', async () => {
+ const post = await $fetch>('/api/content/blog-first?path=/blog/hello&locale=fr')
+
+ expect(post).toBeDefined()
+ expect(post.title).toBe('Bonjour le Monde')
+ expect(post.locale).toBe('fr')
+ })
+
+ test('fallback for single post returns default when translation missing', async () => {
+ const post = await $fetch>('/api/content/blog-first?path=/blog/only-english&locale=fr&fallback=en')
+
+ expect(post).toBeDefined()
+ expect(post.title).toBe('English Only Post')
+ expect(post.locale).toBe('en')
+ })
+ })
+
+ describe('inline i18n (team collection)', () => {
+ test('query team member in default locale', async () => {
+ const members = await $fetch[]>('/api/content/team?locale=en')
+
+ expect(members.length).toBeGreaterThanOrEqual(1)
+ const jane = members.find(m => m.name === 'Jane Doe')
+ expect(jane).toBeDefined()
+ expect(jane?.role).toBe('Developer')
+ expect(jane?.country).toBe('Switzerland')
+ expect(jane?.locale).toBe('en')
+ })
+
+ test('query team member in French', async () => {
+ const members = await $fetch[]>('/api/content/team?locale=fr')
+
+ expect(members.length).toBeGreaterThanOrEqual(1)
+ const jane = members.find(m => m.name === 'Jane Doe')
+ expect(jane).toBeDefined()
+ expect(jane?.role).toBe('Développeuse')
+ expect(jane?.country).toBe('Suisse')
+ expect(jane?.locale).toBe('fr')
+ })
+
+ test('query team member in German', async () => {
+ const members = await $fetch[]>('/api/content/team?locale=de')
+
+ expect(members.length).toBeGreaterThanOrEqual(1)
+ const jane = members.find(m => m.name === 'Jane Doe')
+ expect(jane).toBeDefined()
+ expect(jane?.role).toBe('Entwicklerin')
+ expect(jane?.country).toBe('Schweiz')
+ expect(jane?.locale).toBe('de')
+ // Name should fall back to default since it's not translated
+ expect(jane?.name).toBe('Jane Doe')
+ })
+
+ test('non-default locale items have _i18nSourceHash in meta', async () => {
+ const members = await $fetch[]>('/api/content/team?locale=fr')
+ const jane = members.find(m => m.name === 'Jane Doe')
+ const meta = jane?.meta as Record
+
+ expect(meta?._i18nSourceHash).toBeDefined()
+ expect(typeof meta?._i18nSourceHash).toBe('string')
+ })
+
+ test('default locale items do NOT have _i18nSourceHash', async () => {
+ const members = await $fetch[]>('/api/content/team?locale=en')
+ const jane = members.find(m => m.name === 'Jane Doe')
+ const meta = jane?.meta as Record
+
+ expect(meta?._i18nSourceHash).toBeUndefined()
+ })
+ })
+
+ describe('queryCollectionLocales helper', () => {
+ test('returns all locale variants for a given stem', async () => {
+ const locales = await $fetch<{ locale: string, path: string }[]>(
+ '/api/content/locales?collection=blog&stem=blog/hello',
+ )
+
+ expect(locales.length).toBe(2)
+ const localeCodes = locales.map(l => l.locale).sort()
+ expect(localeCodes).toEqual(['en', 'fr'])
+
+ // Both should have the same path
+ for (const entry of locales) {
+ expect(entry.path).toBe('/blog/hello')
+ }
+ })
+
+ test('returns single locale for untranslated content', async () => {
+ const locales = await $fetch<{ locale: string, path: string }[]>(
+ '/api/content/locales?collection=blog&stem=blog/only-english',
+ )
+
+ expect(locales.length).toBe(1)
+ expect(locales[0].locale).toBe('en')
+ })
+ })
+})
diff --git a/test/unit/assertSafeQuery.test.ts b/test/unit/assertSafeQuery.test.ts
index aea0d7e94..225fcec1f 100644
--- a/test/unit/assertSafeQuery.test.ts
+++ b/test/unit/assertSafeQuery.test.ts
@@ -2,11 +2,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { assertSafeQuery } from '../../src/runtime/internal/security'
import { collectionQueryBuilder } from '../../src/runtime/internal/query'
-// Mock tables from manifest
+// Mock tables and collection metadata from manifest
vi.mock('#content/manifest', () => ({
tables: {
test: '_content_test',
},
+ default: {
+ test: { type: 'data', fields: {} },
+ },
}))
const mockFetch = vi.fn().mockResolvedValue(Promise.resolve([{}]))
const mockCollection = 'test' as never
@@ -46,6 +49,36 @@ describe('decompressSQLDump', () => {
'SELECT "id" FROM _content_test WHERE (x=$\'$ OR x IN (SELECT BLAH) OR x=$\'$) ORDER BY id ASC': false,
}
+ const securityQueries = {
+ // Newline injection
+ 'SELECT * FROM _content_test ORDER BY id ASC\nDROP TABLE _content_test': false,
+ 'SELECT * FROM _content_test ORDER BY id ASC\rDROP TABLE _content_test': false,
+ // Escaped quotes in WHERE values should pass (not be treated as comments)
+ 'SELECT * FROM _content_test WHERE ("title" = \'L\'\'été\') ORDER BY stem ASC': true,
+ 'SELECT * FROM _content_test WHERE ("title" = \'it\'\'s\') ORDER BY stem ASC': true,
+ // Triple-quote edge case — should NOT bypass keyword detection
+ 'SELECT * FROM _content_test WHERE ("x" = \'a\'\'\') UNION SELECT 1 ORDER BY stem ASC': false,
+ // COUNT with quoted field
+ 'SELECT COUNT("title") as count FROM _content_test': true,
+ 'SELECT COUNT(DISTINCT "author") as count FROM _content_test': true,
+ // COUNT without ORDER BY
+ 'SELECT COUNT(*) as count FROM _content_test': true,
+ // Locale-filtered query (typical auto-locale output)
+ 'SELECT * FROM _content_test WHERE ("locale" = \'fr\') ORDER BY stem ASC': true,
+ 'SELECT * FROM _content_test WHERE ("locale" = \'fr\') AND ("stem" = \'navbar\') ORDER BY stem ASC': true,
+ }
+
+ Object.entries(securityQueries).forEach(([query, isValid]) => {
+ it(`security: ${query.slice(0, 60)}...`, () => {
+ if (isValid) {
+ expect(() => assertSafeQuery(query, 'test')).not.toThrow()
+ }
+ else {
+ expect(() => assertSafeQuery(query, 'test')).toThrow()
+ }
+ })
+ })
+
Object.entries(queries).forEach(([query, isValid]) => {
it(`${query}`, () => {
if (isValid) {
diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts
index d6630439b..daabbad17 100644
--- a/test/unit/collectionQueryBuilder.test.ts
+++ b/test/unit/collectionQueryBuilder.test.ts
@@ -1,11 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { collectionQueryBuilder } from '../../src/runtime/internal/query'
-// Mock tables from manifest
+// Mock tables and collection metadata from manifest
vi.mock('#content/manifest', () => ({
tables: {
articles: '_articles',
},
+ default: {
+ articles: {
+ type: 'data',
+ fields: {},
+ i18n: { locales: ['en', 'fr', 'de'], defaultLocale: 'en' },
+ stemPrefix: '',
+ },
+ },
}))
// Mock fetch function
@@ -130,23 +138,23 @@ describe('collectionQueryBuilder', () => {
)
})
- it('builds count query', async () => {
+ it('builds count query without ORDER BY', async () => {
const query = collectionQueryBuilder(mockCollection, mockFetch)
await query.count()
expect(mockFetch).toHaveBeenCalledWith(
'articles',
- 'SELECT COUNT(*) as count FROM _articles ORDER BY stem ASC',
+ 'SELECT COUNT(*) as count FROM _articles',
)
})
- it('builds distinct count query', async () => {
+ it('builds distinct count query without ORDER BY', async () => {
const query = collectionQueryBuilder(mockCollection, mockFetch)
await query.count('author', true)
expect(mockFetch).toHaveBeenCalledWith(
'articles',
- 'SELECT COUNT(DISTINCT author) as count FROM _articles ORDER BY stem ASC',
+ 'SELECT COUNT(DISTINCT "author") as count FROM _articles',
)
})
@@ -180,4 +188,225 @@ describe('collectionQueryBuilder', () => {
'SELECT * FROM _articles WHERE ("path" = \'/blog/my-article\') ORDER BY stem ASC',
)
})
+
+ it('builds query with locale', async () => {
+ const query = collectionQueryBuilder(mockCollection, mockFetch)
+ await query
+ .locale('fr')
+ .all()
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles WHERE ("locale" = \'fr\') ORDER BY stem ASC',
+ )
+ })
+
+ it('builds query with locale and fallback (two queries, sorted by stem)', async () => {
+ mockFetch
+ .mockResolvedValueOnce([{ stem: 'post-c', locale: 'fr' }])
+ .mockResolvedValueOnce([{ stem: 'post-a', locale: 'en' }, { stem: 'post-c', locale: 'en' }])
+
+ const query = collectionQueryBuilder(mockCollection, mockFetch)
+ const results = await query
+ .locale('fr', { fallback: 'en' })
+ .all()
+
+ // Should have called fetch twice: once for locale, once for fallback
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles WHERE ("locale" = \'fr\') ORDER BY stem ASC',
+ )
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC',
+ )
+
+ // Merged results: fr preferred over en duplicate, sorted by stem
+ expect(results).toHaveLength(2)
+ expect(results[0]).toEqual({ stem: 'post-a', locale: 'en' }) // fallback, sorted first
+ expect(results[1]).toEqual({ stem: 'post-c', locale: 'fr' }) // locale preferred over en
+ })
+
+ it('builds query with locale and path', async () => {
+ const query = collectionQueryBuilder('articles' as never, mockFetch)
+ await query
+ .locale('de')
+ .path('/blog/post')
+ .all()
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles WHERE ("locale" = \'de\') AND ("path" = \'/blog/post\') ORDER BY stem ASC',
+ )
+ })
+
+ it('.stem() queries by stem directly when no source prefix', async () => {
+ // stemPrefix is '' (no source subdirectory), so 'navbar' stays 'navbar'
+ const query = collectionQueryBuilder(mockCollection, mockFetch)
+ await query.stem('navbar').all()
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles WHERE ("stem" = \'navbar\') ORDER BY stem ASC',
+ )
+ })
+
+ it('locale fallback merges results in stem order', async () => {
+ // fr has stem c, en has stems a, b, c — fallback should interleave a, b
+ mockFetch
+ .mockResolvedValueOnce([{ stem: 'c', locale: 'fr' }])
+ .mockResolvedValueOnce([{ stem: 'a', locale: 'en' }, { stem: 'b', locale: 'en' }, { stem: 'c', locale: 'en' }])
+
+ const results = await collectionQueryBuilder(mockCollection, mockFetch)
+ .locale('fr', { fallback: 'en' })
+ .all()
+
+ expect(results).toHaveLength(3)
+ expect(results.map((r: { stem: string }) => r.stem)).toEqual(['a', 'b', 'c'])
+ // stem 'c' should come from fr (locale preferred)
+ expect(results[2]).toEqual({ stem: 'c', locale: 'fr' })
+ // stems 'a' and 'b' come from en (fallback)
+ expect(results[0]).toEqual({ stem: 'a', locale: 'en' })
+ expect(results[1]).toEqual({ stem: 'b', locale: 'en' })
+ })
+
+ it('count query omits ORDER BY', async () => {
+ const query = collectionQueryBuilder(mockCollection, mockFetch)
+ await query.count()
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT COUNT(*) as count FROM _articles',
+ )
+ })
+
+ describe('auto-locale detection', () => {
+ it('auto-applies detected locale with fallback when collection has i18n', async () => {
+ mockFetch
+ .mockResolvedValueOnce([{ stem: 'a', locale: 'fr' }])
+ .mockResolvedValueOnce([{ stem: 'a', locale: 'en' }, { stem: 'b', locale: 'en' }])
+
+ // Pass 'fr' as detectedLocale (3rd arg) — simulates what client.ts/server.ts do
+ const results = await collectionQueryBuilder(mockCollection, mockFetch, 'fr').all()
+
+ // Should auto-apply locale with fallback to defaultLocale ('en')
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles WHERE ("locale" = \'fr\') ORDER BY stem ASC',
+ )
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC',
+ )
+ expect(results).toHaveLength(2)
+ })
+
+ it('does not auto-apply locale when .locale() is called explicitly', async () => {
+ // Pass 'fr' as detectedLocale, but call .locale('de') explicitly
+ const query = collectionQueryBuilder(mockCollection, mockFetch, 'fr')
+ await query.locale('de').all()
+
+ // Should use the explicit 'de', not auto-detected 'fr'
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles WHERE ("locale" = \'de\') ORDER BY stem ASC',
+ )
+ })
+
+ it('does not auto-apply locale when no detectedLocale is provided', async () => {
+ // No detectedLocale (undefined) — no auto-locale
+ const query = collectionQueryBuilder(mockCollection, mockFetch)
+ await query.all()
+
+ // Should query without locale filter
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles ORDER BY stem ASC',
+ )
+ })
+
+ it('uses single query (no fallback) when detectedLocale equals defaultLocale', async () => {
+ // Default locale 'en' — should use a single WHERE, not two-query fallback
+ const query = collectionQueryBuilder(mockCollection, mockFetch, 'en')
+ await query.all()
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC',
+ )
+ })
+
+ it('rejects unknown detectedLocale values', async () => {
+ // 'xx' is not in i18nConfig.locales — should be ignored (no locale filter)
+ const query = collectionQueryBuilder(mockCollection, mockFetch, 'xx')
+ await query.all()
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'articles',
+ 'SELECT * FROM _articles ORDER BY stem ASC',
+ )
+ })
+
+ it('injects and strips stem when using select() with locale fallback', async () => {
+ mockFetch
+ .mockResolvedValueOnce([{ title: 'Bonjour', locale: 'fr', stem: 'hello' }])
+ .mockResolvedValueOnce([
+ { title: 'Hello', locale: 'en', stem: 'hello' },
+ { title: 'World', locale: 'en', stem: 'world' },
+ ])
+
+ const results = await collectionQueryBuilder(mockCollection, mockFetch)
+ .select('title' as never, 'locale' as never)
+ .locale('fr', { fallback: 'en' })
+ .all()
+
+ // stem should be stripped from results since it was not explicitly selected
+ expect(results[0]).not.toHaveProperty('stem')
+ // Merge should work correctly: fr 'hello' replaces en 'hello', en 'world' is fallback
+ expect(results).toHaveLength(2)
+ expect(results[0]).toMatchObject({ title: 'Bonjour', locale: 'fr' })
+ expect(results[1]).toMatchObject({ title: 'World', locale: 'en' })
+ })
+
+ it('counts correctly with locale fallback', async () => {
+ mockFetch
+ .mockResolvedValueOnce([
+ { title: 'Bonjour', stem: 'hello' },
+ ])
+ .mockResolvedValueOnce([
+ { title: 'Hello', stem: 'hello' },
+ { title: 'World', stem: 'world' },
+ ])
+
+ const count = await collectionQueryBuilder(mockCollection, mockFetch)
+ .locale('fr', { fallback: 'en' })
+ .count()
+
+ // fr has 'hello', en has 'hello' + 'world'. Merged: 2 unique stems
+ expect(count).toBe(2)
+ })
+
+ it('counts distinct with locale fallback', async () => {
+ mockFetch
+ .mockResolvedValueOnce([
+ { title: 'Same', stem: 'a' },
+ { title: 'Same', stem: 'b' },
+ ])
+ .mockResolvedValueOnce([
+ { title: 'Same', stem: 'a' },
+ { title: 'Different', stem: 'c' },
+ ])
+
+ const count = await collectionQueryBuilder(mockCollection, mockFetch)
+ .locale('fr', { fallback: 'en' })
+ .count('title' as never, true)
+
+ // Merged items: a='Same', b='Same', c='Different'. Distinct titles: 2
+ expect(count).toBe(2)
+ })
+ })
})
diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts
new file mode 100644
index 000000000..4f2dbdc51
--- /dev/null
+++ b/test/unit/i18n.test.ts
@@ -0,0 +1,521 @@
+import { describe, it, expect } from 'vitest'
+import { defuByIndex, expandI18nData, detectLocaleFromPath } from '../../src/utils/i18n'
+import type { CollectionI18nConfig } from '../../src/types/collection'
+import type { ParsedContentFile } from '../../src/types'
+
+const i18nConfig: CollectionI18nConfig = {
+ locales: ['en', 'fr', 'de'],
+ defaultLocale: 'en',
+}
+
+describe('i18n', () => {
+ describe('inline expansion', () => {
+ it('expands inline i18n to per-locale items', () => {
+ const content: ParsedContentFile = {
+ id: 'blog:post.yml',
+ title: 'My Post',
+ description: 'Hello world',
+ stem: 'post',
+ extension: 'yml',
+ meta: {
+ i18n: {
+ fr: { title: 'Mon Article', description: 'Bonjour le monde' },
+ de: { title: 'Mein Artikel' },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+
+ expect(items).toHaveLength(3)
+ expect(items[0]).toMatchObject({ id: 'blog:post.yml', locale: 'en', title: 'My Post', description: 'Hello world' })
+ expect(items[0].meta.i18n).toBeUndefined()
+ expect(items[1]).toMatchObject({ id: 'blog:post.yml#fr', locale: 'fr', title: 'Mon Article', description: 'Bonjour le monde' })
+ expect(items[2]).toMatchObject({ id: 'blog:post.yml#de', locale: 'de', title: 'Mein Artikel', description: 'Hello world' })
+ })
+
+ it('returns single item with default locale when no i18n section', () => {
+ const content: ParsedContentFile = {
+ id: 'blog:simple.yml',
+ title: 'Simple Post',
+ stem: 'simple',
+ extension: 'yml',
+ meta: {},
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+
+ expect(items).toHaveLength(1)
+ expect(items[0]).toMatchObject({ locale: 'en', title: 'Simple Post' })
+ })
+
+ it('preserves existing locale on parsed content', () => {
+ const content: ParsedContentFile = {
+ id: 'blog:post.yml',
+ locale: 'fr',
+ title: 'Mon Article',
+ stem: 'post',
+ extension: 'yml',
+ meta: {
+ i18n: {
+ en: { title: 'My Post' },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+
+ expect(items).toHaveLength(2)
+ expect(items[0]).toMatchObject({ locale: 'fr', title: 'Mon Article' })
+ expect(items[1]).toMatchObject({ locale: 'en', title: 'My Post' })
+ })
+
+ it('deep-merges nested objects in locale overrides', () => {
+ const content: ParsedContentFile = {
+ id: 'team:jane.yml',
+ name: 'Jane Doe',
+ info: { age: 25, country: 'Switzerland' },
+ stem: 'jane',
+ extension: 'yml',
+ meta: {
+ i18n: {
+ de: { info: { country: 'Schweiz' } },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+
+ expect(items).toHaveLength(2)
+ expect(items[0].info).toEqual({ age: 25, country: 'Switzerland' })
+ expect(items[1].info).toEqual({ age: 25, country: 'Schweiz' })
+ })
+
+ it('deep-merges array items by index, preserving untranslated fields', () => {
+ const content: ParsedContentFile = {
+ id: 'nav:navbar.yml',
+ items: [
+ { id: 'overview', label: 'Overview', route: '/' },
+ { id: 'tech', label: 'Technologies', route: '/technologies' },
+ ],
+ stem: 'navbar',
+ extension: 'yml',
+ meta: {
+ i18n: {
+ fr: {
+ items: [
+ { label: 'Vue d\'ensemble' },
+ { label: 'Technologies' },
+ ],
+ },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+ const frItem = items.find(i => i.locale === 'fr')
+
+ expect(frItem?.items).toEqual([
+ { id: 'overview', label: 'Vue d\'ensemble', route: '/' },
+ { id: 'tech', label: 'Technologies', route: '/technologies' },
+ ])
+ })
+
+ it('does not include default locale in expanded items', () => {
+ const content: ParsedContentFile = {
+ id: 'blog:post.yml',
+ title: 'My Post',
+ stem: 'post',
+ extension: 'yml',
+ meta: {
+ i18n: {
+ en: { title: 'English Post' },
+ fr: { title: 'Article Francais' },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+
+ expect(items).toHaveLength(2)
+ expect(items[0]).toMatchObject({ locale: 'en', title: 'My Post' })
+ expect(items[1]).toMatchObject({ locale: 'fr' })
+ })
+
+ it('generates unique IDs with locale suffix', () => {
+ const content: ParsedContentFile = {
+ id: 'data:team/member.json',
+ name: 'John',
+ stem: 'team/member',
+ extension: 'json',
+ meta: {
+ i18n: {
+ fr: { name: 'Jean' },
+ de: { name: 'Johann' },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+ const ids = items.map(i => i.id)
+
+ expect(ids).toEqual([
+ 'data:team/member.json',
+ 'data:team/member.json#fr',
+ 'data:team/member.json#de',
+ ])
+ expect(new Set(ids).size).toBe(3)
+ })
+
+ it('replaces body wholesale for page collections instead of deep-merging', () => {
+ const defaultBody = { type: 'root', children: [{ type: 'text', value: 'Hello' }] }
+ const frBody = { type: 'root', children: [{ type: 'text', value: 'Bonjour' }] }
+
+ const content: ParsedContentFile = {
+ id: 'pages:index.md',
+ title: 'Home',
+ body: defaultBody,
+ stem: 'index',
+ extension: 'md',
+ meta: {
+ i18n: {
+ fr: { title: 'Accueil', body: frBody },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig, 'page')
+ const frItem = items.find(i => i.locale === 'fr')
+
+ // Body should be replaced, not deep-merged
+ expect(frItem?.body).toEqual(frBody)
+ expect(frItem?.body).not.toEqual(defaultBody)
+ expect(frItem?.title).toBe('Accueil')
+ })
+
+ it('deep-merges body for data collections (no replacement)', () => {
+ const content: ParsedContentFile = {
+ id: 'data:config.yml',
+ title: 'Config',
+ body: { nested: { key: 'value', other: 'kept' } },
+ stem: 'config',
+ extension: 'yml',
+ meta: {
+ i18n: {
+ fr: { body: { nested: { key: 'valeur' } } },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig, 'data')
+ const frItem = items.find(i => i.locale === 'fr')
+
+ // Body should be deep-merged for data collections
+ expect(frItem?.body).toMatchObject({ nested: { key: 'valeur', other: 'kept' } })
+ })
+ })
+
+ describe('path-based locale detection', () => {
+ it('detects locale from first path segment', () => {
+ const result = detectLocaleFromPath('/fr/blog/post', 'fr/blog/post', i18nConfig)
+ expect(result).toMatchObject({ locale: 'fr', path: '/blog/post', stem: 'blog/post' })
+ })
+
+ it('assigns default locale when no locale prefix', () => {
+ const result = detectLocaleFromPath('/blog/post', 'blog/post', i18nConfig)
+ expect(result).toMatchObject({ locale: 'en', path: '/blog/post', stem: 'blog/post' })
+ })
+
+ it('handles root path with locale', () => {
+ const result = detectLocaleFromPath('/de', 'de', i18nConfig)
+ expect(result).toMatchObject({ locale: 'de', path: '/', stem: '' })
+ })
+
+ it('does not treat non-locale segments as locale', () => {
+ const result = detectLocaleFromPath('/blog/fr/post', 'blog/fr/post', i18nConfig)
+ expect(result).toMatchObject({ locale: 'en', path: '/blog/fr/post', stem: 'blog/fr/post' })
+ })
+
+ it('leaves stem unchanged when it does not start with locale prefix', () => {
+ const result = detectLocaleFromPath('/fr/docs/guide', 'docs/guide', i18nConfig)
+ expect(result).toMatchObject({ locale: 'fr', path: '/docs/guide', stem: 'docs/guide' })
+ })
+
+ it('handles nested locale paths', () => {
+ const result = detectLocaleFromPath('/en/docs/guide/intro', 'en/docs/guide/intro', i18nConfig)
+ expect(result).toMatchObject({ locale: 'en', path: '/docs/guide/intro', stem: 'docs/guide/intro' })
+ })
+ })
+
+ describe('defuByIndex', () => {
+ it('merges nested arrays recursively', () => {
+ const base = {
+ items: [
+ { title: 'Base', links: [{ title: 'More', url: '/page', icon: { name: 'chevron' } }] },
+ ],
+ }
+ const override = {
+ items: [
+ { title: 'Override', links: [{ title: 'Savoir plus' }] },
+ ],
+ }
+ const result = defuByIndex(override, base) as typeof base
+
+ expect(result.items[0]).toMatchObject({
+ title: 'Override',
+ links: [{ title: 'Savoir plus', url: '/page', icon: { name: 'chevron' } }],
+ })
+ })
+
+ it('does not mutate input objects', () => {
+ const base = { items: [{ a: 1, b: 2 }] }
+ const override = { items: [{ a: 10 }] }
+ const baseCopy = JSON.parse(JSON.stringify(base))
+ const overrideCopy = JSON.parse(JSON.stringify(override))
+ defuByIndex(override, base)
+ expect(base).toEqual(baseCopy)
+ expect(override).toEqual(overrideCopy)
+ })
+
+ describe('edge cases', () => {
+ it('preserves extra default array items when override has fewer', () => {
+ const content: ParsedContentFile = {
+ id: 'nav:navbar.yml',
+ items: [
+ { id: 'a', label: 'A', route: '/a' },
+ { id: 'b', label: 'B', route: '/b' },
+ { id: 'c', label: 'C', route: '/c' },
+ ],
+ stem: 'navbar',
+ extension: 'yml',
+ meta: {
+ i18n: {
+ fr: {
+ items: [
+ { label: 'A-fr' },
+ { label: 'B-fr' },
+ ],
+ },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+ const frItem = items.find(i => i.locale === 'fr')
+
+ expect(frItem?.items).toHaveLength(3)
+ expect(frItem?.items[0]).toMatchObject({ id: 'a', label: 'A-fr', route: '/a' })
+ expect(frItem?.items[1]).toMatchObject({ id: 'b', label: 'B-fr', route: '/b' })
+ expect(frItem?.items[2]).toMatchObject({ id: 'c', label: 'C', route: '/c' })
+ })
+
+ it('deep-merges nested arrays within array items', () => {
+ const content: ParsedContentFile = {
+ id: 'nav:banners.yml',
+ items: [
+ {
+ description: 'Default text',
+ links: [
+ { title: 'More', url: '/page', icon: { name: 'chevron' } },
+ ],
+ },
+ ],
+ stem: 'banners',
+ extension: 'yml',
+ meta: {
+ i18n: {
+ fr: {
+ items: [
+ {
+ description: 'Texte francais',
+ links: [{ title: 'En savoir plus' }],
+ },
+ ],
+ },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+ const frItem = items.find(i => i.locale === 'fr')
+
+ expect(frItem?.items[0]).toMatchObject({
+ description: 'Texte francais',
+ links: [{ title: 'En savoir plus', url: '/page', icon: { name: 'chevron' } }],
+ })
+ })
+
+ it('handles empty i18n overrides object', () => {
+ const content: ParsedContentFile = {
+ id: 'data:config.yml',
+ title: 'Config',
+ stem: 'config',
+ extension: 'yml',
+ meta: { i18n: {} },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+ expect(items).toHaveLength(1)
+ expect(items[0]).toMatchObject({ locale: 'en', title: 'Config' })
+ })
+
+ it('does not mutate original content or override objects', () => {
+ const original = {
+ id: 'data:test.yml',
+ items: [{ label: 'Original', route: '/' }],
+ stem: 'test',
+ extension: 'yml',
+ meta: {
+ i18n: { fr: { items: [{ label: 'French' }] } },
+ },
+ } as ParsedContentFile
+
+ const originalItemsRef = original.items
+ const frOverrideRef = (original.meta.i18n as Record).fr
+
+ expandI18nData(original, i18nConfig)
+
+ expect(originalItemsRef[0].label).toBe('Original')
+ expect((frOverrideRef as Record).items[0]).toEqual({ label: 'French' })
+ })
+
+ it('handles override with extra array items beyond default length', () => {
+ const content: ParsedContentFile = {
+ id: 'nav:test.yml',
+ items: [{ id: 'a', label: 'A' }],
+ stem: 'test',
+ extension: 'yml',
+ meta: {
+ i18n: {
+ fr: {
+ items: [
+ { label: 'A-fr' },
+ { id: 'b', label: 'B-fr', route: '/b' },
+ ],
+ },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+ const frItem = items.find(i => i.locale === 'fr')
+
+ expect(frItem?.items).toHaveLength(2)
+ expect(frItem?.items[0]).toMatchObject({ id: 'a', label: 'A-fr' })
+ expect(frItem?.items[1]).toMatchObject({ id: 'b', label: 'B-fr', route: '/b' })
+ })
+
+ it('handles scalar arrays without merging', () => {
+ const content: ParsedContentFile = {
+ id: 'data:tags.yml',
+ tags: ['javascript', 'vue', 'nuxt'],
+ stem: 'tags',
+ extension: 'yml',
+ meta: {
+ i18n: { de: { tags: ['JavaScript', 'Vue', 'Nuxt'] } },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+ const deItem = items.find(i => i.locale === 'de')
+ expect(deItem?.tags).toEqual(['JavaScript', 'Vue', 'Nuxt'])
+ })
+
+ it('preserves non-translated top-level fields across all locales', () => {
+ const content: ParsedContentFile = {
+ id: 'data:config.yml',
+ title: 'Site Config',
+ apiUrl: 'https://api.example.com',
+ maxRetries: 3,
+ stem: 'config',
+ extension: 'yml',
+ meta: {
+ i18n: {
+ fr: { title: 'Config du site' },
+ de: { title: 'Seitenkonfiguration' },
+ },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+
+ for (const item of items) {
+ expect(item).toMatchObject({ apiUrl: 'https://api.example.com', maxRetries: 3 })
+ }
+ expect(items[1]).toMatchObject({ title: 'Config du site' })
+ expect(items[2]).toMatchObject({ title: 'Seitenkonfiguration' })
+ })
+ })
+ })
+
+ describe('source hash for change tracking', () => {
+ it('adds _i18nSourceHash to non-default locale items', () => {
+ const content: ParsedContentFile = {
+ id: 'blog:post.yml',
+ title: 'My Post',
+ description: 'Hello',
+ stem: 'post',
+ extension: 'yml',
+ meta: {
+ i18n: { fr: { title: 'Mon Article' } },
+ },
+ }
+
+ const items = expandI18nData(content, i18nConfig)
+
+ expect(items[0].meta._i18nSourceHash).toBeUndefined()
+ expect(items[1].meta._i18nSourceHash).toBeDefined()
+ expect(typeof items[1].meta._i18nSourceHash).toBe('string')
+ })
+
+ it('source hash is based on translated fields only', () => {
+ const content1: ParsedContentFile = {
+ id: 'blog:post.yml',
+ title: 'My Post',
+ description: 'Hello',
+ untranslatedField: 'ignored',
+ stem: 'post',
+ extension: 'yml',
+ meta: { i18n: { fr: { title: 'Mon Article' } } },
+ }
+
+ const content2: ParsedContentFile = {
+ id: 'blog:post.yml',
+ title: 'My Post',
+ description: 'Hello',
+ untranslatedField: 'different value',
+ stem: 'post',
+ extension: 'yml',
+ meta: { i18n: { fr: { title: 'Mon Article' } } },
+ }
+
+ const items1 = expandI18nData(content1, i18nConfig)
+ const items2 = expandI18nData(content2, i18nConfig)
+
+ expect(items1[1].meta._i18nSourceHash).toBe(items2[1].meta._i18nSourceHash)
+ })
+
+ it('source hash changes when default locale translated fields change', () => {
+ const content1: ParsedContentFile = {
+ id: 'blog:post.yml',
+ title: 'My Post',
+ stem: 'post',
+ extension: 'yml',
+ meta: { i18n: { fr: { title: 'Mon Article' } } },
+ }
+
+ const content2: ParsedContentFile = {
+ id: 'blog:post.yml',
+ title: 'My Updated Post',
+ stem: 'post',
+ extension: 'yml',
+ meta: { i18n: { fr: { title: 'Mon Article' } } },
+ }
+
+ const items1 = expandI18nData(content1, i18nConfig)
+ const items2 = expandI18nData(content2, i18nConfig)
+
+ expect(items1[1].meta._i18nSourceHash).not.toBe(items2[1].meta._i18nSourceHash)
+ })
+ })
+})