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. - +### 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) + }) + }) +})