diff --git a/docs/content/docs/2.collections/1.define.md b/docs/content/docs/2.collections/1.define.md index eaa840f22..6e6e2146b 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 @@ -181,11 +213,15 @@ type Collection = { // Determines how content is processed type: 'page' | 'data' // Specifies content location - source?: string | CollectionSource + source?: string | CollectionSource | CollectionSource[] // Zod schema for content validation and typing schema?: ZodObject // Database indexes for query optimization indexes?: CollectionIndex[] + // Enable multi-language support. `true` reads `locales` and `defaultLocale` + // from the `@nuxtjs/i18n` module configuration. An object opts in + // explicitly without requiring the module. + i18n?: true | { locales: string[], defaultLocale: string } } type CollectionIndex = { 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..18aa6d972 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. In Vue components this reliably reflects the active locale. In Nitro server handlers, automatic detection often resolves to the configured default locale (see the [i18n integration guide](/docs/integrations/i18n)), so call `.locale()` explicitly when you need the per-request locale there. +:: + +### `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. @@ -239,6 +271,10 @@ const { data } = await useAsyncData(route.path, () => { 5 // number of matches ``` +::note +`count()` ignores any `skip()`/`limit()` on the query and always returns the total number of matches, not the size of the current page. +:: + You can also use `count()` with other methods defined above such as `where()` in order to apply additional conditions within the collection query. ```ts diff --git a/docs/content/docs/4.utils/6.use-query-collection.md b/docs/content/docs/4.utils/6.use-query-collection.md new file mode 100644 index 000000000..0dbe6bf0d --- /dev/null +++ b/docs/content/docs/4.utils/6.use-query-collection.md @@ -0,0 +1,135 @@ +--- +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 +) +``` + +The returned chainable builder mirrors `queryCollection`, but its terminal methods (`all`, `first`, `count`) return an `AsyncData` result instead of a promise. + +The optional generic `R` overrides the return type. When omitted, the collection's generated type is used. + +::warning +Because TypeScript has no partial type-argument inference, providing the `R` override (for example `useQueryCollection('docs')`) widens the inferred collection type. Field-typed methods such as `.where()`, `.order()`, and `.select()` then fall back to the keys common to all collections. Pass `R` only when you do not rely on per-field type checking for that query, or cast the result instead. +:: + +### 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`](https://nuxt.com/docs/4.x/api/composables/use-async-data#return-values), which exposes `data`, `pending`, `error`, `status`, `refresh()`, `execute()`, and `clear()` alongside the result. Following Nuxt's `useAsyncData` conventions, both the value and the error are typed as nullable while the request is in flight: + +- `.all(options?)` — returns `AsyncData` +- `.first(options?)` — returns `AsyncData` +- `.count(field?, distinct?, options?)` — returns `AsyncData` + +Each terminal method accepts an optional [`AsyncDataOptions`](https://nuxt.com/docs/api/composables/use-async-data#params) object that is forwarded to `useAsyncData`. Use it to control fetch behaviour: `lazy`, `server`, `default`, `immediate`, `watch`, `transform`, `pick`, `dedupe`, `getCachedData`, etc. + +```vue + +``` + +## 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/7.query-collection-locales.md b/docs/content/docs/4.utils/7.query-collection-locales.md new file mode 100644 index 000000000..3a96d8d74 --- /dev/null +++ b/docs/content/docs/4.utils/7.query-collection-locales.md @@ -0,0 +1,125 @@ +--- +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] + + + +``` + +::warning +The `path` returned by `queryCollectionLocales` is locale-agnostic (the same value for every locale). Do not use it directly as a link target. Pass it through `@nuxtjs/i18n`'s `useLocalePath()` (or `useSwitchLocalePath()`) so the URL carries the correct locale prefix for your routing strategy. +:: + +::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..7413cb532 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -9,12 +9,16 @@ 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. + +::warning +Server-side auto-detection reads `event.context.nuxtI18n.detectLocale`, which `@nuxtjs/i18n` **v10+** writes only when a server-side localized redirect actually fires. A request that already lands on the correct localized URL (e.g. `/fr/api/posts` for a French user) leaves `detectLocale` undefined, so auto-detection falls back to the *configured default locale* — not the user's per-request locale. Inside event handlers where the active locale matters, call `.locale()` explicitly. Pre-v10 versions don't expose the context at all. +:: ## Setup ::prose-steps -### Install the required module +### Install the required modules ```bash [terminal] npm install @nuxtjs/i18n @@ -27,9 +31,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,135 +41,263 @@ 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({ - type: 'page', - source: { - include: 'en/**', - prefix: '', - }, - schema: commonSchema, - }), - // French content collection - content_fr: defineCollection({ + // Auto-detect locales from @nuxtjs/i18n + docs: defineCollection({ type: 'page', - source: { - include: 'fr/**', - prefix: '', - }, - schema: commonSchema, + source: '*/docs/**', + i18n: true, }), - // Farsi content collection - content_fa: defineCollection({ - type: 'page', - source: { - include: 'fa/**', - prefix: '', + // Or explicit config + team: defineCollection({ + type: 'data', + source: 'data/team.yml', + schema: z.object({ + name: z.string(), + icon: z.object({ name: z.string() }), + info: z.object({ + age: z.number(), + country: z.string(), + }), + description: z.string(), + }), + i18n: { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', }, - 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 +Prefer [`useQueryCollection`](/docs/utils/use-query-collection) for new code — it auto-generates the locale-aware cache key and re-fetches on locale switch without the manual plumbing above. +:: + +::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. + +::note +**Detection paths.** On the client, the locale is read from `nuxtApp.$i18n.locale.value` (a live `WritableComputedRef`). On the server, it is read from `event.context.nuxtI18n?.detectLocale` — which `@nuxtjs/i18n` v10 only writes during server-side localized redirects — with a fallback to `event.context.nuxtI18n?.vueI18nOptions?.locale` (the configured default) for every other server request. If `@nuxtjs/i18n` is not installed, no auto-detection happens and queries return rows from every locale unless you call `.locale()` explicitly. + +A manual filter on the `locale` column — for example `.where('locale', '=', 'fr')` — also suppresses auto-detection so the two don't combine into a contradictory `WHERE locale = 'fr' AND locale = 'en'` clause. Prefer `.locale()` when you can, since it is also schema-aware. + +The authoritative shape of the server-side i18n context is declared in [`nuxt-modules/i18n` `src/runtime/server/context.ts`](https://github.com/nuxt-modules/i18n/blob/main/src/runtime/server/context.ts). +:: + +::warning +**Stem sort order.** When `.locale(x, { fallback: y })` is used (explicitly or via auto-detection on a non-default locale), the two result sets are merged in JavaScript. Under the default `stem ASC` order, the merged result is re-sorted by stem using binary (codepoint) comparison, so the page is deterministic regardless of the database's collation. On a backend whose default collation is not binary (for example PostgreSQL with a linguistic locale), the order of mixed-case or non-ASCII stems may therefore differ slightly from a single-query result. If a custom `.order()` is set, items from the requested locale and items from the fallback locale are concatenated rather than interleaved, since the custom order cannot be reproduced in JavaScript. +:: + +### 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: - return content +```ts +// Matches content/navigation/navbar.yml +queryCollection('navigation').stem('navbar').first() +``` + +## Translator Change Tracking + +For inline i18n, each non-default locale item stores a `_i18nSourceHash` in its `meta`. This hash is computed only from the default locale's values of the fields that this locale actually translates. When the default content of a translated field changes, the hash changes, allowing Studio or custom tooling to detect potentially outdated translations. + +```ts +import { I18N_SOURCE_HASH_FIELD } from '@nuxt/content' + +const item = await queryCollection('team').locale('fr').first() +// `I18N_SOURCE_HASH_FIELD` is the field name (`_i18nSourceHash`), exported for tooling. +console.log(item.meta[I18N_SOURCE_HASH_FIELD]) // Hash of the default locale's translated fields +``` + +## CSP Configuration + +When the browser performs client-side queries against the bundled WASM SQLite database — which currently happens for client-only navigations such as locale switching after the initial SSR render — your Content Security Policy must allow WebAssembly evaluation. If you use `nuxt-security`, add `'wasm-unsafe-eval'` to your `script-src` directive: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + security: { + headers: { + contentSecurityPolicy: { + 'script-src': ["'self'", "'wasm-unsafe-eval'", /* ... */], + }, + }, + }, + routeRules: { + '/__nuxt_content/**': { + // The content API only exposes read-only, content-addressed SQL dumps + // (no user mutations, no session state). CSRF protection adds no value + // here and breaks cross-origin client-side queries from prerendered pages. + csurf: false, + }, + }, }) ``` -::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). + +**Custom route params from `@nuxtjs/i18n`**: When you use `@nuxtjs/i18n`'s `definePageMeta({ locale: { params: … } })` to rewrite slug segments per locale, the route path no longer matches the content `path` column directly. You'll need to map the localized route back to the canonical content path before calling `.path()` — Nuxt Content stores one path per content file (the stripped, locale-agnostic one) and does not consult the `@nuxtjs/i18n` param map. + +**Multiple sources with different prefixes**: `.stem('foo')` auto-resolves the collection's source directory prefix — but only when *every* source in the collection shares the same normalized `prefix`. If a collection mixes sources with different prefixes, `.stem()` will not prepend a prefix and you must pass the full stem yourself. + +**`@nuxtjs/i18n` version & server-side detection**: Auto-detection relies on `event.context.nuxtI18n.detectLocale`, introduced in `@nuxtjs/i18n` **v10** and only populated when a server-side localized redirect fires. Pre-v10 versions expose no server context at all, so no auto-detection happens and server queries return rows from every locale. With v10, a direct hit on an already-localized URL falls back to the configured default locale. Use explicit `.locale()` in server handlers for guaranteed per-request locale resolution in both cases. + +**Locale code mismatch**: Auto-detection only applies when the detected locale is one of the collection's declared `locales`. If `@nuxtjs/i18n` reports a BCP-47 tag such as `en-US` while a collection declares only `en`, the auto-locale filter is skipped and the query returns rows from every locale (a dev-only warning is logged). Declare the exact codes in the collection's `i18n.locales`, or call `.locale()` explicitly. ## Complete Examples -You can see a complete working example: +The example below still demonstrates the legacy per-locale-collection pattern (one collection per language) rather than the `i18n` option described on this page. It remains a useful reference for that approach until it is updated: - **Source**: - **Live Demo**: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a220ccb1c..7da60567b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,6 +392,10 @@ importers: test/fixtures/csv: {} + test/fixtures/i18n: {} + + test/fixtures/multi-collection-hmr: {} + packages: '@ai-sdk/gateway@3.0.104': diff --git a/src/module.ts b/src/module.ts index bb5408020..f73291b10 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,16 +132,23 @@ 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') }, { name: 'useSearchCollection', from: resolver.resolve('./runtime/client') }, ]) + // Auto-import from the canonical `./runtime/server` module rather than the + // deprecated `./runtime/nitro` re-export. Otherwise the `@deprecated` JSDoc + // on the re-exports propagates into users' IDEs whenever they hover an + // auto-imported `queryCollection` in a server handler. 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: 'queryCollection', from: resolver.resolve('./runtime/server') }, + { name: 'queryCollectionSearchSections', from: resolver.resolve('./runtime/server') }, + { name: 'queryCollectionNavigation', from: resolver.resolve('./runtime/server') }, + { name: 'queryCollectionItemSurroundings', from: resolver.resolve('./runtime/server') }, + { name: 'queryCollectionLocales', from: resolver.resolve('./runtime/server') }, ]) addComponent({ name: 'ContentRenderer', filePath: resolver.resolve('./runtime/components/ContentRenderer.vue') }) @@ -376,8 +384,22 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio usedComponents.push(...parsedContent.__metadata.components) } - const { queries, hash } = generateCollectionInsert(collection, parsedContent) - list.push([key, queries, hash]) + // i18n expansion writes one row per locale to the dump. + // Use `item.id` (already suffixed for non-default locales by + // `expandI18nData`) as the dump tuple key so HMR can locate the + // same row later. The bare key matches the SQL row's actual `id`. + if (collection.i18n && (parsedContent?.meta as Record)?.i18n) { + const expandedItems = expandI18nData(parsedContent, collection.i18n, collection.type, Object.keys(collection.fields)) + for (const item of expandedItems) { + const itemKey = item.id as string + 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 41a0b05b3..5bba82db6 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -1,15 +1,24 @@ import type { H3Event } from 'h3' -import { collectionQueryBuilder } from './internal/query' +import { buildGroup, collectionQueryBuilder, collectionQueryGroup, getGroupConditions, referencesLocaleColumn } from './internal/query' import { generateNavigationTree } from './internal/navigation' import { generateItemSurround } from './internal/surround' import type { GenerateSearchSectionsOptions, SearchCollectionOptions, SearchResult } from './internal/search' -import { generateSearchSections, buildFTSIndex, queryFTS, resetFTSIndex } from './internal/search' +import { buildFTSIndex, generateSearchSections, queryFTS, resetFTSIndex } from './internal/search' +import { generateCollectionLocales } from './internal/locales' +import { buildUseQueryCollectionKey, detectClientLocale, detectServerLocale } from './internal/i18n-detection' import { fetchQuery } from './internal/api' -import type { Collections, PageCollections, CollectionQueryBuilder, SurroundOptions, SQLOperator, QueryGroupFunction, ContentNavigationItem, DatabaseAdapter } from '@nuxt/content' -import { ref, toValue, watch, tryUseNuxtApp } from '#imports' -import type { MaybeRefOrGetter } from 'vue' +import { withoutTrailingSlash } from 'ufo' +import type { Collections, ContentLocaleEntry, ContentNavigationItem, CollectionQueryBuilder, DatabaseAdapter, ManifestCollectionsMeta, PageCollections, QueryGroupFunction, SQLOperator, SurroundOptions } from '@nuxt/content' +import manifestMeta from '#content/manifest' +import type { AsyncData, AsyncDataOptions, NuxtError } from '#app' +import type { MaybeRefOrGetter, Ref } from 'vue' +import { computed, ref, toValue, tryUseNuxtApp, useAsyncData, watch } from '#imports' +// `useAsyncData`'s key is a getter. Nuxt watches the resolved string and refetches +// when it changes (reactive keys, available since Nuxt 3.17). The module +// `compatibility` field requires `>=4.1.0 || ^3.19.0`, so a locale-aware key +// change automatically triggers a refetch without an explicit `watch:` option. -export type { SearchCollectionOptions, SearchResult, GenerateSearchSectionsOptions } from './internal/search' +export type { GenerateSearchSectionsOptions, SearchCollectionOptions, SearchResult } from './internal/search' interface ChainablePromise extends Promise { where(field: keyof PageCollections[T] | string, operator: SQLOperator, value?: unknown): ChainablePromise @@ -19,8 +28,12 @@ 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 + // Prefer the client-side locale (a live ref during SPA navigation) and fall back + // to the SSR event context for the initial server render. + const detectedLocale = detectClientLocale(nuxtApp) || detectServerLocale(event) + return collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql), detectedLocale) } export function queryCollectionNavigation(collection: T, fields?: Array): ChainablePromise { @@ -35,6 +48,200 @@ export function queryCollectionSearchSections(c return chainablePromise(collection, qb => generateSearchSections(qb, opts)) } +export function queryCollectionLocales(collection: T, stem: string): Promise { + // Auto-locale is skipped here. This helper needs every locale variant, not just + // the current one, so it builds the query without passing a detected locale. + const event = tryUseNuxtApp()?.ssrContext?.event + const qb = collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql)) + return generateCollectionLocales(qb, String(collection), stem) +} + +/** + * `useAsyncData` wrapper for `queryCollection`. Provides a chainable API that + * wraps execution in `useAsyncData` with an auto-generated cache key. The locale + * is auto-detected from `@nuxtjs/i18n` and content automatically refetches when + * the locale changes. + * + * Must be called in a Vue component setup context, like `useAsyncData` and + * `useFetch`. + * + * Each terminal method (`all`, `first`, `count`) accepts an optional + * `AsyncDataOptions` argument that is forwarded to `useAsyncData`. Use it for + * `lazy`, `server`, `default`, `immediate`, `watch`, `transform`, `pick`, and + * other forwarded options. + * + * @example + * const { data } = await useQueryCollection('technologies').all() + * const { data } = await useQueryCollection('navigation').stem('navbar').first() + * const { data } = await useQueryCollection('docs').all({ lazy: true, default: () => [] }) + */ +export function useQueryCollection(collection: T) { + const nuxtApp = tryUseNuxtApp() + if (!nuxtApp) { + // `useAsyncData` would throw later with the same root cause. Surfacing the + // failure here gives users a clearer hint about *where* the call is + // misplaced rather than a generic "[nuxt] instance unavailable" trace from + // inside `useAsyncData`. + throw new Error( + '[@nuxt/content] `useQueryCollection` must be called inside a Vue component setup (or other Nuxt-aware) context, ' + + 'like `useAsyncData` and `useFetch`. It cannot run in event handlers, watchers, lifecycle hooks, ' + + 'or outside the Nuxt app.', + ) + } + // Only collections that declare `i18n` participate in locale-aware caching. + // For a non-i18n collection the produced SQL is identical across locales, so + // letting the locale into the cache key would refetch and store duplicate + // entries on every locale switch for no benefit. + const collectionI18n = (manifestMeta as ManifestCollectionsMeta)[String(collection)]?.i18n + + const i18nLocaleRef = (nuxtApp?.$i18n as { locale?: Ref } | undefined)?.locale + // The locale value flows into the cache key. Because `useAsyncData`'s key is + // a getter, Nuxt reruns the handler when the locale ref changes. + // + // The SSR event context is a fallback for the case where `@nuxtjs/i18n` is not + // installed (no `$i18n`). When it is installed, `$i18n.locale` already reflects + // the per-request locale during SSR, so the ref wins. + const ssrLocale = detectServerLocale(nuxtApp?.ssrContext?.event) + const localeValue = computed(() => i18nLocaleRef?.value || ssrLocale || '') + + type Item = Collections[T] + // Use the consumer's type override when provided, otherwise the collection type. + type Result = [R] extends [never] ? Item : R + + // Recorded query-chain operations replayed on each execution. + const ops: Array<(qb: CollectionQueryBuilder) => void> = [] + let explicitLocale = false + + // Track key-relevant params directly to avoid building a full query builder + // inside `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) { + // A manual filter on `locale` must also suppress auto-locale here. Otherwise + // the cache key would include a redundant `l:` fragment alongside + // the explicit condition. The underlying query builder already enforces this + // through `referencesLocaleColumn` (in `query.ts`), and this branch mirrors + // the same logic for key stability. + if (field === 'locale') explicitLocale = true + keyParts.conditions.push(`${field}|${operator}|${String(value)}`) + ops.push(qb => qb.where(field, operator, value)) + return builder + }, + andWhere(groupFactory: QueryGroupFunction) { + // Pre-build the group once to derive a stable cache-key fragment. The same + // factory runs again inside `buildQuery()` when the actual query is + // executed. Running it twice is safe because group factories are expected + // to be pure. + const group = groupFactory(collectionQueryGroup(collection)) + const groupConditions = getGroupConditions(group) + if (referencesLocaleColumn(groupConditions)) explicitLocale = true + keyParts.conditions.push(`and${buildGroup(group, 'AND')}`) + ops.push(qb => qb.andWhere(groupFactory)) + return builder + }, + orWhere(groupFactory: QueryGroupFunction) { + const group = groupFactory(collectionQueryGroup(collection)) + const groupConditions = getGroupConditions(group) + if (referencesLocaleColumn(groupConditions)) explicitLocale = true + keyParts.conditions.push(`or${buildGroup(group, 'OR')}`) + 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) { + // Normalize the key fragment the same way `.path()` normalizes the SQL + // value, so `.path('/foo/')` and `.path('/foo')` share a cache entry + // (both produce the same WHERE clause). + keyParts.conditions.push(`path=${withoutTrailingSlash(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(options?: AsyncDataOptions): AsyncData { + return useAsyncData(() => buildKey('all'), () => buildQuery().all() as Promise, options) as AsyncData + }, + first(options?: AsyncDataOptions): AsyncData { + return useAsyncData(() => buildKey('first'), () => buildQuery().first() as Promise, options) as AsyncData + }, + count(field?: keyof Item | '*', distinct?: boolean, options?: AsyncDataOptions): AsyncData { + const countKey = `count:${String(field ?? '*')}:${distinct ? 'd' : ''}` + return useAsyncData(() => buildKey(countKey), () => buildQuery().count(field, distinct), options) as AsyncData + }, + } + + /** 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 without instantiating a query builder. */ + function buildKey(method: string): string { + // Mirror the auto-locale gate in `query.ts`: a locale only affects the result + // (and so the cache key) when the collection is i18n-enabled and the detected + // locale is one it declares. + const locale = localeValue.value + const localeIsActive = !!collectionI18n && collectionI18n.locales.includes(locale) + return buildUseQueryCollectionKey({ + collection: String(collection), + conditions: keyParts.conditions, + orderBy: keyParts.orderBy, + offset: keyParts.offset, + limit: keyParts.limit, + selectedFields: keyParts.selectedFields, + localeFallback: keyParts.localeFallback, + currentLocale: localeIsActive ? locale : undefined, + explicitLocale, + method, + }) + } + + return builder +} + export function useSearchCollection( collection: MaybeRefOrGetter, opts?: GenerateSearchSectionsOptions & { immediate?: boolean }, diff --git a/src/runtime/internal/i18n-detection.ts b/src/runtime/internal/i18n-detection.ts new file mode 100644 index 000000000..d1dc41747 --- /dev/null +++ b/src/runtime/internal/i18n-detection.ts @@ -0,0 +1,102 @@ +import type { H3Event } from 'h3' + +/** + * Minimal type view of the per-request context that `@nuxtjs/i18n` (v10+) + * attaches to H3 events. Kept intentionally narrow, since only the two fields + * read below are needed and pinning a precise type would tightly couple this + * module to an upstream package declared as a soft (optional) integration. + * + * Authoritative shape: https://github.com/nuxt-modules/i18n/blob/main/src/runtime/server/context.ts + */ +interface NuxtI18nServerContext { + /** + * Per-request resolved locale. Written by `@nuxtjs/i18n` v10+ inside the + * `render:before` hook of its Nitro plugin (`src/runtime/server/plugin.ts`), + * but only when a server-side localized redirect actually fires (gated by + * `experimental.nitroContextDetection`, default `true`). + * + * For a request that already lands on the correct localized URL such as + * `/fr/about` for a French user, this stays `undefined`. The fallback to + * `vueI18nOptions.locale` (the configured default) is what `detectServerLocale` + * ultimately returns in that case. + * + * Practical implication, inside a server event handler reached via a + * non-redirected localized URL, prefer an explicit `.locale()` rather + * than relying on auto-detection. Otherwise queries default to the configured + * default locale. + */ + detectLocale?: string + /** Configured vue-i18n options. `vueI18nOptions.locale` is the configured default locale. */ + vueI18nOptions?: { locale?: string } +} + +/** + * Resolve the active locale on the server from `@nuxtjs/i18n`'s event context. + * + * Priority is `detectLocale` (set only during server-side localized redirects), + * then `vueI18nOptions.locale` (the configured default). Returns `undefined` + * when `@nuxtjs/i18n` is not installed or its context has not been initialised. + * + * Because `detectLocale` is unset for the common case of a normal non-redirected + * request, this often returns the configured default locale rather than the + * user's per-request locale. In event handlers where the active locale matters, + * call `.locale()` explicitly. + */ +export function detectServerLocale(event: H3Event | undefined): string | undefined { + const ctx = event?.context?.nuxtI18n as NuxtI18nServerContext | undefined + return ctx?.detectLocale || ctx?.vueI18nOptions?.locale +} + +/** + * Resolve the active locale on the client from `nuxtApp.$i18n.locale` (a Vue ref). + * Returns `undefined` when `@nuxtjs/i18n` is not installed. + * + * The parameter is typed as `unknown` because Nuxt's `NuxtApp` does not declare + * `$i18n` natively (it is a runtime plugin injection). A narrower type would have + * no overlap with `NuxtApp` and force callers into an awkward cast. + */ +export function detectClientLocale(nuxtApp: unknown): string | undefined { + const i18n = (nuxtApp as { $i18n?: { locale?: { value?: string } } } | null | undefined)?.$i18n + return i18n?.locale?.value +} + +/** + * Pure builder for `useQueryCollection`'s cache key. Extracted so it can be + * unit-tested without spinning up a Nuxt app instance. The output shape must + * stay stable, since Nuxt reuses `useAsyncData` entries by key and any change + * here invalidates user caches on upgrade. + * + * `localeFallback` wins over `currentLocale`. When the consumer set + * `.locale(x, { fallback })` explicitly, the auto-detected locale is + * irrelevant. `currentLocale` is only added when no explicit `.locale()` (or + * `.where('locale', ...)`) was used. + */ +export interface UseQueryCollectionKeyParts { + collection: string + conditions: string[] + orderBy: string[] + offset: number + limit: number + selectedFields: string[] + localeFallback?: { locale: string, fallback: string } + currentLocale?: string + explicitLocale: boolean + method: string +} + +export function buildUseQueryCollectionKey(parts: UseQueryCollectionKeyParts): string { + const fragments: string[] = [parts.collection] + if (parts.conditions.length) fragments.push(...parts.conditions) + if (parts.localeFallback) { + fragments.push(`l:${parts.localeFallback.locale}:fb:${parts.localeFallback.fallback}`) + } + else if (parts.currentLocale && !parts.explicitLocale) { + fragments.push(`l:${parts.currentLocale}`) + } + if (parts.orderBy.length) fragments.push(`o:${parts.orderBy.join(',')}`) + if (parts.offset) fragments.push(`s:${parts.offset}`) + if (parts.limit) fragments.push(`n:${parts.limit}`) + if (parts.selectedFields.length) fragments.push(`f:${parts.selectedFields.join(',')}`) + fragments.push(parts.method) + return `content:${JSON.stringify(fragments)}` +} diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts new file mode 100644 index 000000000..d72966383 --- /dev/null +++ b/src/runtime/internal/locales.ts @@ -0,0 +1,53 @@ +import type { CollectionQueryBuilder, ContentLocaleEntry, ManifestCollectionsMeta } from '@nuxt/content' +import manifestMeta from '#content/manifest' + +const LOCALE_ENTRY_FIELDS = ['locale', 'stem', 'path', 'title'] as const + +/** + * 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. + * + * Selects only the columns that map to `ContentLocaleEntry` and that the + * collection actually has. `path` and `title` only exist on `page` collections, + * so they are filtered out for `data` collections. This keeps the payload small + * for data collections with large fields. + * + * Returns an empty array (with a dev-only warning) when called on a collection + * that has no `locale` column, that is, one without `i18n` configured. Without + * this guard the query would still run, but every entry's `locale` would be + * `undefined` cast to `string`, a type lie that surfaces deep in consumer code + * instead of at the call site. + */ +export async function generateCollectionLocales>( + queryBuilder: CollectionQueryBuilder, + collection: string, + stem: string, +): Promise { + const collectionFields = (manifestMeta as ManifestCollectionsMeta)[collection]?.fields ?? {} + if (!('locale' in collectionFields)) { + if (import.meta.dev) { + console.warn( + `[@nuxt/content] queryCollectionLocales: collection "${collection}" has no \`locale\` column. ` + + 'Add `i18n: true` (or an explicit `i18n: { locales, defaultLocale }`) to the collection definition.', + ) + } + return [] + } + const selectFields = LOCALE_ENTRY_FIELDS.filter(f => f in collectionFields) as Array + + const items = await queryBuilder + .select(...selectFields) + .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..f4470d28b 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -1,12 +1,39 @@ import { withoutTrailingSlash } from 'ufo' -import type { Collections, CollectionQueryBuilder, CollectionQueryGroup, QueryGroupFunction, SQLOperator } from '@nuxt/content' -import { tables } from '#content/manifest' +import type { Collections, CollectionI18nConfig, CollectionQueryBuilder, CollectionQueryGroup, ManifestCollectionsMeta, QueryGroupFunction, SQLOperator } from '@nuxt/content' +import manifestMeta, { tables } from '#content/manifest' -const buildGroup = (group: CollectionQueryGroup, type: 'AND' | 'OR') => { - const conditions = (group as unknown as { _conditions: Array })._conditions +/** + * Read the raw conditions accumulated on a group built by `collectionQueryGroup`. + * Exposed so other modules can serialize a group (for example to build a cache key) + * without reaching into the internal `_conditions` field via a cast. + */ +export const getGroupConditions = (group: CollectionQueryGroup): string[] => { + return (group as unknown as { _conditions: Array })._conditions +} + +export const buildGroup = (group: CollectionQueryGroup, type: 'AND' | 'OR'): string => { + const conditions = getGroupConditions(group) return conditions.length > 0 ? `(${conditions.join(` ${type} `)})` : '' } +/** + * Match any condition that filters on the `locale` column, regardless of operator, + * value, or nesting depth. Used to detect manual locale filters so auto-locale + * steps aside. + * + * The quoted column token `"locale"` is detected anywhere in a condition (so a + * filter nested inside an `andWhere`/`orWhere` group still counts), after string + * literals are stripped so a value that happens to contain the text `"locale"` + * does not produce a false match. Column references are always double-quoted while + * values are single-quoted, so the two never collide. + */ +export const referencesLocaleColumn = (conditions: string[]): boolean => + conditions.some(c => stripSingleQuoted(c).includes('"locale"')) + +/** Remove single-quoted string literals, honouring doubled-quote (`''`) escapes. */ +const stripSingleQuoted = (condition: string): string => + condition.replace(/'(?:[^']|'')*'/g, '') + export const collectionQueryGroup = (collection: T): CollectionQueryGroup => { const conditions = [] as Array @@ -69,18 +96,27 @@ 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 the manifest through the shared typed view so the + // property shape stays in sync with `templates.ts`. + const collectionMeta: ManifestCollectionsMeta[string] | undefined + = (manifestMeta as ManifestCollectionsMeta)[String(collection)] + const i18nConfig: CollectionI18nConfig | undefined = collectionMeta?.i18n + const stemPrefix = collectionMeta?.stemPrefix || '' const params = { conditions: [] as Array, selectedFields: [] as Array, offset: 0, limit: 0, orderBy: [] as Array, - // Count query count: { field: '' as keyof Collections[T] | '*', distinct: false, }, + // Locale fallback runs as two queries merged in JS (see `fetchWithLocaleFallback`). + localeFallback: undefined as { locale: string, fallback: string } | undefined, + // Tracks whether `.locale()` was called explicitly. Surfaced to the cache key. + localeExplicitlySet: false, } const query: CollectionQueryBuilder = { @@ -88,17 +124,60 @@ export const collectionQueryBuilder = (collection: __params: params, andWhere(groupFactory: QueryGroupFunction) { const group = groupFactory(collectionQueryGroup(collection)) + // A manual filter on `locale` (in any nested condition) suppresses auto-locale + // so the two cannot combine into a contradictory WHERE clause. + if (referencesLocaleColumn(getGroupConditions(group))) { + params.localeExplicitlySet = true + } params.conditions.push(buildGroup(group, 'AND')) return query }, orWhere(groupFactory: QueryGroupFunction) { const group = groupFactory(collectionQueryGroup(collection)) + if (referencesLocaleColumn(getGroupConditions(group))) { + params.localeExplicitlySet = true + } params.conditions.push(buildGroup(group, 'OR')) return query }, path(path: string) { return query.where('path', '=', withoutTrailingSlash(path)) }, + stem(stem: string) { + // Strip leading and trailing slashes so callers can pass either form. The stored + // `stem` column never contains them, and `stemPrefix` is normalized at template + // generation time (see `templates.ts`). + const normalized = stem.replace(/^\/+|\/+$/g, '') + // Prepend the collection's source prefix when the input does not already include + // it. The segment boundary check (`stemPrefix + '/'`) avoids false matches such + // as prefix `navigation` matching `navigation2/foo`. + const fullStem = stemPrefix && !(normalized === stemPrefix || normalized.startsWith(stemPrefix + '/')) + ? `${stemPrefix}/${normalized}` + : normalized + return query.where('stem', '=', fullStem) + }, + locale(locale: string, opts?: { fallback?: string }) { + // Dev-only guard. Calling `.locale()` on a collection without `i18n` would emit + // `WHERE "locale" = ?` against a table that has no `locale` column, surfacing + // as a confusing "no such column" SQL error far from the user's call site. + // The manifest is the source of truth, so `i18nConfig` is undefined exactly + // when the collection was not declared with `i18n`. + if (import.meta.dev && !i18nConfig) { + console.warn( + `[@nuxt/content] queryCollection("${String(collection)}").locale(${JSON.stringify(locale)}): ` + + `collection "${String(collection)}" has no \`i18n\` configured. The query will fail at the database. ` + + 'Add `i18n: true` (or an explicit `i18n: { locales, defaultLocale }`) to the collection definition.', + ) + } + 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,42 +201,263 @@ export const collectionQueryBuilder = (collection: return query }, async all(): Promise { - return fetch(collection, buildQuery()).then(res => (res || []) as Collections[T][]) + const autoLocale = resolveAutoLocale() + if (params.localeFallback || autoLocale.fallback) { + return fetchWithLocaleFallback({ autoLocale }) + } + return fetch(collection, buildQuery({ autoLocale })).then(res => (res || []) as Collections[T][]) }, async first(): Promise { - return fetch(collection, buildQuery({ limit: 1 })).then(res => res[0] || null) + const autoLocale = resolveAutoLocale() + if (params.localeFallback || autoLocale.fallback) { + return fetchWithLocaleFallback({ limit: 1, autoLocale }).then(res => res[0] || null) + } + return fetch(collection, buildQuery({ limit: 1, autoLocale })).then(res => res[0] || null) }, async count(field: keyof Collections[T] | '*' = '*', distinct: boolean = false) { + const autoLocale = resolveAutoLocale() + if (params.localeFallback || autoLocale.fallback) { + // Compute the effective field list via overrides rather than mutating `params`. + // Mutation would race under `Promise.all([qb.all(), qb.count()])`. + const countField = field !== '*' ? String(field) : undefined + const fieldsOverride = countField + && params.selectedFields.length > 0 + && !params.selectedFields.includes(field as keyof Collections[T]) + ? [...params.selectedFields, field as keyof Collections[T]] + : undefined + + // `bypassPagination` counts the full match set rather than the visible page, + // so `.limit(5).count()` returns the total rather than 5. + const res = await fetchWithLocaleFallback({ + preserveField: countField, + autoLocale, + fieldsOverride, + bypassPagination: true, + }) + if (field === '*') return res.length + const values = res + .map(r => (r as unknown as Record)[String(field)]) + .filter(v => v !== null && v !== undefined) + if (!distinct) return values.length + // Mirror SQL `COUNT(DISTINCT ...)`, which compares serialized values. Key + // the set on a stable serialization so structurally-equal JSON/object + // column values collapse rather than counting as distinct references. + const keys = values.map(v => (typeof v === 'object' ? JSON.stringify(v) : v)) + return new Set(keys).size + } + // `noLimitOffset` is essential here. A COUNT query returns exactly one row, + // so any `LIMIT`/`OFFSET` carried over from `.skip()`/`.limit()` either caps + // a single-row result (harmless but misleading) or, when offset is positive, + // slices past it and yields `[]`, making `m[0].count` throw a TypeError. return fetch(collection, buildQuery({ count: { field: String(field), distinct }, + autoLocale, + noLimitOffset: true, })).then(m => (m[0] as { count: number }).count) }, } - function buildQuery(opts: { count?: { field: string, distinct: boolean }, limit?: number } = {}) { + /** + * Compute the auto-locale effect for this execution **without mutating** persistent + * state. Safe for builder reuse, since each terminal method re-resolves against + * the current `params`. + * + * Returns: + * - `condition`, an extra WHERE fragment to append when the default locale is + * detected (single-query path), or undefined. + * - `fallback`, a `{ locale, fallback }` pair when a non-default locale is + * detected and no explicit `.locale()` was set, or undefined. + * + * Returns an empty object when the collection has no i18n config, no locale was + * detected, the detected locale is not in the configured list, or the user + * already called `.locale()`. + */ + function resolveAutoLocale(): { condition?: string, fallback?: { locale: string, fallback: string } } { + if (params.localeExplicitlySet || !i18nConfig || !detectedLocale) return {} + if (!i18nConfig.locales.includes(detectedLocale)) { + // BCP-47 tag mismatch (for example, `@nuxtjs/i18n` returns `en-US` for a + // collection declaring only `en`). Silently skipping auto-locale here would + // yield rows from every locale, which is rarely what the user expects. + // Surfacing the mismatch at the call site prevents confusing downstream + // symptoms such as a French page rendering English content. + if (import.meta.dev) { + console.warn( + `[@nuxt/content] queryCollection("${String(collection)}"): detected locale ` + + `"${detectedLocale}" is not in this collection's locales ` + + `[${i18nConfig.locales.map(l => `"${l}"`).join(', ')}]. Auto-locale filter skipped, ` + + 'so the query will return rows from every locale. If you use BCP-47 tags ' + + 'like "en-US", either declare them in the collection\'s `i18n.locales` or ' + + 'strip the region subtag before passing it to @nuxtjs/i18n.', + ) + } + return {} + } + if (detectedLocale === i18nConfig.defaultLocale) { + return { condition: `("locale" = ${singleQuote(detectedLocale)})` } + } + return { fallback: { locale: detectedLocale, fallback: i18nConfig.defaultLocale } } + } + + /** + * Two-query locale fallback. Fetches locale-specific rows and default-locale rows, + * then merges by stem so that locale items take priority and fallback items fill + * any gaps. The `stem` column is injected into the SELECT list for merge-key + * deduplication, then stripped from results when the caller did not select it. + * + * All effective state is passed by argument, so this function never mutates + * `params`. That keeps the builder safe to reuse across concurrent calls such as + * `Promise.all([qb.all(), qb.count()])`. An earlier mutate-and-restore approach + * leaked partial state across the race window between concurrent terminals. + * + * When called from the auto-locale path, `params.localeFallback` may be undefined + * and the fallback pair instead comes from `autoLocale.fallback`. + */ + async function fetchWithLocaleFallback(opts: { + limit?: number + preserveField?: string + autoLocale?: { condition?: string, fallback?: { locale: string, fallback: string } } + /** Override `params.selectedFields` for this call (used by `.count()` to add the counted field). */ + fieldsOverride?: Array + /** Ignore `params.offset` and `params.limit` (used by `.count()` so paging does not truncate the count). */ + bypassPagination?: boolean + } = {}): Promise { + // Callers gate on `params.localeFallback || opts.autoLocale?.fallback` being + // truthy, so one of them is always defined here. The `||` collapses both branches. + const fb = (params.localeFallback || opts.autoLocale?.fallback) as { locale: string, fallback: string } + const { locale, fallback } = fb + + // Inject `stem` into the SELECT list so it is available as the merge key. A + // local flag records the injection so the column can be stripped from results + // when the caller did not ask for it. + const baseFields = opts.fieldsOverride ?? params.selectedFields + const stemInjected = baseFields.length > 0 && !baseFields.includes('stem' as keyof Collections[T]) + const fieldsForQuery = stemInjected + ? [...baseFields, 'stem' as keyof Collections[T]] + : baseFields + + // Sub-queries fetch every matching row (no limit, no offset). Pagination is + // applied JS-side on the merged result. The auto-locale `condition` is + // intentionally not propagated here, since each sub-query already pins its own + // locale via `extraCondition` and a second condition would double-filter. + // + // The two queries are independent and issued in parallel via `Promise.all`, + // which halves perceived latency on the non-default-locale path and short + // circuits on the first rejection (the desired behaviour, since the merge + // cannot proceed without both result sets). + const localeQuery = buildQuery({ + extraCondition: `("locale" = ${singleQuote(locale)})`, + noLimitOffset: true, + selectedFields: fieldsForQuery, + }) + const fallbackQuery = buildQuery({ + extraCondition: `("locale" = ${singleQuote(fallback)})`, + noLimitOffset: true, + selectedFields: fieldsForQuery, + }) + const [localeResults, fallbackResults] = await Promise.all([ + fetch(collection, localeQuery).then(res => res || []), + fetch(collection, fallbackQuery).then(res => res || []), + ]) + + // Prefer locale results, fall back to default-locale rows for any missing stems. + 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))) + + // Under the default `ORDER BY stem ASC` the two result sets are re-interleaved + // by stem so the merged page matches a single-query ordering. The navigation + // and surround helpers inject `order('stem', 'ASC')` (serialized as + // `"stem" ASC`) when no explicit order is set, so that case takes the sorted + // path too. Sorting happens in JS rather than relying on the database returning + // rows in binary order, which keeps the result deterministic on backends whose + // default collation is not binary (for example PostgreSQL with a linguistic + // locale). A custom `.order()` cannot be reproduced here without parsing the + // SQL ORDER BY clause, so that path concatenates locale items first, then + // fallback items, each group retaining its database order. + const isDefaultStemOrder = params.orderBy.length === 0 + || (params.orderBy.length === 1 && params.orderBy[0] === '"stem" ASC') + const combined = [...localeResults, ...fallbackOnly] + const merged = isDefaultStemOrder + ? combined.sort((a, b) => { + const sa = getStem(a) + const sb = getStem(b) + return sa < sb ? -1 : sa > sb ? 1 : 0 + }) + : combined + + // Apply offset then limit on the merged result. + let result = merged + if (!opts.bypassPagination) { + 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 the internally injected `stem` column when the caller did not select it, + // unless a `.count()` call is targeting that field via `preserveField`. + 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 + autoLocale?: { condition?: string, fallback?: { locale: string, fallback: string } } + /** Override the SELECT field list without mutating `params.selectedFields`. */ + selectedFields?: ReadonlyArray + } = {}) { 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)) + const fields = Array.from(new Set(opts.selectedFields ?? params.selectedFields)) query += fields.length > 0 ? fields.map(f => `"${String(f)}"`).join(', ') : '*' } query += ` FROM ${tables[String(collection)]}` - if (params.conditions.length > 0) { - query += ` WHERE ${params.conditions.join(' AND ')}` + const conditions = [...params.conditions] + // Auto-locale condition for the single-query path. The fallback path is handled + // by `fetchWithLocaleFallback` and passes `extraCondition` instead. When + // `extraCondition` is set, the caller is a fallback sub-query that already pins + // its own locale, so the auto-locale condition is skipped to avoid double-filtering. + if (opts.autoLocale?.condition && !opts.extraCondition) { + conditions.push(opts.autoLocale.condition) + } + 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 on COUNT queries since PostgreSQL rejects ORDER BY on an + // aggregate without a GROUP BY clause. + 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}` } diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index ad58bd725..7bec8b49e 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -1,6 +1,19 @@ const SQL_COMMANDS = /SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|\$/i -const SQL_COUNT_REGEX = /^COUNT\((DISTINCT )?([a-z_]\w+|\*)\) as count$/i -const SQL_SELECT_REGEX = /^SELECT (.*) FROM (\w+)( WHERE .*)? ORDER BY (["\w,\s]+) (ASC|DESC)( LIMIT \d+)?( OFFSET \d+)?$/ +// Resource-heavy or filesystem/code functions that the query builder never emits. +// Reaching the database through the public query endpoint, these enable a CPU or +// memory denial of service (for example `randomblob(1e9)`) or worse, so they are +// rejected in the WHERE clause where the only function-call surface exists. +const SQL_UNSAFE_FUNCTIONS = /\b(randomblob|zeroblob|load_extension|readfile|writefile|fts3_tokenizer|pg_sleep|pg_read_file|pg_read_binary_file|dblink|lo_import|lo_export)\s*\(/i +// Upper bound on the accepted query length. The builder never produces anything +// near this, and the cap prevents a multi-kilobyte payload from driving the lazy +// quantifiers in `SQL_SELECT_REGEX` into pathological backtracking. +const MAX_QUERY_LENGTH = 50_000 +// Combines the upstream security tightening (anchored regex, required `as count` +// alias) with the feature additions (quoted column names, optional `ORDER BY` for +// count queries). Column identifiers allow a single character (`\w*`) since a +// schema field can legitimately be one character long. +const SQL_COUNT_REGEX = /^COUNT\((DISTINCT )?("[a-z_]\w*"|[a-z_]\w*|\*)\) as count$/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 +29,15 @@ export function assertSafeQuery(sql: string, collection: string) { throw new Error('Invalid query: Query cannot be empty') } + if (sql.length > MAX_QUERY_LENGTH) { + throw new Error('Invalid query: Query exceeds the maximum allowed length') + } + + // 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 +50,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(', ') || [] @@ -36,19 +58,19 @@ export function assertSafeQuery(sql: string, collection: string) { if ( columns[0] !== '*' && !columns[0]?.match(SQL_COUNT_REGEX) - && !columns[0]?.match(/^"[a-z_]\w+"$/i) + && !columns[0]?.match(/^"[a-z_]\w*"$/i) ) { throw new Error(`Invalid query: Column '${columns[0]}' has invalid format. Expected *, COUNT(), or a quoted column name`) } } - else if (!columns.every(column => column.match(/^"[a-z_]\w+"$/i))) { + else if (!columns.every(column => column.match(/^"[a-z_]\w*"$/i))) { throw new Error('Invalid query: Multiple columns must be properly quoted and alphanumeric') } // 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 @@ -60,12 +82,20 @@ export function assertSafeQuery(sql: string, collection: string) { if (noString.match(SQL_COMMANDS)) { throw new Error('Invalid query: WHERE clause contains unsafe SQL commands') } + if (noString.match(SQL_UNSAFE_FUNCTIONS)) { + throw new Error('Invalid query: WHERE clause contains unsafe SQL functions') + } } - // 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 is optional, since COUNT queries omit it. + if (orderBy && order) { + const _order = (orderBy + ' ' + order).split(', ') + // Column names must start with a letter or underscore but may contain digits + // afterwards (for example `h1`, `field_2024`, `version2`). An earlier upstream + // regex forbade digits entirely, which rejected legitimate user schema fields. + if (!_order.every(column => column.match(/^("[a-zA-Z_]\w*"|[a-zA-Z_]\w*) (ASC|DESC)$/))) { + throw new Error('Invalid query: ORDER BY clause must contain valid column names followed by ASC or DESC') + } } // LIMIT @@ -82,55 +112,65 @@ export function assertSafeQuery(sql: string, collection: string) { } function cleanupQuery(query: string, options: { removeString: boolean } = { removeString: false }) { - // Track whether we're inside a string literal + // Track whether the scanner is currently inside a string literal. let inString = false let stringFence = '' 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 (for example `''` inside a string). Skip both + // characters and stay inside the string. + if (!options?.removeString) { + // Preserve both quotes when not stripping the string content. + result += char + char + } + i++ continue } - - inString = false - stringFence = '' - continue + else { + // Closing quote of the string literal. + inString = false + stringFence = '' + } } - else { - inString = true - stringFence = char - continue + // Inside a string, keep each character when not removing strings. + if (!options?.removeString) { + result += char } + continue } - if (!inString) { - if (char === '-' && nextChar === '-') { - // everything after this is a comment - return result + // Outside a string, an 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 === '-') { + // Line comment, 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..cdc4e024b 100644 --- a/src/runtime/nitro.ts +++ b/src/runtime/nitro.ts @@ -3,8 +3,8 @@ import type { Collections, CollectionQueryBuilder } from '@nuxt/content' import type { H3Event } from 'h3' /** - * `@nuxt/content/nitro` import is deprecated and will be removed in the next major version. - * Use `@nuxt/content/server` instead. + * The `@nuxt/content/nitro` entry is deprecated and will be removed in the next + * major version. Use `@nuxt/content/server` instead. */ /** @@ -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..cbbd60717 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -3,8 +3,10 @@ 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 { detectServerLocale } from './internal/i18n-detection' 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 +16,10 @@ interface ChainablePromise extends Promise(event: H3Event, collection: T): CollectionQueryBuilder => { - return collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql)) + // Auto-detect from the `@nuxtjs/i18n` server context, falling back to the + // configured default when per-request detection did not run. See + // `internal/i18n-detection.ts` for the contract. + return collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql), detectServerLocale(event)) } export function queryCollectionNavigation(event: H3Event, collection: T, fields?: Array) { @@ -29,6 +34,13 @@ export function queryCollectionSearchSections(e return chainablePromise(event, collection, qb => generateSearchSections(qb, opts)) } +export function queryCollectionLocales(event: H3Event, collection: T, stem: string): Promise { + // Auto-locale is skipped here. This helper needs every locale variant, not just + // the current one, so it builds the query without passing a detected locale. + const qb = collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql)) + return generateCollectionLocales(qb, String(collection), 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..de626cf62 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,16 +114,26 @@ export interface DefinedCollection { extendedSchema: Draft07 fields: Record indexes?: CollectionIndex[] + /** + * The `true` shorthand is resolved from `@nuxtjs/i18n` during 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 /** - * Whether the collection is private or not. - * Private collections will not be available in the runtime. + * Whether the collection is private. Private collections are not exposed at + * runtime. */ private: boolean + /** + * Fully resolved i18n config. Never `true`, since the shorthand is resolved + * before this point. + */ + i18n?: CollectionI18nConfig } export interface CollectionInfo { 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..dce7680de 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,7 @@ export type * from './collection' export type * from './hooks' +export type * from './locales' +export type * from './manifest' 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..a2c9a69db --- /dev/null +++ b/src/types/locales.ts @@ -0,0 +1,34 @@ +/** + * 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 +} + +/** + * The `meta` field name where non-default-locale items (produced by inline-i18n + * expansion) store a hash of the default-locale source fields they translate. + * Exposed as a constant so tooling (Nuxt Studio, custom translator pipelines, and + * similar consumers) can detect outdated translations by comparing this hash + * across versions. + * + * @see `expandI18nData` in `src/utils/i18n.ts` + */ +export const I18N_SOURCE_HASH_FIELD = '_i18nSourceHash' as const + +/** + * Shape of `meta` on i18n-expanded items. The hash is per-locale and computed + * only from the default-locale values of fields *this* locale overrides, so a + * change to a field that this locale does not translate will not change its + * hash. + */ +export interface ContentI18nMeta { + /** Hash of the default-locale source fields for the locale's translated fields. */ + [I18N_SOURCE_HASH_FIELD]?: string +} diff --git a/src/types/manifest.ts b/src/types/manifest.ts index d205d7442..8b7074447 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -1,4 +1,4 @@ -import type { ResolvedCollection } from './collection' +import type { CollectionI18nConfig, CollectionType, ResolvedCollection } from './collection' export interface Manifest { checksumStructure: Record @@ -7,3 +7,16 @@ export interface Manifest { components: string[] collections: ResolvedCollection[] } + +/** + * Shape of a single collection entry in the generated `#content/manifest` default export. + * Kept here so runtime code can import a typed view rather than casting. + */ +export interface ManifestCollectionMeta { + type: CollectionType + fields: Record + i18n?: CollectionI18nConfig + stemPrefix?: string +} + +export type ManifestCollectionsMeta = Record diff --git a/src/types/query.ts b/src/types/query.ts index 3a9b76806..6dd0f5913 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..ba93eb712 100644 --- a/src/utils/collection.ts +++ b/src/utils/collection.ts @@ -1,9 +1,9 @@ import { hash } from 'ohash' -import type { Collection, ResolvedCollection, CollectionSource, DefinedCollection, ResolvedCollectionSource, CustomCollectionSource, ResolvedCustomCollectionSource } from '../types/collection' +import type { Collection, CollectionIndex, ResolvedCollection, CollectionSource, DefinedCollection, ResolvedCollectionSource, CustomCollectionSource, ResolvedCustomCollectionSource } from '../types/collection' 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' @@ -12,6 +12,35 @@ export function getTableName(name: string) { return `_content_${name}` } +/** + * Detect a user-declared `(locale, stem)` composite index so the i18n auto-index + * doesn't pile a duplicate on top of it. Exported so the `i18n: true` resolver + * in `config.ts` applies the same guard. + */ +export function hasLocaleStemIndex(indexes: CollectionIndex[] | undefined): boolean { + return (indexes || []).some( + idx => idx.columns.length === 2 && idx.columns[0] === 'locale' && idx.columns[1] === 'stem', + ) +} + +/** + * Force the `locale` schema property to `string`. The i18n integration relies on + * the locale being a text column, so a user declaration of another type (which + * would otherwise win the schema merge and serialize locale codes as `NaN`) is + * overridden with a warning. Exported so both the explicit-config and the + * `i18n: true` resolution paths apply the same guard. + */ +export function normalizeLocaleField(extendedSchema: Draft07, collectionName?: string): void { + const props = extendedSchema?.definitions?.__SCHEMA__?.properties as Record | undefined + if (props?.locale && props.locale.type !== 'string') { + logger.warn( + `Collection${collectionName ? ` "${collectionName}"` : ''} declares a non-string \`locale\` field. ` + + 'The i18n integration requires `locale` to be a string and is overriding it.', + ) + props.locale = { type: 'string' } + } +} + export function defineCollection(collection: Collection): DefinedCollection { let standardSchema: Draft07 = emptyStandardSchema @@ -27,15 +56,47 @@ export function defineCollection(collection: Collection): DefinedCollectio extendedSchema = mergeStandardSchema(pageStandardSchema, extendedSchema) } + // Add the `locale` field only when i18n is fully configured. The `true` + // shorthand is resolved later in `loadContentConfig` via `resolveI18nConfig`. + const hasI18nConfig = collection.i18n && collection.i18n !== true + // Resolve the effective i18n config (may patch `defaultLocale` into `locales` + // without mutating the caller's config object). + let resolvedI18n: typeof collection.i18n = collection.i18n + if (hasI18nConfig) { + extendedSchema = mergeStandardSchema(localeStandardSchema, extendedSchema) + normalizeLocaleField(extendedSchema) + // Surface defaultLocale-not-in-locales early. Auto-locale detection and + // path-based detection both fail silently if this invariant is violated. + const i18n = collection.i18n as { locales: string[], defaultLocale: string } + if (!i18n.locales.includes(i18n.defaultLocale)) { + logger.warn( + `Collection \`i18n\` config has \`defaultLocale: "${i18n.defaultLocale}"\` that is not in ` + + `\`locales: [${i18n.locales.map(l => `"${l}"`).join(', ')}]\`. Adding it automatically, ` + + 'declare it explicitly in `locales` to silence this warning.', + ) + resolvedI18n = { ...i18n, locales: [i18n.defaultLocale, ...i18n.locales] } + } + } + extendedSchema = mergeStandardSchema(metaStandardSchema, extendedSchema) + // Auto-add a composite index on (locale, stem) for i18n collections. Skipped + // when the user already declared an equivalent index, which avoids a + // duplicate `CREATE INDEX` that would otherwise survive when the user + // supplies a custom `name`. + const indexes = collection.indexes ? [...collection.indexes] : [] + if (hasI18nConfig && !hasLocaleStemIndex(indexes)) { + 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: resolvedI18n, } } @@ -67,6 +128,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..a0f03acf1 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 { defineCollection, resolveCollections } from './collection' +import type { CollectionI18nConfig, DefinedCollection, ModuleOptions } from '../types' +import { defineCollection, hasLocaleStemIndex, normalizeLocaleField, 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,101 @@ 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 } } + +interface RawI18nModuleOptions { + locales?: Array + defaultLocale?: string +} + +/** + * Read `@nuxtjs/i18n` locale options. Prefers the resolved options on + * `nuxt.options.i18n`, then falls back to inline options passed in the `modules` + * array, which are present before `@nuxtjs/i18n` normalizes them. Locales + * contributed only through layers or the module's own hooks may not be visible at + * this point, in which case an explicit `i18n: { locales, defaultLocale }` config + * is required. + */ +function readI18nModuleOptions(nuxt: Nuxt): RawI18nModuleOptions | undefined { + const fromOptions = (nuxt.options as unknown as { i18n?: RawI18nModuleOptions }).i18n + if (fromOptions?.locales?.length) { + return fromOptions + } + const moduleEntry = (nuxt.options.modules || []).find( + (m): m is [string, RawI18nModuleOptions] => Array.isArray(m) && m[0] === '@nuxtjs/i18n', + ) + if (moduleEntry?.[1]?.locales?.length) { + return moduleEntry[1] + } + return fromOptions +} + +/** + * Resolve `i18n: true` shorthand on collections by reading locale config + * from the `@nuxtjs/i18n` module. If the module is not installed (or its locale + * config cannot be read) and a collection uses `i18n: true`, a warning is logged + * and i18n is disabled for that collection. + */ +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 + let reason = '@nuxtjs/i18n module is not installed' + + if (hasNuxtModule('@nuxtjs/i18n', nuxt)) { + const i18nOptions = readI18nModuleOptions(nuxt) + + if (!i18nOptions?.locales?.length) { + reason = '@nuxtjs/i18n is installed but no `locales` could be read at this point' + } + else if (!i18nOptions.defaultLocale) { + reason = '@nuxtjs/i18n is installed but has no `defaultLocale` configured' + } + else { + const localeCodes = i18nOptions.locales.map(l => typeof l === 'string' ? l : l.code) + // `@nuxtjs/i18n` permits `defaultLocale` outside `locales` (it falls + // through at runtime), but for content filtering it is a footgun. Files + // under `content//` would never be path-detected. The + // invariant is forced here so it cannot silently drift. + resolvedConfig = { + locales: localeCodes.includes(i18nOptions.defaultLocale) + ? localeCodes + : [i18nOptions.defaultLocale, ...localeCodes], + defaultLocale: i18nOptions.defaultLocale, + } + } + } + + for (const [name, collection] of Object.entries(collections)) { + if (collection.i18n !== true) continue + + if (resolvedConfig) { + collection.i18n = resolvedConfig + collection.extendedSchema = mergeStandardSchema(localeStandardSchema, collection.extendedSchema) + // A user-declared `locale` field wins the schema merge; force it to string + // so it cannot produce invalid SQL. + normalizeLocaleField(collection.extendedSchema, name) + collection.fields = getCollectionFieldsTypes(collection.extendedSchema) + // Shared with `defineCollection`'s explicit-config path so both routes + // dedupe against a user-declared `(locale, stem)` composite index. + if (!hasLocaleStemIndex(collection.indexes)) { + collection.indexes = [...(collection.indexes || []), { columns: ['locale', 'stem'] }] + } + } + else { + logger.warn( + `Collection "${name}" has \`i18n: true\` but ${reason}. ` + + 'Provide an explicit `i18n: { locales, defaultLocale }` config or configure @nuxtjs/i18n.', + ) + collection.i18n = undefined + } + } +} diff --git a/src/utils/content/index.ts b/src/utils/content/index.ts index c24e9504d..218f1068a 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,23 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) } } + // Detect the locale when the collection has i18n configured, then strip the + // locale prefix from `path` and `stem` so each row is addressable by a single + // locale-agnostic key. Detection reads the id-derived stem (`pathMetaFields.stem`) + // rather than `result.path`, because a custom `path` front-matter value would + // otherwise assign the wrong locale and leave the stem unstripped. + if (collection.i18n && collectionKeys.includes('locale')) { + const sourceStem = String(pathMetaFields.stem ?? result.stem ?? '') + const currentPath = String(result.path ?? pathMetaFields.path ?? '') + const detected = detectLocaleFromPath(currentPath, sourceStem, 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 8ef4b7efd..728b36c11 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' @@ -164,11 +165,49 @@ 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) + + // Expand inline i18n translations into one DB row per locale. + if (collection.i18n && (parsed?.meta as Record)?.i18n) { + const i18nData = (parsed.meta as Record).i18n as Record> + // Capture the 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, Object.keys(collection.fields)) + for (const item of expandedItems) { + // Use `item.id` directly as the dump and DB key. `expandI18nData` + // already returns the default-locale item with the bare id (matching + // the SQL row's `id` column) and non-default items with a `#` + // suffix. Reconstructing the key from `item.locale` would incorrectly + // suffix the default row and desync the DELETE / INSERT pair in + // `broadcast`. + const itemKey = item.id as string + const { queries } = generateCollectionInsert(collection, item) + await broadcast(collection, itemKey, queries) + } + + // Remove locale rows that are no longer present 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 + // has now been removed from the file. The default-locale row is stored + // under the bare key, so it is skipped here. + if (collection.i18n) { + for (const locale of collection.i18n.locales) { + if (locale === collection.i18n.defaultLocale) continue + await broadcast(collection, `${keyInCollection}#${locale}`) + } + } + const { queries: insertQuery } = generateCollectionInsert(collection, parsed) + await broadcast(collection, keyInCollection, insertQuery) + } } } } @@ -199,7 +238,15 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani await db.deleteDevelopmentCache(keyInCollection) + // Remove the main row and all non-default locale variant rows. The + // default-locale row is the main row stored under the bare key. await broadcast(collection, keyInCollection) + if (collection.i18n) { + for (const locale of collection.i18n.locales) { + if (locale === collection.i18n.defaultLocale) continue + await broadcast(collection, `${keyInCollection}#${locale}`) + } + } } } } @@ -213,9 +260,31 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani } const collectionDump = manifest.dump[collection.name]! - const keyIndex = collectionDump.findIndex(item => item.includes(`'${key}'`)) + // Match an entry that references this row. Three exact shapes can occur: + // 1. `INSERT INTO ... VALUES ('key', ...)` contains `'key',` + // 2. `INSERT INTO ... VALUES ('key')` ends with `'key')` + // 3. `UPDATE ... WHERE id = 'key' AND ...` contains `id = 'key'` + // The UPDATE shape comes from `generateCollectionInsert` splitting oversized + // rows into an INSERT followed by chained UPDATE fragments. Without case 3 + // those fragments would be left behind in the dump as dead no-ops, slowly + // bloating it on every HMR cycle. + const escapedKey = key.replace(/'/g, '\'\'') + const keyMatch = (item: string) => + item.includes(`'${escapedKey}',`) + || item.endsWith(`'${escapedKey}')`) + || item.includes(`id = '${escapedKey}'`) + const keyIndex = collectionDump.findIndex(keyMatch) const indexToUpdate = keyIndex !== -1 ? keyIndex : collectionDump.length - const itemsToRemove = keyIndex === -1 ? 0 : 1 + + // Count every consecutive dump entry belonging to this key. Large content + // splits into an INSERT plus 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..e02017e8a --- /dev/null +++ b/src/utils/i18n.ts @@ -0,0 +1,248 @@ +import { createDefu } from 'defu' +import { hash } from 'ohash' +import type { CollectionI18nConfig } from '../types/collection' +import { I18N_SOURCE_HASH_FIELD } from '../types/locales' +import { logger } from './dev' +import type { ParsedContentFile } from '../types' + +/** + * Identity fields that describe a row rather than its translatable content. + * They are stripped from inline locale overrides so a translation cannot change + * a row's identity, which would break the `(locale, stem)` pairing used by the + * fallback merge and by `queryCollectionLocales`. + */ +const IDENTITY_OVERRIDE_KEYS = ['id', 'stem', 'path', 'locale', 'extension'] + +/** + * Merge two arrays item by item. Override items take priority and default items + * fill any gaps. Arrays of plain values replace the default wholesale (including + * an empty array, which clears the list), since pad-filling a shorter + * locale-specific list from the default tail is virtually never intended. + * Object items deep-merge, and nested arrays merge by index recursively. + */ +function mergeArraysByIndex(overrideArr: unknown[], defaultArr: unknown[]): unknown[] { + const isScalarOverride = overrideArr.every(item => typeof item !== 'object' || item === null) + if (isScalarOverride) { + return [...overrideArr] + } + + const maxLen = Math.max(overrideArr.length, defaultArr.length) + const result: unknown[] = [] + for (let i = 0; i < maxLen; i++) { + const overrideItem = overrideArr[i] + const defaultItem = defaultArr[i] + if (Array.isArray(overrideItem) && Array.isArray(defaultItem)) { + // Both items are themselves arrays. Recurse directly instead of delegating + // to `defuByIndex`, whose merger only runs for arrays nested inside objects + // and cannot take arrays as top-level arguments. + result.push(mergeArraysByIndex(overrideItem, defaultItem)) + } + else if (overrideItem !== undefined && defaultItem !== undefined + && typeof overrideItem === 'object' && overrideItem !== null && !Array.isArray(overrideItem) + && typeof defaultItem === 'object' && defaultItem !== null && !Array.isArray(defaultItem)) { + result.push(defuByIndex(overrideItem, defaultItem)) + } + else { + result.push(overrideItem !== undefined ? overrideItem : defaultItem) + } + } + return result +} + +/** + * Custom `defu` merger that combines arrays by index (item by item) instead of + * concatenating. Applied recursively to all nested arrays within merged objects. + * + * Used for inline i18n expansion. Arrays of objects (such as nav items or cards) + * deep-merge so untranslated fields (routes, IDs, icons, URLs) are preserved from + * the default. + * + * Inside `createDefu`'s merger, `obj[key]` is the defaults (second arg) and + * `value` is the overrides (first arg). + */ +export const defuByIndex = createDefu((obj, key, value) => { + if (Array.isArray(obj[key]) && Array.isArray(value)) { + ;(obj as Record)[key as string] = mergeArraysByIndex(value as unknown[], obj[key] as unknown[]) + return true + } +}) + +/** + * Pick the default-locale values that correspond to the leaf paths a locale + * override actually translates. Walks the override structure and copies only the + * matching leaves from the source, so a translation of `info.country` captures + * the default `info.country` rather than the whole `info` object. This keeps the + * per-locale source hash from flipping when an untranslated sibling field changes. + */ +function pickSourceByOverride(source: unknown, override: unknown): unknown { + if (override && typeof override === 'object' && !Array.isArray(override) + && source && typeof source === 'object' && !Array.isArray(source)) { + const out: Record = {} + for (const k of Object.keys(override as Record)) { + out[k] = pickSourceByOverride((source as Record)[k], (override as Record)[k]) + } + return out + } + return source +} + +/** + * Expand inline i18n data from a parsed content file into per-locale items. + * The default locale keeps the original content, while non-default locales get + * a deep-merged copy in which 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. + * + * Override handling rules: + * - Identity fields (id, stem, path, locale, extension) are stripped from + * overrides so a translation cannot change a row's identity. + * - A locale key not listed in the collection's `i18n.locales` is skipped, since + * it would never be queryable and dev HMR cleanup could never remove it. + * - Translations of fields not declared in the collection schema are dropped by + * the insert step (non-schema fields live on `meta`), so a dev warning surfaces + * the lost translation when `schemaKeys` is provided. + * + * `meta` semantics: the default locale keeps its full `meta` (minus the `i18n` + * key). Non-default locale items receive a fresh `meta` of the form + * `{ ...cleanMeta, _i18nSourceHash }`. Any `meta` provided inside a locale + * override is intentionally dropped, because locale-specific tracking state + * (`_i18nSourceHash`) lives on `meta` and must not be overridden by user content. + * + * This function mutates `parsedContent.meta` (removing the `i18n` key) and sets + * `parsedContent.locale` if not already set. This is acceptable because the + * source content is always consumed (inserted into the database) immediately + * after expansion. + */ +export function expandI18nData( + parsedContent: ParsedContentFile, + i18nConfig: CollectionI18nConfig, + collectionType?: 'page' | 'data', + schemaKeys?: string[], +): 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 + } + + const items: ParsedContentFile[] = [parsedContent] + + for (const [locale, rawOverrides] of Object.entries(i18nData)) { + if (locale === parsedContent.locale) continue + + if (!i18nConfig.locales.includes(locale)) { + logger.warn( + `Inline i18n in "${parsedContent.id}" defines locale "${locale}" which is not in the collection's ` + + `locales [${i18nConfig.locales.map(l => `"${l}"`).join(', ')}]. This translation is ignored.`, + ) + continue + } + + // Strip identity fields so a translation cannot change the row's identity. + const overrides: Record = {} + for (const [field, value] of Object.entries(rawOverrides)) { + if (!IDENTITY_OVERRIDE_KEYS.includes(field)) { + overrides[field] = value + } + } + + // Warn when a translated field is not a schema column. Such fields are routed + // to `meta` by the parser and dropped by the insert step, so the translation + // would be silently lost. + if (schemaKeys) { + for (const field of Object.keys(overrides)) { + if (field !== 'body' && !schemaKeys.includes(field)) { + logger.warn( + `Inline i18n in "${parsedContent.id}" translates "${field}" for locale "${locale}", but "${field}" ` + + 'is not declared in the collection schema. Non-schema fields are not persisted per locale. ' + + 'Declare the field in the collection schema to translate it.', + ) + } + } + } + + // The source hash is per-locale. It is computed only from the default values + // of the leaf fields that *this* locale actually translates, so a default + // change to an untranslated sibling does not flip the hash. + const sourceFields: Record = {} + for (const field of Object.keys(overrides)) { + sourceFields[field] = pickSourceByOverride(parsedContent[field], overrides[field]) + } + const i18nSourceHash = hash(sourceFields) + + // Deep merge preserves untranslated fields (routes, IDs, icons). For page + // collections, the body AST must not be deep-merged and is instead replaced + // wholesale. The `'body' in overrides` check ensures explicit null or empty + // bodies still replace, rather than falling back to deep-merging the default + // AST. + const merged = defuByIndex(overrides, parsedContent) as ParsedContentFile + if (collectionType === 'page' && 'body' in overrides) { + merged.body = overrides.body as ParsedContentFile['body'] + } + + const localeItem: ParsedContentFile = { + ...merged, + id: `${parsedContent.id}#${locale}`, + locale, + meta: { ...cleanMeta, [I18N_SOURCE_HASH_FIELD]: i18nSourceHash }, + } + + items.push(localeItem) + } + + return items +} + +/** + * Detect the locale from the content stem and strip the locale prefix from both + * path and stem. The locale is read from the stem's first segment rather than the + * path's, because the stem is derived from the file id and is never influenced by + * a custom `path` front-matter value. Returns the default locale when no prefix + * matches. + */ +export function detectLocaleFromPath( + path: string, + stem: string, + i18nConfig: CollectionI18nConfig, +): { locale: string, path: string, stem: string } { + const stemParts = stem.split('/').filter(Boolean) + const firstPart = stemParts[0] + + if (firstPart && i18nConfig.locales.includes(firstPart)) { + const newStem = stemParts.slice(1).join('/') + + // Strip the locale segment from `path` only when it is actually present. A + // custom front-matter `path` that does not start with the locale segment is + // left untouched. + const pathParts = path.split('/').filter(Boolean) + const strippedPath = pathParts[0] === firstPart + ? '/' + pathParts.slice(1).join('/') + : path + + return { + locale: firstPart, + path: strippedPath === '/' || strippedPath === '' ? '/' : strippedPath, + stem: newStem, + } + } + + return { + locale: i18nConfig.defaultLocale, + path, + stem, + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index a4088891e..de1412325 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,13 @@ export { defineCollection, defineCollectionSource } from './collection' export { defineContentConfig } from './config' export { defineTransformer } from './content/transformers/utils' +/** + * The `meta` field name where inline-i18n expansion stores the source-content + * hash on non-default-locale items. Exposed so tooling (Nuxt Studio, custom + * translator pipelines) can detect outdated translations. + */ +export { I18N_SOURCE_HASH_FIELD } from '../types/locales' + /** * This is only for backward compatibility with Zod v3. Consider using direct import from 'zod' instead. This will be removed in the next major version. */ 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 473a32c5f..e72344c52 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -6,7 +6,7 @@ import type { JSONSchema } from 'json-schema-to-typescript-lite' import { pascalCase } from 'scule' import type { Schema } from 'untyped' import type { CollectionInfo, ResolvedCollection } from '../types/collection' -import type { Manifest } from '../types/manifest' +import type { Manifest, ManifestCollectionMeta, ManifestCollectionsMeta } from '../types/manifest' import type { GitInfo } from './git' import { generateCollectionTableDefinition } from './collection' @@ -49,8 +49,17 @@ export const contentTypesTemplate = (collections: ResolvedCollection[]) => ({ 'declare module \'@nuxt/content\' {', ...(await Promise.all( publicCollections.map(async (c) => { - const type = await jsonSchemaToTypescript(c.schema as JSONSchema, 'CLASS') + let type = await jsonSchemaToTypescript(c.schema as JSONSchema, 'CLASS') .then(code => code.replace('export interface CLASS', `interface ${pascalCase(c.name)}CollectionItem extends ${parentInterface(c)}`)) + if (c.i18n) { + // i18n collections carry a resolved `locale` on every row. It is added + // here, per collection, so non-i18n collections never advertise a + // phantom `locale` property. + type = type.replace( + /(interface \w+CollectionItem extends \w+ ?\{)/, + '$1\n locale: string', + ) + } return indentLines(` ${type}`) }), )), @@ -176,12 +185,23 @@ export const manifestTemplate = (manifest: Manifest) => ({ filename: moduleTemplates.manifest, getContents: ({ options }: { options: { manifest: Manifest } }) => { const collectionsMeta = options.manifest.collections.reduce((acc, collection) => { - acc[collection.name] = { + // The stem prefix is used by `.stem()` to auto-resolve a leading source + // directory. It is only emitted when every source of a collection shares + // the same normalized prefix. Otherwise the heuristic would silently + // prepend the wrong directory for some files. + const normalize = (p: string | undefined) => (p || '').replace(/^\/|\/$/g, '') + const sources = collection.source || [] + const stemPrefixes = new Set(sources.map(s => normalize(s.prefix))) + const stemPrefix = stemPrefixes.size === 1 ? [...stemPrefixes][0]! : '' + const entry: ManifestCollectionMeta = { type: collection.type, fields: collection.fields, + ...(collection.i18n ? { i18n: collection.i18n } : {}), + ...(stemPrefix ? { stemPrefix } : {}), } + acc[collection.name] = entry return acc - }, {} as Record) + }, {} as ManifestCollectionsMeta) return [ `export const checksums = ${JSON.stringify(manifest.checksum, null, 2)}`, diff --git a/test/fixtures/i18n/content.config.ts b/test/fixtures/i18n/content.config.ts new file mode 100644 index 000000000..e2b354280 --- /dev/null +++ b/test/fixtures/i18n/content.config.ts @@ -0,0 +1,43 @@ +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', + }, + }), + // `i18n: true` shorthand without @nuxtjs/i18n installed: the integration is + // disabled with a warning and no `locale` column is added. + notes: defineCollection({ + type: 'data', + source: 'notes/*.yml', + schema: z.object({ + text: z.string(), + }), + i18n: true, + }), + }, +}) 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/content/notes/first.yml b/test/fixtures/i18n/content/notes/first.yml new file mode 100644 index 000000000..4ce4e01ba --- /dev/null +++ b/test/fixtures/i18n/content/notes/first.yml @@ -0,0 +1 @@ +text: Hello 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-auto.get.ts b/test/fixtures/i18n/server/api/content/blog-auto.get.ts new file mode 100644 index 000000000..e043d04a8 --- /dev/null +++ b/test/fixtures/i18n/server/api/content/blog-auto.get.ts @@ -0,0 +1,14 @@ +import { eventHandler, getQuery } from 'h3' + +// Exercises server-side auto-locale detection without @nuxtjs/i18n installed by +// faking the per-request context the module reads. No explicit `.locale()` call +// is made, so the result reflects automatic detection from `event.context.nuxtI18n`. +export default eventHandler(async (event) => { + const { locale } = getQuery(event) as { locale?: string } + + if (locale) { + event.context.nuxtI18n = { detectLocale: locale } + } + + return await queryCollection(event, 'blog').all() +}) 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..2bc974598 --- /dev/null +++ b/test/i18n.test.ts @@ -0,0 +1,277 @@ +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, + // Let the OS assign a free port to avoid `EADDRINUSE` on CI. + port: 0, + }) + + 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') + }) + + test('notes table (i18n: true without @nuxtjs/i18n) has no locale column', async () => { + // The shorthand resolves to disabled when the module is absent, so no + // `locale` column is added. + const tableInfo = await db.database?.prepare(`PRAGMA table_info(${getTableName('notes')});`).all() as { name: string }[] + const columnNames = tableInfo.map(c => c.name) + + expect(columnNames).toContain('text') + expect(columnNames).not.toContain('locale') + }) + }) + + 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') + }) + + test('returns entries without path/title on data collections', async () => { + // The `team` collection has no `path` or `title` columns. + // `generateCollectionLocales` filters the SELECT list against the manifest + // fields, so the helper must succeed and return `path: undefined` and + // `title: undefined` rather than failing with a SQL "no such column" error. + const locales = await $fetch<{ locale: string, stem: string, path?: string, title?: string }[]>( + '/api/content/locales?collection=team&stem=data/team', + ) + + expect(locales.length).toBeGreaterThanOrEqual(2) + for (const entry of locales) { + expect(entry.locale).toBeDefined() + expect(entry.stem).toBe('data/team') + expect(entry.path).toBeUndefined() + expect(entry.title).toBeUndefined() + } + }) + }) + + describe('server-side auto-locale detection', () => { + test('auto-detects the default locale from the request context (single query)', async () => { + const posts = await $fetch[]>('/api/content/blog-auto?locale=en') + + expect(posts.length).toBeGreaterThanOrEqual(2) + for (const post of posts) { + expect(post.locale).toBe('en') + } + }) + + test('auto-detects a non-default locale and falls back for missing translations', async () => { + const posts = await $fetch[]>('/api/content/blog-auto?locale=fr') + + const titles = posts.map(p => p.title) + // French translation is preferred where available. + expect(titles).toContain('Bonjour le Monde') + // The English-only post is filled in from the default locale. + expect(titles).toContain('English Only Post') + }) + + test('returns rows from every locale when no locale is detected', async () => { + const posts = await $fetch[]>('/api/content/blog-auto') + const locales = new Set(posts.map(p => p.locale)) + expect(locales.has('en')).toBe(true) + expect(locales.has('fr')).toBe(true) + }) + }) +}) diff --git a/test/mock/content-manifest.ts b/test/mock/content-manifest.ts index 9b77d5e40..1f9a06db2 100644 --- a/test/mock/content-manifest.ts +++ b/test/mock/content-manifest.ts @@ -1,3 +1,9 @@ export const tables = { test: '_content_test', } + +// The query builder imports the default export to read per-collection metadata +// (i18n config and stem prefix). Keep it in sync with `ManifestCollectionsMeta`. +export default { + test: { type: 'data', fields: {} }, +} diff --git a/test/unit/assertSafeQuery.test.ts b/test/unit/assertSafeQuery.test.ts index 294568268..e56a01330 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 @@ -47,6 +50,60 @@ describe('decompressSQLDump', () => { 'SELECT COUNT(*) as c,(SELECT group_concat(name,\'|\') FROM sqlite_master WHERE type=\'table\') AS leak FROM _content_content ORDER BY stem 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. Must 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, + // Columns with digits (e.g. heading levels, year suffixes) must be allowed + 'SELECT * FROM _content_test ORDER BY h1 ASC': true, + 'SELECT * FROM _content_test ORDER BY "h2" DESC': true, + 'SELECT * FROM _content_test ORDER BY field_2024 ASC, h1 DESC': true, + // Columns must still start with a letter or underscore, not a digit + 'SELECT * FROM _content_test ORDER BY 1field ASC': false, + // Single-character column names are legitimate schema fields + 'SELECT COUNT("x") as count FROM _content_test': true, + 'SELECT COUNT(DISTINCT "y") as count FROM _content_test': true, + 'SELECT "a", "b" FROM _content_test ORDER BY stem ASC': true, + // A non-COUNT SELECT may legitimately omit ORDER BY + 'SELECT * FROM _content_test': true, + 'SELECT * FROM _content_test WHERE ("locale" = \'fr\')': true, + // A WHERE value that contains ORDER BY / LIMIT text must not escape validation + 'SELECT * FROM _content_test WHERE ("title" = \'x ORDER BY y ASC LIMIT 5\') ORDER BY stem ASC': true, + // Expensive or filesystem functions in WHERE are rejected (DoS / exfiltration) + 'SELECT * FROM _content_test WHERE (randomblob(1000000000) NOTNULL) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE (zeroblob(1000000000) NOTNULL) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE (pg_sleep(10) IS NULL) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE (load_extension(\'x\') IS NULL) ORDER BY stem ASC': false, + // A column literally named like a value containing a function is still fine + 'SELECT * FROM _content_test WHERE ("title" = \'randomblob(1)\') ORDER BY stem ASC': true, + // Unterminated block comment must be rejected + 'SELECT * FROM _content_test WHERE ("x" = 1) /* unterminated ORDER BY stem ASC': false, + } + + 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..953e18140 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -1,10 +1,34 @@ 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', + navigation: '_navigation', + plain: '_plain', + }, + default: { + articles: { + type: 'data', + fields: {}, + i18n: { locales: ['en', 'fr', 'de'], defaultLocale: 'en' }, + stemPrefix: '', + }, + // i18n collection whose source lives under a `navigation` directory, so + // `.stem()` resolves the prefix. + navigation: { + type: 'data', + fields: {}, + i18n: { locales: ['en', 'fr', 'de'], defaultLocale: 'en' }, + stemPrefix: 'navigation', + }, + // Collection without i18n, used to assert auto-locale is a no-op. + plain: { + type: 'data', + fields: {}, + stemPrefix: '', + }, }, })) @@ -130,23 +154,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 +204,586 @@ 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', + ) + }) + + describe('.stem() input normalization', () => { + it('strips leading slashes from user input', async () => { + // Stored stems never carry a leading slash, so the slash from callers is + // tolerated and stripped. + await collectionQueryBuilder(mockCollection, mockFetch).stem('/navbar').all() + expect(mockFetch).toHaveBeenLastCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("stem" = \'navbar\') ORDER BY stem ASC', + ) + }) + + it('strips trailing slashes from user input', async () => { + // A trailing slash would never match the stored stem, so collapse it silently. + await collectionQueryBuilder(mockCollection, mockFetch).stem('navbar/').all() + expect(mockFetch).toHaveBeenLastCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("stem" = \'navbar\') ORDER BY stem ASC', + ) + }) + + it('strips both leading and trailing slashes', async () => { + await collectionQueryBuilder(mockCollection, mockFetch).stem('/foo/bar/').all() + expect(mockFetch).toHaveBeenLastCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("stem" = \'foo/bar\') ORDER BY stem ASC', + ) + }) + }) + + it('locale fallback merges results in stem order', async () => { + // `fr` has stem `c`, while `en` has stems `a`, `b`, `c`. The fallback should + // interleave `a` and `b` ahead of `c`. + 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() ignores LIMIT carried over from .limit()', async () => { + // Regression. COUNT returns exactly one row, so appending `LIMIT N` is at + // best misleading. Combined with `OFFSET` it returns `[]`, and `m[0].count` + // would then throw. + mockFetch.mockResolvedValueOnce([{ count: 42 }]) + const query = collectionQueryBuilder(mockCollection, mockFetch) + const result = await query.limit(5).count() + + expect(result).toBe(42) + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT COUNT(*) as count FROM _articles', + ) + }) + + it('count() ignores OFFSET carried over from .skip()/.limit()', async () => { + mockFetch.mockResolvedValueOnce([{ count: 7 }]) + const query = collectionQueryBuilder(mockCollection, mockFetch) + const result = await query.skip(10).limit(5).count() + + expect(result).toBe(7) + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT COUNT(*) as count FROM _articles', + ) + }) + + it('count(field, true) ignores LIMIT/OFFSET in single-query path', async () => { + mockFetch.mockResolvedValueOnce([{ count: 3 }]) + const query = collectionQueryBuilder(mockCollection, mockFetch) + const result = await query.skip(2).limit(10).count('author', true) + + expect(result).toBe(3) + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT COUNT(DISTINCT "author") 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) to simulate what `client.ts` + // and `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` means no auto-locale is applied. + 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 () => { + // When the default locale `'en'` is detected, a single `WHERE` is used + // rather than the two-query fallback path. + 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`, so it is ignored (no locale filter + // is added). + 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) + }) + + it('does not leak auto-locale into persistent conditions across calls', async () => { + // Regression. The auto-applied locale used to be pushed to + // `params.conditions` on first execution, so a second `.all()` on the + // same builder would reapply it. Any intervening `.locale()` call would + // then produce a contradictory `loc=X AND loc=Y`. + const qb = collectionQueryBuilder(mockCollection, mockFetch, 'en') + + mockFetch.mockResolvedValueOnce([]) + // Auto-locale resolves to `en` (the default), so the single-query path runs. + await qb.all() + expect(mockFetch).toHaveBeenLastCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC', + ) + + mockFetch.mockResolvedValueOnce([]) + // The second call must not stack a duplicate locale condition. + await qb.all() + expect(mockFetch).toHaveBeenLastCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC', + ) + + // An explicit `.locale()` must fully override the auto-locale on the next call. + mockFetch.mockResolvedValueOnce([]) + await qb.locale('de').all() + expect(mockFetch).toHaveBeenLastCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("locale" = \'de\') ORDER BY stem ASC', + ) + }) + + it('suppresses auto-locale when .where("locale", ...) is used directly', async () => { + // Regression. Previously `.where('locale', ...)` did not flip + // `localeExplicitlySet`, so auto-detection would still append its own + // locale filter and produce a contradictory `locale = 'fr' AND locale = 'en'`. + const qb = collectionQueryBuilder(mockCollection, mockFetch, 'fr') + await qb.where('locale', '=', 'en').all() + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC', + ) + }) + + it('suppresses auto-locale when .andWhere() contains a locale filter', async () => { + // A manual locale filter nested inside an `andWhere` group should also + // disable auto-locale. The detector matches any condition starting with + // the quoted column name `"locale"`. + const qb = collectionQueryBuilder(mockCollection, mockFetch, 'fr') + await qb + .andWhere(g => g.where('title', 'LIKE', '%foo%').where('locale', '=', 'en')) + .all() + + expect(mockFetch).toHaveBeenCalledTimes(1) + const sql = mockFetch.mock.lastCall![1] as string + // No `locale = 'fr'` from auto-detection + expect(sql).not.toContain('"locale" = \'fr\'') + expect(sql).toContain('"locale" = \'en\'') + }) + + it('restores selectedFields after a failed locale-fallback fetch', async () => { + // Regression. State mutation on the locale-fallback path used to leak + // when the underlying fetch threw, so the next query would then SELECT an + // extra `stem` column. + const qb = collectionQueryBuilder(mockCollection, mockFetch) + .select('title' as never, 'locale' as never) + .locale('fr', { fallback: 'en' }) + + mockFetch.mockRejectedValueOnce(new Error('boom')) + await expect(qb.all()).rejects.toThrow('boom') + + // Run an unrelated query on a *new* builder using the same select set. + // This verifies test isolation, since the bug was about builder-internal + // mutation. + mockFetch.mockClear() + mockFetch.mockResolvedValueOnce([{ title: 'x', locale: 'en' }]) + await collectionQueryBuilder(mockCollection, mockFetch) + .select('title' as never, 'locale' as never) + .all() + + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT "title", "locale" FROM _articles ORDER BY stem ASC', + ) + }) + + it('is safe under concurrent .all()/.count() on the same builder (no shared-state race)', async () => { + // Regression. `.count()` and `fetchWithLocaleFallback` used to mutate + // `params.selectedFields`, `params.offset`, and `params.limit` in a + // save-mutate-restore-in-finally pattern. Under `Promise.all`, a second + // terminal could observe the mutated state mid-flight. Both terminals + // now pass overrides into `buildQuery` and never touch `params`. + const qb = collectionQueryBuilder(mockCollection, mockFetch) + .select('title' as never, 'locale' as never) + .locale('fr', { fallback: 'en' }) + + // Four fetches are expected, two from `.all()` (fr + en) and two from + // `.count()` (fr + en). `.count()` injects `title` into `selectedFields`, + // and `.all()` must NOT observe that injection. + mockFetch + .mockResolvedValueOnce([{ title: 'a-fr', locale: 'fr', stem: 'a' }]) + .mockResolvedValueOnce([{ title: 'b-en', locale: 'en', stem: 'b' }]) + .mockResolvedValueOnce([{ title: 'a-fr', locale: 'fr', stem: 'a' }]) + .mockResolvedValueOnce([{ title: 'b-en', locale: 'en', stem: 'b' }]) + + const [items, count] = await Promise.all([qb.all(), qb.count('title' as never)]) + + expect(items).toHaveLength(2) + expect(count).toBe(2) + + // Every issued query must have selected exactly the user's fields (+ injected stem), + // not the count's extra 'title' bleeding into .all()'s SELECT list. + const queries = mockFetch.mock.calls.map(c => c[1] as string) + for (const q of queries) { + // Should not have `title` duplicated in the SELECT list + expect(q.match(/"title"/g)?.length ?? 0).toBeLessThanOrEqual(1) + } + }) + + it('count() with selected fields and locale fallback bypasses pagination', async () => { + // skip(10).limit(5).count() should return the full count, not just the visible page. + mockFetch + .mockResolvedValueOnce([ + { title: 'a-fr', stem: 'a' }, + { title: 'b-fr', stem: 'b' }, + { title: 'c-fr', stem: 'c' }, + ]) + .mockResolvedValueOnce([ + { title: 'd-en', stem: 'd' }, + { title: 'e-en', stem: 'e' }, + ]) + + const count = await collectionQueryBuilder(mockCollection, mockFetch) + .locale('fr', { fallback: 'en' }) + .skip(10) // would slice everything away if not bypassed + .limit(2) + .count() + + expect(count).toBe(5) + }) + + it('skips auto-locale when detected locale is a BCP-47 tag not in collection.locales', async () => { + // `@nuxtjs/i18n` may return `en-US`. A collection declaring only `en` skips + // auto-locale rather than producing rows from every locale. The dev-only + // warning is gated by `import.meta.dev` (false in this test environment), so + // only the no-filter behaviour is asserted here. + await collectionQueryBuilder(mockCollection, mockFetch, 'en-US').all() + const sql = mockFetch.mock.lastCall![1] as string + expect(sql).not.toContain('"locale" =') + }) + + it('does not auto-apply a locale on a collection without i18n config', async () => { + // The `plain` collection has no `i18n` in the manifest, so a detected locale + // must not add any filter even though the locale looks valid. + await collectionQueryBuilder('plain' as never, mockFetch, 'fr').all() + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + 'plain', + 'SELECT * FROM _plain ORDER BY stem ASC', + ) + }) + + it('suppresses auto-locale when a locale filter is nested inside a group', async () => { + // A locale filter reachable only through a nested `andWhere` group must still + // disable auto-locale, otherwise the two combine into a contradictory + // `locale = 'fr' AND locale = 'en'` clause. + const qb = collectionQueryBuilder(mockCollection, mockFetch, 'fr') + await qb + .andWhere(g => g.andWhere(g2 => g2.where('locale', '=', 'en'))) + .all() + + expect(mockFetch).toHaveBeenCalledTimes(1) + const sql = mockFetch.mock.lastCall![1] as string + expect(sql).not.toContain('"locale" = \'fr\'') + expect(sql).toContain('"locale" = \'en\'') + }) + + it('keeps auto-locale active when a value merely contains the text "locale"', async () => { + // A string value that contains the token `"locale"` must not be mistaken for + // a locale-column filter, so auto-locale still applies. + const qb = collectionQueryBuilder(mockCollection, mockFetch, 'en') + await qb.where('title', '=', 'say "locale"').all() + + const sql = mockFetch.mock.lastCall![1] as string + expect(sql).toContain('"locale" = \'en\'') + }) + }) + + describe('.stem() source-prefix resolution', () => { + it('prepends the collection stem prefix when absent', async () => { + await collectionQueryBuilder('navigation' as never, mockFetch).stem('navbar').all() + expect(mockFetch).toHaveBeenLastCalledWith( + 'navigation', + 'SELECT * FROM _navigation WHERE ("stem" = \'navigation/navbar\') ORDER BY stem ASC', + ) + }) + + it('does not double the prefix when the stem already includes it', async () => { + await collectionQueryBuilder('navigation' as never, mockFetch).stem('navigation/navbar').all() + expect(mockFetch).toHaveBeenLastCalledWith( + 'navigation', + 'SELECT * FROM _navigation WHERE ("stem" = \'navigation/navbar\') ORDER BY stem ASC', + ) + }) + + it('treats the prefix as present only on a segment boundary', async () => { + // `navigation2` must not be mistaken for the `navigation` prefix. + await collectionQueryBuilder('navigation' as never, mockFetch).stem('navigation2/foo').all() + expect(mockFetch).toHaveBeenLastCalledWith( + 'navigation', + 'SELECT * FROM _navigation WHERE ("stem" = \'navigation/navigation2/foo\') ORDER BY stem ASC', + ) + }) + + it('matches the bare prefix exactly without doubling it', async () => { + await collectionQueryBuilder('navigation' as never, mockFetch).stem('navigation').all() + expect(mockFetch).toHaveBeenLastCalledWith( + 'navigation', + 'SELECT * FROM _navigation WHERE ("stem" = \'navigation\') ORDER BY stem ASC', + ) + }) + }) + + describe('locale fallback ordering and counting', () => { + it('interleaves by stem when navigation injects the default order', async () => { + // The navigation/surround helpers inject `order('stem', 'ASC')`. The merge + // must still interleave the two result sets by stem rather than concatenate. + mockFetch + .mockResolvedValueOnce([{ stem: '1.intro', locale: 'fr' }, { stem: '3.advanced', locale: 'fr' }]) + .mockResolvedValueOnce([ + { stem: '1.intro', locale: 'en' }, + { stem: '2.guide', locale: 'en' }, + { stem: '3.advanced', locale: 'en' }, + ]) + + const results = await collectionQueryBuilder(mockCollection, mockFetch) + .order('stem', 'ASC') + .locale('fr', { fallback: 'en' }) + .all() + + expect(results.map((r: { stem: string }) => r.stem)).toEqual(['1.intro', '2.guide', '3.advanced']) + // The translated page wins over its fallback at each shared stem. + expect(results[0]).toMatchObject({ stem: '1.intro', locale: 'fr' }) + expect(results[1]).toMatchObject({ stem: '2.guide', locale: 'en' }) + }) + + it('re-sorts the merged result rather than trusting sub-query order', async () => { + // Sub-queries may arrive in a non-binary order (for example a linguistic + // collation on PostgreSQL). The merge re-sorts by stem so the page is + // deterministic regardless of the backend's collation. + mockFetch + .mockResolvedValueOnce([{ stem: 'b', locale: 'fr' }, { stem: 'a', locale: 'fr' }]) + .mockResolvedValueOnce([{ stem: 'c', locale: 'en' }]) + + const results = await collectionQueryBuilder(mockCollection, mockFetch) + .locale('fr', { fallback: 'en' }) + .all() + + expect(results.map((r: { stem: string }) => r.stem)).toEqual(['a', 'b', 'c']) + }) + + it('counts distinct object column values by structural equality', async () => { + // SQL `COUNT(DISTINCT ...)` compares serialized values. The fallback path + // mirrors that, so two structurally-equal JSON values count once. + mockFetch + .mockResolvedValueOnce([{ tags: { a: 1 }, stem: 'x' }]) + .mockResolvedValueOnce([{ tags: { a: 1 }, stem: 'y' }, { tags: { a: 2 }, stem: 'z' }]) + + const count = await collectionQueryBuilder(mockCollection, mockFetch) + .locale('fr', { fallback: 'en' }) + .count('tags' as never, true) + + // { a: 1 } (x and y share the stem-distinct rows) and { a: 2 }: 2 distinct values. + expect(count).toBe(2) + }) + }) + + describe('mergeSortedArrays / locale-fallback ordering', () => { + it('preserves DB sort order for mixed-case stems (binary, not localeCompare)', async () => { + // SQLite BINARY puts 'A' (65) before 'a' (97). localeCompare would put 'a' first. + // The merge must agree with the DB so the interleaving stays in true ASC order. + mockFetch + .mockResolvedValueOnce([ + { stem: 'B', locale: 'fr' }, + ]) + .mockResolvedValueOnce([ + { stem: 'A', locale: 'en' }, + { stem: 'a', locale: 'en' }, + ]) + + const results = await collectionQueryBuilder(mockCollection, mockFetch) + .locale('fr', { fallback: 'en' }) + .all() + + expect(results.map((r: { stem: string }) => r.stem)).toEqual(['A', 'B', 'a']) + }) + }) }) diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts new file mode 100644 index 000000000..7f7412393 --- /dev/null +++ b/test/unit/i18n.test.ts @@ -0,0 +1,721 @@ +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('detects the locale from the stem, not a custom path prefix', () => { + // A custom front-matter `path` can put a locale segment on the path while + // the file lives in a non-locale directory. Detection keys off the stem, so + // the locale stays the default and the custom path is left untouched. + const result = detectLocaleFromPath('/fr/docs/guide', 'docs/guide', i18nConfig) + expect(result).toMatchObject({ locale: 'en', path: '/fr/docs/guide', stem: 'docs/guide' }) + }) + + it('strips the stem locale segment without touching a non-matching custom path', () => { + // File at `fr/bar` with a custom `path: /custom`. The locale comes from the + // stem (`fr`), the stem is stripped, and the custom path is preserved. + const result = detectLocaleFromPath('/custom', 'fr/bar', i18nConfig) + expect(result).toMatchObject({ locale: 'fr', path: '/custom', stem: 'bar' }) + }) + + 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('replaces scalar arrays wholesale when override is shorter than default', () => { + // A shorter scalar override must NOT pad-fill from the default tail. + // When authors intentionally provide a shorter list, they want exactly + // that. + const content: ParsedContentFile = { + id: 'data:tags.yml', + tags: ['javascript', 'vue', 'nuxt', 'content'], + stem: 'tags', + extension: 'yml', + meta: { + i18n: { fr: { tags: ['javascript', 'vue'] } }, + }, + } + + const items = expandI18nData(content, i18nConfig) + const frItem = items.find(i => i.locale === 'fr') + expect(frItem?.tags).toEqual(['javascript', 'vue']) + }) + + it('replaces with empty scalar override (clears the list)', () => { + const content: ParsedContentFile = { + id: 'data:tags.yml', + tags: ['a', 'b', 'c'], + stem: 'tags', + extension: 'yml', + meta: { + i18n: { fr: { tags: [] } }, + }, + } + const items = expandI18nData(content, i18nConfig) + expect(items.find(i => i.locale === 'fr')?.tags).toEqual([]) + }) + + 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' }) + }) + + it('merges arrays whose items are themselves arrays by index', () => { + // Regression: an array of arrays used to collapse its first inner array to + // an empty object when passed through `defu`. + const content: ParsedContentFile = { + id: 'data:matrix.yml', + rows: [['a', 'b'], ['c', 'd']], + stem: 'matrix', + extension: 'yml', + meta: { + i18n: { fr: { rows: [['x', 'y']] } }, + }, + } + + const items = expandI18nData(content, i18nConfig) + const frItem = items.find(i => i.locale === 'fr') + + // First inner array is overridden wholesale (scalar), second preserved. + expect(frItem?.rows).toEqual([['x', 'y'], ['c', 'd']]) + }) + }) + + describe('override safety', () => { + it('ignores identity-field overrides so row identity is preserved', () => { + const content: ParsedContentFile = { + id: 'data:team.yml', + name: 'Jane', + stem: 'data/team', + extension: 'yml', + meta: { + i18n: { + fr: { name: 'Jeanne', stem: 'data/equipe', id: 'evil', locale: 'de', extension: 'json' }, + }, + }, + } + + const items = expandI18nData(content, i18nConfig) + const frItem = items.find(i => i.id === 'data:team.yml#fr') + + expect(frItem).toBeDefined() + expect(frItem?.name).toBe('Jeanne') + // Identity fields keep the source row's values. + expect(frItem?.stem).toBe('data/team') + expect(frItem?.locale).toBe('fr') + expect(frItem?.extension).toBe('yml') + }) + + it('skips locale keys that are not in the collection locales', () => { + const content: ParsedContentFile = { + id: 'data:team.yml', + name: 'Jane', + stem: 'data/team', + extension: 'yml', + meta: { + i18n: { + fr: { name: 'Jeanne' }, + es: { name: 'Juana' }, + }, + }, + } + + const items = expandI18nData(content, i18nConfig) + const locales = items.map(i => i.locale) + + expect(locales).toContain('fr') + expect(locales).not.toContain('es') + expect(items).toHaveLength(2) + }) + }) + }) + + 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) + }) + + it('source hash captures only translated nested leaves, not sibling fields', () => { + // `de` translates only `info.country`. Changing the untranslated sibling + // `info.age` must NOT change the hash, because the hash is scoped to the + // translated leaf. + const base = { + id: 'team:jane.yml', + name: 'Jane', + stem: 'jane', + extension: 'yml', + } + const content1: ParsedContentFile = { + ...base, + info: { age: 25, country: 'Switzerland' }, + meta: { i18n: { de: { info: { country: 'Schweiz' } } } }, + } + const content2: ParsedContentFile = { + ...base, + info: { age: 30, country: 'Switzerland' }, + meta: { i18n: { de: { info: { country: 'Schweiz' } } } }, + } + + const de1 = expandI18nData(content1, i18nConfig).find(i => i.locale === 'de') + const de2 = expandI18nData(content2, i18nConfig).find(i => i.locale === 'de') + + expect(de1?.meta._i18nSourceHash).toBe(de2?.meta._i18nSourceHash) + }) + + it('source hash is per-locale: a field translated only in another locale does not affect this locale\'s hash', () => { + // `fr` translates only `title`, while `de` translates only `description`. + // Changing `description` between `content1` and `content2` must NOT + // change `fr`'s hash (because `fr` does not translate `description`) but + // MUST change `de`'s hash. + const content1: ParsedContentFile = { + id: 'blog:post.yml', + title: 'My Post', + description: 'Original description', + stem: 'post', + extension: 'yml', + meta: { + i18n: { + fr: { title: 'Mon Article' }, + de: { description: 'Beschreibung' }, + }, + }, + } + const content2: ParsedContentFile = { + ...content1, + description: 'Updated description', + meta: { + i18n: { + fr: { title: 'Mon Article' }, + de: { description: 'Beschreibung' }, + }, + }, + } + + const items1 = expandI18nData(content1, i18nConfig) + const items2 = expandI18nData(content2, i18nConfig) + + const fr1 = items1.find(i => i.locale === 'fr')! + const fr2 = items2.find(i => i.locale === 'fr')! + const de1 = items1.find(i => i.locale === 'de')! + const de2 = items2.find(i => i.locale === 'de')! + + expect(fr1.meta._i18nSourceHash).toBe(fr2.meta._i18nSourceHash) + expect(de1.meta._i18nSourceHash).not.toBe(de2.meta._i18nSourceHash) + }) + }) + + describe('page collection body replacement', () => { + it('replaces wholesale when override sets body to null', () => { + // Edge case. An override that explicitly clears the body. Without an + // `'in' overrides` check, this would deep-merge the default AST back in. + const defaultBody = { type: 'root', children: [{ type: 'text', value: 'Hi' }] } + const content: ParsedContentFile = { + id: 'pages:index.md', + title: 'Home', + body: defaultBody, + stem: 'index', + extension: 'md', + meta: { i18n: { fr: { title: 'Accueil', body: null } } }, + } + + const items = expandI18nData(content, i18nConfig, 'page') + const frItem = items.find(i => i.locale === 'fr') + + expect(frItem?.body).toBeNull() + }) + }) +}) diff --git a/test/unit/i18nDetection.test.ts b/test/unit/i18nDetection.test.ts new file mode 100644 index 000000000..241db3b7c --- /dev/null +++ b/test/unit/i18nDetection.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest' +import { + buildUseQueryCollectionKey, + detectClientLocale, + detectServerLocale, +} from '../../src/runtime/internal/i18n-detection' +import { ref } from 'vue' + +describe('detectServerLocale', () => { + it('returns undefined when no event is provided', () => { + expect(detectServerLocale(undefined)).toBeUndefined() + }) + + it('returns undefined when event.context.nuxtI18n is absent', () => { + // Empty context. Either `@nuxtjs/i18n` is not installed, or its middleware + // did not run for this request. + const event = { context: {} } as never + expect(detectServerLocale(event)).toBeUndefined() + }) + + it('prefers `detectLocale` (per-request resolved locale)', () => { + const event = { context: { nuxtI18n: { detectLocale: 'fr', vueI18nOptions: { locale: 'en' } } } } as never + expect(detectServerLocale(event)).toBe('fr') + }) + + it('falls back to `vueI18nOptions.locale` when detection did not run', () => { + // Mirrors the case where a prerender or a route outside the i18n middleware + // lacks `detectLocale`. + const event = { context: { nuxtI18n: { vueI18nOptions: { locale: 'en' } } } } as never + expect(detectServerLocale(event)).toBe('en') + }) +}) + +describe('detectClientLocale', () => { + it('returns undefined when nuxtApp is null', () => { + expect(detectClientLocale(null)).toBeUndefined() + }) + + it('returns undefined when $i18n is absent', () => { + expect(detectClientLocale({})).toBeUndefined() + }) + + it('reads $i18n.locale (a Vue ref)', () => { + const locale = ref('de') + expect(detectClientLocale({ $i18n: { locale } })).toBe('de') + }) +}) + +describe('buildUseQueryCollectionKey', () => { + const baseParts = { + collection: 'docs', + conditions: [] as string[], + orderBy: [] as string[], + offset: 0, + limit: 0, + selectedFields: [] as string[], + explicitLocale: false, + method: 'all', + } + + it('produces a stable shape for the trivial case', () => { + const key = buildUseQueryCollectionKey(baseParts) + expect(key).toBe('content:["docs","all"]') + }) + + it('includes auto-detected locale when no explicit .locale() was set', () => { + const key = buildUseQueryCollectionKey({ ...baseParts, currentLocale: 'fr' }) + expect(key).toContain('"l:fr"') + }) + + it('OMITS auto-detected locale when explicitLocale is true', () => { + // Regression. A manual `.where('locale', ...)` or `.locale()` call must + // suppress the auto-locale fragment so the wrapper key matches the actual + // SQL behaviour. + const key = buildUseQueryCollectionKey({ ...baseParts, currentLocale: 'fr', explicitLocale: true }) + expect(key).not.toContain('l:fr') + }) + + it('emits a fallback fragment when localeFallback is set, ignoring currentLocale', () => { + const key = buildUseQueryCollectionKey({ + ...baseParts, + currentLocale: 'de', + localeFallback: { locale: 'fr', fallback: 'en' }, + }) + expect(key).toContain('"l:fr:fb:en"') + expect(key).not.toContain('l:de') + }) + + it('preserves the relative order of fragments (cache key is order-sensitive)', () => { + const key = buildUseQueryCollectionKey({ + ...baseParts, + conditions: ['path=/foo'], + orderBy: ['date:DESC'], + offset: 10, + limit: 5, + selectedFields: ['title', 'date'], + currentLocale: 'fr', + }) + // Expected fragment order: `collection`, `conditions`, `locale`, `orderBy`, + // `offset`, `limit`, `selectedFields`, `method`. + const parsed = JSON.parse(key.slice('content:'.length)) as string[] + expect(parsed).toEqual([ + 'docs', + 'path=/foo', + 'l:fr', + 'o:date:DESC', + 's:10', + 'n:5', + 'f:title,date', + 'all', + ]) + }) + + it('returns identical keys for inputs that produce identical SQL', () => { + // Demonstrates the contract. Equal inputs (including equivalent + // normalization upstream) produce equal keys. + const a = buildUseQueryCollectionKey({ ...baseParts, conditions: ['path=/foo'] }) + const b = buildUseQueryCollectionKey({ ...baseParts, conditions: ['path=/foo'] }) + expect(a).toBe(b) + }) +})