From 5e292e34780a2412e3362098758790375d2b4cc0 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 14:57:51 +0100 Subject: [PATCH 01/67] feat: add i18n support for collections --- package.json | 3 +- src/module.ts | 36 ++- src/runtime/internal/query.ts | 58 +++- src/types/collection.ts | 31 ++ src/types/query.ts | 8 + src/utils/collection.ts | 16 +- src/utils/content/index.ts | 25 ++ src/utils/schema/definitions.ts | 17 ++ test/fixtures/i18n/content.config.ts | 33 ++ test/fixtures/i18n/content/data/team.yml | 10 + test/fixtures/i18n/content/en/blog/hello.md | 9 + .../i18n/content/en/blog/only-english.md | 9 + test/fixtures/i18n/content/fr/blog/hello.md | 9 + test/fixtures/i18n/nuxt.config.ts | 9 + test/fixtures/i18n/package.json | 7 + .../i18n/server/api/content/blog-first.get.ts | 17 ++ .../i18n/server/api/content/blog.get.ts | 13 + .../i18n/server/api/content/team.get.ts | 13 + test/i18n.test.ts | 176 +++++++++++ test/unit/collectionQueryBuilder.test.ts | 52 ++++ test/unit/i18n.test.ts | 286 ++++++++++++++++++ 21 files changed, 829 insertions(+), 8 deletions(-) create mode 100644 test/fixtures/i18n/content.config.ts create mode 100644 test/fixtures/i18n/content/data/team.yml create mode 100644 test/fixtures/i18n/content/en/blog/hello.md create mode 100644 test/fixtures/i18n/content/en/blog/only-english.md create mode 100644 test/fixtures/i18n/content/fr/blog/hello.md create mode 100644 test/fixtures/i18n/nuxt.config.ts create mode 100644 test/fixtures/i18n/package.json create mode 100644 test/fixtures/i18n/server/api/content/blog-first.get.ts create mode 100644 test/fixtures/i18n/server/api/content/blog.get.ts create mode 100644 test/fixtures/i18n/server/api/content/team.get.ts create mode 100644 test/i18n.test.ts create mode 100644 test/unit/i18n.test.ts diff --git a/package.json b/package.json index e2f9f4bb8..d53aa285f 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "test:bun": "bun test ./test/bun.test.ts", "test:watch": "vitest watch", "test:types": "vue-tsc --noEmit", - "verify": "npm run dev:prepare && npm run prepack && npm run lint && npm run test && npm run typecheck" + "verify": "npm run dev:prepare && npm run prepack && npm run lint && npm run test && npm run typecheck", + "prepare": "skilld prepare || true" }, "dependencies": { "@nuxt/kit": "^4.4.2", diff --git a/src/module.ts b/src/module.ts index 99603aae6..3b448e92c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -375,8 +375,40 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio usedComponents.push(...parsedContent.__metadata.components) } - const { queries, hash } = generateCollectionInsert(collection, parsedContent) - list.push([key, queries, hash]) + // i18n: expand inline translations to per-locale rows + if (collection.i18n && parsedContent?.meta?.i18n) { + const i18nData = parsedContent.meta.i18n as Record> + const { i18n: _removed, ...cleanMeta } = parsedContent.meta + parsedContent.meta = cleanMeta + + // Default locale item + if (!parsedContent.locale) { + parsedContent.locale = collection.i18n.defaultLocale + } + + const defaultItem = parsedContent + const { queries: defaultQueries, hash: defaultHash } = generateCollectionInsert(collection, defaultItem) + list.push([`${key}#${defaultItem.locale}`, defaultQueries, defaultHash]) + + // Create one item per non-default locale + for (const [locale, overrides] of Object.entries(i18nData)) { + if (locale === defaultItem.locale) continue + + const localeItem: ParsedContentFile = { + ...defu(overrides, defaultItem) as ParsedContentFile, + id: `${parsedContent.id}#${locale}`, + locale, + meta: { ...cleanMeta }, + } + + const { queries: localeQueries, hash: localeHash } = generateCollectionInsert(collection, localeItem) + list.push([`${key}#${locale}`, localeQueries, localeHash]) + } + } + else { + const { queries, hash } = generateCollectionInsert(collection, parsedContent) + list.push([key, 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/internal/query.ts b/src/runtime/internal/query.ts index 6b617d225..eb873e383 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -81,6 +81,8 @@ export const collectionQueryBuilder = (collection: field: '' as keyof Collections[T] | '*', distinct: false, }, + // Locale fallback (handled via two queries + JS merge) + localeFallback: undefined as { locale: string, fallback: string } | undefined, } const query: CollectionQueryBuilder = { @@ -99,6 +101,15 @@ export const collectionQueryBuilder = (collection: path(path: string) { return query.where('path', '=', withoutTrailingSlash(path)) }, + locale(locale: string, opts?: { fallback?: string }) { + 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,9 +133,15 @@ export const collectionQueryBuilder = (collection: return query }, async all(): Promise { + if (params.localeFallback) { + return fetchWithLocaleFallback() + } return fetch(collection, buildQuery()).then(res => (res || []) as Collections[T][]) }, async first(): Promise { + if (params.localeFallback) { + return fetchWithLocaleFallback({ limit: 1 }).then(res => res[0] || null) + } return fetch(collection, buildQuery({ limit: 1 })).then(res => res[0] || null) }, async count(field: keyof Collections[T] | '*' = '*', distinct: boolean = false) { @@ -134,7 +151,37 @@ export const collectionQueryBuilder = (collection: }, } - function buildQuery(opts: { count?: { field: string, distinct: boolean }, limit?: number } = {}) { + async function fetchWithLocaleFallback(opts: { limit?: number } = {}): Promise { + const { locale, fallback } = params.localeFallback! + + // Query for the requested locale + const localeCondition = `("locale" = ${singleQuote(locale)})` + const localeQuery = buildQuery({ extraCondition: localeCondition }) + const localeResults = await fetch(collection, localeQuery).then(res => res || []) + + // Query for the fallback locale + const fallbackCondition = `("locale" = ${singleQuote(fallback)})` + const fallbackQuery = buildQuery({ extraCondition: fallbackCondition }) + const fallbackResults = await fetch(collection, fallbackQuery).then(res => res || []) + + // Merge: prefer locale results, fill gaps from fallback by stem + const stemSet = new Set(localeResults.map((r: Collections[T]) => (r as unknown as { stem: string }).stem)) + const merged = [...localeResults] + for (const item of fallbackResults) { + if (!stemSet.has((item as unknown as { stem: string }).stem)) { + merged.push(item) + } + } + + // Apply limit if specified + if (opts.limit && opts.limit > 0) { + return merged.slice(0, opts.limit) as Collections[T][] + } + + return merged as Collections[T][] + } + + function buildQuery(opts: { count?: { field: string, distinct: boolean }, limit?: number, extraCondition?: string } = {}) { let query = 'SELECT ' if (opts?.count) { query += `COUNT(${opts.count.distinct ? 'DISTINCT ' : ''}${opts.count.field}) as count` @@ -145,8 +192,13 @@ export const collectionQueryBuilder = (collection: } query += ` FROM ${tables[String(collection)]}` - if (params.conditions.length > 0) { - query += ` WHERE ${params.conditions.join(' AND ')}` + const conditions = [...params.conditions] + if (opts.extraCondition) { + conditions.push(opts.extraCondition) + } + + if (conditions.length > 0) { + query += ` WHERE ${conditions.join(' AND ')}` } if (params.orderBy.length > 0) { diff --git a/src/types/collection.ts b/src/types/collection.ts index 9e71e893c..2d79173e4 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,11 @@ export interface PageCollection { source?: string | CollectionSource | CollectionSource[] | ResolvedCustomCollectionSource schema?: ContentStandardSchemaV1 indexes?: CollectionIndex[] + /** + * Enable i18n support for this collection. + * Adds a `locale` field and enables path-based locale detection and inline i18n expansion. + */ + i18n?: CollectionI18nConfig } export interface DataCollection { @@ -76,6 +96,11 @@ export interface DataCollection { source?: string | CollectionSource | CollectionSource[] | ResolvedCustomCollectionSource schema: ContentStandardSchemaV1 indexes?: CollectionIndex[] + /** + * Enable i18n support for this collection. + * Adds a `locale` field and enables inline i18n expansion. + */ + i18n?: CollectionI18nConfig } export type Collection = PageCollection | DataCollection @@ -87,6 +112,7 @@ export interface DefinedCollection { extendedSchema: Draft07 fields: Record indexes?: CollectionIndex[] + i18n?: CollectionI18nConfig } export interface ResolvedCollection extends DefinedCollection { @@ -115,6 +141,11 @@ export interface CollectionItemBase { stem: string extension: string meta: Record + /** + * Locale code for this content item. + * Only present when the collection has i18n enabled. + */ + locale?: string } export interface PageCollectionItemBase extends CollectionItemBase { diff --git a/src/types/query.ts b/src/types/query.ts index 5b013feaa..d6bb17cce 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -4,6 +4,14 @@ export type QueryGroupFunction = (group: CollectionQueryGroup) => Collecti export interface CollectionQueryBuilder { path(path: 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..7b00d19ec 100644 --- a/src/utils/collection.ts +++ b/src/utils/collection.ts @@ -3,7 +3,7 @@ import type { Collection, ResolvedCollection, CollectionSource, DefinedCollectio import { getOrderedSchemaKeys, describeProperty, getCollectionFieldsTypes } from '../runtime/internal/schema' import type { Draft07, ParsedContentFile } from '../types' import { defineLocalSource, defineGitSource } from './source' -import { emptyStandardSchema, mergeStandardSchema, metaStandardSchema, pageStandardSchema, infoStandardSchema, detectSchemaVendor, replaceComponentSchemas } from './schema' +import { emptyStandardSchema, mergeStandardSchema, metaStandardSchema, pageStandardSchema, localeStandardSchema, infoStandardSchema, detectSchemaVendor, replaceComponentSchemas } from './schema' import { logger } from './dev' import nuxtContentContext from './context' import { formatDate, formatDateTime } from './content/transformers/utils' @@ -27,15 +27,27 @@ export function defineCollection(collection: Collection): DefinedCollectio extendedSchema = mergeStandardSchema(pageStandardSchema, extendedSchema) } + // Add locale field when i18n is configured + if (collection.i18n) { + extendedSchema = mergeStandardSchema(localeStandardSchema, extendedSchema) + } + extendedSchema = mergeStandardSchema(metaStandardSchema, extendedSchema) + // Auto-add composite index on (locale, stem) for i18n collections + const indexes = collection.indexes ? [...collection.indexes] : [] + if (collection.i18n) { + indexes.push({ columns: ['locale', 'stem'] }) + } + return { type: collection.type, source: resolveSource(collection.source), schema: standardSchema, extendedSchema: extendedSchema, fields: getCollectionFieldsTypes(extendedSchema), - indexes: collection.indexes, + indexes, + i18n: collection.i18n, } } diff --git a/src/utils/content/index.ts b/src/utils/content/index.ts index 696e2c093..c8b82f3b6 100644 --- a/src/utils/content/index.ts +++ b/src/utils/content/index.ts @@ -216,6 +216,31 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) } } + // i18n: detect locale from path prefix when collection has i18n configured + if (collection.i18n && collectionKeys.includes('locale')) { + const currentPath = result.path || pathMetaFields.path || '' + const pathParts = currentPath.split('/').filter(Boolean) + const firstPart = pathParts[0] + + if (firstPart && collection.i18n.locales.includes(firstPart)) { + result.locale = firstPart + // Strip locale prefix from path and stem + const pathWithoutLocale = '/' + pathParts.slice(1).join('/') + if (collectionKeys.includes('path')) { + result.path = pathWithoutLocale === '/' ? '/' : pathWithoutLocale + } + const currentStem = result.stem || pathMetaFields.stem || '' + const stemParts = currentStem.split('/') + if (stemParts[0] === firstPart) { + result.stem = stemParts.slice(1).join('/') + } + } + else { + // No locale prefix - assign default locale + result.locale = collection.i18n.defaultLocale + } + } + 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/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/test/fixtures/i18n/content.config.ts b/test/fixtures/i18n/content.config.ts new file mode 100644 index 000000000..676805d02 --- /dev/null +++ b/test/fixtures/i18n/content.config.ts @@ -0,0 +1,33 @@ +import { defineCollection, defineContentConfig } from '@nuxt/content' +import { z } from 'zod' + +export default defineContentConfig({ + collections: { + // Path-based i18n collection: content organized by locale directories + blog: defineCollection({ + type: 'page', + source: '*/blog/**', + schema: z.object({ + date: z.string().optional(), + }), + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + }, + }), + // Inline i18n collection: translations embedded in the content file + team: defineCollection({ + type: 'data', + source: 'data/team.yml', + schema: z.object({ + name: z.string(), + role: z.string(), + country: z.string().optional(), + }), + i18n: { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + }, + }), + }, +}) diff --git a/test/fixtures/i18n/content/data/team.yml b/test/fixtures/i18n/content/data/team.yml new file mode 100644 index 000000000..d1e615842 --- /dev/null +++ b/test/fixtures/i18n/content/data/team.yml @@ -0,0 +1,10 @@ +name: Jane Doe +role: Developer +country: Switzerland +i18n: + fr: + role: Développeuse + country: Suisse + de: + role: Entwicklerin + country: Schweiz diff --git a/test/fixtures/i18n/content/en/blog/hello.md b/test/fixtures/i18n/content/en/blog/hello.md new file mode 100644 index 000000000..1e71ed450 --- /dev/null +++ b/test/fixtures/i18n/content/en/blog/hello.md @@ -0,0 +1,9 @@ +--- +title: Hello World +description: An introductory post +date: '2025-01-01' +--- + +# Hello World + +Welcome to the blog. diff --git a/test/fixtures/i18n/content/en/blog/only-english.md b/test/fixtures/i18n/content/en/blog/only-english.md new file mode 100644 index 000000000..53a74c0e1 --- /dev/null +++ b/test/fixtures/i18n/content/en/blog/only-english.md @@ -0,0 +1,9 @@ +--- +title: English Only Post +description: This post only exists in English +date: '2025-02-01' +--- + +# English Only + +This post has no French translation. diff --git a/test/fixtures/i18n/content/fr/blog/hello.md b/test/fixtures/i18n/content/fr/blog/hello.md new file mode 100644 index 000000000..589edbc96 --- /dev/null +++ b/test/fixtures/i18n/content/fr/blog/hello.md @@ -0,0 +1,9 @@ +--- +title: Bonjour le Monde +description: Un article d'introduction +date: '2025-01-01' +--- + +# Bonjour le Monde + +Bienvenue sur le blog. diff --git a/test/fixtures/i18n/nuxt.config.ts b/test/fixtures/i18n/nuxt.config.ts new file mode 100644 index 000000000..04d37b47f --- /dev/null +++ b/test/fixtures/i18n/nuxt.config.ts @@ -0,0 +1,9 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: [ + '@nuxt/content', + ], + devtools: { enabled: true }, + compatibilityDate: '2025-09-03', +}) diff --git a/test/fixtures/i18n/package.json b/test/fixtures/i18n/package.json new file mode 100644 index 000000000..57a196b38 --- /dev/null +++ b/test/fixtures/i18n/package.json @@ -0,0 +1,7 @@ +{ + "name": "nuxt-content-test-i18n", + "private": true, + "scripts": { + "dev": "nuxi dev" + } +} diff --git a/test/fixtures/i18n/server/api/content/blog-first.get.ts b/test/fixtures/i18n/server/api/content/blog-first.get.ts new file mode 100644 index 000000000..c0605a245 --- /dev/null +++ b/test/fixtures/i18n/server/api/content/blog-first.get.ts @@ -0,0 +1,17 @@ +import { eventHandler, getQuery } from 'h3' + +export default eventHandler(async (event) => { + const { path, locale, fallback } = getQuery(event) as { path?: string, locale?: string, fallback?: string } + + let query = queryCollection(event, 'blog') + + if (locale) { + query = query.locale(locale, fallback ? { fallback } : undefined) + } + + if (path) { + query = query.path(path) + } + + return await query.first() +}) diff --git a/test/fixtures/i18n/server/api/content/blog.get.ts b/test/fixtures/i18n/server/api/content/blog.get.ts new file mode 100644 index 000000000..084e695e1 --- /dev/null +++ b/test/fixtures/i18n/server/api/content/blog.get.ts @@ -0,0 +1,13 @@ +import { eventHandler, getQuery } from 'h3' + +export default eventHandler(async (event) => { + const { locale, fallback } = getQuery(event) as { locale?: string, fallback?: string } + + let query = queryCollection(event, 'blog') + + if (locale) { + query = query.locale(locale, fallback ? { fallback } : undefined) + } + + return await query.all() +}) diff --git a/test/fixtures/i18n/server/api/content/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..11516d679 --- /dev/null +++ b/test/i18n.test.ts @@ -0,0 +1,176 @@ +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, + }) + + describe('database', () => { + let db: LocalDevelopmentDatabase + afterAll(async () => { + if (db) { + await db.close() + } + }) + + test('local database is created', async () => { + const stat = await fs.stat(resolver.resolve('./fixtures/i18n/.data/content/contents.sqlite')) + expect(stat?.isFile()).toBe(true) + }) + + test('blog table exists with locale column', async () => { + db = await getLocalDatabase({ type: 'sqlite', filename: resolver.resolve('./fixtures/i18n/.data/content/contents.sqlite') }, { nativeSqlite: true }) + + const tableInfo = await db.database?.prepare(`PRAGMA table_info(${getTableName('blog')});`).all() as { name: string }[] + const columnNames = tableInfo.map(c => c.name) + + expect(columnNames).toContain('locale') + expect(columnNames).toContain('path') + expect(columnNames).toContain('title') + }) + + test('team table exists with locale column', async () => { + const tableInfo = await db.database?.prepare(`PRAGMA table_info(${getTableName('team')});`).all() as { name: string }[] + const columnNames = tableInfo.map(c => c.name) + + expect(columnNames).toContain('locale') + expect(columnNames).toContain('name') + expect(columnNames).toContain('role') + }) + }) + + describe('path-based i18n (blog collection)', () => { + test('query English blog posts', async () => { + const posts = await $fetch[]>('/api/content/blog?locale=en') + + expect(posts.length).toBeGreaterThanOrEqual(2) + const titles = posts.map(p => p.title) + expect(titles).toContain('Hello World') + expect(titles).toContain('English Only Post') + + // All posts should have locale = 'en' + for (const post of posts) { + expect(post.locale).toBe('en') + } + }) + + test('query French blog posts', async () => { + const posts = await $fetch[]>('/api/content/blog?locale=fr') + + expect(posts.length).toBeGreaterThanOrEqual(1) + const titles = posts.map(p => p.title) + expect(titles).toContain('Bonjour le Monde') + + for (const post of posts) { + expect(post.locale).toBe('fr') + } + }) + + test('locale strips path prefix', async () => { + const posts = await $fetch[]>('/api/content/blog?locale=en') + const helloPost = posts.find(p => p.title === 'Hello World') + + // Path should NOT contain the locale prefix + expect(helloPost?.path).toBe('/blog/hello') + expect((helloPost?.path as string)?.startsWith('/en/')).toBe(false) + }) + + test('same content has same path across locales', async () => { + const enPosts = await $fetch[]>('/api/content/blog?locale=en') + const frPosts = await $fetch[]>('/api/content/blog?locale=fr') + + const enHello = enPosts.find(p => p.title === 'Hello World') + const frHello = frPosts.find(p => p.title === 'Bonjour le Monde') + + // Both should have the same path (locale prefix stripped) + expect(enHello?.path).toBe('/blog/hello') + expect(frHello?.path).toBe('/blog/hello') + }) + + test('fallback returns default locale for missing translations', async () => { + const posts = await $fetch[]>('/api/content/blog?locale=fr&fallback=en') + + const titles = posts.map(p => p.title) + // Should include the French translation + expect(titles).toContain('Bonjour le Monde') + // Should also include English-only post as fallback + expect(titles).toContain('English Only Post') + }) + + test('query specific post by path and locale', async () => { + const post = await $fetch>('/api/content/blog-first?path=/blog/hello&locale=fr') + + expect(post).toBeDefined() + expect(post.title).toBe('Bonjour le Monde') + expect(post.locale).toBe('fr') + }) + + test('fallback for single post returns default when translation missing', async () => { + const post = await $fetch>('/api/content/blog-first?path=/blog/only-english&locale=fr&fallback=en') + + expect(post).toBeDefined() + expect(post.title).toBe('English Only Post') + expect(post.locale).toBe('en') + }) + }) + + describe('inline i18n (team collection)', () => { + test('query team member in default locale', async () => { + const members = await $fetch[]>('/api/content/team?locale=en') + + expect(members.length).toBeGreaterThanOrEqual(1) + const jane = members.find(m => m.name === 'Jane Doe') + expect(jane).toBeDefined() + expect(jane?.role).toBe('Developer') + expect(jane?.country).toBe('Switzerland') + expect(jane?.locale).toBe('en') + }) + + test('query team member in French', async () => { + const members = await $fetch[]>('/api/content/team?locale=fr') + + expect(members.length).toBeGreaterThanOrEqual(1) + const jane = members.find(m => m.name === 'Jane Doe') + expect(jane).toBeDefined() + expect(jane?.role).toBe('Développeuse') + expect(jane?.country).toBe('Suisse') + expect(jane?.locale).toBe('fr') + }) + + test('query team member in German', async () => { + const members = await $fetch[]>('/api/content/team?locale=de') + + expect(members.length).toBeGreaterThanOrEqual(1) + const jane = members.find(m => m.name === 'Jane Doe') + expect(jane).toBeDefined() + expect(jane?.role).toBe('Entwicklerin') + expect(jane?.country).toBe('Schweiz') + expect(jane?.locale).toBe('de') + // Name should fall back to default since it's not translated + expect(jane?.name).toBe('Jane Doe') + }) + }) +}) diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index d6630439b..b2190e0d6 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -180,4 +180,56 @@ 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)', async () => { + mockFetch + .mockResolvedValueOnce([{ stem: 'post-a', locale: 'fr' }]) + .mockResolvedValueOnce([{ stem: 'post-a', locale: 'en' }, { stem: 'post-b', 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', + ) + + // Results should merge: fr items preferred, en items fill gaps + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ stem: 'post-a', locale: 'fr' }) + expect(results[1]).toEqual({ stem: 'post-b', locale: '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', + ) + }) }) diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts new file mode 100644 index 000000000..348a26dbf --- /dev/null +++ b/test/unit/i18n.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect } from 'vitest' +import defu from 'defu' +import type { CollectionI18nConfig } from '../../src/types/collection' +import type { ParsedContentFile } from '../../src/types' + +/** + * Expand inline i18n data from a parsed content file into per-locale items. + * This is the same logic used in processCollectionItems (src/module.ts). + */ +function expandI18n( + parsedContent: ParsedContentFile, + i18nConfig: CollectionI18nConfig, +): ParsedContentFile[] { + const i18nData = parsedContent.meta?.i18n as Record> | undefined + if (!i18nData) { + // No inline i18n - just assign default locale if missing + if (!parsedContent.locale) { + parsedContent.locale = i18nConfig.defaultLocale + } + return [parsedContent] + } + + const { i18n: _removed, ...cleanMeta } = parsedContent.meta + parsedContent.meta = cleanMeta + + if (!parsedContent.locale) { + parsedContent.locale = i18nConfig.defaultLocale + } + + const items: ParsedContentFile[] = [parsedContent] + + for (const [locale, overrides] of Object.entries(i18nData)) { + if (locale === parsedContent.locale) continue + + const localeItem: ParsedContentFile = { + ...defu(overrides, parsedContent) as ParsedContentFile, + id: `${parsedContent.id}#${locale}`, + locale, + meta: { ...cleanMeta }, + } + + items.push(localeItem) + } + + return items +} + +/** + * Detect locale from path prefix and strip it. + * This is the same logic used in createParser (src/utils/content/index.ts). + */ +function detectLocaleFromPath( + path: string, + stem: string, + i18nConfig: CollectionI18nConfig, +): { locale: string, path: string, stem: string } { + const pathParts = path.split('/').filter(Boolean) + const firstPart = pathParts[0] + + if (firstPart && i18nConfig.locales.includes(firstPart)) { + const pathWithoutLocale = '/' + pathParts.slice(1).join('/') + const stemParts = stem.split('/') + const newStem = stemParts[0] === firstPart ? stemParts.slice(1).join('/') : stem + + return { + locale: firstPart, + path: pathWithoutLocale === '/' ? '/' : pathWithoutLocale, + stem: newStem, + } + } + + return { + locale: i18nConfig.defaultLocale, + path, + stem, + } +} + +describe('i18n - inline expansion', () => { + const i18nConfig: CollectionI18nConfig = { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + } + + 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 = expandI18n(content, i18nConfig) + + expect(items).toHaveLength(3) + + // Default locale item + expect(items[0].id).toBe('blog:post.yml') + expect(items[0].locale).toBe('en') + expect(items[0].title).toBe('My Post') + expect(items[0].description).toBe('Hello world') + expect(items[0].meta.i18n).toBeUndefined() + + // French item + expect(items[1].id).toBe('blog:post.yml#fr') + expect(items[1].locale).toBe('fr') + expect(items[1].title).toBe('Mon Article') + expect(items[1].description).toBe('Bonjour le monde') + + // German item - description falls back to default + expect(items[2].id).toBe('blog:post.yml#de') + expect(items[2].locale).toBe('de') + expect(items[2].title).toBe('Mein Artikel') + expect(items[2].description).toBe('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 = expandI18n(content, i18nConfig) + + expect(items).toHaveLength(1) + expect(items[0].locale).toBe('en') + expect(items[0].title).toBe('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 = expandI18n(content, i18nConfig) + + expect(items).toHaveLength(2) + expect(items[0].locale).toBe('fr') + expect(items[0].title).toBe('Mon Article') + expect(items[1].locale).toBe('en') + expect(items[1].title).toBe('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 = expandI18n(content, i18nConfig) + + expect(items).toHaveLength(2) + + // Default keeps original + expect(items[0].info).toEqual({ age: 25, country: 'Switzerland' }) + + // German override merges deeply - country overridden, age preserved + expect(items[1].info).toEqual({ age: 25, country: 'Schweiz' }) + }) + + 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' }, // same as default locale + fr: { title: 'Article Francais' }, + }, + }, + } + + const items = expandI18n(content, i18nConfig) + + // Should have 2 items: default (en) + fr + // The 'en' key in i18n is skipped since it matches defaultLocale + expect(items).toHaveLength(2) + expect(items[0].locale).toBe('en') + expect(items[0].title).toBe('My Post') // top-level value, not from i18n.en + expect(items[1].locale).toBe('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 = expandI18n(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', + ]) + + // All IDs are unique + expect(new Set(ids).size).toBe(3) + }) +}) + +describe('i18n - path-based locale detection', () => { + const i18nConfig: CollectionI18nConfig = { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + } + + it('detects locale from first path segment', () => { + const result = detectLocaleFromPath('/fr/blog/post', 'fr/blog/post', i18nConfig) + + expect(result.locale).toBe('fr') + expect(result.path).toBe('/blog/post') + expect(result.stem).toBe('blog/post') + }) + + it('assigns default locale when no locale prefix', () => { + const result = detectLocaleFromPath('/blog/post', 'blog/post', i18nConfig) + + expect(result.locale).toBe('en') + expect(result.path).toBe('/blog/post') + expect(result.stem).toBe('blog/post') + }) + + it('handles root path with locale', () => { + const result = detectLocaleFromPath('/de', 'de', i18nConfig) + + expect(result.locale).toBe('de') + expect(result.path).toBe('/') + expect(result.stem).toBe('') + }) + + it('does not treat non-locale segments as locale', () => { + const result = detectLocaleFromPath('/blog/fr/post', 'blog/fr/post', i18nConfig) + + // 'blog' is not a locale, so default is used + expect(result.locale).toBe('en') + expect(result.path).toBe('/blog/fr/post') + expect(result.stem).toBe('blog/fr/post') + }) + + it('handles nested locale paths', () => { + const result = detectLocaleFromPath('/en/docs/guide/intro', 'en/docs/guide/intro', i18nConfig) + + expect(result.locale).toBe('en') + expect(result.path).toBe('/docs/guide/intro') + expect(result.stem).toBe('docs/guide/intro') + }) +}) From fe7981fdc0e01fd96599beae83b6b05aacdb327a Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 15:11:19 +0100 Subject: [PATCH 02/67] feat: add auto-config and locale utilities --- src/module.ts | 13 ++- src/runtime/client.ts | 6 ++ src/runtime/internal/locales.ts | 31 ++++++ src/runtime/nitro.ts | 5 + src/runtime/server.ts | 6 ++ src/types/collection.ts | 22 ++-- src/utils/collection.ts | 2 + src/utils/config.ts | 49 ++++++++- .../i18n/server/api/content/locales.get.ts | 11 ++ test/i18n.test.ts | 43 ++++++++ test/unit/i18n.test.ts | 102 +++++++++++++++++- 11 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 src/runtime/internal/locales.ts create mode 100644 test/fixtures/i18n/server/api/content/locales.get.ts diff --git a/src/module.ts b/src/module.ts index 3b448e92c..18d86f478 100644 --- a/src/module.ts +++ b/src/module.ts @@ -134,12 +134,14 @@ export default defineNuxtModule({ { name: 'queryCollectionSearchSections', from: resolver.resolve('./runtime/client') }, { name: 'queryCollectionNavigation', from: resolver.resolve('./runtime/client') }, { name: 'queryCollectionItemSurroundings', from: resolver.resolve('./runtime/client') }, + { name: 'queryCollectionLocales', from: resolver.resolve('./runtime/client') }, ]) addServerImports([ { name: 'queryCollection', from: resolver.resolve('./runtime/nitro') }, { name: 'queryCollectionSearchSections', from: resolver.resolve('./runtime/nitro') }, { name: 'queryCollectionNavigation', from: resolver.resolve('./runtime/nitro') }, { name: 'queryCollectionItemSurroundings', from: resolver.resolve('./runtime/nitro') }, + { name: 'queryCollectionLocales', from: resolver.resolve('./runtime/nitro') }, ]) addComponent({ name: 'ContentRenderer', filePath: resolver.resolve('./runtime/components/ContentRenderer.vue') }) @@ -386,6 +388,15 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio parsedContent.locale = collection.i18n.defaultLocale } + // Compute source hash from default locale's translatable fields + // Used by translators / Studio to detect when the source content changes + const translatedFields = new Set(Object.values(i18nData).flatMap(Object.keys)) + const sourceFields: Record = {} + for (const field of translatedFields) { + sourceFields[field] = parsedContent[field] + } + const i18nSourceHash = hash(sourceFields) + const defaultItem = parsedContent const { queries: defaultQueries, hash: defaultHash } = generateCollectionInsert(collection, defaultItem) list.push([`${key}#${defaultItem.locale}`, defaultQueries, defaultHash]) @@ -398,7 +409,7 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio ...defu(overrides, defaultItem) as ParsedContentFile, id: `${parsedContent.id}#${locale}`, locale, - meta: { ...cleanMeta }, + meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, } const { queries: localeQueries, hash: localeHash } = generateCollectionInsert(collection, localeItem) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 6f9708005..2fa8f61f9 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -3,6 +3,7 @@ import { collectionQueryBuilder } from './internal/query' import { generateNavigationTree } from './internal/navigation' import { generateItemSurround } from './internal/surround' import { type GenerateSearchSectionsOptions, generateSearchSections } from './internal/search' +import { type ContentLocaleEntry, generateCollectionLocales } from './internal/locales' import { fetchQuery } from './internal/api' import type { Collections, PageCollections, CollectionQueryBuilder, SurroundOptions, SQLOperator, QueryGroupFunction, ContentNavigationItem } from '@nuxt/content' import { tryUseNuxtApp } from '#imports' @@ -31,6 +32,11 @@ export function queryCollectionSearchSections(c return chainablePromise(collection, qb => generateSearchSections(qb, opts)) } +export function queryCollectionLocales(collection: T, stem: string): Promise { + const qb = queryCollection(collection) + return generateCollectionLocales(qb, stem) +} + async function executeContentQuery(event: H3Event | undefined, collection: T, sql: string) { if (import.meta.client && window.WebAssembly) { return queryContentSqlClientWasm(collection, sql) as Promise diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts new file mode 100644 index 000000000..1d02a633b --- /dev/null +++ b/src/runtime/internal/locales.ts @@ -0,0 +1,31 @@ +import type { CollectionQueryBuilder } from '@nuxt/content' + +export interface ContentLocaleEntry { + locale: string + path: string + stem: string + title?: string +} + +/** + * Query all locale variants for a given content stem within an i18n-enabled collection. + * Returns one entry per locale, useful for building language switchers and hreflang tags. + */ +export async function generateCollectionLocales>( + queryBuilder: CollectionQueryBuilder, + stem: string, +): Promise { + const items = await queryBuilder + .where('stem', '=', stem) + .all() + + return items.map((item) => { + const record = item as unknown as Record + return { + locale: record.locale as string, + path: record.path as string, + stem: record.stem as string, + title: record.title as string | undefined, + } + }) +} diff --git a/src/runtime/nitro.ts b/src/runtime/nitro.ts index 1ab6b7af4..13ec1b408 100644 --- a/src/runtime/nitro.ts +++ b/src/runtime/nitro.ts @@ -28,3 +28,8 @@ export const queryCollectionItemSurroundings = server.queryCollectionItemSurroun * @deprecated Import from `@nuxt/content/server` instead */ export const queryCollectionSearchSections = server.queryCollectionSearchSections + +/** + * @deprecated Import from `@nuxt/content/server` instead + */ +export const queryCollectionLocales = server.queryCollectionLocales diff --git a/src/runtime/server.ts b/src/runtime/server.ts index 3e98af8ca..c5a654624 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -3,6 +3,7 @@ import { collectionQueryBuilder } from './internal/query' import { generateNavigationTree } from './internal/navigation' import { generateItemSurround } from './internal/surround' import { type GenerateSearchSectionsOptions, generateSearchSections } from './internal/search' +import { type ContentLocaleEntry, generateCollectionLocales } from './internal/locales' import { fetchQuery } from './internal/api' import type { Collections, CollectionQueryBuilder, PageCollections, SurroundOptions, SQLOperator, QueryGroupFunction } from '@nuxt/content' @@ -29,6 +30,11 @@ export function queryCollectionSearchSections(e return chainablePromise(event, collection, qb => generateSearchSections(qb, opts)) } +export function queryCollectionLocales(event: H3Event, collection: T, stem: string): Promise { + const qb = queryCollection(event, collection) + return generateCollectionLocales(qb, stem) +} + function chainablePromise(event: H3Event, collection: T, fn: (qb: CollectionQueryBuilder) => Promise) { const queryBuilder = queryCollection(event, collection) diff --git a/src/types/collection.ts b/src/types/collection.ts index 2d79173e4..cb88f8a89 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -86,9 +86,10 @@ export interface PageCollection { indexes?: CollectionIndex[] /** * Enable i18n support for this collection. - * Adds a `locale` field and enables path-based locale detection and inline i18n expansion. + * Pass `true` to auto-detect from `@nuxtjs/i18n` module config, or + * pass a `CollectionI18nConfig` object to configure manually. */ - i18n?: CollectionI18nConfig + i18n?: true | CollectionI18nConfig } export interface DataCollection { @@ -98,9 +99,10 @@ export interface DataCollection { indexes?: CollectionIndex[] /** * Enable i18n support for this collection. - * Adds a `locale` field and enables inline i18n expansion. + * Pass `true` to auto-detect from `@nuxtjs/i18n` module config, or + * pass a `CollectionI18nConfig` object to configure manually. */ - i18n?: CollectionI18nConfig + i18n?: true | CollectionI18nConfig } export type Collection = PageCollection | DataCollection @@ -112,10 +114,14 @@ export interface DefinedCollection { extendedSchema: Draft07 fields: Record indexes?: CollectionIndex[] - i18n?: CollectionI18nConfig + /** + * `true` is the shorthand resolved from `@nuxtjs/i18n` in config loading. + * After resolution, this is always `CollectionI18nConfig | undefined`. + */ + i18n?: true | CollectionI18nConfig } -export interface ResolvedCollection extends DefinedCollection { +export interface ResolvedCollection extends Omit { name: string tableName: string /** @@ -123,6 +129,10 @@ export interface ResolvedCollection extends DefinedCollection { * Private collections will not be available in the runtime. */ private: boolean + /** + * Fully resolved i18n config (never `true` — that's resolved before this point). + */ + i18n?: CollectionI18nConfig } export interface CollectionInfo { diff --git a/src/utils/collection.ts b/src/utils/collection.ts index 7b00d19ec..eb597142d 100644 --- a/src/utils/collection.ts +++ b/src/utils/collection.ts @@ -79,6 +79,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..dcce2fdf3 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -2,7 +2,7 @@ import { loadConfig, watchConfig, createDefineConfig } from 'c12' import { relative } from 'pathe' import { hasNuxtModule, useNuxt } from '@nuxt/kit' import type { Nuxt } from '@nuxt/schema' -import type { DefinedCollection, ModuleOptions } from '../types' +import type { CollectionI18nConfig, DefinedCollection, ModuleOptions } from '../types' import { defineCollection, resolveCollections } from './collection' import { logger } from './dev' import { resolveStudioCollection } from './studio' @@ -75,7 +75,54 @@ export async function loadContentConfig(nuxt: Nuxt, options?: ModuleOptions) { resolveStudioCollection(nuxt, finalCollectionsConfig) } + // Resolve `i18n: true` shorthand from @nuxtjs/i18n module config + resolveI18nConfig(nuxt, finalCollectionsConfig) + const collections = resolveCollections(finalCollectionsConfig) return { collections } } + +/** + * Resolve `i18n: true` shorthand on collections by reading locale config + * from the `@nuxtjs/i18n` module. If nuxt-i18n is not installed and a + * collection uses `i18n: true`, a warning is logged and i18n is disabled. + */ +function resolveI18nConfig(nuxt: Nuxt, collections: Record) { + // Check which collections need resolution + const needsResolution = Object.values(collections).some(c => c.i18n === true) + if (!needsResolution) return + + let resolvedConfig: CollectionI18nConfig | undefined + + if (hasNuxtModule('@nuxtjs/i18n', nuxt)) { + const i18nOptions = (nuxt.options as unknown as { + i18n?: { + locales?: Array + defaultLocale?: string + } + }).i18n + + if (i18nOptions?.locales?.length && i18nOptions.defaultLocale) { + resolvedConfig = { + locales: i18nOptions.locales.map(l => typeof l === 'string' ? l : l.code), + defaultLocale: i18nOptions.defaultLocale, + } + } + } + + for (const [name, collection] of Object.entries(collections)) { + if (collection.i18n !== true) continue + + if (resolvedConfig) { + collection.i18n = resolvedConfig + } + else { + logger.warn( + `Collection "${name}" has \`i18n: true\` but @nuxtjs/i18n module is not installed or has no locales configured. ` + + 'Provide an explicit `i18n: { locales, defaultLocale }` config or install @nuxtjs/i18n.', + ) + collection.i18n = undefined + } + } +} diff --git a/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/i18n.test.ts b/test/i18n.test.ts index 11516d679..88cd5e389 100644 --- a/test/i18n.test.ts +++ b/test/i18n.test.ts @@ -172,5 +172,48 @@ describe('i18n', async () => { // Name should fall back to default since it's not translated expect(jane?.name).toBe('Jane Doe') }) + + test('non-default locale items have _i18nSourceHash in meta', async () => { + const members = await $fetch[]>('/api/content/team?locale=fr') + const jane = members.find(m => m.name === 'Jane Doe') + const meta = jane?.meta as Record + + expect(meta?._i18nSourceHash).toBeDefined() + expect(typeof meta?._i18nSourceHash).toBe('string') + }) + + test('default locale items do NOT have _i18nSourceHash', async () => { + const members = await $fetch[]>('/api/content/team?locale=en') + const jane = members.find(m => m.name === 'Jane Doe') + const meta = jane?.meta as Record + + expect(meta?._i18nSourceHash).toBeUndefined() + }) + }) + + describe('queryCollectionLocales helper', () => { + test('returns all locale variants for a given stem', async () => { + const locales = await $fetch<{ locale: string, path: string }[]>( + '/api/content/locales?collection=blog&stem=blog/hello', + ) + + expect(locales.length).toBe(2) + const localeCodes = locales.map(l => l.locale).sort() + expect(localeCodes).toEqual(['en', 'fr']) + + // Both should have the same path + for (const entry of locales) { + expect(entry.path).toBe('/blog/hello') + } + }) + + test('returns single locale for untranslated content', async () => { + const locales = await $fetch<{ locale: string, path: string }[]>( + '/api/content/locales?collection=blog&stem=blog/only-english', + ) + + expect(locales.length).toBe(1) + expect(locales[0].locale).toBe('en') + }) }) }) diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index 348a26dbf..a20b906e7 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import defu from 'defu' +import { hash } from 'ohash' import type { CollectionI18nConfig } from '../../src/types/collection' import type { ParsedContentFile } from '../../src/types' @@ -27,6 +28,14 @@ function expandI18n( parsedContent.locale = i18nConfig.defaultLocale } + // Compute source hash from default locale's translatable fields + const translatedFields = new Set(Object.values(i18nData).flatMap(Object.keys)) + const sourceFields: Record = {} + for (const field of translatedFields) { + sourceFields[field] = parsedContent[field] + } + const i18nSourceHash = hash(sourceFields) + const items: ParsedContentFile[] = [parsedContent] for (const [locale, overrides] of Object.entries(i18nData)) { @@ -36,7 +45,7 @@ function expandI18n( ...defu(overrides, parsedContent) as ParsedContentFile, id: `${parsedContent.id}#${locale}`, locale, - meta: { ...cleanMeta }, + meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, } items.push(localeItem) @@ -284,3 +293,94 @@ describe('i18n - path-based locale detection', () => { expect(result.stem).toBe('docs/guide/intro') }) }) + +describe('i18n - source hash for change tracking', () => { + const i18nConfig: CollectionI18nConfig = { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + } + + 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 = expandI18n(content, i18nConfig) + + // Default locale should NOT have _i18nSourceHash + expect(items[0].meta._i18nSourceHash).toBeUndefined() + + // French locale SHOULD have _i18nSourceHash + 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 = expandI18n(content1, i18nConfig) + const items2 = expandI18n(content2, i18nConfig) + + // Hash should be the same since only 'title' is translated and it's unchanged + 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', // title changed + stem: 'post', + extension: 'yml', + meta: { + i18n: { fr: { title: 'Mon Article' } }, + }, + } + + const items1 = expandI18n(content1, i18nConfig) + const items2 = expandI18n(content2, i18nConfig) + + // Hash should differ because source 'title' changed + expect(items1[1].meta._i18nSourceHash).not.toBe(items2[1].meta._i18nSourceHash) + }) +}) From ab899917a7026c04f357c0f0bd1b7c61c69b6846 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 15:49:43 +0100 Subject: [PATCH 03/67] feat: refine collection queries and result sorting --- src/runtime/client.ts | 6 +++--- src/runtime/internal/locales.ts | 9 +-------- src/runtime/internal/query.ts | 10 ++++++---- src/runtime/server.ts | 6 +++--- src/types/index.ts | 1 + src/types/locales.ts | 10 ++++++++++ test/unit/collectionQueryBuilder.test.ts | 12 ++++++------ 7 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 src/types/locales.ts diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 2fa8f61f9..12eb3ee55 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -3,9 +3,9 @@ import { collectionQueryBuilder } from './internal/query' import { generateNavigationTree } from './internal/navigation' import { generateItemSurround } from './internal/surround' import { type GenerateSearchSectionsOptions, generateSearchSections } from './internal/search' -import { type ContentLocaleEntry, generateCollectionLocales } from './internal/locales' +import { generateCollectionLocales } from './internal/locales' import { fetchQuery } from './internal/api' -import type { Collections, PageCollections, CollectionQueryBuilder, SurroundOptions, SQLOperator, QueryGroupFunction, ContentNavigationItem } from '@nuxt/content' +import type { Collections, PageCollections, CollectionQueryBuilder, ContentLocaleEntry, SurroundOptions, SQLOperator, QueryGroupFunction, ContentNavigationItem } from '@nuxt/content' import { tryUseNuxtApp } from '#imports' interface ChainablePromise extends Promise { @@ -32,7 +32,7 @@ export function queryCollectionSearchSections(c return chainablePromise(collection, qb => generateSearchSections(qb, opts)) } -export function queryCollectionLocales(collection: T, stem: string): Promise { +export function queryCollectionLocales(collection: T, stem: string): Promise { const qb = queryCollection(collection) return generateCollectionLocales(qb, stem) } diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts index 1d02a633b..fb4980bb5 100644 --- a/src/runtime/internal/locales.ts +++ b/src/runtime/internal/locales.ts @@ -1,11 +1,4 @@ -import type { CollectionQueryBuilder } from '@nuxt/content' - -export interface ContentLocaleEntry { - locale: string - path: string - stem: string - title?: string -} +import type { CollectionQueryBuilder, ContentLocaleEntry } from '@nuxt/content' /** * Query all locale variants for a given content stem within an i18n-enabled collection. diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index eb873e383..658fca9b5 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -164,14 +164,16 @@ export const collectionQueryBuilder = (collection: const fallbackQuery = buildQuery({ extraCondition: fallbackCondition }) const fallbackResults = await fetch(collection, fallbackQuery).then(res => res || []) - // Merge: prefer locale results, fill gaps from fallback by stem - const stemSet = new Set(localeResults.map((r: Collections[T]) => (r as unknown as { stem: string }).stem)) - const merged = [...localeResults] + // Merge: prefer locale results, fill gaps from fallback — preserve stem order + const getStem = (r: Collections[T]) => (r as unknown as { stem: string }).stem + const localeByIndex = new Map(localeResults.map((r, i) => [getStem(r), i])) + const merged: Collections[T][] = [...localeResults] for (const item of fallbackResults) { - if (!stemSet.has((item as unknown as { stem: string }).stem)) { + if (!localeByIndex.has(getStem(item))) { merged.push(item) } } + merged.sort((a, b) => getStem(a).localeCompare(getStem(b))) // Apply limit if specified if (opts.limit && opts.limit > 0) { diff --git a/src/runtime/server.ts b/src/runtime/server.ts index c5a654624..5571b51fd 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -3,9 +3,9 @@ import { collectionQueryBuilder } from './internal/query' import { generateNavigationTree } from './internal/navigation' import { generateItemSurround } from './internal/surround' import { type GenerateSearchSectionsOptions, generateSearchSections } from './internal/search' -import { type ContentLocaleEntry, generateCollectionLocales } from './internal/locales' +import { generateCollectionLocales } from './internal/locales' import { fetchQuery } from './internal/api' -import type { Collections, CollectionQueryBuilder, PageCollections, SurroundOptions, SQLOperator, QueryGroupFunction } from '@nuxt/content' +import type { Collections, CollectionQueryBuilder, ContentLocaleEntry, PageCollections, SurroundOptions, SQLOperator, QueryGroupFunction } from '@nuxt/content' interface ChainablePromise extends Promise { where(field: keyof PageCollections[T] | string, operator: SQLOperator, value?: unknown): ChainablePromise @@ -30,7 +30,7 @@ export function queryCollectionSearchSections(e return chainablePromise(event, collection, qb => generateSearchSections(qb, opts)) } -export function queryCollectionLocales(event: H3Event, collection: T, stem: string): Promise { +export function queryCollectionLocales(event: H3Event, collection: T, stem: string): Promise { const qb = queryCollection(event, collection) return generateCollectionLocales(qb, stem) } diff --git a/src/types/index.ts b/src/types/index.ts index fa34160e5..4bb9814f3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,6 @@ export type * from './collection' export type * from './hooks' +export type * from './locales' export type * from './module' export type * from './navigation' export type * from './surround' diff --git a/src/types/locales.ts b/src/types/locales.ts new file mode 100644 index 000000000..47a1dab05 --- /dev/null +++ b/src/types/locales.ts @@ -0,0 +1,10 @@ +/** + * Represents a single locale variant of a content item. + * Returned by `queryCollectionLocales`, useful for language switchers and hreflang tags. + */ +export interface ContentLocaleEntry { + locale: string + path: string + stem: string + title?: string +} diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index b2190e0d6..154c16ced 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -193,10 +193,10 @@ describe('collectionQueryBuilder', () => { ) }) - it('builds query with locale and fallback (two queries)', async () => { + it('builds query with locale and fallback (two queries, sorted by stem)', async () => { mockFetch - .mockResolvedValueOnce([{ stem: 'post-a', locale: 'fr' }]) - .mockResolvedValueOnce([{ stem: 'post-a', locale: 'en' }, { stem: 'post-b', locale: 'en' }]) + .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 @@ -214,10 +214,10 @@ describe('collectionQueryBuilder', () => { 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC', ) - // Results should merge: fr items preferred, en items fill gaps + // Merged results: fr preferred over en duplicate, sorted by stem expect(results).toHaveLength(2) - expect(results[0]).toEqual({ stem: 'post-a', locale: 'fr' }) - expect(results[1]).toEqual({ stem: 'post-b', locale: 'en' }) + 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 () => { From 11b6f6f1f7ab9785381177c5b9848e74b7c82ab8 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 15:58:12 +0100 Subject: [PATCH 04/67] refactor: replace defu deep-merge with shallow spread --- src/module.ts | 5 ++- src/runtime/internal/locales.ts | 2 +- src/runtime/internal/query.ts | 13 +++++--- src/types/locales.ts | 4 ++- src/utils/collection.ts | 8 +++-- src/utils/config.ts | 7 +++++ src/utils/content/index.ts | 9 +++--- src/utils/dev.ts | 54 +++++++++++++++++++++++++++++++-- test/unit/i18n.test.ts | 13 ++++---- 9 files changed, 91 insertions(+), 24 deletions(-) diff --git a/src/module.ts b/src/module.ts index 18d86f478..73dc00447 100644 --- a/src/module.ts +++ b/src/module.ts @@ -405,8 +405,11 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio for (const [locale, overrides] of Object.entries(i18nData)) { if (locale === defaultItem.locale) continue + // Shallow spread: overrides replace whole top-level fields + // (defu would deep-merge body AST / nested objects, corrupting them) const localeItem: ParsedContentFile = { - ...defu(overrides, defaultItem) as ParsedContentFile, + ...defaultItem, + ...overrides, id: `${parsedContent.id}#${locale}`, locale, meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts index fb4980bb5..f2920cee4 100644 --- a/src/runtime/internal/locales.ts +++ b/src/runtime/internal/locales.ts @@ -16,8 +16,8 @@ export async function generateCollectionLocales return { locale: record.locale as string, - path: record.path as string, stem: record.stem as string, + path: record.path as string | undefined, title: record.title as string | undefined, } }) diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 658fca9b5..a44347f61 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -164,16 +164,21 @@ export const collectionQueryBuilder = (collection: const fallbackQuery = buildQuery({ extraCondition: fallbackCondition }) const fallbackResults = await fetch(collection, fallbackQuery).then(res => res || []) - // Merge: prefer locale results, fill gaps from fallback — preserve stem order + // Merge: prefer locale results, fill gaps from fallback const getStem = (r: Collections[T]) => (r as unknown as { stem: string }).stem - const localeByIndex = new Map(localeResults.map((r, i) => [getStem(r), i])) + const localeStemSet = new Set(localeResults.map(getStem)) const merged: Collections[T][] = [...localeResults] for (const item of fallbackResults) { - if (!localeByIndex.has(getStem(item))) { + if (!localeStemSet.has(getStem(item))) { merged.push(item) } } - merged.sort((a, b) => getStem(a).localeCompare(getStem(b))) + // Re-sort by stem only when no custom ORDER BY was specified (default ordering) + // When the user provides a custom .order(), both sub-queries already respect it + // and we preserve that order (locale results first, then fallback fills) + if (params.orderBy.length === 0) { + merged.sort((a, b) => getStem(a).localeCompare(getStem(b))) + } // Apply limit if specified if (opts.limit && opts.limit > 0) { diff --git a/src/types/locales.ts b/src/types/locales.ts index 47a1dab05..575a4af14 100644 --- a/src/types/locales.ts +++ b/src/types/locales.ts @@ -4,7 +4,9 @@ */ export interface ContentLocaleEntry { locale: string - path: string stem: string + /** Only present for `page` collections. */ + path?: string + /** Only present for `page` collections. */ title?: string } diff --git a/src/utils/collection.ts b/src/utils/collection.ts index eb597142d..79c7af06e 100644 --- a/src/utils/collection.ts +++ b/src/utils/collection.ts @@ -27,8 +27,10 @@ export function defineCollection(collection: Collection): DefinedCollectio extendedSchema = mergeStandardSchema(pageStandardSchema, extendedSchema) } - // Add locale field when i18n is configured - if (collection.i18n) { + // Add locale field when i18n is fully configured (not `true` shorthand — + // that gets resolved later in loadContentConfig via resolveI18nConfig) + const hasI18nConfig = collection.i18n && collection.i18n !== true + if (hasI18nConfig) { extendedSchema = mergeStandardSchema(localeStandardSchema, extendedSchema) } @@ -36,7 +38,7 @@ export function defineCollection(collection: Collection): DefinedCollectio // Auto-add composite index on (locale, stem) for i18n collections const indexes = collection.indexes ? [...collection.indexes] : [] - if (collection.i18n) { + if (hasI18nConfig) { indexes.push({ columns: ['locale', 'stem'] }) } diff --git a/src/utils/config.ts b/src/utils/config.ts index dcce2fdf3..ee6533014 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -4,6 +4,8 @@ import { hasNuxtModule, useNuxt } from '@nuxt/kit' import type { Nuxt } from '@nuxt/schema' import type { CollectionI18nConfig, DefinedCollection, ModuleOptions } from '../types' import { defineCollection, resolveCollections } from './collection' +import { localeStandardSchema, mergeStandardSchema } from './schema' +import { getCollectionFieldsTypes } from '../runtime/internal/schema' import { logger } from './dev' import { resolveStudioCollection } from './studio' @@ -116,6 +118,11 @@ function resolveI18nConfig(nuxt: Nuxt, collections: Record> + const { i18n: _removed, ...cleanMeta } = parsed.meta + parsed.meta = cleanMeta + if (!parsed.locale) parsed.locale = collection.i18n.defaultLocale + + const translatedFields = new Set(Object.values(i18nData).flatMap(Object.keys)) + const sourceFields: Record = {} + for (const field of translatedFields) sourceFields[field] = parsed[field] + const i18nSourceHash = hash(sourceFields) + + // Upsert default locale row + const { queries: defaultQueries } = generateCollectionInsert(collection, parsed) + await broadcast(collection, keyInCollection, defaultQueries) + + // Upsert each non-default locale row + for (const [locale, overrides] of Object.entries(i18nData)) { + if (locale === parsed.locale) continue + const localeKey = `${keyInCollection}#${locale}` + const localeItem: ParsedContentFile = { + ...parsed, + ...overrides, + id: localeKey, + locale, + meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, + } + const { queries: localeQueries } = generateCollectionInsert(collection, localeItem) + await broadcast(collection, localeKey, localeQueries) + } + + // Remove locale rows that are no longer in the i18n section + for (const locale of collection.i18n.locales) { + if (locale === parsed.locale || locale in i18nData) continue + await broadcast(collection, `${keyInCollection}#${locale}`) + } + } + else { + const { queries: insertQuery } = generateCollectionInsert(collection, parsed) + await broadcast(collection, keyInCollection, insertQuery) + } } } @@ -193,7 +235,13 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani await db.deleteDevelopmentCache(keyInCollection) + // Remove main row and all locale variant rows await broadcast(collection, keyInCollection) + if (collection.i18n) { + for (const locale of collection.i18n.locales) { + await broadcast(collection, `${keyInCollection}#${locale}`) + } + } } } diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index a20b906e7..19a1db5c2 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect } from 'vitest' -import defu from 'defu' import { hash } from 'ohash' import type { CollectionI18nConfig } from '../../src/types/collection' import type { ParsedContentFile } from '../../src/types' @@ -14,7 +13,6 @@ function expandI18n( ): ParsedContentFile[] { const i18nData = parsedContent.meta?.i18n as Record> | undefined if (!i18nData) { - // No inline i18n - just assign default locale if missing if (!parsedContent.locale) { parsedContent.locale = i18nConfig.defaultLocale } @@ -41,8 +39,10 @@ function expandI18n( for (const [locale, overrides] of Object.entries(i18nData)) { if (locale === parsedContent.locale) continue + // Shallow spread: overrides replace whole top-level fields (not deep-merge) const localeItem: ParsedContentFile = { - ...defu(overrides, parsedContent) as ParsedContentFile, + ...parsedContent, + ...overrides, id: `${parsedContent.id}#${locale}`, locale, meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, @@ -169,7 +169,7 @@ describe('i18n - inline expansion', () => { expect(items[1].title).toBe('My Post') }) - it('deep-merges nested objects in locale overrides', () => { + it('shallow-replaces nested objects in locale overrides', () => { const content: ParsedContentFile = { id: 'team:jane.yml', name: 'Jane Doe', @@ -190,8 +190,9 @@ describe('i18n - inline expansion', () => { // Default keeps original expect(items[0].info).toEqual({ age: 25, country: 'Switzerland' }) - // German override merges deeply - country overridden, age preserved - expect(items[1].info).toEqual({ age: 25, country: 'Schweiz' }) + // German override replaces the whole `info` object (shallow spread, not deep-merge) + // This prevents corrupting complex objects like body AST + expect(items[1].info).toEqual({ country: 'Schweiz' }) }) it('does not include default locale in expanded items', () => { From e795838e0ea1678d02b89526e8a6b25750111b15 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 16:07:13 +0100 Subject: [PATCH 05/67] refactor: replace regexp with string operations for locale stem stripping --- src/runtime/internal/query.ts | 32 +++++++++++++++++++------------- src/utils/content/index.ts | 8 ++++++-- src/utils/dev.ts | 2 +- test/unit/i18n.test.ts | 14 ++++++++++++-- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index a44347f61..1cbaffa9f 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -145,6 +145,10 @@ export const collectionQueryBuilder = (collection: return fetch(collection, buildQuery({ limit: 1 })).then(res => res[0] || null) }, async count(field: keyof Collections[T] | '*' = '*', distinct: boolean = false) { + if (params.localeFallback) { + // Count the merged deduplicated result set + return fetchWithLocaleFallback().then(res => res.length) + } return fetch(collection, buildQuery({ count: { field: String(field), distinct }, })).then(m => (m[0] as { count: number }).count) @@ -154,14 +158,13 @@ export const collectionQueryBuilder = (collection: async function fetchWithLocaleFallback(opts: { limit?: number } = {}): Promise { const { locale, fallback } = params.localeFallback! - // Query for the requested locale + // Sub-queries fetch ALL matching rows (no limit/offset) — we apply those JS-side on the merged result const localeCondition = `("locale" = ${singleQuote(locale)})` - const localeQuery = buildQuery({ extraCondition: localeCondition }) + const localeQuery = buildQuery({ extraCondition: localeCondition, noLimitOffset: true }) const localeResults = await fetch(collection, localeQuery).then(res => res || []) - // Query for the fallback locale const fallbackCondition = `("locale" = ${singleQuote(fallback)})` - const fallbackQuery = buildQuery({ extraCondition: fallbackCondition }) + const fallbackQuery = buildQuery({ extraCondition: fallbackCondition, noLimitOffset: true }) const fallbackResults = await fetch(collection, fallbackQuery).then(res => res || []) // Merge: prefer locale results, fill gaps from fallback @@ -173,22 +176,25 @@ export const collectionQueryBuilder = (collection: merged.push(item) } } - // Re-sort by stem only when no custom ORDER BY was specified (default ordering) - // When the user provides a custom .order(), both sub-queries already respect it - // and we preserve that order (locale results first, then fallback fills) + // Re-sort by stem only when no custom ORDER BY was specified if (params.orderBy.length === 0) { merged.sort((a, b) => getStem(a).localeCompare(getStem(b))) } - // Apply limit if specified - if (opts.limit && opts.limit > 0) { - return merged.slice(0, opts.limit) as Collections[T][] + // Apply offset then limit on the merged result + let result = merged + if (params.offset > 0) { + result = result.slice(params.offset) + } + const limit = opts.limit ?? (params.limit > 0 ? params.limit : 0) + if (limit > 0) { + result = result.slice(0, limit) } - return merged as Collections[T][] + return result as Collections[T][] } - function buildQuery(opts: { count?: { field: string, distinct: boolean }, limit?: number, extraCondition?: string } = {}) { + function buildQuery(opts: { count?: { field: string, distinct: boolean }, limit?: number, extraCondition?: string, noLimitOffset?: boolean } = {}) { let query = 'SELECT ' if (opts?.count) { query += `COUNT(${opts.count.distinct ? 'DISTINCT ' : ''}${opts.count.field}) as count` @@ -216,7 +222,7 @@ export const collectionQueryBuilder = (collection: } 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/utils/content/index.ts b/src/utils/content/index.ts index 455707ec5..bcce63245 100644 --- a/src/utils/content/index.ts +++ b/src/utils/content/index.ts @@ -231,8 +231,12 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) } // Always strip locale prefix from stem (regardless of stem format) const currentStem = result.stem || pathMetaFields.stem || '' - result.stem = currentStem - .replace(new RegExp(`^${firstPart}(/|$)`), '') + if (currentStem === firstPart) { + result.stem = '' + } + else if (currentStem.startsWith(firstPart + '/')) { + result.stem = currentStem.slice(firstPart.length + 1) + } } else { // No locale prefix - assign default locale diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 33696bc3b..b7dea68bb 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -160,7 +160,7 @@ 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 parsed: ParsedContentFile = JSON.parse(parsedContent) diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index 19a1db5c2..20a8232e6 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -58,6 +58,9 @@ function expandI18n( * Detect locale from path prefix and strip it. * This is the same logic used in createParser (src/utils/content/index.ts). */ +/** + * Mirrors the production logic in src/utils/content/index.ts exactly. + */ function detectLocaleFromPath( path: string, stem: string, @@ -68,8 +71,15 @@ function detectLocaleFromPath( if (firstPart && i18nConfig.locales.includes(firstPart)) { const pathWithoutLocale = '/' + pathParts.slice(1).join('/') - const stemParts = stem.split('/') - const newStem = stemParts[0] === firstPart ? stemParts.slice(1).join('/') : stem + + // Stem stripping: same string logic as production (no RegExp) + let newStem = stem + if (stem === firstPart) { + newStem = '' + } + else if (stem.startsWith(firstPart + '/')) { + newStem = stem.slice(firstPart.length + 1) + } return { locale: firstPart, From 0ccf8a9a23d20a8eae332c3d8b1e6307fd29e801 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 16:17:54 +0100 Subject: [PATCH 06/67] feat: add sorted array merge for locale fallback results --- src/runtime/internal/locales.ts | 1 + src/runtime/internal/query.ts | 61 ++++++++++++++++++------ src/runtime/internal/security.ts | 14 +++--- test/unit/collectionQueryBuilder.test.ts | 37 ++++++++++++-- 4 files changed, 88 insertions(+), 25 deletions(-) diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts index f2920cee4..98b1c2144 100644 --- a/src/runtime/internal/locales.ts +++ b/src/runtime/internal/locales.ts @@ -3,6 +3,7 @@ import type { CollectionQueryBuilder, ContentLocaleEntry } from '@nuxt/content' /** * Query all locale variants for a given content stem within an i18n-enabled collection. * Returns one entry per locale, useful for building language switchers and hreflang tags. + * Only fetches the fields needed (not full body ASTs). */ export async function generateCollectionLocales>( queryBuilder: CollectionQueryBuilder, diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 1cbaffa9f..65bc8afc5 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -170,16 +170,11 @@ export const collectionQueryBuilder = (collection: // Merge: prefer locale results, fill gaps from fallback const getStem = (r: Collections[T]) => (r as unknown as { stem: string }).stem const localeStemSet = new Set(localeResults.map(getStem)) - const merged: Collections[T][] = [...localeResults] - for (const item of fallbackResults) { - if (!localeStemSet.has(getStem(item))) { - merged.push(item) - } - } - // Re-sort by stem only when no custom ORDER BY was specified - if (params.orderBy.length === 0) { - merged.sort((a, b) => getStem(a).localeCompare(getStem(b))) - } + const fallbackOnly = fallbackResults.filter(item => !localeStemSet.has(getStem(item))) + + // Both sub-queries share the same ORDER BY, so we merge two sorted arrays. + // Use interleaved merge to preserve the DB-provided sort order. + const merged = mergeSortedArrays(localeResults, fallbackOnly, getStem) // Apply offset then limit on the merged result let result = merged @@ -214,11 +209,14 @@ export const collectionQueryBuilder = (collection: query += ` WHERE ${conditions.join(' AND ')}` } - if (params.orderBy.length > 0) { - query += ` ORDER BY ${params.orderBy.join(', ')}` - } - else { - query += ` ORDER BY stem ASC` + // Skip ORDER BY for COUNT queries (PostgreSQL rejects ORDER BY on aggregate without GROUP BY) + if (!opts?.count) { + if (params.orderBy.length > 0) { + query += ` ORDER BY ${params.orderBy.join(', ')}` + } + else { + query += ` ORDER BY stem ASC` + } } const limit = opts?.limit || params.limit @@ -237,6 +235,39 @@ export const collectionQueryBuilder = (collection: return query } +/** + * Merge two arrays that are already sorted by the same criteria (from DB ORDER BY). + * Uses the `stem` field as tie-breaker key to interleave items in the correct position. + * This preserves any ORDER BY the DB applied (date DESC, custom fields, etc.). + */ +function mergeSortedArrays(a: T[], b: T[], getStem: (r: T) => string): T[] { + // Both arrays come from the DB with the same ORDER BY. + // Build a position map from array `a` stems to interleave `b` items correctly. + // Items in `b` whose stem falls between two `a` items get inserted at that position. + const result: T[] = [] + let ai = 0 + let bi = 0 + while (ai < a.length && bi < b.length) { + if (getStem(a[ai]!).localeCompare(getStem(b[bi]!)) <= 0) { + result.push(a[ai]!) + ai++ + } + else { + result.push(b[bi]!) + bi++ + } + } + while (ai < a.length) { + result.push(a[ai]!) + ai++ + } + while (bi < b.length) { + result.push(b[bi]!) + bi++ + } + return result +} + function singleQuote(value: unknown) { return `'${String(value).replace(/'/g, '\'\'')}'` } diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index 7304b8da0..8e07e9281 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -1,6 +1,6 @@ const SQL_COMMANDS = /SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|\$/i const SQL_COUNT_REGEX = /COUNT\((DISTINCT )?([a-z_]\w+|\*)\)/i -const SQL_SELECT_REGEX = /^SELECT (.*) FROM (\w+)( WHERE .*)? ORDER BY (["\w,\s]+) (ASC|DESC)( LIMIT \d+)?( OFFSET \d+)?$/ +const SQL_SELECT_REGEX = /^SELECT (.*) FROM (\w+)( WHERE .*?)?( ORDER BY (["\w,\s]+) (ASC|DESC))?( LIMIT \d+)?( OFFSET \d+)?$/ /** * Assert that the query is safe @@ -28,7 +28,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(', ') || [] @@ -62,10 +62,12 @@ export function assertSafeQuery(sql: string, collection: string) { } } - // ORDER BY - const _order = (orderBy + ' ' + order).split(', ') - if (!_order.every(column => column.match(/^("[a-zA-Z_]+"|[a-zA-Z_]+) (ASC|DESC)$/))) { - throw new Error('Invalid query: ORDER BY clause must contain valid column names followed by ASC or DESC') + // ORDER BY (optional — COUNT queries omit it) + if (orderBy && order) { + const _order = (orderBy + ' ' + order).split(', ') + if (!_order.every(column => column.match(/^("[a-zA-Z_]+"|[a-zA-Z_]+) (ASC|DESC)$/))) { + throw new Error('Invalid query: ORDER BY clause must contain valid column names followed by ASC or DESC') + } } // LIMIT diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index 154c16ced..9cce1b157 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -130,23 +130,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', ) }) @@ -232,4 +232,33 @@ describe('collectionQueryBuilder', () => { 'SELECT * FROM _articles WHERE ("locale" = \'de\') AND ("path" = \'/blog/post\') ORDER BY stem ASC', ) }) + + it('locale fallback merges results in stem order', async () => { + // fr has stem c, en has stems a, b, c — fallback should interleave a, b + mockFetch + .mockResolvedValueOnce([{ stem: 'c', locale: 'fr' }]) + .mockResolvedValueOnce([{ stem: 'a', locale: 'en' }, { stem: 'b', locale: 'en' }, { stem: 'c', locale: 'en' }]) + + const results = await collectionQueryBuilder(mockCollection, mockFetch) + .locale('fr', { fallback: 'en' }) + .all() + + expect(results).toHaveLength(3) + expect(results.map((r: { stem: string }) => r.stem)).toEqual(['a', 'b', 'c']) + // stem 'c' should come from fr (locale preferred) + expect(results[2]).toEqual({ stem: 'c', locale: 'fr' }) + // stems 'a' and 'b' come from en (fallback) + expect(results[0]).toEqual({ stem: 'a', locale: 'en' }) + expect(results[1]).toEqual({ stem: 'b', locale: 'en' }) + }) + + it('count query omits ORDER BY', async () => { + const query = collectionQueryBuilder(mockCollection, mockFetch) + await query.count() + + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT COUNT(*) as count FROM _articles', + ) + }) }) From fa1a21cc07e0a74212816e2d1503b7742125a44e Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 16:25:35 +0100 Subject: [PATCH 07/67] fix: optimize locale query with select and conditional merge strategy --- src/runtime/internal/locales.ts | 15 ++++++++------- src/runtime/internal/query.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts index 98b1c2144..5c9f4e31f 100644 --- a/src/runtime/internal/locales.ts +++ b/src/runtime/internal/locales.ts @@ -3,23 +3,24 @@ import type { CollectionQueryBuilder, ContentLocaleEntry } from '@nuxt/content' /** * Query all locale variants for a given content stem within an i18n-enabled collection. * Returns one entry per locale, useful for building language switchers and hreflang tags. - * Only fetches the fields needed (not full body ASTs). + * Only fetches the fields needed (locale, stem, path, title) — not full body ASTs. */ export async function generateCollectionLocales>( queryBuilder: CollectionQueryBuilder, stem: string, ): Promise { - const items = await queryBuilder + // Select only the lightweight fields we need — avoids fetching large body ASTs + const items = await (queryBuilder as unknown as CollectionQueryBuilder>) + .select('locale' as never, 'stem' as never, 'path' as never, 'title' as never) .where('stem', '=', stem) .all() return items.map((item) => { - const record = item as unknown as Record return { - locale: record.locale as string, - stem: record.stem as string, - path: record.path as string | undefined, - title: record.title as string | undefined, + locale: item.locale as string, + stem: item.stem as string, + path: item.path as string | undefined, + title: item.title as string | undefined, } }) } diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 65bc8afc5..d48e74897 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -172,9 +172,14 @@ export const collectionQueryBuilder = (collection: const localeStemSet = new Set(localeResults.map(getStem)) const fallbackOnly = fallbackResults.filter(item => !localeStemSet.has(getStem(item))) - // Both sub-queries share the same ORDER BY, so we merge two sorted arrays. - // Use interleaved merge to preserve the DB-provided sort order. - const merged = mergeSortedArrays(localeResults, fallbackOnly, getStem) + // When using the default ORDER BY (stem ASC), we can do a proper sorted merge. + // When a custom ORDER BY is specified, both sub-queries are already DB-sorted + // by that field — we keep locale items first and append fallback items after, + // preserving each group's DB order. A full interleave would require parsing + // the SQL ORDER BY clause in JS, which is not feasible. + const merged = params.orderBy.length === 0 + ? mergeSortedArrays(localeResults, fallbackOnly, getStem) + : [...localeResults, ...fallbackOnly] // Apply offset then limit on the merged result let result = merged From 0f567dd7470cfe14ab8c4903d16ef7d37420c530 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 16:35:58 +0100 Subject: [PATCH 08/67] chore: remove prepare script --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index d53aa285f..e2f9f4bb8 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,7 @@ "test:bun": "bun test ./test/bun.test.ts", "test:watch": "vitest watch", "test:types": "vue-tsc --noEmit", - "verify": "npm run dev:prepare && npm run prepack && npm run lint && npm run test && npm run typecheck", - "prepare": "skilld prepare || true" + "verify": "npm run dev:prepare && npm run prepack && npm run lint && npm run test && npm run typecheck" }, "dependencies": { "@nuxt/kit": "^4.4.2", From 1280746447fd492a355d012ee1b243015bd7829d Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 22:18:53 +0100 Subject: [PATCH 09/67] feat: auto-detect locale from @nuxtjs/i18n context --- src/runtime/client.ts | 8 +++- src/runtime/internal/query.ts | 30 +++++++++++-- src/runtime/server.ts | 4 +- src/utils/templates.ts | 1 + test/unit/assertSafeQuery.test.ts | 5 ++- test/unit/collectionQueryBuilder.test.ts | 56 +++++++++++++++++++++++- 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 12eb3ee55..9131144df 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -16,8 +16,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 + // Auto-detect locale from @nuxtjs/i18n (client: $i18n.locale, SSR: event.context.nuxtI18n) + const detectedLocale = (nuxtApp?.$i18n as { locale?: { value?: string } })?.locale?.value + || (event?.context?.nuxtI18n as { vueI18nOptions?: { locale?: string } })?.vueI18nOptions?.locale + return collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql), detectedLocale) } export function queryCollectionNavigation(collection: T, fields?: Array): ChainablePromise { diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index d48e74897..5ec6a220e 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -1,6 +1,6 @@ import { withoutTrailingSlash } from 'ufo' import type { Collections, CollectionQueryBuilder, CollectionQueryGroup, QueryGroupFunction, SQLOperator } from '@nuxt/content' -import { tables } from '#content/manifest' +import manifestMeta, { tables } from '#content/manifest' const buildGroup = (group: CollectionQueryGroup, type: 'AND' | 'OR') => { const conditions = (group as unknown as { _conditions: Array })._conditions @@ -69,7 +69,13 @@ 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 => { + // Auto-detect i18n config from manifest for this collection + const collectionMeta = (manifestMeta as Record)[String(collection)] + const i18nConfig = collectionMeta?.i18n + // Track whether .locale() was called explicitly + let localeExplicitlySet = false + const params = { conditions: [] as Array, selectedFields: [] as Array, @@ -102,6 +108,7 @@ export const collectionQueryBuilder = (collection: return query.where('path', '=', withoutTrailingSlash(path)) }, locale(locale: string, opts?: { fallback?: string }) { + localeExplicitlySet = true if (opts?.fallback) { params.localeFallback = { locale, fallback: opts.fallback } } @@ -133,20 +140,22 @@ export const collectionQueryBuilder = (collection: return query }, async all(): Promise { + applyAutoLocale() if (params.localeFallback) { return fetchWithLocaleFallback() } return fetch(collection, buildQuery()).then(res => (res || []) as Collections[T][]) }, async first(): Promise { + applyAutoLocale() if (params.localeFallback) { return fetchWithLocaleFallback({ limit: 1 }).then(res => res[0] || null) } return fetch(collection, buildQuery({ limit: 1 })).then(res => res[0] || null) }, async count(field: keyof Collections[T] | '*' = '*', distinct: boolean = false) { + applyAutoLocale() if (params.localeFallback) { - // Count the merged deduplicated result set return fetchWithLocaleFallback().then(res => res.length) } return fetch(collection, buildQuery({ @@ -155,6 +164,21 @@ export const collectionQueryBuilder = (collection: }, } + /** + * Auto-apply locale filter when: + * 1. The collection has i18n configured (in manifest) + * 2. No explicit .locale() call was made + * 3. A locale was detected from @nuxtjs/i18n + * Runs once before query execution (all/first/count). + */ + let autoLocaleApplied = false + function applyAutoLocale() { + if (autoLocaleApplied || localeExplicitlySet || !i18nConfig || !detectedLocale) return + autoLocaleApplied = true + // Auto-apply with fallback to the collection's default locale + params.localeFallback = { locale: detectedLocale, fallback: i18nConfig.defaultLocale } + } + async function fetchWithLocaleFallback(opts: { limit?: number } = {}): Promise { const { locale, fallback } = params.localeFallback! diff --git a/src/runtime/server.ts b/src/runtime/server.ts index 5571b51fd..3aee929da 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -15,7 +15,9 @@ interface ChainablePromise extends Promise(event: H3Event, collection: T): CollectionQueryBuilder => { - return collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql)) + // Auto-detect locale from @nuxtjs/i18n server context + const detectedLocale = (event.context?.nuxtI18n as { vueI18nOptions?: { locale?: string } })?.vueI18nOptions?.locale + return collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql), detectedLocale) } export function queryCollectionNavigation(event: H3Event, collection: T, fields?: Array) { diff --git a/src/utils/templates.ts b/src/utils/templates.ts index f9e0cc447..d9c31fe84 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -179,6 +179,7 @@ export const manifestTemplate = (manifest: Manifest) => ({ acc[collection.name] = { type: collection.type, fields: collection.fields, + ...(collection.i18n ? { i18n: collection.i18n } : {}), } return acc }, {} as Record) diff --git a/test/unit/assertSafeQuery.test.ts b/test/unit/assertSafeQuery.test.ts index aea0d7e94..f9d942f5a 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 diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index 9cce1b157..6801cc1f6 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -1,11 +1,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { collectionQueryBuilder } from '../../src/runtime/internal/query' -// Mock tables from manifest +// Mock tables and collection metadata from manifest vi.mock('#content/manifest', () => ({ tables: { articles: '_articles', }, + default: { + articles: { + type: 'data', + fields: {}, + i18n: { locales: ['en', 'fr', 'de'], defaultLocale: 'en' }, + }, + }, })) // Mock fetch function @@ -261,4 +268,51 @@ describe('collectionQueryBuilder', () => { 'SELECT COUNT(*) as count FROM _articles', ) }) + + describe('auto-locale detection', () => { + it('auto-applies detected locale with fallback when collection has i18n', async () => { + mockFetch + .mockResolvedValueOnce([{ stem: 'a', locale: 'fr' }]) + .mockResolvedValueOnce([{ stem: 'a', locale: 'en' }, { stem: 'b', locale: 'en' }]) + + // Pass 'fr' as detectedLocale (3rd arg) — simulates what client.ts/server.ts do + const results = await collectionQueryBuilder(mockCollection, mockFetch, 'fr').all() + + // Should auto-apply locale with fallback to defaultLocale ('en') + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("locale" = \'fr\') ORDER BY stem ASC', + ) + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC', + ) + expect(results).toHaveLength(2) + }) + + it('does not auto-apply locale when .locale() is called explicitly', async () => { + // Pass 'fr' as detectedLocale, but call .locale('de') explicitly + const query = collectionQueryBuilder(mockCollection, mockFetch, 'fr') + await query.locale('de').all() + + // Should use the explicit 'de', not auto-detected 'fr' + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("locale" = \'de\') ORDER BY stem ASC', + ) + }) + + it('does not auto-apply locale when no detectedLocale is provided', async () => { + // No detectedLocale (undefined) — no auto-locale + const query = collectionQueryBuilder(mockCollection, mockFetch) + await query.all() + + // Should query without locale filter + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT * FROM _articles ORDER BY stem ASC', + ) + }) + }) }) From 59009124b841a67f9814b2956ea701dc21c50353 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 22:19:00 +0100 Subject: [PATCH 10/67] fix: skip auto-locale in locale listing and optimize default locale queries --- src/runtime/client.ts | 4 +++- src/runtime/internal/query.ts | 10 ++++++++-- src/runtime/server.ts | 10 +++++++--- test/unit/collectionQueryBuilder.test.ts | 12 ++++++++++++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 9131144df..de364453b 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -37,7 +37,9 @@ export function queryCollectionSearchSections(c } export function queryCollectionLocales(collection: T, stem: string): Promise { - const qb = queryCollection(collection) + // Skip auto-locale: this helper needs ALL locale variants, not just the current one + const event = tryUseNuxtApp()?.ssrContext?.event + const qb = collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql)) return generateCollectionLocales(qb, stem) } diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 5ec6a220e..437b16a79 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -175,8 +175,14 @@ export const collectionQueryBuilder = (collection: function applyAutoLocale() { if (autoLocaleApplied || localeExplicitlySet || !i18nConfig || !detectedLocale) return autoLocaleApplied = true - // Auto-apply with fallback to the collection's default locale - params.localeFallback = { locale: detectedLocale, fallback: i18nConfig.defaultLocale } + if (detectedLocale === i18nConfig.defaultLocale) { + // Default locale: single query, no fallback needed + params.conditions.push(`("locale" = ${singleQuote(detectedLocale)})`) + } + else { + // Non-default locale: query with fallback to default + params.localeFallback = { locale: detectedLocale, fallback: i18nConfig.defaultLocale } + } } async function fetchWithLocaleFallback(opts: { limit?: number } = {}): Promise { diff --git a/src/runtime/server.ts b/src/runtime/server.ts index 3aee929da..6f981233c 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -15,8 +15,11 @@ interface ChainablePromise extends Promise(event: H3Event, collection: T): CollectionQueryBuilder => { - // Auto-detect locale from @nuxtjs/i18n server context - const detectedLocale = (event.context?.nuxtI18n as { vueI18nOptions?: { locale?: string } })?.vueI18nOptions?.locale + // Auto-detect locale from @nuxtjs/i18n server context (resilient to different i18n versions) + const i18nCtx = event.context?.nuxtI18n as Record | undefined + const detectedLocale = (i18nCtx?.vueI18nOptions as { locale?: string })?.locale + || (i18nCtx?.locale as string) + || undefined return collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql), detectedLocale) } @@ -33,7 +36,8 @@ export function queryCollectionSearchSections(e } export function queryCollectionLocales(event: H3Event, collection: T, stem: string): Promise { - const qb = queryCollection(event, collection) + // Skip auto-locale: this helper needs ALL locale variants, not just the current one + const qb = collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql)) return generateCollectionLocales(qb, stem) } diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index 6801cc1f6..fb597925f 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -314,5 +314,17 @@ describe('collectionQueryBuilder', () => { 'SELECT * FROM _articles ORDER BY stem ASC', ) }) + + it('uses single query (no fallback) when detectedLocale equals defaultLocale', async () => { + // Default locale 'en' — should use a single WHERE, not two-query fallback + const query = collectionQueryBuilder(mockCollection, mockFetch, 'en') + await query.all() + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC', + ) + }) }) }) From ef57d230227a6fb786d6a671d269473ac1f280f0 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 22:37:16 +0100 Subject: [PATCH 11/67] feat: add stem() method with automatic source prefix resolution --- src/runtime/internal/query.ts | 12 ++++++++++-- src/types/query.ts | 6 ++++++ src/utils/templates.ts | 4 ++++ test/unit/collectionQueryBuilder.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 437b16a79..8971f950b 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -70,9 +70,10 @@ export const collectionQueryGroup = (collection: T) } export const collectionQueryBuilder = (collection: T, fetch: (collection: T, sql: string) => Promise, detectedLocale?: string): CollectionQueryBuilder => { - // Auto-detect i18n config from manifest for this collection - const collectionMeta = (manifestMeta as Record)[String(collection)] + // Read collection metadata from manifest + const collectionMeta = (manifestMeta as Record)[String(collection)] const i18nConfig = collectionMeta?.i18n + const stemPrefix = collectionMeta?.stemPrefix || '' // Track whether .locale() was called explicitly let localeExplicitlySet = false @@ -107,6 +108,13 @@ export const collectionQueryBuilder = (collection: path(path: string) { return query.where('path', '=', withoutTrailingSlash(path)) }, + stem(stem: string) { + // Resolve full stem by prepending the collection's source prefix if not already present + const fullStem = stemPrefix && !stem.startsWith(stemPrefix) + ? `${stemPrefix}/${stem}` + : stem + return query.where('stem', '=', fullStem) + }, locale(locale: string, opts?: { fallback?: string }) { localeExplicitlySet = true if (opts?.fallback) { diff --git a/src/types/query.ts b/src/types/query.ts index d6bb17cce..0883d1ab8 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -4,6 +4,12 @@ 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') diff --git a/src/utils/templates.ts b/src/utils/templates.ts index d9c31fe84..7d60757da 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -176,10 +176,14 @@ export const manifestTemplate = (manifest: Manifest) => ({ filename: moduleTemplates.manifest, getContents: ({ options }: { options: { manifest: Manifest } }) => { const collectionsMeta = options.manifest.collections.reduce((acc, collection) => { + // Compute stem prefix: join(collectionName, sourcePrefix) — what gets prepended to the filename in the stem + const sourcePrefix = collection.source?.[0]?.prefix || '' + const stemPrefix = [collection.name, sourcePrefix].filter(Boolean).join('/').replace(/^\/|\/$/g, '') acc[collection.name] = { type: collection.type, fields: collection.fields, ...(collection.i18n ? { i18n: collection.i18n } : {}), + ...(stemPrefix ? { stemPrefix } : {}), } return acc }, {} as Record) diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index fb597925f..537121f12 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -11,6 +11,7 @@ vi.mock('#content/manifest', () => ({ type: 'data', fields: {}, i18n: { locales: ['en', 'fr', 'de'], defaultLocale: 'en' }, + stemPrefix: 'articles', }, }, })) @@ -240,6 +241,27 @@ describe('collectionQueryBuilder', () => { ) }) + it('.stem() auto-prefixes with collection stemPrefix', async () => { + const query = collectionQueryBuilder(mockCollection, mockFetch) + await query.stem('navbar').all() + + // stemPrefix is 'articles', so 'navbar' → 'articles/navbar' + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("stem" = \'articles/navbar\') ORDER BY stem ASC', + ) + }) + + it('.stem() does not double-prefix when full stem is passed', async () => { + const query = collectionQueryBuilder(mockCollection, mockFetch) + await query.stem('articles/navbar').all() + + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("stem" = \'articles/navbar\') ORDER BY stem ASC', + ) + }) + it('locale fallback merges results in stem order', async () => { // fr has stem c, en has stems a, b, c — fallback should interleave a, b mockFetch From f8491fcbe034bb3fbd1d468ef2c4a42d79f74251 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 22:37:26 +0100 Subject: [PATCH 12/67] fix: exclude collection name from stem prefix computation --- src/utils/templates.ts | 4 ++-- test/unit/collectionQueryBuilder.test.ts | 18 ++++-------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/utils/templates.ts b/src/utils/templates.ts index 7d60757da..7e70afa0b 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -176,9 +176,9 @@ export const manifestTemplate = (manifest: Manifest) => ({ filename: moduleTemplates.manifest, getContents: ({ options }: { options: { manifest: Manifest } }) => { const collectionsMeta = options.manifest.collections.reduce((acc, collection) => { - // Compute stem prefix: join(collectionName, sourcePrefix) — what gets prepended to the filename in the stem + // Stem prefix = source prefix only (collection name is stripped by describeId in path-meta.ts) const sourcePrefix = collection.source?.[0]?.prefix || '' - const stemPrefix = [collection.name, sourcePrefix].filter(Boolean).join('/').replace(/^\/|\/$/g, '') + const stemPrefix = sourcePrefix.replace(/^\/|\/$/g, '') acc[collection.name] = { type: collection.type, fields: collection.fields, diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index 537121f12..d185fa849 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -11,7 +11,7 @@ vi.mock('#content/manifest', () => ({ type: 'data', fields: {}, i18n: { locales: ['en', 'fr', 'de'], defaultLocale: 'en' }, - stemPrefix: 'articles', + stemPrefix: '', }, }, })) @@ -241,24 +241,14 @@ describe('collectionQueryBuilder', () => { ) }) - it('.stem() auto-prefixes with collection stemPrefix', async () => { + 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() - // stemPrefix is 'articles', so 'navbar' → 'articles/navbar' expect(mockFetch).toHaveBeenCalledWith( 'articles', - 'SELECT * FROM _articles WHERE ("stem" = \'articles/navbar\') ORDER BY stem ASC', - ) - }) - - it('.stem() does not double-prefix when full stem is passed', async () => { - const query = collectionQueryBuilder(mockCollection, mockFetch) - await query.stem('articles/navbar').all() - - expect(mockFetch).toHaveBeenCalledWith( - 'articles', - 'SELECT * FROM _articles WHERE ("stem" = \'articles/navbar\') ORDER BY stem ASC', + 'SELECT * FROM _articles WHERE ("stem" = \'navbar\') ORDER BY stem ASC', ) }) From b7d612f74bcda62d8c52854063c0cc4bb76b8a56 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 23:01:58 +0100 Subject: [PATCH 13/67] feat: add useQueryCollection composable --- src/module.ts | 1 + src/runtime/client.ts | 86 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/module.ts b/src/module.ts index 73dc00447..40a600f86 100644 --- a/src/module.ts +++ b/src/module.ts @@ -131,6 +131,7 @@ 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') }, diff --git a/src/runtime/client.ts b/src/runtime/client.ts index de364453b..8e33ae700 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -6,7 +6,8 @@ import { type GenerateSearchSectionsOptions, generateSearchSections } from './in import { generateCollectionLocales } from './internal/locales' import { fetchQuery } from './internal/api' import type { Collections, PageCollections, CollectionQueryBuilder, ContentLocaleEntry, SurroundOptions, SQLOperator, QueryGroupFunction, ContentNavigationItem } from '@nuxt/content' -import { tryUseNuxtApp } from '#imports' +import type { AsyncData, NuxtError } from '#app' +import { tryUseNuxtApp, useAsyncData } from '#imports' interface ChainablePromise extends Promise { where(field: keyof PageCollections[T] | string, operator: SQLOperator, value?: unknown): ChainablePromise @@ -43,6 +44,89 @@ export function queryCollectionLocales(collection: return generateCollectionLocales(qb, stem) } +/** + * useAsyncData wrapper for queryCollection. + * Provides a chainable API that auto-wraps execution in useAsyncData + * with an auto-generated cache key. + * + * @example + * const { data } = await useQueryCollection('technologies').all() + * const { data } = await useQueryCollection('navigation').stem('navbar').first() + */ +export function useQueryCollection(collection: T) { + const qb = queryCollection(collection) + + type Item = Collections[T] + + const builder = { + where(field: string, operator: SQLOperator, value?: unknown) { + qb.where(field, operator, value) + return builder + }, + andWhere(groupFactory: QueryGroupFunction) { + qb.andWhere(groupFactory) + return builder + }, + orWhere(groupFactory: QueryGroupFunction) { + qb.orWhere(groupFactory) + return builder + }, + order(field: keyof Item, direction: 'ASC' | 'DESC') { + qb.order(field, direction) + return builder + }, + select(...fields: K[]) { + qb.select(...fields) + return builder + }, + skip(skip: number) { + qb.skip(skip) + return builder + }, + limit(limit: number) { + qb.limit(limit) + return builder + }, + path(path: string) { + qb.path(path) + return builder + }, + stem(stem: string) { + qb.stem(stem) + return builder + }, + locale(locale: string, opts?: { fallback?: string }) { + qb.locale(locale, opts) + return builder + }, + all(): AsyncData { + const key = generateKey(collection, qb) + return useAsyncData(key, () => qb.all()) as AsyncData + }, + first(): AsyncData { + const key = generateKey(collection, qb) + return useAsyncData(key, () => qb.first()) as AsyncData + }, + count(field?: keyof Item | '*', distinct?: boolean): AsyncData { + const key = generateKey(collection, qb) + return useAsyncData(key, () => qb.count(field, distinct)) as AsyncData + }, + } + + return builder +} + +function generateKey(collection: T, qb: CollectionQueryBuilder): string { + // Use internal params to build a stable cache key + const params = (qb as unknown as { __params: Record }).__params + const parts = [String(collection)] + const conditions = params.conditions as string[] + if (conditions?.length) parts.push(...conditions) + const fallback = params.localeFallback as { locale: string } | undefined + if (fallback) parts.push(`locale:${fallback.locale}`) + return `content:${parts.join(':')}` +} + async function executeContentQuery(event: H3Event | undefined, collection: T, sql: string) { if (import.meta.client && window.WebAssembly) { return queryContentSqlClientWasm(collection, sql) as Promise From bc19995fa7baf731cda9db14809a3d6c5586660f Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 23:02:07 +0100 Subject: [PATCH 14/67] refactor: include all query params in useQueryCollection cache key --- src/runtime/client.ts | 47 ++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 8e33ae700..76c29cf65 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -47,13 +47,19 @@ export function queryCollectionLocales(collection: /** * useAsyncData wrapper for queryCollection. * Provides a chainable API that auto-wraps execution in useAsyncData - * with an auto-generated cache key. + * with an auto-generated cache key. Locale is auto-detected from @nuxtjs/i18n. + * + * Must be called in a Vue component setup context (like useAsyncData, useFetch). * * @example * const { data } = await useQueryCollection('technologies').all() * const { data } = await useQueryCollection('navigation').stem('navbar').first() */ export function useQueryCollection(collection: T) { + const nuxtApp = tryUseNuxtApp() + // Capture detected locale for cache key (same logic as queryCollection) + const detectedLocale = (nuxtApp?.$i18n as { locale?: { value?: string } })?.locale?.value + || (nuxtApp?.ssrContext?.event?.context?.nuxtI18n as { vueI18nOptions?: { locale?: string } })?.vueI18nOptions?.locale const qb = queryCollection(collection) type Item = Collections[T] @@ -100,31 +106,36 @@ export function useQueryCollection(collection: T) { return builder }, all(): AsyncData { - const key = generateKey(collection, qb) - return useAsyncData(key, () => qb.all()) as AsyncData + return useAsyncData(buildKey('all'), () => qb.all()) as AsyncData }, first(): AsyncData { - const key = generateKey(collection, qb) - return useAsyncData(key, () => qb.first()) as AsyncData + return useAsyncData(buildKey('first'), () => qb.first()) as AsyncData }, count(field?: keyof Item | '*', distinct?: boolean): AsyncData { - const key = generateKey(collection, qb) - return useAsyncData(key, () => qb.count(field, distinct)) as AsyncData + return useAsyncData(buildKey('count'), () => qb.count(field, distinct)) as AsyncData }, } - return builder -} + function buildKey(method: string): string { + const params = (qb as unknown as { __params: Record }).__params + const parts = [String(collection)] + // Include all query-differentiating params + const conditions = params.conditions as string[] + if (conditions?.length) parts.push(...conditions) + const fallback = params.localeFallback as { locale: string } | undefined + if (fallback) parts.push(`l:${fallback.locale}`) + else if (detectedLocale) parts.push(`l:${detectedLocale}`) + const orderBy = params.orderBy as string[] + if (orderBy?.length) parts.push(`o:${orderBy.join(',')}`) + if (params.offset) parts.push(`s:${params.offset}`) + if (params.limit) parts.push(`n:${params.limit}`) + const fields = params.selectedFields as string[] + if (fields?.length) parts.push(`f:${fields.join(',')}`) + parts.push(method) + return `content:${parts.join(':')}` + } -function generateKey(collection: T, qb: CollectionQueryBuilder): string { - // Use internal params to build a stable cache key - const params = (qb as unknown as { __params: Record }).__params - const parts = [String(collection)] - const conditions = params.conditions as string[] - if (conditions?.length) parts.push(...conditions) - const fallback = params.localeFallback as { locale: string } | undefined - if (fallback) parts.push(`locale:${fallback.locale}`) - return `content:${parts.join(':')}` + return builder } async function executeContentQuery(event: H3Event | undefined, collection: T, sql: string) { From ecf01b87f58fbe50d13ce9e61564667d2badef08 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 23:02:17 +0100 Subject: [PATCH 15/67] fix: expose localeExplicitlySet in params for accurate cache keys --- src/runtime/client.ts | 3 ++- src/runtime/internal/query.ts | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 76c29cf65..430893fa1 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -123,8 +123,9 @@ export function useQueryCollection(collection: T) { const conditions = params.conditions as string[] if (conditions?.length) parts.push(...conditions) const fallback = params.localeFallback as { locale: string } | undefined + const localeExplicit = params.localeExplicitlySet as boolean if (fallback) parts.push(`l:${fallback.locale}`) - else if (detectedLocale) parts.push(`l:${detectedLocale}`) + else if (detectedLocale && !localeExplicit) parts.push(`l:${detectedLocale}`) const orderBy = params.orderBy as string[] if (orderBy?.length) parts.push(`o:${orderBy.join(',')}`) if (params.offset) parts.push(`s:${params.offset}`) diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 8971f950b..88da385d2 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -74,9 +74,6 @@ export const collectionQueryBuilder = (collection: const collectionMeta = (manifestMeta as Record)[String(collection)] const i18nConfig = collectionMeta?.i18n const stemPrefix = collectionMeta?.stemPrefix || '' - // Track whether .locale() was called explicitly - let localeExplicitlySet = false - const params = { conditions: [] as Array, selectedFields: [] as Array, @@ -90,6 +87,8 @@ export const collectionQueryBuilder = (collection: }, // Locale fallback (handled via two queries + JS merge) localeFallback: undefined as { locale: string, fallback: string } | undefined, + // Track whether .locale() was called explicitly (exposed for cache key generation) + localeExplicitlySet: false, } const query: CollectionQueryBuilder = { @@ -116,7 +115,7 @@ export const collectionQueryBuilder = (collection: return query.where('stem', '=', fullStem) }, locale(locale: string, opts?: { fallback?: string }) { - localeExplicitlySet = true + params.localeExplicitlySet = true if (opts?.fallback) { params.localeFallback = { locale, fallback: opts.fallback } } @@ -181,7 +180,7 @@ export const collectionQueryBuilder = (collection: */ let autoLocaleApplied = false function applyAutoLocale() { - if (autoLocaleApplied || localeExplicitlySet || !i18nConfig || !detectedLocale) return + if (autoLocaleApplied || params.localeExplicitlySet || !i18nConfig || !detectedLocale) return autoLocaleApplied = true if (detectedLocale === i18nConfig.defaultLocale) { // Default locale: single query, no fallback needed From 93ae75ebcc9eee234a636409f407f13a8acc2dbd Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 23:23:45 +0100 Subject: [PATCH 16/67] fix: ensure stem in locale fallback select and fix dev watcher key matching --- src/runtime/internal/query.ts | 5 +++++ src/utils/dev.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 88da385d2..f53b6e59a 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -195,6 +195,11 @@ export const collectionQueryBuilder = (collection: async function fetchWithLocaleFallback(opts: { limit?: number } = {}): Promise { const { locale, fallback } = params.localeFallback! + // Ensure `stem` is always fetched — needed for merge-key deduplication + if (params.selectedFields.length > 0 && !params.selectedFields.includes('stem' as keyof Collections[T])) { + params.selectedFields.push('stem' as keyof Collections[T]) + } + // Sub-queries fetch ALL matching rows (no limit/offset) — we apply those JS-side on the merged result const localeCondition = `("locale" = ${singleQuote(locale)})` const localeQuery = buildQuery({ extraCondition: localeCondition, noLimitOffset: true }) diff --git a/src/utils/dev.ts b/src/utils/dev.ts index b7dea68bb..faeaf1c55 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -254,7 +254,10 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani } const collectionDump = manifest.dump[collection.name]! - const keyIndex = collectionDump.findIndex(item => item.includes(`'${key}'`)) + // Use exact key match: look for the id as a complete SQL string literal ('key',) to avoid + // substring matches (e.g., 'team.yml' matching 'team.yml#fr') + const escapedKey = key.replace(/'/g, '\'\'') + const keyIndex = collectionDump.findIndex(item => item.includes(`'${escapedKey}',`) || item.endsWith(`'${escapedKey}')`)) const indexToUpdate = keyIndex !== -1 ? keyIndex : collectionDump.length const itemsToRemove = keyIndex === -1 ? 0 : 1 From 04f13d3a12efb8fddca7ab939f299e332981e284 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 23:23:53 +0100 Subject: [PATCH 17/67] refactor: replay ops in useQueryCollection for reactive locale --- src/runtime/client.ts | 67 ++++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 430893fa1..92ea409f1 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -7,7 +7,8 @@ import { generateCollectionLocales } from './internal/locales' import { fetchQuery } from './internal/api' import type { Collections, PageCollections, CollectionQueryBuilder, ContentLocaleEntry, SurroundOptions, SQLOperator, QueryGroupFunction, ContentNavigationItem } from '@nuxt/content' import type { AsyncData, NuxtError } from '#app' -import { tryUseNuxtApp, useAsyncData } from '#imports' +import type { Ref } from 'vue' +import { tryUseNuxtApp, useAsyncData, computed } from '#imports' interface ChainablePromise extends Promise { where(field: keyof PageCollections[T] | string, operator: SQLOperator, value?: unknown): ChainablePromise @@ -47,7 +48,8 @@ export function queryCollectionLocales(collection: /** * useAsyncData wrapper for queryCollection. * Provides a chainable API that auto-wraps execution in useAsyncData - * with an auto-generated cache key. Locale is auto-detected from @nuxtjs/i18n. + * with an auto-generated cache key. Locale is auto-detected from @nuxtjs/i18n + * and content automatically re-fetches when the locale changes. * * Must be called in a Vue component setup context (like useAsyncData, useFetch). * @@ -57,75 +59,94 @@ export function queryCollectionLocales(collection: */ export function useQueryCollection(collection: T) { const nuxtApp = tryUseNuxtApp() - // Capture detected locale for cache key (same logic as queryCollection) - const detectedLocale = (nuxtApp?.$i18n as { locale?: { value?: string } })?.locale?.value - || (nuxtApp?.ssrContext?.event?.context?.nuxtI18n as { vueI18nOptions?: { locale?: string } })?.vueI18nOptions?.locale - const qb = queryCollection(collection) + const i18nLocaleRef = (nuxtApp?.$i18n as { locale?: Ref })?.locale + // Reactive locale for cache key and watch + const localeValue = computed(() => i18nLocaleRef?.value || '') type Item = Collections[T] + // Collect query chain operations to replay on each execution + const ops: Array<(qb: CollectionQueryBuilder) => void> = [] + let explicitLocale = false + const builder = { where(field: string, operator: SQLOperator, value?: unknown) { - qb.where(field, operator, value) + ops.push(qb => qb.where(field, operator, value)) return builder }, andWhere(groupFactory: QueryGroupFunction) { - qb.andWhere(groupFactory) + ops.push(qb => qb.andWhere(groupFactory)) return builder }, orWhere(groupFactory: QueryGroupFunction) { - qb.orWhere(groupFactory) + ops.push(qb => qb.orWhere(groupFactory)) return builder }, order(field: keyof Item, direction: 'ASC' | 'DESC') { - qb.order(field, direction) + ops.push(qb => qb.order(field, direction)) return builder }, select(...fields: K[]) { - qb.select(...fields) + ops.push(qb => qb.select(...fields)) return builder }, skip(skip: number) { - qb.skip(skip) + ops.push(qb => qb.skip(skip)) return builder }, limit(limit: number) { - qb.limit(limit) + ops.push(qb => qb.limit(limit)) return builder }, path(path: string) { - qb.path(path) + ops.push(qb => qb.path(path)) return builder }, stem(stem: string) { - qb.stem(stem) + ops.push(qb => qb.stem(stem)) return builder }, locale(locale: string, opts?: { fallback?: string }) { - qb.locale(locale, opts) + explicitLocale = true + ops.push(qb => qb.locale(locale, opts)) return builder }, all(): AsyncData { - return useAsyncData(buildKey('all'), () => qb.all()) as AsyncData + const key = buildKey('all') + const watchSources = !explicitLocale && localeValue.value ? [localeValue] : undefined + return useAsyncData(key, () => buildQuery().all(), { watch: watchSources }) as AsyncData }, first(): AsyncData { - return useAsyncData(buildKey('first'), () => qb.first()) as AsyncData + const key = buildKey('first') + const watchSources = !explicitLocale && localeValue.value ? [localeValue] : undefined + return useAsyncData(key, () => buildQuery().first(), { watch: watchSources }) as AsyncData }, count(field?: keyof Item | '*', distinct?: boolean): AsyncData { - return useAsyncData(buildKey('count'), () => qb.count(field, distinct)) as AsyncData + const key = buildKey('count') + const watchSources = !explicitLocale && localeValue.value ? [localeValue] : undefined + return useAsyncData(key, () => buildQuery().count(field, distinct), { watch: watchSources }) 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 + } + function buildKey(method: string): string { - const params = (qb as unknown as { __params: Record }).__params + // Build key from the ops chain description + locale const parts = [String(collection)] - // Include all query-differentiating params + // Replay ops on a temporary builder to read params + const tmpQb = queryCollection(collection) + for (const op of ops) op(tmpQb) + const params = (tmpQb as unknown as { __params: Record }).__params const conditions = params.conditions as string[] if (conditions?.length) parts.push(...conditions) const fallback = params.localeFallback as { locale: string } | undefined - const localeExplicit = params.localeExplicitlySet as boolean if (fallback) parts.push(`l:${fallback.locale}`) - else if (detectedLocale && !localeExplicit) parts.push(`l:${detectedLocale}`) + else if (localeValue.value && !explicitLocale) parts.push(`l:${localeValue.value}`) const orderBy = params.orderBy as string[] if (orderBy?.length) parts.push(`o:${orderBy.join(',')}`) if (params.offset) parts.push(`s:${params.offset}`) From 11b530073b99611111505dea104e3b4ce3ae031f Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 23:24:02 +0100 Subject: [PATCH 18/67] refactor: extract watchSources helper and include fallback in cache key --- src/runtime/client.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 92ea409f1..fc2e0d47c 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -113,21 +113,23 @@ export function useQueryCollection(collection: T) { }, all(): AsyncData { const key = buildKey('all') - const watchSources = !explicitLocale && localeValue.value ? [localeValue] : undefined - return useAsyncData(key, () => buildQuery().all(), { watch: watchSources }) as AsyncData + return useAsyncData(key, () => buildQuery().all(), { watch: watchSources() }) as AsyncData }, first(): AsyncData { const key = buildKey('first') - const watchSources = !explicitLocale && localeValue.value ? [localeValue] : undefined - return useAsyncData(key, () => buildQuery().first(), { watch: watchSources }) as AsyncData + return useAsyncData(key, () => buildQuery().first(), { watch: watchSources() }) as AsyncData }, count(field?: keyof Item | '*', distinct?: boolean): AsyncData { const key = buildKey('count') - const watchSources = !explicitLocale && localeValue.value ? [localeValue] : undefined - return useAsyncData(key, () => buildQuery().count(field, distinct), { watch: watchSources }) as AsyncData + return useAsyncData(key, () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData }, } + /** Watch locale ref for auto-refetch — only when i18n ref exists and locale isn't explicit. */ + function watchSources() { + return !explicitLocale && i18nLocaleRef ? [localeValue] : undefined + } + /** Rebuild a fresh query builder with all chained ops replayed. */ function buildQuery(): CollectionQueryBuilder { const qb = queryCollection(collection) @@ -136,7 +138,6 @@ export function useQueryCollection(collection: T) { } function buildKey(method: string): string { - // Build key from the ops chain description + locale const parts = [String(collection)] // Replay ops on a temporary builder to read params const tmpQb = queryCollection(collection) @@ -144,8 +145,8 @@ export function useQueryCollection(collection: T) { const params = (tmpQb as unknown as { __params: Record }).__params const conditions = params.conditions as string[] if (conditions?.length) parts.push(...conditions) - const fallback = params.localeFallback as { locale: string } | undefined - if (fallback) parts.push(`l:${fallback.locale}`) + const fb = params.localeFallback as { locale: string, fallback: string } | undefined + if (fb) parts.push(`l:${fb.locale}:fb:${fb.fallback}`) else if (localeValue.value && !explicitLocale) parts.push(`l:${localeValue.value}`) const orderBy = params.orderBy as string[] if (orderBy?.length) parts.push(`o:${orderBy.join(',')}`) From b6c6ebb7bf0538d2d76831a7f41f0ac6f9bb27cd Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 23:39:58 +0100 Subject: [PATCH 19/67] feat: add generic type override to useQueryCollection --- src/runtime/client.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index fc2e0d47c..ca4456aa9 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -57,13 +57,15 @@ export function queryCollectionLocales(collection: * const { data } = await useQueryCollection('technologies').all() * const { data } = await useQueryCollection('navigation').stem('navbar').first() */ -export function useQueryCollection(collection: T) { +export function useQueryCollection(collection: T) { const nuxtApp = tryUseNuxtApp() const i18nLocaleRef = (nuxtApp?.$i18n as { locale?: Ref })?.locale // Reactive locale for cache key and watch const localeValue = computed(() => i18nLocaleRef?.value || '') type Item = Collections[T] + // Use the consumer's type override if provided, otherwise the collection type + type Result = [R] extends [never] ? Item : R // Collect query chain operations to replay on each execution const ops: Array<(qb: CollectionQueryBuilder) => void> = [] @@ -111,13 +113,13 @@ export function useQueryCollection(collection: T) { ops.push(qb => qb.locale(locale, opts)) return builder }, - all(): AsyncData { + all(): AsyncData { const key = buildKey('all') - return useAsyncData(key, () => buildQuery().all(), { watch: watchSources() }) as AsyncData + return useAsyncData(key, () => buildQuery().all(), { watch: watchSources() }) as AsyncData }, - first(): AsyncData { + first(): AsyncData { const key = buildKey('first') - return useAsyncData(key, () => buildQuery().first(), { watch: watchSources() }) as AsyncData + return useAsyncData(key, () => buildQuery().first(), { watch: watchSources() }) as AsyncData }, count(field?: keyof Item | '*', distinct?: boolean): AsyncData { const key = buildKey('count') From 21bef61d12ed5874f8548ed9d31e97997ebd6b47 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 00:11:06 +0100 Subject: [PATCH 20/67] fix: use function key and raw locale ref in useQueryCollection --- src/runtime/client.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index ca4456aa9..df9b9a902 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -114,22 +114,18 @@ export function useQueryCollection { - const key = buildKey('all') - return useAsyncData(key, () => buildQuery().all(), { watch: watchSources() }) as AsyncData + return useAsyncData(() => buildKey('all'), () => buildQuery().all(), { watch: watchSources() }) as AsyncData }, first(): AsyncData { - const key = buildKey('first') - return useAsyncData(key, () => buildQuery().first(), { watch: watchSources() }) as AsyncData + return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) as AsyncData }, count(field?: keyof Item | '*', distinct?: boolean): AsyncData { - const key = buildKey('count') - return useAsyncData(key, () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData + return useAsyncData(() => buildKey('count'), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData }, } - /** Watch locale ref for auto-refetch — only when i18n ref exists and locale isn't explicit. */ function watchSources() { - return !explicitLocale && i18nLocaleRef ? [localeValue] : undefined + return !explicitLocale && i18nLocaleRef ? [i18nLocaleRef] : undefined } /** Rebuild a fresh query builder with all chained ops replayed. */ @@ -147,6 +143,7 @@ export function useQueryCollection }).__params const conditions = params.conditions as string[] if (conditions?.length) parts.push(...conditions) + // Include locale in key — with a function key, this is re-evaluated reactively const fb = params.localeFallback as { locale: string, fallback: string } | undefined if (fb) parts.push(`l:${fb.locale}:fb:${fb.fallback}`) else if (localeValue.value && !explicitLocale) parts.push(`l:${localeValue.value}`) From b922036040873e09de669b2342ba078365e559bf Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 00:11:13 +0100 Subject: [PATCH 21/67] perf: track key params inline instead of replaying temp query builder --- src/runtime/client.ts | 51 ++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index df9b9a902..d8055f89b 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -71,45 +71,70 @@ export function useQueryCollection) => void> = [] let explicitLocale = false + // Track key-relevant params directly — avoids creating a full query builder in buildKey + const keyParts = { + conditions: [] as string[], + orderBy: [] as string[], + offset: 0, + limit: 0, + selectedFields: [] as string[], + localeFallback: undefined as { locale: string, fallback: string } | undefined, + } + const builder = { where(field: string, operator: SQLOperator, value?: unknown) { + keyParts.conditions.push(`${field}${operator}${value}`) ops.push(qb => qb.where(field, operator, value)) return builder }, andWhere(groupFactory: QueryGroupFunction) { + keyParts.conditions.push('andWhere') ops.push(qb => qb.andWhere(groupFactory)) return builder }, orWhere(groupFactory: QueryGroupFunction) { + keyParts.conditions.push('orWhere') ops.push(qb => qb.orWhere(groupFactory)) return builder }, order(field: keyof Item, direction: 'ASC' | 'DESC') { + keyParts.orderBy.push(`${String(field)}:${direction}`) ops.push(qb => qb.order(field, direction)) return builder }, select(...fields: K[]) { + keyParts.selectedFields.push(...fields.map(String)) ops.push(qb => qb.select(...fields)) return builder }, skip(skip: number) { + keyParts.offset = skip ops.push(qb => qb.skip(skip)) return builder }, limit(limit: number) { + keyParts.limit = limit ops.push(qb => qb.limit(limit)) return builder }, path(path: string) { + keyParts.conditions.push(`path=${path}`) ops.push(qb => qb.path(path)) return builder }, stem(stem: string) { + keyParts.conditions.push(`stem=${stem}`) ops.push(qb => qb.stem(stem)) return builder }, locale(locale: string, opts?: { fallback?: string }) { explicitLocale = true + if (opts?.fallback) { + keyParts.localeFallback = { locale, fallback: opts.fallback } + } + else { + keyParts.conditions.push(`locale=${locale}`) + } ops.push(qb => qb.locale(locale, opts)) return builder }, @@ -117,10 +142,10 @@ export function useQueryCollection buildKey('all'), () => buildQuery().all(), { watch: watchSources() }) as AsyncData }, first(): AsyncData { - return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) as AsyncData + return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) as AsyncData as unknown as AsyncData }, count(field?: keyof Item | '*', distinct?: boolean): AsyncData { - return useAsyncData(() => buildKey('count'), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData + return useAsyncData(() => buildKey('count'), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData as unknown as AsyncData }, } @@ -135,24 +160,16 @@ export function useQueryCollection }).__params - const conditions = params.conditions as string[] - if (conditions?.length) parts.push(...conditions) - // Include locale in key — with a function key, this is re-evaluated reactively - const fb = params.localeFallback as { locale: string, fallback: string } | undefined - if (fb) parts.push(`l:${fb.locale}:fb:${fb.fallback}`) + if (keyParts.conditions.length) parts.push(...keyParts.conditions) + if (keyParts.localeFallback) parts.push(`l:${keyParts.localeFallback.locale}:fb:${keyParts.localeFallback.fallback}`) else if (localeValue.value && !explicitLocale) parts.push(`l:${localeValue.value}`) - const orderBy = params.orderBy as string[] - if (orderBy?.length) parts.push(`o:${orderBy.join(',')}`) - if (params.offset) parts.push(`s:${params.offset}`) - if (params.limit) parts.push(`n:${params.limit}`) - const fields = params.selectedFields as string[] - if (fields?.length) parts.push(`f:${fields.join(',')}`) + if (keyParts.orderBy.length) parts.push(`o:${keyParts.orderBy.join(',')}`) + if (keyParts.offset) parts.push(`s:${keyParts.offset}`) + if (keyParts.limit) parts.push(`n:${keyParts.limit}`) + if (keyParts.selectedFields.length) parts.push(`f:${keyParts.selectedFields.join(',')}`) parts.push(method) return `content:${parts.join(':')}` } From 78f9512192277ae689e26be508eca71ae6c9e4d8 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 00:11:22 +0100 Subject: [PATCH 22/67] fix: serialize andWhere/orWhere conditions for deterministic cache keys --- src/runtime/client.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index d8055f89b..8aac8d8ee 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -1,5 +1,5 @@ import type { H3Event } from 'h3' -import { collectionQueryBuilder } from './internal/query' +import { collectionQueryBuilder, collectionQueryGroup } from './internal/query' import { generateNavigationTree } from './internal/navigation' import { generateItemSurround } from './internal/surround' import { type GenerateSearchSectionsOptions, generateSearchSections } from './internal/search' @@ -88,12 +88,16 @@ export function useQueryCollection) { - keyParts.conditions.push('andWhere') + const group = groupFactory(collectionQueryGroup(collection)) + const cond = (group as unknown as { _conditions: string[] })._conditions.join(' AND ') + keyParts.conditions.push(`and(${cond})`) ops.push(qb => qb.andWhere(groupFactory)) return builder }, orWhere(groupFactory: QueryGroupFunction) { - keyParts.conditions.push('orWhere') + const group = groupFactory(collectionQueryGroup(collection)) + const cond = (group as unknown as { _conditions: string[] })._conditions.join(' OR ') + keyParts.conditions.push(`or(${cond})`) ops.push(qb => qb.orWhere(groupFactory)) return builder }, From cb9e218ffc6ff0660698e32f3cfdad7db551a31a Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 00:43:08 +0100 Subject: [PATCH 23/67] fix: deep-merge locale overrides for data collections --- src/module.ts | 10 ++++++---- src/utils/dev.ts | 7 +++++-- test/unit/i18n.test.ts | 13 ++++++------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/module.ts b/src/module.ts index 40a600f86..24255da39 100644 --- a/src/module.ts +++ b/src/module.ts @@ -406,11 +406,13 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio for (const [locale, overrides] of Object.entries(i18nData)) { if (locale === defaultItem.locale) continue - // Shallow spread: overrides replace whole top-level fields - // (defu would deep-merge body AST / nested objects, corrupting them) + // Deep merge for data collections (safe — no body AST to corrupt) + // Shallow spread for page collections (body AST would be corrupted by defu) + const merged = collection.type === 'data' + ? defu(overrides, defaultItem) as ParsedContentFile + : { ...defaultItem, ...overrides } const localeItem: ParsedContentFile = { - ...defaultItem, - ...overrides, + ...merged, id: `${parsedContent.id}#${locale}`, locale, meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, diff --git a/src/utils/dev.ts b/src/utils/dev.ts index faeaf1c55..b2340eaa9 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -3,6 +3,7 @@ import type { ViteDevServer } from 'vite' import crypto from 'node:crypto' import { readFile } from 'node:fs/promises' import { join, resolve } from 'pathe' +import defu from 'defu' import type { Nuxt } from '@nuxt/schema' import { isIgnored, updateTemplates, useLogger } from '@nuxt/kit' import type { ConsolaInstance } from 'consola' @@ -185,9 +186,11 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani for (const [locale, overrides] of Object.entries(i18nData)) { if (locale === parsed.locale) continue const localeKey = `${keyInCollection}#${locale}` + const merged = collection.type === 'data' + ? defu(overrides, parsed) as ParsedContentFile + : { ...parsed, ...overrides } const localeItem: ParsedContentFile = { - ...parsed, - ...overrides, + ...merged, id: localeKey, locale, meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index 20a8232e6..c81a3a580 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { hash } from 'ohash' +import defu from 'defu' import type { CollectionI18nConfig } from '../../src/types/collection' import type { ParsedContentFile } from '../../src/types' @@ -39,10 +40,9 @@ function expandI18n( for (const [locale, overrides] of Object.entries(i18nData)) { if (locale === parsedContent.locale) continue - // Shallow spread: overrides replace whole top-level fields (not deep-merge) + // Deep merge for data collections: translated fields override, untranslated fields preserved const localeItem: ParsedContentFile = { - ...parsedContent, - ...overrides, + ...defu(overrides, parsedContent) as ParsedContentFile, id: `${parsedContent.id}#${locale}`, locale, meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, @@ -179,7 +179,7 @@ describe('i18n - inline expansion', () => { expect(items[1].title).toBe('My Post') }) - it('shallow-replaces nested objects in locale overrides', () => { + it('deep-merges nested objects in locale overrides for data collections', () => { const content: ParsedContentFile = { id: 'team:jane.yml', name: 'Jane Doe', @@ -200,9 +200,8 @@ describe('i18n - inline expansion', () => { // Default keeps original expect(items[0].info).toEqual({ age: 25, country: 'Switzerland' }) - // German override replaces the whole `info` object (shallow spread, not deep-merge) - // This prevents corrupting complex objects like body AST - expect(items[1].info).toEqual({ country: 'Schweiz' }) + // German override deep-merges: country overridden, age preserved + expect(items[1].info).toEqual({ age: 25, country: 'Schweiz' }) }) it('does not include default locale in expanded items', () => { From 2ee227d190d5db3ab5f7dc519bec41434593b004 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 00:43:17 +0100 Subject: [PATCH 24/67] feat: add defuByIndex merge strategy for array fields --- src/module.ts | 3 ++- src/utils/dev.ts | 4 ++-- src/utils/i18n.ts | 21 +++++++++++++++++++++ test/unit/i18n.test.ts | 4 ++-- 4 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 src/utils/i18n.ts diff --git a/src/module.ts b/src/module.ts index 24255da39..a856f3de9 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 { defuByIndex } from './utils/i18n' import { version } from '../package.json' import { generateCollectionInsert, generateCollectionTableDefinition } from './utils/collection' import { componentsManifestTemplate, contentTypesTemplate, fullDatabaseRawDumpTemplate, manifestTemplate, moduleTemplates } from './utils/templates' @@ -409,7 +410,7 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio // Deep merge for data collections (safe — no body AST to corrupt) // Shallow spread for page collections (body AST would be corrupted by defu) const merged = collection.type === 'data' - ? defu(overrides, defaultItem) as ParsedContentFile + ? defuByIndex(overrides, defaultItem) as ParsedContentFile : { ...defaultItem, ...overrides } const localeItem: ParsedContentFile = { ...merged, diff --git a/src/utils/dev.ts b/src/utils/dev.ts index b2340eaa9..2347ae635 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -3,7 +3,7 @@ import type { ViteDevServer } from 'vite' import crypto from 'node:crypto' import { readFile } from 'node:fs/promises' import { join, resolve } from 'pathe' -import defu from 'defu' +import { defuByIndex } from './i18n' import type { Nuxt } from '@nuxt/schema' import { isIgnored, updateTemplates, useLogger } from '@nuxt/kit' import type { ConsolaInstance } from 'consola' @@ -187,7 +187,7 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani if (locale === parsed.locale) continue const localeKey = `${keyInCollection}#${locale}` const merged = collection.type === 'data' - ? defu(overrides, parsed) as ParsedContentFile + ? defuByIndex(overrides, parsed) as ParsedContentFile : { ...parsed, ...overrides } const localeItem: ParsedContentFile = { ...merged, diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts new file mode 100644 index 000000000..67642fb58 --- /dev/null +++ b/src/utils/i18n.ts @@ -0,0 +1,21 @@ +import defu, { createDefu } from 'defu' + +/** + * Custom defu that merges arrays by index (item-by-item) instead of concatenating. + * Used for inline i18n expansion: locale overrides merge with default locale items + * so untranslated fields (routes, IDs, progress) are preserved from the default. + */ +export const defuByIndex = createDefu((obj, key, value) => { + if (Array.isArray(obj[key]) && Array.isArray(value)) { + const base = obj[key] + obj[key] = value.map((item, i) => + i < base.length && typeof item === 'object' && item !== null && typeof base[i] === 'object' && base[i] !== null + ? defu(item, base[i]) + : item, + ) + if (base.length > value.length) { + obj[key].push(...base.slice(value.length)) + } + return true + } +}) diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index c81a3a580..19584114a 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { hash } from 'ohash' -import defu from 'defu' +import { defuByIndex } from '../../src/utils/i18n' import type { CollectionI18nConfig } from '../../src/types/collection' import type { ParsedContentFile } from '../../src/types' @@ -42,7 +42,7 @@ function expandI18n( // Deep merge for data collections: translated fields override, untranslated fields preserved const localeItem: ParsedContentFile = { - ...defu(overrides, parsedContent) as ParsedContentFile, + ...defuByIndex(overrides, parsedContent) as ParsedContentFile, id: `${parsedContent.id}#${locale}`, locale, meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, From 0f69353296181afb33287e08cb5904718e4d9427 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 00:43:25 +0100 Subject: [PATCH 25/67] refactor: rewrite defuByIndex with explicit loop and add array merge test --- src/utils/i18n.ts | 28 ++++++++++++++++++++-------- test/unit/i18n.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 67642fb58..efdb2421a 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -4,18 +4,30 @@ import defu, { createDefu } from 'defu' * Custom defu that merges arrays by index (item-by-item) instead of concatenating. * Used for inline i18n expansion: locale overrides merge with default locale items * so untranslated fields (routes, IDs, progress) are preserved from the default. + * + * In createDefu's merger: obj[key] = accumulated result (has defaults), value = override. + * Override items take priority; default items fill gaps for missing fields. */ export const defuByIndex = createDefu((obj, key, value) => { if (Array.isArray(obj[key]) && Array.isArray(value)) { - const base = obj[key] - obj[key] = value.map((item, i) => - i < base.length && typeof item === 'object' && item !== null && typeof base[i] === 'object' && base[i] !== null - ? defu(item, base[i]) - : item, - ) - if (base.length > value.length) { - obj[key].push(...base.slice(value.length)) + const defaultArr = obj[key] + const overrideArr = value + const maxLen = Math.max(overrideArr.length, defaultArr.length) + const result = [] + for (let i = 0; i < maxLen; i++) { + const overrideItem = overrideArr[i] + const defaultItem = defaultArr[i] + if (overrideItem !== undefined && defaultItem !== undefined + && typeof overrideItem === 'object' && overrideItem !== null + && typeof defaultItem === 'object' && defaultItem !== null) { + // Override first (priority), default as fallback + result.push(defu(overrideItem, defaultItem)) + } + else { + result.push(overrideItem !== undefined ? overrideItem : defaultItem) + } } + obj[key] = result return true } }) diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index 19584114a..13aff44c2 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -204,6 +204,36 @@ describe('i18n - inline expansion', () => { 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 = expandI18n(content, i18nConfig) + const frItem = items.find(i => i.locale === 'fr') + // Array items merged by index: label overridden, id + route preserved from default + 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', From a5660b1da490d09e6b478d89477d686c9dddeaec Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 00:49:38 +0100 Subject: [PATCH 26/67] fix: apply index-based array merge recursively in defuByIndex --- src/utils/i18n.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index efdb2421a..26533cc9b 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -1,9 +1,10 @@ -import defu, { createDefu } from 'defu' +import { createDefu } from 'defu' /** * Custom defu that merges arrays by index (item-by-item) instead of concatenating. + * Applied recursively to all nested arrays within merged objects. * Used for inline i18n expansion: locale overrides merge with default locale items - * so untranslated fields (routes, IDs, progress) are preserved from the default. + * so untranslated fields (routes, IDs, icons, URLs) are preserved from the default. * * In createDefu's merger: obj[key] = accumulated result (has defaults), value = override. * Override items take priority; default items fill gaps for missing fields. @@ -20,8 +21,8 @@ export const defuByIndex = createDefu((obj, key, value) => { if (overrideItem !== undefined && defaultItem !== undefined && typeof overrideItem === 'object' && overrideItem !== null && typeof defaultItem === 'object' && defaultItem !== null) { - // Override first (priority), default as fallback - result.push(defu(overrideItem, defaultItem)) + // Recursively merge with defuByIndex so nested arrays also merge by index + result.push(defuByIndex(overrideItem, defaultItem)) } else { result.push(overrideItem !== undefined ? overrideItem : defaultItem) From 0837c22f4ade58021211e8770147ac04e7967fc0 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 00:54:56 +0100 Subject: [PATCH 27/67] test: add edge case tests for defuByIndex merge strategy --- test/unit/i18n.test.ts | 194 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index 13aff44c2..8113ea220 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -334,6 +334,200 @@ describe('i18n - path-based locale detection', () => { }) }) +describe('i18n - defuByIndex edge cases', () => { + const i18nConfig: CollectionI18nConfig = { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + } + + 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' }, + // No 3rd item — should preserve default 'C' + ], + }, + }, + }, + } + + const items = expandI18n(content, i18nConfig) + const frItem = items.find(i => i.locale === 'fr') + + expect(frItem?.items).toHaveLength(3) + expect(frItem?.items[0]).toEqual({ id: 'a', label: 'A-fr', route: '/a' }) + expect(frItem?.items[1]).toEqual({ id: 'b', label: 'B-fr', route: '/b' }) + expect(frItem?.items[2]).toEqual({ id: 'c', label: 'C', route: '/c' }) + }) + + it('deep-merges nested arrays within array items (e.g. links inside 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 = expandI18n(content, i18nConfig) + const frItem = items.find(i => i.locale === 'fr') + + // Description overridden + expect(frItem?.items[0].description).toBe('Texte francais') + // Link title overridden, but url and icon preserved from default + expect(frItem?.items[0].links[0].title).toBe('En savoir plus') + expect(frItem?.items[0].links[0].url).toBe('/page') + expect(frItem?.items[0].links[0].icon).toEqual({ 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 = expandI18n(content, i18nConfig) + + expect(items).toHaveLength(1) + expect(items[0].locale).toBe('en') + expect(items[0].title).toBe('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 + + expandI18n(original, i18nConfig) + + // Original items array should not be mutated + expect(originalItemsRef[0].label).toBe('Original') + // Override object should not be mutated + 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 = expandI18n(content, i18nConfig) + const frItem = items.find(i => i.locale === 'fr') + + expect(frItem?.items).toHaveLength(2) + expect(frItem?.items[0]).toEqual({ id: 'a', label: 'A-fr' }) + expect(frItem?.items[1]).toEqual({ id: 'b', label: 'B-fr', route: '/b' }) + }) + + it('handles scalar arrays (not objects) 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 = expandI18n(content, i18nConfig) + const deItem = items.find(i => i.locale === 'de') + + // Scalar arrays: override replaces entirely (no object merge) + expect(deItem?.tags).toEqual(['JavaScript', 'Vue', 'Nuxt']) + }) + + it('preserves non-translated top-level fields across all locales', () => { + const content: ParsedContentFile = { + id: 'data:config.yml', + title: 'Site Config', + apiUrl: 'https://api.example.com', + maxRetries: 3, + stem: 'config', + extension: 'yml', + meta: { + i18n: { + fr: { title: 'Config du site' }, + de: { title: 'Seitenkonfiguration' }, + }, + }, + } + + const items = expandI18n(content, i18nConfig) + + for (const item of items) { + // Locale-invariant fields preserved in all locale variants + expect(item.apiUrl).toBe('https://api.example.com') + expect(item.maxRetries).toBe(3) + } + expect(items[1].title).toBe('Config du site') + expect(items[2].title).toBe('Seitenkonfiguration') + }) +}) + describe('i18n - source hash for change tracking', () => { const i18nConfig: CollectionI18nConfig = { locales: ['en', 'fr', 'de'], From 0c740571cd9b866bc58e10e5a87d935447e3dfdb Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 01:17:00 +0100 Subject: [PATCH 28/67] docs: rewrite integration guide and add useQueryCollection and queryCollectionLocales pages --- .../docs/4.utils/1.query-collection.md | 32 +++ .../docs/4.utils/5.use-query-collection.md | 115 +++++++++ .../4.utils/6.query-collection-locales.md | 93 +++++++ docs/content/docs/7.integrations/01.i18n.md | 239 +++++++++++------- 4 files changed, 388 insertions(+), 91 deletions(-) create mode 100644 docs/content/docs/4.utils/5.use-query-collection.md create mode 100644 docs/content/docs/4.utils/6.query-collection-locales.md diff --git a/docs/content/docs/4.utils/1.query-collection.md b/docs/content/docs/4.utils/1.query-collection.md index 4f0a52bcb..0eceed427 100644 --- a/docs/content/docs/4.utils/1.query-collection.md +++ b/docs/content/docs/4.utils/1.query-collection.md @@ -223,6 +223,38 @@ const { data } = await useAsyncData(route.path, () => { }) ``` +### `locale(locale: string, opts?: { fallback?: string })` + +Filter results by locale. Only applicable to collections with `i18n` configured. + +- Parameters: + - `locale`: The locale code to filter by (e.g. `'fr'`) + - `opts.fallback`: Optional fallback locale code. When set, items missing in the requested locale will be filled from the fallback locale. + +```ts +// Filter by French locale +queryCollection('docs').locale('fr').all() + +// With fallback to English for missing items +queryCollection('docs').locale('fr', { fallback: 'en' }).all() +``` + +::tip +When `@nuxtjs/i18n` is installed and the collection has `i18n` configured, locale filtering is applied automatically based on the current locale. You only need `.locale()` for explicit control. +:: + +### `stem(stem: string)` + +Filter by stem (filename without extension). Automatically resolves the full stem path including the collection's source prefix. Useful for querying data collections by filename. + +- Parameter: + - `stem`: The stem to match (e.g. `'navbar'` for `content/navigation/navbar.yml`) + +```ts +// Matches content/navigation/navbar.yml when source is 'navigation/*.yml' +queryCollection('navigation').stem('navbar').first() +``` + ### `count()` Count the number of matched collection entries based on the query. diff --git a/docs/content/docs/4.utils/5.use-query-collection.md b/docs/content/docs/4.utils/5.use-query-collection.md new file mode 100644 index 000000000..4615da9ef --- /dev/null +++ b/docs/content/docs/4.utils/5.use-query-collection.md @@ -0,0 +1,115 @@ +--- +title: useQueryCollection +description: The useQueryCollection composable wraps queryCollection with useAsyncData for automatic caching and locale reactivity. +--- + +## Usage + +`useQueryCollection` provides the same chainable API as `queryCollection`, but wraps execution in `useAsyncData` with automatic cache key generation and locale-reactive re-fetching. + +```vue [pages/technologies.vue] + +``` + +::warning +`useQueryCollection` must be called in a Vue component setup context, just like `useAsyncData` and `useFetch`. It cannot be called in event handlers, watchers, or lifecycle hooks. +:: + +## API + +### Type + +```ts +function useQueryCollection( + collection: T +): UseQueryCollectionBuilder +``` + +The optional generic `R` overrides the return type. When omitted, the collection's generated type is used. + +### Methods + +`useQueryCollection` supports all the same chainable methods as `queryCollection`: + +- `.where(field, operator, value)` +- `.andWhere(groupFactory)` +- `.orWhere(groupFactory)` +- `.order(field, direction)` +- `.select(...fields)` +- `.skip(n)` +- `.limit(n)` +- `.path(path)` +- `.stem(stem)` +- `.locale(locale, opts?)` + +### Terminal Methods + +Terminal methods execute the query and return `AsyncData`: + +- `.all()` — returns `AsyncData` +- `.first()` — returns `AsyncData` +- `.count(field?, distinct?)` — returns `AsyncData` + +## Locale Reactivity + +When `@nuxtjs/i18n` is installed and the collection has `i18n` configured, `useQueryCollection` automatically: + +1. Detects the current locale +2. Includes it in the cache key +3. Watches the locale ref for changes +4. Re-fetches content when the locale changes (no page reload needed) + +```vue [app/layouts/default.vue] + +``` + +## Type Override + +Use the generic parameter to override the return type when the collection's generated type doesn't match your component's expected interface: + +```vue [pages/technologies.vue] + +``` + +## Examples + +### Single Item by Stem + +```vue + +``` + +### Filtered and Ordered + +```vue + +``` + +### With Explicit Locale + +```vue + +``` diff --git a/docs/content/docs/4.utils/6.query-collection-locales.md b/docs/content/docs/4.utils/6.query-collection-locales.md new file mode 100644 index 000000000..35e3eb4fb --- /dev/null +++ b/docs/content/docs/4.utils/6.query-collection-locales.md @@ -0,0 +1,93 @@ +--- +title: queryCollectionLocales +description: Query all locale variants of a content item for language switchers and hreflang tags. +--- + +## Usage + +`queryCollectionLocales` returns all locale variants for a given content stem. This is useful for building language switchers, generating hreflang SEO tags, and implementing `defineI18nRoute()` with `@nuxtjs/i18n`. + +```vue [app/components/LanguageSwitcher.vue] + + + +``` + +::tip +`queryCollectionLocales` bypasses automatic locale filtering — it always returns all locale variants regardless of the current locale. +:: + +## API + +### Type + +```ts +// Client-side (auto-imported) +function queryCollectionLocales( + collection: T, + stem: string +): Promise + +// Server-side +function queryCollectionLocales( + event: H3Event, + collection: T, + stem: string +): Promise + +interface ContentLocaleEntry { + locale: string + stem: string + path?: string // Only for page collections + title?: string // Only for page collections +} +``` + +### Parameters + +- `collection`: The collection name +- `stem`: The content stem (e.g. `'docs/getting-started'`) + +## Server Usage + +```ts [server/api/locales.ts] +export default eventHandler(async (event) => { + const stem = getQuery(event).stem as string + return await queryCollectionLocales(event, 'docs', stem) +}) +``` + +## Use Cases + +### Language Switcher + +```vue + +``` + +### Hreflang Meta Tags + +```vue + +``` diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index 9ba6c4d4c..ded319e3e 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -9,12 +9,12 @@ seo: description: Learn how to create multi-language websites using Nuxt Content with the @nuxtjs/i18n module. --- -Nuxt Content integrates with `@nuxtjs/i18n` to create multi-language websites. When both modules are configured together, you can organize content by language and automatically serve the correct content based on the user's locale. +Nuxt Content integrates with `@nuxtjs/i18n` to create multi-language websites. Content can be organized by locale directories (path-based) or with inline translations in a single file. Locale detection is automatic when `@nuxtjs/i18n` is installed. ## Setup ::prose-steps -### Install the required module +### Install the required modules ```bash [terminal] npm install @nuxtjs/i18n @@ -27,9 +27,9 @@ export default defineNuxtConfig({ modules: ['@nuxt/content', '@nuxtjs/i18n'], i18n: { locales: [ - { code: 'en', name: 'English', language: 'en-US', dir: 'ltr' }, + { code: 'en', name: 'English', language: 'en-US' }, { code: 'fr', name: 'French', language: 'fr-FR' }, - { code: 'fa', name: 'Farsi', language: 'fa-IR', dir: 'rtl' }, + { code: 'de', name: 'German', language: 'de-DE' }, ], strategy: 'prefix_except_default', defaultLocale: 'en', @@ -37,131 +37,188 @@ 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' export default defineContentConfig({ collections: { - // English content collection - content_en: defineCollection({ + // Auto-detect locales from @nuxtjs/i18n + docs: defineCollection({ type: 'page', - source: { - include: 'en/**', - prefix: '', - }, - schema: commonSchema, + source: '*/docs/**', + i18n: true, }), - // French content collection - content_fr: defineCollection({ - type: 'page', - source: { - include: 'fr/**', - prefix: '', + // Or explicit config + team: defineCollection({ + type: 'data', + source: 'data/team.yml', + schema: z.object({ + name: z.string(), + role: z.string(), + }), + i18n: { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', }, - schema: commonSchema, - }), - // Farsi content collection - content_fa: defineCollection({ - type: 'page', - source: { - include: 'fa/**', - prefix: '', - }, - schema: commonSchema, }), }, }) ``` -### Create dynamic pages +When `i18n` is configured, a `locale` column is automatically added to the collection schema and an index on `(locale, stem)` is created. +:: -Create a catch-all page that fetches content based on the current locale: +## Content Approaches -```vue [pages/[...slug\\].vue] - +``` + +For the default locale, a single `WHERE locale = ?` query is issued. For non-default locales, content is fetched with automatic fallback to the default locale for missing items. + +### useQueryCollection - +The `useQueryCollection` composable wraps `queryCollection` with `useAsyncData`, providing automatic cache key generation and locale-reactive re-fetching: + +```vue [pages/technologies.vue] + ``` + +::warning +`useQueryCollection` must be called in a Vue component setup context (like `useAsyncData` and `useFetch`). :: -That's it! 🚀 Your multi-language content site is ready. +### Explicit Locale Control -## Content Structure +Use `.locale()` to override the auto-detected locale: -Organize your content files in language-specific folders to match your collections: +```ts +// Filter by a specific locale +queryCollection('docs').locale('fr').all() -```text -content/ - en/ - index.md - about.md - blog/ - post-1.md - fr/ - index.md - about.md - blog/ - post-1.md - fa/ - index.md - about.md +// With fallback to default locale for missing items +queryCollection('docs').locale('fr', { fallback: 'en' }).all() ``` -Each language folder should contain the same structure to ensure content parity across locales. +### Language Switcher (All Locale Variants) -## Fallback Strategy +Use `queryCollectionLocales` to get all locale variants for a given content item — useful for building language switchers and hreflang tags: -You can implement a fallback strategy to show content from the default locale when content is missing in the current locale: +```ts +const locales = await queryCollectionLocales('docs', 'docs/getting-started') +// Returns: [{ locale: 'en', path: '/docs/getting-started', stem: '...', title: '...' }, ...] +``` -```ts [pages/[...slug\\].vue] -const { data: page } = await useAsyncData('page-' + slug.value, async () => { - const collection = ('content_' + locale.value) as keyof Collections - let content = await queryCollection(collection).path(slug.value).first() +### Stem Queries - // Fallback to default locale if content is missing - if (!content && locale.value !== 'en') { - content = await queryCollection('content_en').path(slug.value).first() - } +Use `.stem()` to query data collections by filename. The source directory prefix is resolved automatically: - return content -}) +```ts +// Matches content/navigation/navbar.yml +queryCollection('navigation').stem('navbar').first() ``` -::prose-warning -Make sure to handle missing content gracefully and provide clear feedback to users when content is not available in their preferred language. -:: +## Translator Change Tracking + +For inline i18n, each non-default locale item stores a `_i18nSourceHash` in its `meta`. This hash is computed from the default locale's translated fields. When the default content changes, the hash changes — allowing Studio or custom tooling to detect potentially outdated translations. + +```ts +const item = await queryCollection('team').locale('fr').first() +console.log(item.meta._i18nSourceHash) // Hash of the default locale's translated fields +``` + +## CSP Configuration + +If you use `nuxt-security` with Content Security Policy, add `'wasm-unsafe-eval'` to your `script-src` directive. Nuxt Content uses WebAssembly SQLite for client-side queries during locale switching: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + security: { + headers: { + contentSecurityPolicy: { + 'script-src': ["'self'", "'wasm-unsafe-eval'", /* ... */], + }, + }, + }, + routeRules: { + '/__nuxt_content/**': { + csurf: false, // Exempt content API from CSRF + }, + }, +}) +``` ## Complete Examples From f7e61cd14890588a9f7b60e337a529248919291e Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 01:17:07 +0100 Subject: [PATCH 29/67] fix: quote count field names and reject newlines in queries --- src/runtime/internal/query.ts | 3 ++- src/runtime/internal/security.ts | 7 ++++++- test/unit/collectionQueryBuilder.test.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index f53b6e59a..06c549b7d 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -239,7 +239,8 @@ export const collectionQueryBuilder = (collection: function buildQuery(opts: { count?: { field: string, distinct: boolean }, limit?: number, extraCondition?: string, noLimitOffset?: boolean } = {}) { let query = 'SELECT ' if (opts?.count) { - query += `COUNT(${opts.count.distinct ? 'DISTINCT ' : ''}${opts.count.field}) as count` + const countField = opts.count.field === '*' ? '*' : `"${opts.count.field.replace(/"/g, '')}"` + query += `COUNT(${opts.count.distinct ? 'DISTINCT ' : ''}${countField}) as count` } else { const fields = Array.from(new Set(params.selectedFields)) diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index 8e07e9281..5cc2d8e81 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -1,5 +1,5 @@ const SQL_COMMANDS = /SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|\$/i -const SQL_COUNT_REGEX = /COUNT\((DISTINCT )?([a-z_]\w+|\*)\)/i +const SQL_COUNT_REGEX = /COUNT\((DISTINCT )?("[a-z_]\w+"|[a-z_]\w+|\*)\)/i const SQL_SELECT_REGEX = /^SELECT (.*) FROM (\w+)( WHERE .*?)?( ORDER BY (["\w,\s]+) (ASC|DESC))?( LIMIT \d+)?( OFFSET \d+)?$/ /** @@ -16,6 +16,11 @@ export function assertSafeQuery(sql: string, collection: string) { throw new Error('Invalid query: Query cannot be empty') } + // Reject newlines to prevent multi-statement injection + if (sql.includes('\n') || sql.includes('\r')) { + throw new Error('Invalid query: Newlines are not allowed in queries') + } + const cleanedupQuery = cleanupQuery(sql) // Query is invalid if the cleaned up query is not the same as the original query (it contains comments) diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index d185fa849..da1cbdecd 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -154,7 +154,7 @@ describe('collectionQueryBuilder', () => { expect(mockFetch).toHaveBeenCalledWith( 'articles', - 'SELECT COUNT(DISTINCT author) as count FROM _articles', + 'SELECT COUNT(DISTINCT "author") as count FROM _articles', ) }) From e38580bf8a32245c471ab68b7145f93833088984 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 01:17:15 +0100 Subject: [PATCH 30/67] fix: handle doubled-quote escapes in SQL string parser --- src/runtime/internal/security.ts | 60 +++++++++++++++++--------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index 5cc2d8e81..6d24a59a5 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -95,49 +95,53 @@ function cleanupQuery(query: string, options: { removeString: boolean } = { remo let result = '' for (let i = 0; i < query.length; i++) { const char = query[i] - const prevChar = query[i - 1] const nextChar = query[i + 1] - if (char === '\'' || char === '"') { + if (inString) { + if (char === stringFence) { + if (nextChar === stringFence) { + // Doubled quote escape (e.g., '' inside a string) — skip both, stay in string + i++ + } + else { + // String closing quote + inString = false + stringFence = '' + } + } + // Inside string: skip character (don't add to result when removeString is active) if (!options?.removeString) { result += char - continue } + continue + } - if (inString) { - if (char !== stringFence || nextChar === stringFence || prevChar === stringFence) { - // skip character, it's part of a string - continue - } - - inString = false - stringFence = '' - continue - } - else { + // Not in string + if (char === '\'' || char === '"') { + if (options?.removeString) { inString = true stringFence = char continue } + result += char + continue } - if (!inString) { - if (char === '-' && nextChar === '-') { - // everything after this is a comment - return result - } + if (char === '-' && nextChar === '-') { + // everything after this is a comment + return result + } - if (char === '/' && nextChar === '*') { - i += 2 - while (i < query.length && !(query[i] === '*' && query[i + 1] === '/')) { - i += 1 - } - i += 2 - continue + if (char === '/' && nextChar === '*') { + i += 2 + while (i < query.length && !(query[i] === '*' && query[i + 1] === '/')) { + i += 1 } - - result += char + i += 2 + continue } + + result += char } return result } From dded51b93259b3fb6a6e90f9d1f413017ac60beb Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 01:17:22 +0100 Subject: [PATCH 31/67] docs: add i18n sections to collection definition, YAML, and JSON pages --- docs/content/docs/2.collections/1.define.md | 31 +++++++++++++++++++++ docs/content/docs/3.files/2.yaml.md | 20 +++++++++++++ docs/content/docs/3.files/3.json.md | 18 ++++++++++++ 3 files changed, 69 insertions(+) diff --git a/docs/content/docs/2.collections/1.define.md b/docs/content/docs/2.collections/1.define.md index eaa840f22..3a7202e7b 100644 --- a/docs/content/docs/2.collections/1.define.md +++ b/docs/content/docs/2.collections/1.define.md @@ -138,6 +138,37 @@ 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' + +export default defineContentConfig({ + collections: { + // Auto-detect from @nuxtjs/i18n + docs: defineCollection({ + type: 'page', + source: '*/docs/**', + i18n: true, + }), + // Explicit config + team: defineCollection({ + type: 'data', + source: 'data/*.yml', + schema: z.object({ name: z.string(), role: z.string() }), + i18n: { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + }, + }), + }, +}) +``` + +When `i18n` is configured, a `locale` column and a composite `(locale, stem)` index are automatically added to the collection. See the [i18n integration guide](/docs/integrations/i18n) for full documentation. + **Performance Tips:** - Index columns used in `where()` queries for faster filtering diff --git a/docs/content/docs/3.files/2.yaml.md b/docs/content/docs/3.files/2.yaml.md index 654dce9a9..80d68ba08 100644 --- a/docs/content/docs/3.files/2.yaml.md +++ b/docs/content/docs/3.files/2.yaml.md @@ -43,6 +43,26 @@ url: https://github.com/larbish ``` :: +## Inline i18n + +YAML files in i18n-enabled collections can include an `i18n` section for inline translations. Untranslated fields are preserved from the default locale automatically: + +```yaml [jane.yml] +name: Jane Doe +role: Developer +country: Switzerland + +i18n: + fr: + role: Développeuse + country: Suisse + de: + role: Entwicklerin + country: Schweiz +``` + +See the [i18n integration guide](/docs/integrations/i18n) for full documentation. + ## Query Data Now we can query authors: diff --git a/docs/content/docs/3.files/3.json.md b/docs/content/docs/3.files/3.json.md index 17017dcde..562baa46a 100644 --- a/docs/content/docs/3.files/3.json.md +++ b/docs/content/docs/3.files/3.json.md @@ -51,6 +51,24 @@ Create authors files in `content/authors/` directory. Each file in `data` collection should contain only one object, therefore having top level array in a JSON file will cause invalid result in query time. :: +## Inline i18n + +JSON files in i18n-enabled collections can include an `i18n` key for inline translations. Untranslated fields are preserved from the default locale automatically: + +```json [jane.json] +{ + "name": "Jane Doe", + "role": "Developer", + "country": "Switzerland", + "i18n": { + "fr": { "role": "Développeuse", "country": "Suisse" }, + "de": { "role": "Entwicklerin", "country": "Schweiz" } + } +} +``` + +See the [i18n integration guide](/docs/integrations/i18n) for full documentation. + ## Query Data Now we can query authors: From da8bf59abad100961cb6b737bc6d796aa864e5dc Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 01:33:13 +0100 Subject: [PATCH 32/67] fix: address PR review feedback - locales.ts: remove .select() to avoid invalid SQL on data collections, use .stem() for consistency - query.ts: fix stem prefix boundary check, don't mutate selectedFields in fallback, update mergeSortedArrays comment - client.ts: use JSON.stringify for cache keys, pass generics to useAsyncData instead of double-casting - content/index.ts: only assign locale when not already set Co-Authored-By: Claude Opus 4.6 (1M context) --- src/runtime/client.ts | 6 +++--- src/runtime/internal/locales.ts | 7 +++---- src/runtime/internal/query.ts | 35 +++++++++++++++++++++------------ src/utils/content/index.ts | 6 +++--- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 8aac8d8ee..16dcc16b6 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -146,10 +146,10 @@ export function useQueryCollection buildKey('all'), () => buildQuery().all(), { watch: watchSources() }) as AsyncData }, first(): AsyncData { - return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) as AsyncData as unknown as AsyncData + return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) }, count(field?: keyof Item | '*', distinct?: boolean): AsyncData { - return useAsyncData(() => buildKey('count'), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData as unknown as AsyncData + return useAsyncData(() => buildKey('count'), () => buildQuery().count(field, distinct), { watch: watchSources() }) }, } @@ -175,7 +175,7 @@ export function useQueryCollection>( queryBuilder: CollectionQueryBuilder, stem: string, ): Promise { - // Select only the lightweight fields we need — avoids fetching large body ASTs + // No .select() — data collections lack path/title columns; SELECT * is safe here + // because ContentLocaleEntry marks path? and title? as optional. const items = await (queryBuilder as unknown as CollectionQueryBuilder>) - .select('locale' as never, 'stem' as never, 'path' as never, 'title' as never) - .where('stem', '=', stem) + .stem(stem) .all() return items.map((item) => { diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 06c549b7d..7f5cf9c38 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -108,8 +108,9 @@ export const collectionQueryBuilder = (collection: return query.where('path', '=', withoutTrailingSlash(path)) }, stem(stem: string) { - // Resolve full stem by prepending the collection's source prefix if not already present - const fullStem = stemPrefix && !stem.startsWith(stemPrefix) + // Resolve full stem by prepending the collection's source prefix if not already present. + // Check segment boundary to avoid false matches (e.g. prefix "navigation" matching "navigation2/foo"). + const fullStem = stemPrefix && !(stem === stemPrefix || stem.startsWith(stemPrefix + '/')) ? `${stemPrefix}/${stem}` : stem return query.where('stem', '=', fullStem) @@ -195,9 +196,11 @@ export const collectionQueryBuilder = (collection: async function fetchWithLocaleFallback(opts: { limit?: number } = {}): Promise { const { locale, fallback } = params.localeFallback! - // Ensure `stem` is always fetched — needed for merge-key deduplication - if (params.selectedFields.length > 0 && !params.selectedFields.includes('stem' as keyof Collections[T])) { - params.selectedFields.push('stem' as keyof Collections[T]) + // Ensure `stem` is always fetched — needed for merge-key deduplication. + // Use a local copy to avoid mutating the shared selectedFields array. + const savedFields = params.selectedFields + if (savedFields.length > 0 && !savedFields.includes('stem' as keyof Collections[T])) { + params.selectedFields = [...savedFields, 'stem' as keyof Collections[T]] } // Sub-queries fetch ALL matching rows (no limit/offset) — we apply those JS-side on the merged result @@ -209,16 +212,21 @@ export const collectionQueryBuilder = (collection: const fallbackQuery = buildQuery({ extraCondition: fallbackCondition, noLimitOffset: true }) const fallbackResults = await fetch(collection, fallbackQuery).then(res => res || []) + // Restore original selectedFields to avoid side-effects on repeated calls + params.selectedFields = savedFields + // Merge: prefer locale results, fill gaps from fallback const getStem = (r: Collections[T]) => (r as unknown as { stem: string }).stem const localeStemSet = new Set(localeResults.map(getStem)) const fallbackOnly = fallbackResults.filter(item => !localeStemSet.has(getStem(item))) - // When using the default ORDER BY (stem ASC), we can do a proper sorted merge. - // When a custom ORDER BY is specified, both sub-queries are already DB-sorted - // by that field — we keep locale items first and append fallback items after, - // preserving each group's DB order. A full interleave would require parsing - // the SQL ORDER BY clause in JS, which is not feasible. + // When using the default ORDER BY (stem ASC), we can do a proper sorted merge + // because mergeSortedArrays compares by stem. + // LIMITATION: when a custom ORDER BY is specified, we cannot interleave the + // two result sets correctly because that would require parsing the SQL ORDER BY + // clause and re-implementing the comparison in JS. Instead we concatenate + // locale items first, then fallback items — each group retains its DB order + // but the overall sequence may not match a single-query ORDER BY. const merged = params.orderBy.length === 0 ? mergeSortedArrays(localeResults, fallbackOnly, getStem) : [...localeResults, ...fallbackOnly] @@ -284,9 +292,10 @@ export const collectionQueryBuilder = (collection: } /** - * Merge two arrays that are already sorted by the same criteria (from DB ORDER BY). - * Uses the `stem` field as tie-breaker key to interleave items in the correct position. - * This preserves any ORDER BY the DB applied (date DESC, custom fields, etc.). + * Merge two arrays that are both sorted by `stem` ASC (the default ORDER BY). + * Interleaves items using lexicographic comparison of their `stem` values. + * Precondition: both arrays must be sorted by stem ASC — this function does NOT + * handle arbitrary ORDER BY clauses (use concatenation for custom sorts). */ function mergeSortedArrays(a: T[], b: T[], getStem: (r: T) => string): T[] { // Both arrays come from the DB with the same ORDER BY. diff --git a/src/utils/content/index.ts b/src/utils/content/index.ts index bcce63245..c6d7fa3c4 100644 --- a/src/utils/content/index.ts +++ b/src/utils/content/index.ts @@ -223,7 +223,7 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) const firstPart = pathParts[0] if (firstPart && collection.i18n.locales.includes(firstPart)) { - result.locale = firstPart + result.locale = result.locale ?? firstPart // Strip locale prefix from path const pathWithoutLocale = '/' + pathParts.slice(1).join('/') if (collectionKeys.includes('path')) { @@ -239,8 +239,8 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) } } else { - // No locale prefix - assign default locale - result.locale = collection.i18n.defaultLocale + // No locale prefix - assign default locale (only if not already set) + result.locale = result.locale ?? collection.i18n.defaultLocale } } From 111a918857b46fa2183d7ef65f0f82c0235530e3 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 03:01:48 +0200 Subject: [PATCH 33/67] fix: resolve TypeScript strict-mode errors in i18n code paths --- src/module.ts | 6 +++--- src/runtime/client.ts | 4 ++-- src/utils/content/index.ts | 4 ++-- src/utils/dev.ts | 6 +++--- src/utils/i18n.ts | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/module.ts b/src/module.ts index a856f3de9..eed1cd742 100644 --- a/src/module.ts +++ b/src/module.ts @@ -380,9 +380,9 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio } // i18n: expand inline translations to per-locale rows - if (collection.i18n && parsedContent?.meta?.i18n) { - const i18nData = parsedContent.meta.i18n as Record> - const { i18n: _removed, ...cleanMeta } = parsedContent.meta + if (collection.i18n && (parsedContent?.meta as Record)?.i18n) { + const i18nData = (parsedContent.meta as Record).i18n as Record> + const { i18n: _removed, ...cleanMeta } = parsedContent.meta as Record parsedContent.meta = cleanMeta // Default locale item diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 16dcc16b6..28af8abb2 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -146,10 +146,10 @@ export function useQueryCollection buildKey('all'), () => buildQuery().all(), { watch: watchSources() }) as AsyncData }, first(): AsyncData { - return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) + return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) as AsyncData }, count(field?: keyof Item | '*', distinct?: boolean): AsyncData { - return useAsyncData(() => buildKey('count'), () => buildQuery().count(field, distinct), { watch: watchSources() }) + return useAsyncData(() => buildKey('count'), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData }, } diff --git a/src/utils/content/index.ts b/src/utils/content/index.ts index c6d7fa3c4..89b16f382 100644 --- a/src/utils/content/index.ts +++ b/src/utils/content/index.ts @@ -218,7 +218,7 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) // i18n: detect locale from path prefix when collection has i18n configured if (collection.i18n && collectionKeys.includes('locale')) { - const currentPath = result.path || pathMetaFields.path || '' + const currentPath = String(result.path || pathMetaFields.path || '') const pathParts = currentPath.split('/').filter(Boolean) const firstPart = pathParts[0] @@ -230,7 +230,7 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) result.path = pathWithoutLocale === '/' ? '/' : pathWithoutLocale } // Always strip locale prefix from stem (regardless of stem format) - const currentStem = result.stem || pathMetaFields.stem || '' + const currentStem = String(result.stem || pathMetaFields.stem || '') if (currentStem === firstPart) { result.stem = '' } diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 2347ae635..fe156d110 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -167,9 +167,9 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani const parsed: ParsedContentFile = JSON.parse(parsedContent) // i18n: expand inline translations to per-locale DB rows (same logic as processCollectionItems) - if (collection.i18n && parsed?.meta?.i18n) { - const i18nData = parsed.meta.i18n as Record> - const { i18n: _removed, ...cleanMeta } = parsed.meta + if (collection.i18n && (parsed?.meta as Record)?.i18n) { + const i18nData = (parsed.meta as Record).i18n as Record> + const { i18n: _removed, ...cleanMeta } = parsed.meta as Record parsed.meta = cleanMeta if (!parsed.locale) parsed.locale = collection.i18n.defaultLocale diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 26533cc9b..3a3d5fb24 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -28,7 +28,7 @@ export const defuByIndex = createDefu((obj, key, value) => { result.push(overrideItem !== undefined ? overrideItem : defaultItem) } } - obj[key] = result + ;(obj as Record)[key as string] = result return true } }) From 6ea2702bc5266f5b225162953015757162157463 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 03:01:56 +0200 Subject: [PATCH 34/67] fix: differentiate count cache keys, clean stale locale variants, and fix docs --- docs/content/docs/2.collections/1.define.md | 1 + .../docs/4.utils/6.query-collection-locales.md | 9 ++++++++- docs/content/docs/7.integrations/01.i18n.md | 1 + src/runtime/client.ts | 3 ++- src/runtime/internal/security.ts | 11 +++++------ src/utils/dev.ts | 6 ++++++ 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/2.collections/1.define.md b/docs/content/docs/2.collections/1.define.md index 3a7202e7b..60e4e07bd 100644 --- a/docs/content/docs/2.collections/1.define.md +++ b/docs/content/docs/2.collections/1.define.md @@ -144,6 +144,7 @@ Enable multi-language content for a collection by adding the `i18n` option. Pass ```ts [content.config.ts] import { defineCollection, defineContentConfig } from '@nuxt/content' +import { z } from 'zod' export default defineContentConfig({ collections: { diff --git a/docs/content/docs/4.utils/6.query-collection-locales.md b/docs/content/docs/4.utils/6.query-collection-locales.md index 35e3eb4fb..63502905a 100644 --- a/docs/content/docs/4.utils/6.query-collection-locales.md +++ b/docs/content/docs/4.utils/6.query-collection-locales.md @@ -72,7 +72,14 @@ export default eventHandler(async (event) => { ```vue ``` diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index ded319e3e..0956b5814 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -43,6 +43,7 @@ Add `i18n: true` to auto-detect locales from `@nuxtjs/i18n`, or provide an expli ```ts [content.config.ts] import { defineCollection, defineContentConfig } from '@nuxt/content' +import { z } from 'zod' export default defineContentConfig({ collections: { diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 28af8abb2..f5e866fe8 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -149,7 +149,8 @@ export function useQueryCollection buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) as AsyncData }, count(field?: keyof Item | '*', distinct?: boolean): AsyncData { - return useAsyncData(() => buildKey('count'), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData + const countKey = `count:${String(field ?? '*')}:${distinct ? 'd' : ''}` + return useAsyncData(() => buildKey(countKey), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData }, } diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index 6d24a59a5..03cf7dfce 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -116,14 +116,13 @@ function cleanupQuery(query: string, options: { removeString: boolean } = { remo continue } - // Not in string + // Not in string — opening quote starts string tracking regardless of removeString mode if (char === '\'' || char === '"') { - if (options?.removeString) { - inString = true - stringFence = char - continue + inString = true + stringFence = char + if (!options?.removeString) { + result += char } - result += char continue } diff --git a/src/utils/dev.ts b/src/utils/dev.ts index fe156d110..be0f4f3f5 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -206,6 +206,12 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani } } else { + // Clean up stale locale variants if i18n was previously present but removed + if (collection.i18n) { + for (const locale of collection.i18n.locales) { + await broadcast(collection, `${keyInCollection}#${locale}`) + } + } const { queries: insertQuery } = generateCollectionInsert(collection, parsed) await broadcast(collection, keyInCollection, insertQuery) } From a576ca068775a49df8df3c40756487f0f2bf52c5 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 03:02:04 +0200 Subject: [PATCH 35/67] fix: preserve doubled-quote escapes in cleanupQuery output --- src/runtime/internal/security.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index 03cf7dfce..3e005a302 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -101,7 +101,11 @@ function cleanupQuery(query: string, options: { removeString: boolean } = { remo if (char === stringFence) { if (nextChar === stringFence) { // Doubled quote escape (e.g., '' inside a string) — skip both, stay in string + if (!options?.removeString) { + result += char + char // preserve both quotes + } i++ + continue } else { // String closing quote @@ -109,7 +113,7 @@ function cleanupQuery(query: string, options: { removeString: boolean } = { remo stringFence = '' } } - // Inside string: skip character (don't add to result when removeString is active) + // Inside string: keep character when not removing strings if (!options?.removeString) { result += char } From 301ed5e1ab678ba301fb7129dc921b455d87c6e5 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 03:21:13 +0200 Subject: [PATCH 36/67] fix: correct count with locale fallback and strip injected stem field --- src/runtime/internal/query.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 7f5cf9c38..06053eb49 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -164,7 +164,13 @@ export const collectionQueryBuilder = (collection: async count(field: keyof Collections[T] | '*' = '*', distinct: boolean = false) { applyAutoLocale() if (params.localeFallback) { - return fetchWithLocaleFallback().then(res => res.length) + return fetchWithLocaleFallback({ preserveField: field !== '*' ? String(field) : undefined }).then((res) => { + if (field === '*') return res.length + const values = res + .map(r => (r as unknown as Record)[String(field)]) + .filter(v => v !== null && v !== undefined) + return distinct ? new Set(values).size : values.length + }) } return fetch(collection, buildQuery({ count: { field: String(field), distinct }, @@ -193,13 +199,14 @@ export const collectionQueryBuilder = (collection: } } - async function fetchWithLocaleFallback(opts: { limit?: number } = {}): Promise { + async function fetchWithLocaleFallback(opts: { limit?: number, preserveField?: string } = {}): Promise { const { locale, fallback } = params.localeFallback! // Ensure `stem` is always fetched — needed for merge-key deduplication. - // Use a local copy to avoid mutating the shared selectedFields array. + // Track whether we injected it so we can strip it from results later. const savedFields = params.selectedFields - if (savedFields.length > 0 && !savedFields.includes('stem' as keyof Collections[T])) { + const stemInjected = savedFields.length > 0 && !savedFields.includes('stem' as keyof Collections[T]) + if (stemInjected) { params.selectedFields = [...savedFields, 'stem' as keyof Collections[T]] } @@ -241,6 +248,15 @@ export const collectionQueryBuilder = (collection: result = result.slice(0, limit) } + // Strip internally-injected 'stem' if the caller didn't select it + // (unless it's needed by a count() call targeting that field) + if (stemInjected && opts.preserveField !== 'stem') { + return result.map((item) => { + const { stem: _, ...rest } = item as unknown as Record + return rest as Collections[T] + }) + } + return result as Collections[T][] } From a44b89ef3b327cd494597fa8ea08082e86a66162 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 03:21:21 +0200 Subject: [PATCH 37/67] fix: use deep merge for all collection types and replace body AST wholesale --- docs/content/docs/7.integrations/01.i18n.md | 18 +++++++++++++++++- src/module.ts | 11 ++++++----- src/types/database.ts | 2 +- src/utils/dev.ts | 7 ++++--- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index 0956b5814..a7c136ffe 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -132,12 +132,28 @@ When `@nuxtjs/i18n` is installed, `queryCollection` automatically detects the cu ```vue [pages/[...slug\\].vue] ``` +::tip +Content paths are stored **without** the locale prefix (e.g., `/docs/getting-started` not `/en/docs/getting-started`). When using `@nuxtjs/i18n` with `prefix_except_default` strategy, strip the locale prefix from `route.path` before querying. +:: + For the default locale, a single `WHERE locale = ?` query is issued. For non-default locales, content is fetched with automatic fallback to the default locale for missing items. ### useQueryCollection diff --git a/src/module.ts b/src/module.ts index eed1cd742..ad0bdea12 100644 --- a/src/module.ts +++ b/src/module.ts @@ -407,11 +407,12 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio for (const [locale, overrides] of Object.entries(i18nData)) { if (locale === defaultItem.locale) continue - // Deep merge for data collections (safe — no body AST to corrupt) - // Shallow spread for page collections (body AST would be corrupted by defu) - const merged = collection.type === 'data' - ? defuByIndex(overrides, defaultItem) as ParsedContentFile - : { ...defaultItem, ...overrides } + // Deep merge preserves untranslated fields (routes, IDs, icons). + // For page collections, body AST must not be deep-merged — replace it wholesale. + const merged = defuByIndex(overrides, defaultItem) as ParsedContentFile + if (collection.type === 'page' && overrides.body) { + merged.body = overrides.body + } const localeItem: ParsedContentFile = { ...merged, id: `${parsedContent.id}#${locale}`, diff --git a/src/types/database.ts b/src/types/database.ts index 7e827f2b1..888b28f9b 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): void deleteDevelopmentCache(id: string): void dropContentTables(): void exec(sql: string): void diff --git a/src/utils/dev.ts b/src/utils/dev.ts index be0f4f3f5..49e863cc0 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -186,9 +186,10 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani for (const [locale, overrides] of Object.entries(i18nData)) { if (locale === parsed.locale) continue const localeKey = `${keyInCollection}#${locale}` - const merged = collection.type === 'data' - ? defuByIndex(overrides, parsed) as ParsedContentFile - : { ...parsed, ...overrides } + const merged = defuByIndex(overrides, parsed) as ParsedContentFile + if (collection.type === 'page' && overrides.body) { + merged.body = overrides.body + } const localeItem: ParsedContentFile = { ...merged, id: localeKey, From aaf195db98b724fa24931038f5e92b00e2d1f729 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 03:30:52 +0200 Subject: [PATCH 38/67] fix: align useQueryCollection return types with useAsyncData signature --- src/runtime/client.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index f5e866fe8..621c8cb69 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -142,15 +142,15 @@ export function useQueryCollection qb.locale(locale, opts)) return builder }, - all(): AsyncData { - return useAsyncData(() => buildKey('all'), () => buildQuery().all(), { watch: watchSources() }) as AsyncData + all(): AsyncData { + return useAsyncData(() => buildKey('all'), () => buildQuery().all(), { watch: watchSources() }) as AsyncData }, - first(): AsyncData { - return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) as AsyncData + first(): AsyncData { + return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) as AsyncData }, - count(field?: keyof Item | '*', distinct?: boolean): AsyncData { + count(field?: keyof Item | '*', distinct?: boolean): AsyncData { const countKey = `count:${String(field ?? '*')}:${distinct ? 'd' : ''}` - return useAsyncData(() => buildKey(countKey), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData + return useAsyncData(() => buildKey(countKey), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData }, } From b5e4767d72f24d571b5f560862d0a37c5f68f58b Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 03:42:13 +0200 Subject: [PATCH 39/67] fix: bypass pagination and ensure counted field in locale fallback count --- docs/content/docs/7.integrations/01.i18n.md | 2 +- src/runtime/internal/query.ts | 15 ++++++++++++++- src/types/database.ts | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index a7c136ffe..6f49856be 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -120,7 +120,7 @@ i18n: - Nested objects and arrays within items are also merged recursively ::tip -For **data collections** (YAML/JSON), deep merge is used — translated fields override, untranslated fields are preserved from the default. For **page collections** (Markdown), a shallow spread is used to prevent corrupting the body AST. +Deep merge is used for all collection types — translated fields override, untranslated fields (routes, IDs, URLs) are preserved from the default. For **page collections**, the `body` field (Markdown AST) is replaced wholesale when the override provides it, preventing AST corruption from deep-merging. :: ## Querying Content diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 06053eb49..2baeec2ee 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -164,7 +164,20 @@ export const collectionQueryBuilder = (collection: async count(field: keyof Collections[T] | '*' = '*', distinct: boolean = false) { applyAutoLocale() if (params.localeFallback) { - return fetchWithLocaleFallback({ preserveField: field !== '*' ? String(field) : undefined }).then((res) => { + // Ensure the counted field is fetched and bypass pagination for accurate counts + const countField = field !== '*' ? String(field) : undefined + const savedFields = params.selectedFields + const savedOffset = params.offset + const savedLimit = params.limit + if (countField && savedFields.length > 0 && !savedFields.includes(field as keyof Collections[T])) { + params.selectedFields = [...savedFields, field as keyof Collections[T]] + } + params.offset = 0 + params.limit = 0 + return fetchWithLocaleFallback({ preserveField: countField }).then((res) => { + params.selectedFields = savedFields + params.offset = savedOffset + params.limit = savedLimit if (field === '*') return res.length const values = res .map(r => (r as unknown as Record)[String(field)]) diff --git a/src/types/database.ts b/src/types/database.ts index 888b28f9b..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, value: string, checksum: string): void + insertDevelopmentCache(id: string, value: string, checksum: string): Promise deleteDevelopmentCache(id: string): void dropContentTables(): void exec(sql: string): void From cb576f242e9d833c3eb0a3f626879c3e851fe81c Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 03:42:20 +0200 Subject: [PATCH 40/67] fix: add fallback locale detection path for alternative i18n context shape --- src/runtime/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 621c8cb69..c187bd890 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -23,6 +23,7 @@ export const queryCollection = (collection: T): Col // Auto-detect locale from @nuxtjs/i18n (client: $i18n.locale, SSR: event.context.nuxtI18n) const detectedLocale = (nuxtApp?.$i18n as { locale?: { value?: string } })?.locale?.value || (event?.context?.nuxtI18n as { vueI18nOptions?: { locale?: string } })?.vueI18nOptions?.locale + || (event?.context?.nuxtI18n as { locale?: string })?.locale return collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql), detectedLocale) } From 19d4dae06277988c8defe88660cf492a810f913e Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 03:46:06 +0200 Subject: [PATCH 41/67] test: add security and defuByIndex edge case tests --- test/unit/assertSafeQuery.test.ts | 30 ++++++++++++++++++++++++++++++ test/unit/i18n.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/test/unit/assertSafeQuery.test.ts b/test/unit/assertSafeQuery.test.ts index f9d942f5a..225fcec1f 100644 --- a/test/unit/assertSafeQuery.test.ts +++ b/test/unit/assertSafeQuery.test.ts @@ -49,6 +49,36 @@ describe('decompressSQLDump', () => { 'SELECT "id" FROM _content_test WHERE (x=$\'$ OR x IN (SELECT BLAH) OR x=$\'$) ORDER BY id ASC': false, } + const securityQueries = { + // Newline injection + 'SELECT * FROM _content_test ORDER BY id ASC\nDROP TABLE _content_test': false, + 'SELECT * FROM _content_test ORDER BY id ASC\rDROP TABLE _content_test': false, + // Escaped quotes in WHERE values should pass (not be treated as comments) + 'SELECT * FROM _content_test WHERE ("title" = \'L\'\'été\') ORDER BY stem ASC': true, + 'SELECT * FROM _content_test WHERE ("title" = \'it\'\'s\') ORDER BY stem ASC': true, + // Triple-quote edge case — should NOT bypass keyword detection + 'SELECT * FROM _content_test WHERE ("x" = \'a\'\'\') UNION SELECT 1 ORDER BY stem ASC': false, + // COUNT with quoted field + 'SELECT COUNT("title") as count FROM _content_test': true, + 'SELECT COUNT(DISTINCT "author") as count FROM _content_test': true, + // COUNT without ORDER BY + 'SELECT COUNT(*) as count FROM _content_test': true, + // Locale-filtered query (typical auto-locale output) + 'SELECT * FROM _content_test WHERE ("locale" = \'fr\') ORDER BY stem ASC': true, + 'SELECT * FROM _content_test WHERE ("locale" = \'fr\') AND ("stem" = \'navbar\') ORDER BY stem ASC': true, + } + + Object.entries(securityQueries).forEach(([query, isValid]) => { + it(`security: ${query.slice(0, 60)}...`, () => { + if (isValid) { + expect(() => assertSafeQuery(query, 'test')).not.toThrow() + } + else { + expect(() => assertSafeQuery(query, 'test')).toThrow() + } + }) + }) + Object.entries(queries).forEach(([query, isValid]) => { it(`${query}`, () => { if (isValid) { diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index 8113ea220..beb4d0ed6 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -286,6 +286,36 @@ describe('i18n - inline expansion', () => { }) }) +describe('i18n - defuByIndex standalone', () => { + 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].title).toBe('Override') + expect(result.items[0].links[0].title).toBe('Savoir plus') + expect(result.items[0].links[0].url).toBe('/page') + expect(result.items[0].links[0].icon).toEqual({ 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('i18n - path-based locale detection', () => { const i18nConfig: CollectionI18nConfig = { locales: ['en', 'fr', 'de'], From 6fbf6ae45f6f403239dedebb8b0e3cf8ed661782 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sun, 29 Mar 2026 03:53:26 +0200 Subject: [PATCH 42/67] fix: use port 0 in i18n integration test to avoid EADDRINUSE on CI --- test/i18n.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/i18n.test.ts b/test/i18n.test.ts index 88cd5e389..894f0ea9c 100644 --- a/test/i18n.test.ts +++ b/test/i18n.test.ts @@ -26,6 +26,7 @@ describe('i18n', async () => { await setup({ rootDir: resolver.resolve('./fixtures/i18n'), dev: true, + port: 0, // Let OS assign a free port to avoid EADDRINUSE on CI }) describe('database', () => { From 28817cf104684af314ab5bdf95722fb04f4692ab Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Tue, 31 Mar 2026 08:54:19 +0200 Subject: [PATCH 43/67] refactor: extract expandI18nData and detectLocaleFromPath into utils --- docs/content/docs/7.integrations/01.i18n.md | 4 + src/runtime/internal/query.ts | 7 + src/utils/i18n.ts | 91 ++ test/unit/i18n.test.ts | 1055 ++++++++----------- 4 files changed, 539 insertions(+), 618 deletions(-) diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index 6f49856be..a918fc9e5 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -237,6 +237,10 @@ export default defineNuxtConfig({ }) ``` +## Known Limitations + +**Translatable slugs with different filenames**: When locale versions use different filenames (e.g., `en/products.md` vs `de/produkte.md`), `queryCollectionLocales` cannot automatically link them because the stems differ after locale-prefix stripping. Content with the same filename across locale directories works correctly. This limitation requires coordination with `@nuxtjs/i18n` and is tracked in [nuxt-modules/i18n#3028](https://github.com/nuxt-modules/i18n/discussions/3028). + ## Complete Examples You can see a complete working example: diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 2baeec2ee..85187cd15 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -212,6 +212,13 @@ export const collectionQueryBuilder = (collection: } } + /** + * Two-query locale fallback: fetches locale-specific rows and default-locale rows, + * then merges by stem (locale items take priority, fallback fills gaps). + * Internally injects 'stem' into selectedFields for merge-key deduplication, + * stripping it from results when the caller didn't explicitly select it. + * Accepts an optional limit override and a preserveField to keep for count operations. + */ async function fetchWithLocaleFallback(opts: { limit?: number, preserveField?: string } = {}): Promise { const { locale, fallback } = params.localeFallback! diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 3a3d5fb24..0af0d9764 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -1,4 +1,7 @@ import { createDefu } from 'defu' +import { hash } from 'ohash' +import type { CollectionI18nConfig } from '../types/collection' +import type { ParsedContentFile } from '../types' /** * Custom defu that merges arrays by index (item-by-item) instead of concatenating. @@ -32,3 +35,91 @@ export const defuByIndex = createDefu((obj, key, value) => { return true } }) + +/** + * Expand inline i18n data from a parsed content file into per-locale items. + * The default locale keeps the original content; non-default locales get a deep-merged + * copy where only overridden fields differ. Non-default items include `_i18nSourceHash` + * for tracking whether the source content has changed since translation. + */ +export function expandI18nData( + parsedContent: ParsedContentFile, + i18nConfig: CollectionI18nConfig, +): ParsedContentFile[] { + const i18nData = parsedContent.meta?.i18n as Record> | undefined + if (!i18nData) { + if (!parsedContent.locale) { + parsedContent.locale = i18nConfig.defaultLocale + } + return [parsedContent] + } + + const { i18n: _removed, ...cleanMeta } = parsedContent.meta + parsedContent.meta = cleanMeta + + if (!parsedContent.locale) { + parsedContent.locale = i18nConfig.defaultLocale + } + + // Compute source hash from default locale's translatable fields + const translatedFields = new Set(Object.values(i18nData).flatMap(Object.keys)) + const sourceFields: Record = {} + for (const field of translatedFields) { + sourceFields[field] = parsedContent[field] + } + const i18nSourceHash = hash(sourceFields) + + const items: ParsedContentFile[] = [parsedContent] + + for (const [locale, overrides] of Object.entries(i18nData)) { + if (locale === parsedContent.locale) continue + + const localeItem: ParsedContentFile = { + ...defuByIndex(overrides, parsedContent) as ParsedContentFile, + id: `${parsedContent.id}#${locale}`, + locale, + meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, + } + + items.push(localeItem) + } + + return items +} + +/** + * Detect locale from the first path segment and strip the locale prefix + * from both path and stem. Returns default locale when no prefix matches. + */ +export function detectLocaleFromPath( + path: string, + stem: string, + i18nConfig: CollectionI18nConfig, +): { locale: string, path: string, stem: string } { + const pathParts = path.split('/').filter(Boolean) + const firstPart = pathParts[0] + + if (firstPart && i18nConfig.locales.includes(firstPart)) { + const pathWithoutLocale = '/' + pathParts.slice(1).join('/') + + let newStem = stem + if (stem === firstPart) { + newStem = '' + } + else if (stem.startsWith(firstPart + '/')) { + newStem = stem.slice(firstPart.length + 1) + } + + return { + locale: firstPart, + path: pathWithoutLocale === '/' ? '/' : pathWithoutLocale, + stem: newStem, + } + } + + return { + locale: i18nConfig.defaultLocale, + path, + stem, + } +} diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index beb4d0ed6..da691c6cb 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -1,650 +1,469 @@ import { describe, it, expect } from 'vitest' -import { hash } from 'ohash' -import { defuByIndex } from '../../src/utils/i18n' +import { defuByIndex, expandI18nData, detectLocaleFromPath } from '../../src/utils/i18n' import type { CollectionI18nConfig } from '../../src/types/collection' import type { ParsedContentFile } from '../../src/types' -/** - * Expand inline i18n data from a parsed content file into per-locale items. - * This is the same logic used in processCollectionItems (src/module.ts). - */ -function expandI18n( - parsedContent: ParsedContentFile, - i18nConfig: CollectionI18nConfig, -): ParsedContentFile[] { - const i18nData = parsedContent.meta?.i18n as Record> | undefined - if (!i18nData) { - if (!parsedContent.locale) { - parsedContent.locale = i18nConfig.defaultLocale - } - return [parsedContent] - } - - const { i18n: _removed, ...cleanMeta } = parsedContent.meta - parsedContent.meta = cleanMeta - - if (!parsedContent.locale) { - parsedContent.locale = i18nConfig.defaultLocale - } - - // Compute source hash from default locale's translatable fields - const translatedFields = new Set(Object.values(i18nData).flatMap(Object.keys)) - const sourceFields: Record = {} - for (const field of translatedFields) { - sourceFields[field] = parsedContent[field] - } - const i18nSourceHash = hash(sourceFields) - - const items: ParsedContentFile[] = [parsedContent] - - for (const [locale, overrides] of Object.entries(i18nData)) { - if (locale === parsedContent.locale) continue - - // Deep merge for data collections: translated fields override, untranslated fields preserved - const localeItem: ParsedContentFile = { - ...defuByIndex(overrides, parsedContent) as ParsedContentFile, - id: `${parsedContent.id}#${locale}`, - locale, - meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, - } - - items.push(localeItem) - } - - return items +const i18nConfig: CollectionI18nConfig = { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', } -/** - * Detect locale from path prefix and strip it. - * This is the same logic used in createParser (src/utils/content/index.ts). - */ -/** - * Mirrors the production logic in src/utils/content/index.ts exactly. - */ -function detectLocaleFromPath( - path: string, - stem: string, - i18nConfig: CollectionI18nConfig, -): { locale: string, path: string, stem: string } { - const pathParts = path.split('/').filter(Boolean) - const firstPart = pathParts[0] - - if (firstPart && i18nConfig.locales.includes(firstPart)) { - const pathWithoutLocale = '/' + pathParts.slice(1).join('/') - - // Stem stripping: same string logic as production (no RegExp) - let newStem = stem - if (stem === firstPart) { - newStem = '' - } - else if (stem.startsWith(firstPart + '/')) { - newStem = stem.slice(firstPart.length + 1) - } - - return { - locale: firstPart, - path: pathWithoutLocale === '/' ? '/' : pathWithoutLocale, - stem: newStem, - } - } - - return { - locale: i18nConfig.defaultLocale, - path, - stem, - } -} - -describe('i18n - inline expansion', () => { - const i18nConfig: CollectionI18nConfig = { - locales: ['en', 'fr', 'de'], - defaultLocale: 'en', - } - - 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' }, +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 = expandI18n(content, i18nConfig) - - expect(items).toHaveLength(3) - - // Default locale item - expect(items[0].id).toBe('blog:post.yml') - expect(items[0].locale).toBe('en') - expect(items[0].title).toBe('My Post') - expect(items[0].description).toBe('Hello world') - expect(items[0].meta.i18n).toBeUndefined() - - // French item - expect(items[1].id).toBe('blog:post.yml#fr') - expect(items[1].locale).toBe('fr') - expect(items[1].title).toBe('Mon Article') - expect(items[1].description).toBe('Bonjour le monde') - - // German item - description falls back to default - expect(items[2].id).toBe('blog:post.yml#de') - expect(items[2].locale).toBe('de') - expect(items[2].title).toBe('Mein Artikel') - expect(items[2].description).toBe('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 = expandI18n(content, i18nConfig) - - expect(items).toHaveLength(1) - expect(items[0].locale).toBe('en') - expect(items[0].title).toBe('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(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 = expandI18n(content, i18nConfig) - - expect(items).toHaveLength(2) - expect(items[0].locale).toBe('fr') - expect(items[0].title).toBe('Mon Article') - expect(items[1].locale).toBe('en') - expect(items[1].title).toBe('My Post') - }) - - it('deep-merges nested objects in locale overrides for data collections', () => { - 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]).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 = expandI18n(content, i18nConfig) - - expect(items).toHaveLength(2) - - // Default keeps original - expect(items[0].info).toEqual({ age: 25, country: 'Switzerland' }) + } + + 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' }, + ], + }, + }, + }, + } - // German override deep-merges: country overridden, age preserved - expect(items[1].info).toEqual({ age: 25, country: 'Schweiz' }) - }) + const items = expandI18nData(content, i18nConfig) + const frItem = items.find(i => i.locale === 'fr') - it('deep-merges array items by index, preserving untranslated fields', () => { - const content: ParsedContentFile = { - id: 'nav:navbar.yml', - items: [ - { id: 'overview', label: 'Overview', route: '/' }, + expect(frItem?.items).toEqual([ + { id: 'overview', label: 'Vue d\'ensemble', route: '/' }, { id: 'tech', label: 'Technologies', route: '/technologies' }, - ], - stem: 'navbar', - extension: 'yml', - meta: { - i18n: { - fr: { - items: [ - { label: 'Vue d\'ensemble' }, - { label: '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 = expandI18n(content, i18nConfig) - const frItem = items.find(i => i.locale === 'fr') - // Array items merged by index: label overridden, id + route preserved from default - 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' }, // same as default locale - fr: { title: 'Article Francais' }, - }, - }, - } - - const items = expandI18n(content, i18nConfig) - - // Should have 2 items: default (en) + fr - // The 'en' key in i18n is skipped since it matches defaultLocale - expect(items).toHaveLength(2) - expect(items[0].locale).toBe('en') - expect(items[0].title).toBe('My Post') // top-level value, not from i18n.en - expect(items[1].locale).toBe('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) + + 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 = expandI18n(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', - ]) - - // All IDs are unique - expect(new Set(ids).size).toBe(3) - }) -}) - -describe('i18n - defuByIndex standalone', () => { - 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].title).toBe('Override') - expect(result.items[0].links[0].title).toBe('Savoir plus') - expect(result.items[0].links[0].url).toBe('/page') - expect(result.items[0].links[0].icon).toEqual({ 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('i18n - path-based locale detection', () => { - const i18nConfig: CollectionI18nConfig = { - locales: ['en', 'fr', 'de'], - defaultLocale: 'en', - } - - it('detects locale from first path segment', () => { - const result = detectLocaleFromPath('/fr/blog/post', 'fr/blog/post', i18nConfig) - - expect(result.locale).toBe('fr') - expect(result.path).toBe('/blog/post') - expect(result.stem).toBe('blog/post') + } + + 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('assigns default locale when no locale prefix', () => { - const result = detectLocaleFromPath('/blog/post', 'blog/post', i18nConfig) - - expect(result.locale).toBe('en') - expect(result.path).toBe('/blog/post') - expect(result.stem).toBe('blog/post') - }) - - it('handles root path with locale', () => { - const result = detectLocaleFromPath('/de', 'de', i18nConfig) - - expect(result.locale).toBe('de') - expect(result.path).toBe('/') - expect(result.stem).toBe('') + 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('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' }) + }) }) - it('does not treat non-locale segments as locale', () => { - const result = detectLocaleFromPath('/blog/fr/post', 'blog/fr/post', i18nConfig) - - // 'blog' is not a locale, so default is used - expect(result.locale).toBe('en') - expect(result.path).toBe('/blog/fr/post') - expect(result.stem).toBe('blog/fr/post') - }) - - it('handles nested locale paths', () => { - const result = detectLocaleFromPath('/en/docs/guide/intro', 'en/docs/guide/intro', i18nConfig) - - expect(result.locale).toBe('en') - expect(result.path).toBe('/docs/guide/intro') - expect(result.stem).toBe('docs/guide/intro') - }) -}) - -describe('i18n - defuByIndex edge cases', () => { - const i18nConfig: CollectionI18nConfig = { - locales: ['en', 'fr', 'de'], - defaultLocale: 'en', - } - - 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' }, - // No 3rd item — should preserve default 'C' - ], + 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 = expandI18n(content, i18nConfig) - const frItem = items.find(i => i.locale === 'fr') - - expect(frItem?.items).toHaveLength(3) - expect(frItem?.items[0]).toEqual({ id: 'a', label: 'A-fr', route: '/a' }) - expect(frItem?.items[1]).toEqual({ id: 'b', label: 'B-fr', route: '/b' }) - expect(frItem?.items[2]).toEqual({ id: 'c', label: 'C', route: '/c' }) - }) - - it('deep-merges nested arrays within array items (e.g. links inside items)', () => { - const content: ParsedContentFile = { - id: 'nav:banners.yml', - items: [ - { - description: 'Default text', - links: [ - { title: 'More', url: '/page', icon: { name: 'chevron' } }, + } + + 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' }, + stem: 'banners', + extension: 'yml', + meta: { + i18n: { + fr: { + items: [ + { + description: 'Texte francais', + links: [{ title: 'En savoir plus' }], + }, ], }, - ], + }, }, - }, - }, - } - - const items = expandI18n(content, i18nConfig) - const frItem = items.find(i => i.locale === 'fr') - - // Description overridden - expect(frItem?.items[0].description).toBe('Texte francais') - // Link title overridden, but url and icon preserved from default - expect(frItem?.items[0].links[0].title).toBe('En savoir plus') - expect(frItem?.items[0].links[0].url).toBe('/page') - expect(frItem?.items[0].links[0].icon).toEqual({ 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 = expandI18n(content, i18nConfig) - - expect(items).toHaveLength(1) - expect(items[0].locale).toBe('en') - expect(items[0].title).toBe('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 - - expandI18n(original, i18nConfig) - - // Original items array should not be mutated - expect(originalItemsRef[0].label).toBe('Original') - // Override object should not be mutated - 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[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' }] } }, }, - }, - }, - } - - const items = expandI18n(content, i18nConfig) - const frItem = items.find(i => i.locale === 'fr') - - expect(frItem?.items).toHaveLength(2) - expect(frItem?.items[0]).toEqual({ id: 'a', label: 'A-fr' }) - expect(frItem?.items[1]).toEqual({ id: 'b', label: 'B-fr', route: '/b' }) - }) - - it('handles scalar arrays (not objects) 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 = expandI18n(content, i18nConfig) - const deItem = items.find(i => i.locale === 'de') + } as ParsedContentFile + + const originalItemsRef = original.items + const frOverrideRef = (original.meta.i18n as Record).fr + + expandI18nData(original, i18nConfig) + + expect(originalItemsRef[0].label).toBe('Original') + expect((frOverrideRef as Record).items[0]).toEqual({ label: 'French' }) + }) + + it('handles override with extra array items beyond default length', () => { + const content: ParsedContentFile = { + id: 'nav:test.yml', + items: [{ id: 'a', label: 'A' }], + stem: 'test', + extension: 'yml', + meta: { + i18n: { + fr: { + items: [ + { label: 'A-fr' }, + { id: 'b', label: 'B-fr', route: '/b' }, + ], + }, + }, + }, + } + + const items = expandI18nData(content, i18nConfig) + const frItem = items.find(i => i.locale === 'fr') + + expect(frItem?.items).toHaveLength(2) + expect(frItem?.items[0]).toMatchObject({ id: 'a', label: 'A-fr' }) + expect(frItem?.items[1]).toMatchObject({ id: 'b', label: 'B-fr', route: '/b' }) + }) + + it('handles scalar arrays without merging', () => { + const content: ParsedContentFile = { + id: 'data:tags.yml', + tags: ['javascript', 'vue', 'nuxt'], + stem: 'tags', + extension: 'yml', + meta: { + i18n: { de: { tags: ['JavaScript', 'Vue', 'Nuxt'] } }, + }, + } + + const items = expandI18nData(content, i18nConfig) + const deItem = items.find(i => i.locale === 'de') + expect(deItem?.tags).toEqual(['JavaScript', 'Vue', 'Nuxt']) + }) + + it('preserves non-translated top-level fields across all locales', () => { + const content: ParsedContentFile = { + id: 'data:config.yml', + title: 'Site Config', + apiUrl: 'https://api.example.com', + maxRetries: 3, + stem: 'config', + extension: 'yml', + meta: { + i18n: { + fr: { title: 'Config du site' }, + de: { title: 'Seitenkonfiguration' }, + }, + }, + } - // Scalar arrays: override replaces entirely (no object merge) - expect(deItem?.tags).toEqual(['JavaScript', 'Vue', 'Nuxt']) - }) + const items = expandI18nData(content, i18nConfig) - 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 = expandI18n(content, i18nConfig) - - for (const item of items) { - // Locale-invariant fields preserved in all locale variants - expect(item.apiUrl).toBe('https://api.example.com') - expect(item.maxRetries).toBe(3) - } - expect(items[1].title).toBe('Config du site') - expect(items[2].title).toBe('Seitenkonfiguration') + for (const item of items) { + expect(item).toMatchObject({ apiUrl: 'https://api.example.com', maxRetries: 3 }) + } + expect(items[1]).toMatchObject({ title: 'Config du site' }) + expect(items[2]).toMatchObject({ title: 'Seitenkonfiguration' }) + }) + }) }) -}) -describe('i18n - source hash for change tracking', () => { - const i18nConfig: CollectionI18nConfig = { - locales: ['en', 'fr', 'de'], - defaultLocale: 'en', - } - - 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' }, + 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 = expandI18n(content, i18nConfig) - - // Default locale should NOT have _i18nSourceHash - expect(items[0].meta._i18nSourceHash).toBeUndefined() - - // French locale SHOULD have _i18nSourceHash - 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 = expandI18n(content1, i18nConfig) - const items2 = expandI18n(content2, i18nConfig) - - // Hash should be the same since only 'title' is translated and it's unchanged - 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', // title changed - stem: 'post', - extension: 'yml', - meta: { - i18n: { fr: { title: 'Mon Article' } }, - }, - } - - const items1 = expandI18n(content1, i18nConfig) - const items2 = expandI18n(content2, i18nConfig) - - // Hash should differ because source 'title' changed - expect(items1[1].meta._i18nSourceHash).not.toBe(items2[1].meta._i18nSourceHash) + } + + 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) + }) }) }) From a2a490515f1894e5871bb33c8acc1354fe41a5c4 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Tue, 31 Mar 2026 08:57:57 +0200 Subject: [PATCH 44/67] refactor: deduplicate expansion logic in module and dev watcher --- src/module.ts | 48 ++++---------------------------- src/runtime/internal/query.ts | 1 + src/runtime/internal/security.ts | 4 +-- src/utils/dev.ts | 40 ++++++-------------------- src/utils/i18n.ts | 17 ++++++++++- 5 files changed, 33 insertions(+), 77 deletions(-) diff --git a/src/module.ts b/src/module.ts index ad0bdea12..e459bbfc3 100644 --- a/src/module.ts +++ b/src/module.ts @@ -20,7 +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 { defuByIndex } from './utils/i18n' +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' @@ -381,47 +381,11 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio // i18n: expand inline translations to per-locale rows if (collection.i18n && (parsedContent?.meta as Record)?.i18n) { - const i18nData = (parsedContent.meta as Record).i18n as Record> - const { i18n: _removed, ...cleanMeta } = parsedContent.meta as Record - parsedContent.meta = cleanMeta - - // Default locale item - if (!parsedContent.locale) { - parsedContent.locale = collection.i18n.defaultLocale - } - - // Compute source hash from default locale's translatable fields - // Used by translators / Studio to detect when the source content changes - const translatedFields = new Set(Object.values(i18nData).flatMap(Object.keys)) - const sourceFields: Record = {} - for (const field of translatedFields) { - sourceFields[field] = parsedContent[field] - } - const i18nSourceHash = hash(sourceFields) - - const defaultItem = parsedContent - const { queries: defaultQueries, hash: defaultHash } = generateCollectionInsert(collection, defaultItem) - list.push([`${key}#${defaultItem.locale}`, defaultQueries, defaultHash]) - - // Create one item per non-default locale - for (const [locale, overrides] of Object.entries(i18nData)) { - if (locale === defaultItem.locale) continue - - // Deep merge preserves untranslated fields (routes, IDs, icons). - // For page collections, body AST must not be deep-merged — replace it wholesale. - const merged = defuByIndex(overrides, defaultItem) as ParsedContentFile - if (collection.type === 'page' && overrides.body) { - merged.body = overrides.body - } - const localeItem: ParsedContentFile = { - ...merged, - id: `${parsedContent.id}#${locale}`, - locale, - meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, - } - - const { queries: localeQueries, hash: localeHash } = generateCollectionInsert(collection, localeItem) - list.push([`${key}#${locale}`, localeQueries, localeHash]) + const expandedItems = expandI18nData(parsedContent, collection.i18n, collection.type) + for (const item of expandedItems) { + const itemKey = item.locale ? `${key}#${item.locale}` : key + const { queries: itemQueries, hash: itemHash } = generateCollectionInsert(collection, item) + list.push([itemKey, itemQueries, itemHash]) } } else { diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 85187cd15..a98ac4d8c 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -201,6 +201,7 @@ export const collectionQueryBuilder = (collection: let autoLocaleApplied = false function applyAutoLocale() { if (autoLocaleApplied || params.localeExplicitlySet || !i18nConfig || !detectedLocale) return + if (!i18nConfig.locales.includes(detectedLocale)) return autoLocaleApplied = true if (detectedLocale === i18nConfig.defaultLocale) { // Default locale: single query, no fallback needed diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index 3e005a302..ee4826b03 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -1,6 +1,6 @@ const SQL_COMMANDS = /SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|\$/i const SQL_COUNT_REGEX = /COUNT\((DISTINCT )?("[a-z_]\w+"|[a-z_]\w+|\*)\)/i -const SQL_SELECT_REGEX = /^SELECT (.*) FROM (\w+)( WHERE .*?)?( ORDER BY (["\w,\s]+) (ASC|DESC))?( LIMIT \d+)?( OFFSET \d+)?$/ +const SQL_SELECT_REGEX = /^SELECT (.*?) FROM (\w+)( WHERE .*?)?( ORDER BY (["\w,\s]+) (ASC|DESC))?( LIMIT \d+)?( OFFSET \d+)?$/ /** * Assert that the query is safe @@ -140,7 +140,7 @@ function cleanupQuery(query: string, options: { removeString: boolean } = { remo while (i < query.length && !(query[i] === '*' && query[i + 1] === '/')) { i += 1 } - i += 2 + if (i < query.length) i += 2 continue } diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 49e863cc0..ab9a0d524 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -3,14 +3,13 @@ import type { ViteDevServer } from 'vite' import crypto from 'node:crypto' import { readFile } from 'node:fs/promises' import { join, resolve } from 'pathe' -import { defuByIndex } from './i18n' +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 { hash } from 'ohash' import type { ModuleOptions, ParsedContentFile, ResolvedCollection } from '../types' import type { Manifest } from '../types/manifest' import { getLocalDatabase } from './database' @@ -166,38 +165,15 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani const parsed: ParsedContentFile = JSON.parse(parsedContent) - // i18n: expand inline translations to per-locale DB rows (same logic as processCollectionItems) + // i18n: expand inline translations to per-locale DB rows if (collection.i18n && (parsed?.meta as Record)?.i18n) { const i18nData = (parsed.meta as Record).i18n as Record> - const { i18n: _removed, ...cleanMeta } = parsed.meta as Record - parsed.meta = cleanMeta - if (!parsed.locale) parsed.locale = collection.i18n.defaultLocale - - const translatedFields = new Set(Object.values(i18nData).flatMap(Object.keys)) - const sourceFields: Record = {} - for (const field of translatedFields) sourceFields[field] = parsed[field] - const i18nSourceHash = hash(sourceFields) - - // Upsert default locale row - const { queries: defaultQueries } = generateCollectionInsert(collection, parsed) - await broadcast(collection, keyInCollection, defaultQueries) - - // Upsert each non-default locale row - for (const [locale, overrides] of Object.entries(i18nData)) { - if (locale === parsed.locale) continue - const localeKey = `${keyInCollection}#${locale}` - const merged = defuByIndex(overrides, parsed) as ParsedContentFile - if (collection.type === 'page' && overrides.body) { - merged.body = overrides.body - } - const localeItem: ParsedContentFile = { - ...merged, - id: localeKey, - locale, - meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, - } - const { queries: localeQueries } = generateCollectionInsert(collection, localeItem) - await broadcast(collection, localeKey, localeQueries) + + const expandedItems = expandI18nData(parsed, collection.i18n, collection.type) + for (const item of expandedItems) { + const itemKey = item.locale ? `${keyInCollection}#${item.locale}` : keyInCollection + const { queries } = generateCollectionInsert(collection, item) + await broadcast(collection, itemKey, queries) } // Remove locale rows that are no longer in the i18n section diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 0af0d9764..de40231fd 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -41,10 +41,18 @@ export const defuByIndex = createDefu((obj, key, value) => { * The default locale keeps the original content; non-default locales get a deep-merged * copy where only overridden fields differ. Non-default items include `_i18nSourceHash` * for tracking whether the source content has changed since translation. + * + * For page collections (`collectionType: 'page'`), the body AST is replaced wholesale + * rather than deep-merged, since body is a parsed markdown tree that cannot be meaningfully merged. + * + * Note: this function mutates `parsedContent.meta` (removes the `i18n` key) and + * sets `parsedContent.locale` if not already set. This is acceptable because the + * source content is always consumed (inserted into DB) immediately after expansion. */ export function expandI18nData( parsedContent: ParsedContentFile, i18nConfig: CollectionI18nConfig, + collectionType?: 'page' | 'data', ): ParsedContentFile[] { const i18nData = parsedContent.meta?.i18n as Record> | undefined if (!i18nData) { @@ -74,8 +82,15 @@ export function expandI18nData( for (const [locale, overrides] of Object.entries(i18nData)) { if (locale === parsedContent.locale) continue + // Deep merge preserves untranslated fields (routes, IDs, icons). + // For page collections, body AST must not be deep-merged — replace it wholesale. + const merged = defuByIndex(overrides, parsedContent) as ParsedContentFile + if (collectionType === 'page' && overrides.body) { + merged.body = overrides.body + } + const localeItem: ParsedContentFile = { - ...defuByIndex(overrides, parsedContent) as ParsedContentFile, + ...merged, id: `${parsedContent.id}#${locale}`, locale, meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, From 8dcb79448969c60350ffe85f2ac8a94cbb052f18 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Tue, 31 Mar 2026 09:05:23 +0200 Subject: [PATCH 45/67] refactor: use detectLocaleFromPath in content parser and add query builder tests --- src/runtime/internal/security.ts | 4 +- src/utils/content/index.ts | 30 +++------- src/utils/i18n.ts | 4 +- test/unit/collectionQueryBuilder.test.ts | 70 ++++++++++++++++++++++++ test/unit/i18n.test.ts | 47 ++++++++++++++++ 5 files changed, 129 insertions(+), 26 deletions(-) diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index ee4826b03..8c2070bbf 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -52,8 +52,8 @@ export function assertSafeQuery(sql: string, collection: string) { // FROM if (from !== `_content_${collection}`) { - const collection = String(from || '').replace(/^_content_/, '') - throw new Error(`Invalid query: Collection '${collection}' does not exist`) + const invalidCollection = String(from || '').replace(/^_content_/, '') + throw new Error(`Invalid query: Collection '${invalidCollection}' does not exist`) } // WHERE diff --git a/src/utils/content/index.ts b/src/utils/content/index.ts index 89b16f382..54f4a1dd8 100644 --- a/src/utils/content/index.ts +++ b/src/utils/content/index.ts @@ -6,6 +6,7 @@ import type { Nuxt } from '@nuxt/schema' import { resolveAlias } from '@nuxt/kit' import type { LanguageRegistration } from 'shiki' import { defu } from 'defu' +import { detectLocaleFromPath } from '../i18n' import { createJiti } from 'jiti' import { createOnigurumaEngine } from 'shiki/engine/oniguruma' import { visit } from 'unist-util-visit' @@ -219,29 +220,14 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) // i18n: detect locale from path prefix when collection has i18n configured if (collection.i18n && collectionKeys.includes('locale')) { const currentPath = String(result.path || pathMetaFields.path || '') - const pathParts = currentPath.split('/').filter(Boolean) - const firstPart = pathParts[0] - - if (firstPart && collection.i18n.locales.includes(firstPart)) { - result.locale = result.locale ?? firstPart - // Strip locale prefix from path - const pathWithoutLocale = '/' + pathParts.slice(1).join('/') - if (collectionKeys.includes('path')) { - result.path = pathWithoutLocale === '/' ? '/' : pathWithoutLocale - } - // Always strip locale prefix from stem (regardless of stem format) - const currentStem = String(result.stem || pathMetaFields.stem || '') - if (currentStem === firstPart) { - result.stem = '' - } - else if (currentStem.startsWith(firstPart + '/')) { - result.stem = currentStem.slice(firstPart.length + 1) - } - } - else { - // No locale prefix - assign default locale (only if not already set) - result.locale = result.locale ?? collection.i18n.defaultLocale + const currentStem = String(result.stem || pathMetaFields.stem || '') + const detected = detectLocaleFromPath(currentPath, currentStem, collection.i18n) + + result.locale = result.locale ?? detected.locale + if (collectionKeys.includes('path')) { + result.path = detected.path } + result.stem = detected.stem } const afterParseCtx: FileAfterParseHook = { file: hookedFile, content: result as ParsedContentFile, collection } diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index de40231fd..2686f41d4 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -9,8 +9,8 @@ import type { ParsedContentFile } from '../types' * Used for inline i18n expansion: locale overrides merge with default locale items * so untranslated fields (routes, IDs, icons, URLs) are preserved from the default. * - * In createDefu's merger: obj[key] = accumulated result (has defaults), value = override. - * Override items take priority; default items fill gaps for missing fields. + * In createDefu's merger: obj[key] = accumulated result (built from source/overrides), + * value = current default being merged in. Source items take priority; defaults fill gaps. */ export const defuByIndex = createDefu((obj, key, value) => { if (Array.isArray(obj[key]) && Array.isArray(value)) { diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index da1cbdecd..daabbad17 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -338,5 +338,75 @@ describe('collectionQueryBuilder', () => { 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC', ) }) + + it('rejects unknown detectedLocale values', async () => { + // 'xx' is not in i18nConfig.locales — should be ignored (no locale filter) + const query = collectionQueryBuilder(mockCollection, mockFetch, 'xx') + await query.all() + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + 'articles', + 'SELECT * FROM _articles ORDER BY stem ASC', + ) + }) + + it('injects and strips stem when using select() with locale fallback', async () => { + mockFetch + .mockResolvedValueOnce([{ title: 'Bonjour', locale: 'fr', stem: 'hello' }]) + .mockResolvedValueOnce([ + { title: 'Hello', locale: 'en', stem: 'hello' }, + { title: 'World', locale: 'en', stem: 'world' }, + ]) + + const results = await collectionQueryBuilder(mockCollection, mockFetch) + .select('title' as never, 'locale' as never) + .locale('fr', { fallback: 'en' }) + .all() + + // stem should be stripped from results since it was not explicitly selected + expect(results[0]).not.toHaveProperty('stem') + // Merge should work correctly: fr 'hello' replaces en 'hello', en 'world' is fallback + expect(results).toHaveLength(2) + expect(results[0]).toMatchObject({ title: 'Bonjour', locale: 'fr' }) + expect(results[1]).toMatchObject({ title: 'World', locale: 'en' }) + }) + + it('counts correctly with locale fallback', async () => { + mockFetch + .mockResolvedValueOnce([ + { title: 'Bonjour', stem: 'hello' }, + ]) + .mockResolvedValueOnce([ + { title: 'Hello', stem: 'hello' }, + { title: 'World', stem: 'world' }, + ]) + + const count = await collectionQueryBuilder(mockCollection, mockFetch) + .locale('fr', { fallback: 'en' }) + .count() + + // fr has 'hello', en has 'hello' + 'world'. Merged: 2 unique stems + expect(count).toBe(2) + }) + + it('counts distinct with locale fallback', async () => { + mockFetch + .mockResolvedValueOnce([ + { title: 'Same', stem: 'a' }, + { title: 'Same', stem: 'b' }, + ]) + .mockResolvedValueOnce([ + { title: 'Same', stem: 'a' }, + { title: 'Different', stem: 'c' }, + ]) + + const count = await collectionQueryBuilder(mockCollection, mockFetch) + .locale('fr', { fallback: 'en' }) + .count('title' as never, true) + + // Merged items: a='Same', b='Same', c='Different'. Distinct titles: 2 + expect(count).toBe(2) + }) }) }) diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index da691c6cb..81ad72f23 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -166,6 +166,53 @@ describe('i18n', () => { ]) 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', () => { From 6ba6f63303dc306eeb2c2c5b5616d179982bcae3 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Tue, 31 Mar 2026 09:16:44 +0200 Subject: [PATCH 46/67] fix: clean up bare row in dev watcher when i18n is added to a file --- src/utils/dev.ts | 3 +++ src/utils/i18n.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils/dev.ts b/src/utils/dev.ts index ab9a0d524..151d13ec0 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -176,6 +176,9 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani await broadcast(collection, itemKey, queries) } + // Clean up the bare (un-suffixed) row in case i18n was just added to this file + await broadcast(collection, keyInCollection) + // Remove locale rows that are no longer in the i18n section for (const locale of collection.i18n.locales) { if (locale === parsed.locale || locale in i18nData) continue diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 2686f41d4..590353f23 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -9,8 +9,8 @@ import type { ParsedContentFile } from '../types' * Used for inline i18n expansion: locale overrides merge with default locale items * so untranslated fields (routes, IDs, icons, URLs) are preserved from the default. * - * In createDefu's merger: obj[key] = accumulated result (built from source/overrides), - * value = current default being merged in. Source items take priority; defaults fill gaps. + * In createDefu's merger: obj[key] = defaults (second arg), value = overrides (first arg). + * Override items take priority; default items fill gaps for missing fields. */ export const defuByIndex = createDefu((obj, key, value) => { if (Array.isArray(obj[key]) && Array.isArray(value)) { From 3f7291cdad3b7b01379a76b6c5e46cca1b0a90dd Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Tue, 31 Mar 2026 09:29:28 +0200 Subject: [PATCH 47/67] fix: handle multi-fragment dump entries when splicing collection updates --- src/utils/dev.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 151d13ec0..2e131eed2 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -246,9 +246,18 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani // Use exact key match: look for the id as a complete SQL string literal ('key',) to avoid // substring matches (e.g., 'team.yml' matching 'team.yml#fr') const escapedKey = key.replace(/'/g, '\'\'') - const keyIndex = collectionDump.findIndex(item => item.includes(`'${escapedKey}',`) || item.endsWith(`'${escapedKey}')`)) + const keyMatch = (item: string) => item.includes(`'${escapedKey}',`) || item.endsWith(`'${escapedKey}')`) + const keyIndex = collectionDump.findIndex(keyMatch) const indexToUpdate = keyIndex !== -1 ? keyIndex : collectionDump.length - const itemsToRemove = keyIndex === -1 ? 0 : 1 + + // Count all consecutive dump entries belonging to this key (large content splits + // into INSERT + UPDATE fragments that each reference the same key literal) + let itemsToRemove = 0 + if (keyIndex !== -1) { + for (let i = keyIndex; i < collectionDump.length && keyMatch(collectionDump[i]); i++) { + itemsToRemove++ + } + } if (insertQuery) { collectionDump.splice(indexToUpdate, itemsToRemove, ...insertQuery) From 45ef2338d4818f3e7412eb97182339a75ef866ec Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Tue, 31 Mar 2026 09:41:53 +0200 Subject: [PATCH 48/67] fix: use correct key variable and capture source locale before mutation --- src/module.ts | 4 ++-- src/utils/dev.ts | 4 +++- test/unit/i18n.test.ts | 5 +++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/module.ts b/src/module.ts index e459bbfc3..66d01158c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -383,14 +383,14 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio if (collection.i18n && (parsedContent?.meta as Record)?.i18n) { const expandedItems = expandI18nData(parsedContent, collection.i18n, collection.type) for (const item of expandedItems) { - const itemKey = item.locale ? `${key}#${item.locale}` : key + const itemKey = item.locale ? `${keyInCollection}#${item.locale}` : keyInCollection const { queries: itemQueries, hash: itemHash } = generateCollectionInsert(collection, item) list.push([itemKey, itemQueries, itemHash]) } } else { const { queries, hash } = generateCollectionInsert(collection, parsedContent) - list.push([key, queries, hash]) + list.push([keyInCollection, queries, hash]) } } catch (e: unknown) { diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 2e131eed2..62df56710 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -168,6 +168,8 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani // i18n: expand inline translations to per-locale DB rows if (collection.i18n && (parsed?.meta as Record)?.i18n) { const i18nData = (parsed.meta as Record).i18n as Record> + // Capture source locale before expandI18nData mutates parsed.locale + const sourceLocale = (parsed.locale as string | undefined) || collection.i18n.defaultLocale const expandedItems = expandI18nData(parsed, collection.i18n, collection.type) for (const item of expandedItems) { @@ -181,7 +183,7 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani // Remove locale rows that are no longer in the i18n section for (const locale of collection.i18n.locales) { - if (locale === parsed.locale || locale in i18nData) continue + if (locale === sourceLocale || locale in i18nData) continue await broadcast(collection, `${keyInCollection}#${locale}`) } } diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index 81ad72f23..4f2dbdc51 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -236,6 +236,11 @@ describe('i18n', () => { expect(result).toMatchObject({ locale: 'en', path: '/blog/fr/post', stem: 'blog/fr/post' }) }) + it('leaves stem unchanged when it does not start with locale prefix', () => { + const result = detectLocaleFromPath('/fr/docs/guide', 'docs/guide', i18nConfig) + expect(result).toMatchObject({ locale: 'fr', path: '/docs/guide', stem: 'docs/guide' }) + }) + it('handles nested locale paths', () => { const result = detectLocaleFromPath('/en/docs/guide/intro', 'en/docs/guide/intro', i18nConfig) expect(result).toMatchObject({ locale: 'en', path: '/docs/guide/intro', stem: 'docs/guide/intro' }) From 169fc5642d5dc0bcaa20e30e5f82bb116b23446f Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Tue, 31 Mar 2026 09:53:47 +0200 Subject: [PATCH 49/67] fix: use default type parameter in generateCollectionLocales generic --- src/runtime/internal/locales.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts index f4406b3b7..7dea34e32 100644 --- a/src/runtime/internal/locales.ts +++ b/src/runtime/internal/locales.ts @@ -4,7 +4,7 @@ import type { CollectionQueryBuilder, ContentLocaleEntry } from '@nuxt/content' * Query all locale variants for a given content stem within an i18n-enabled collection. * Returns one entry per locale, useful for building language switchers and hreflang tags. */ -export async function generateCollectionLocales>( +export async function generateCollectionLocales>( queryBuilder: CollectionQueryBuilder, stem: string, ): Promise { From 99e694221a856a885ed08a5610a9c51253bd69ad Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Tue, 31 Mar 2026 14:50:42 +0200 Subject: [PATCH 50/67] fix: add non-null assertions to satisfy strict type checks --- src/utils/dev.ts | 2 +- src/utils/i18n.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 62df56710..a0c41ca68 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -256,7 +256,7 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani // into INSERT + UPDATE fragments that each reference the same key literal) let itemsToRemove = 0 if (keyIndex !== -1) { - for (let i = keyIndex; i < collectionDump.length && keyMatch(collectionDump[i]); i++) { + for (let i = keyIndex; i < collectionDump.length && keyMatch(collectionDump[i]!); i++) { itemsToRemove++ } } diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 590353f23..698594bdb 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -54,7 +54,8 @@ export function expandI18nData( i18nConfig: CollectionI18nConfig, collectionType?: 'page' | 'data', ): ParsedContentFile[] { - const i18nData = parsedContent.meta?.i18n as Record> | undefined + const meta = parsedContent.meta as Record | undefined + const i18nData = meta?.i18n as Record> | undefined if (!i18nData) { if (!parsedContent.locale) { parsedContent.locale = i18nConfig.defaultLocale @@ -62,7 +63,7 @@ export function expandI18nData( return [parsedContent] } - const { i18n: _removed, ...cleanMeta } = parsedContent.meta + const { i18n: _removed, ...cleanMeta } = meta! parsedContent.meta = cleanMeta if (!parsedContent.locale) { From 61c15ba7bb03002f4d03f8212afc45a9c59a80bc Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Thu, 2 Apr 2026 15:34:17 +0200 Subject: [PATCH 51/67] fix: simplify type casting in generateCollectionLocales --- src/runtime/internal/locales.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts index 7dea34e32..4a9ff481a 100644 --- a/src/runtime/internal/locales.ts +++ b/src/runtime/internal/locales.ts @@ -10,16 +10,17 @@ export async function generateCollectionLocales>( ): Promise { // No .select() — data collections lack path/title columns; SELECT * is safe here // because ContentLocaleEntry marks path? and title? as optional. - const items = await (queryBuilder as unknown as CollectionQueryBuilder>) + const items = await queryBuilder .stem(stem) .all() return items.map((item) => { + const row = item as Record return { - locale: item.locale as string, - stem: item.stem as string, - path: item.path as string | undefined, - title: item.title as string | undefined, + locale: row.locale as string, + stem: row.stem as string, + path: row.path as string | undefined, + title: row.title as string | undefined, } }) } From cf5602a08176e3196fcc99b1fcf9411cb92e12f4 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 03:06:51 +0200 Subject: [PATCH 52/67] feat: add locale support to the collection query builder --- docs/content/docs/7.integrations/01.i18n.md | 12 ++ src/runtime/client.ts | 10 + src/runtime/internal/query.ts | 206 ++++++++++++-------- src/types/index.ts | 1 + src/types/manifest.ts | 15 +- src/utils/dev.ts | 14 +- src/utils/i18n.ts | 6 + src/utils/templates.ts | 17 +- test/unit/collectionQueryBuilder.test.ts | 74 +++++++ 9 files changed, 260 insertions(+), 95 deletions(-) diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index a918fc9e5..4bef70b9b 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -91,6 +91,10 @@ content/ The locale prefix is automatically stripped from `path` and `stem`, so both files have `path: /docs/getting-started` — the locale is stored in the `locale` field. +::tip +Locale-prefix stripping only applies to collections with `i18n` configured. Collections without `i18n` keep the full path (including any leading directory that happens to match a locale code). +:: + ### Inline i18n (Single File) Write translations directly in the same file using an `i18n` section. This is ideal for data collections (YAML, JSON) where most fields are locale-invariant: @@ -156,6 +160,14 @@ Content paths are stored **without** the locale prefix (e.g., `/docs/getting-sta 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`. On the server, it is read from `event.context.nuxtI18n?.vueI18nOptions?.locale` (with a fallback to `event.context.nuxtI18n?.locale`). If `@nuxtjs/i18n` is not installed, no auto-detection happens and queries return rows from every locale unless you call `.locale()` explicitly. +:: + +::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. The merge assumes both arrays are sorted by `stem ASC` using the database's binary/codepoint collation (SQLite default, PostgreSQL `C`). If a custom `.order()` is set, items from the requested locale and items from the fallback locale are concatenated rather than interleaved. +:: + ### useQueryCollection The `useQueryCollection` composable wraps `queryCollection` with `useAsyncData`, providing automatic cache key generation and locale-reactive re-fetching: diff --git a/src/runtime/client.ts b/src/runtime/client.ts index ad8b5b130..2f6ba5521 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -63,6 +63,16 @@ export function queryCollectionLocales(collection: */ export function useQueryCollection(collection: T) { const nuxtApp = tryUseNuxtApp() + if (!nuxtApp) { + // useAsyncData would throw the same way; surface the cause earlier so users + // get a clear 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.', + ) + } const i18nLocaleRef = (nuxtApp?.$i18n as { locale?: Ref })?.locale // Reactive locale for cache key and watch const localeValue = computed(() => i18nLocaleRef?.value || '') diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index a98ac4d8c..8531fa7b7 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -1,5 +1,5 @@ import { withoutTrailingSlash } from 'ufo' -import type { Collections, CollectionQueryBuilder, CollectionQueryGroup, QueryGroupFunction, SQLOperator } from '@nuxt/content' +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') => { @@ -70,9 +70,11 @@ export const collectionQueryGroup = (collection: T) } export const collectionQueryBuilder = (collection: T, fetch: (collection: T, sql: string) => Promise, detectedLocale?: string): CollectionQueryBuilder => { - // Read collection metadata from manifest - const collectionMeta = (manifestMeta as Record)[String(collection)] - const i18nConfig = collectionMeta?.i18n + // Read collection metadata from manifest. Cast 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, @@ -148,23 +150,24 @@ export const collectionQueryBuilder = (collection: return query }, async all(): Promise { - applyAutoLocale() - if (params.localeFallback) { - return fetchWithLocaleFallback() + const autoLocale = resolveAutoLocale() + if (params.localeFallback || autoLocale.fallback) { + return fetchWithLocaleFallback({ autoLocale }) } - return fetch(collection, buildQuery()).then(res => (res || []) as Collections[T][]) + return fetch(collection, buildQuery({ autoLocale })).then(res => (res || []) as Collections[T][]) }, async first(): Promise { - applyAutoLocale() - if (params.localeFallback) { - return fetchWithLocaleFallback({ 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 })).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) { - applyAutoLocale() - if (params.localeFallback) { - // Ensure the counted field is fetched and bypass pagination for accurate counts + const autoLocale = resolveAutoLocale() + if (params.localeFallback || autoLocale.fallback) { + // Ensure the counted field is fetched and bypass pagination for accurate counts. + // Save/restore in finally so an error in fetch doesn't leak state to later calls. const countField = field !== '*' ? String(field) : undefined const savedFields = params.selectedFields const savedOffset = params.offset @@ -174,43 +177,47 @@ export const collectionQueryBuilder = (collection: } params.offset = 0 params.limit = 0 - return fetchWithLocaleFallback({ preserveField: countField }).then((res) => { - params.selectedFields = savedFields - params.offset = savedOffset - params.limit = savedLimit + try { + const res = await fetchWithLocaleFallback({ preserveField: countField, autoLocale }) if (field === '*') return res.length const values = res .map(r => (r as unknown as Record)[String(field)]) .filter(v => v !== null && v !== undefined) return distinct ? new Set(values).size : values.length - }) + } + finally { + params.selectedFields = savedFields + params.offset = savedOffset + params.limit = savedLimit + } } return fetch(collection, buildQuery({ count: { field: String(field), distinct }, + autoLocale, })).then(m => (m[0] as { count: number }).count) }, } /** - * Auto-apply locale filter when: - * 1. The collection has i18n configured (in manifest) - * 2. No explicit .locale() call was made - * 3. A locale was detected from @nuxtjs/i18n - * Runs once before query execution (all/first/count). + * Compute the auto-locale effect for this execution **without mutating** persistent state. + * Safe for builder reuse: each terminal method re-resolves based on 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. + * + * Skips entirely when the collection has no i18n config, no locale was detected, + * the detected locale isn't in the configured list, or the user already called `.locale()`. */ - let autoLocaleApplied = false - function applyAutoLocale() { - if (autoLocaleApplied || params.localeExplicitlySet || !i18nConfig || !detectedLocale) return - if (!i18nConfig.locales.includes(detectedLocale)) return - autoLocaleApplied = true + function resolveAutoLocale(): { condition?: string, fallback?: { locale: string, fallback: string } } { + if (params.localeExplicitlySet || !i18nConfig || !detectedLocale) return {} + if (!i18nConfig.locales.includes(detectedLocale)) return {} if (detectedLocale === i18nConfig.defaultLocale) { - // Default locale: single query, no fallback needed - params.conditions.push(`("locale" = ${singleQuote(detectedLocale)})`) - } - else { - // Non-default locale: query with fallback to default - params.localeFallback = { locale: detectedLocale, fallback: i18nConfig.defaultLocale } + return { condition: `("locale" = ${singleQuote(detectedLocale)})` } } + return { fallback: { locale: detectedLocale, fallback: i18nConfig.defaultLocale } } } /** @@ -219,9 +226,17 @@ export const collectionQueryBuilder = (collection: * Internally injects 'stem' into selectedFields for merge-key deduplication, * stripping it from results when the caller didn't explicitly select it. * Accepts an optional limit override and a preserveField to keep for count operations. + * + * Note: when called from auto-locale path, params.localeFallback may be undefined + * and the fallback target comes from autoLocale.fallback instead. */ - async function fetchWithLocaleFallback(opts: { limit?: number, preserveField?: string } = {}): Promise { - const { locale, fallback } = params.localeFallback! + async function fetchWithLocaleFallback(opts: { + limit?: number + preserveField?: string + autoLocale?: { condition?: string, fallback?: { locale: string, fallback: string } } + } = {}): Promise { + const fb = params.localeFallback || opts.autoLocale?.fallback! + const { locale, fallback } = fb // Ensure `stem` is always fetched — needed for merge-key deduplication. // Track whether we injected it so we can strip it from results later. @@ -231,57 +246,67 @@ export const collectionQueryBuilder = (collection: params.selectedFields = [...savedFields, 'stem' as keyof Collections[T]] } - // Sub-queries fetch ALL matching rows (no limit/offset) — we apply those JS-side on the merged result - const localeCondition = `("locale" = ${singleQuote(locale)})` - const localeQuery = buildQuery({ extraCondition: localeCondition, noLimitOffset: true }) - const localeResults = await fetch(collection, localeQuery).then(res => res || []) + try { + // Sub-queries fetch ALL matching rows (no limit/offset) — we apply those JS-side on the merged result. + // Auto-locale's `condition` is intentionally NOT applied to sub-queries: we are overriding the locale + // here, so passing the original autoLocale would double-filter. + const localeCondition = `("locale" = ${singleQuote(locale)})` + const localeQuery = buildQuery({ extraCondition: localeCondition, noLimitOffset: true }) + const localeResults = await fetch(collection, localeQuery).then(res => res || []) - const fallbackCondition = `("locale" = ${singleQuote(fallback)})` - const fallbackQuery = buildQuery({ extraCondition: fallbackCondition, noLimitOffset: true }) - const fallbackResults = await fetch(collection, fallbackQuery).then(res => res || []) + const fallbackCondition = `("locale" = ${singleQuote(fallback)})` + const fallbackQuery = buildQuery({ extraCondition: fallbackCondition, noLimitOffset: true }) + const fallbackResults = await fetch(collection, fallbackQuery).then(res => res || []) - // Restore original selectedFields to avoid side-effects on repeated calls - params.selectedFields = savedFields + // Merge: prefer locale results, fill gaps from fallback + const getStem = (r: Collections[T]) => (r as unknown as { stem: string }).stem + const localeStemSet = new Set(localeResults.map(getStem)) + const fallbackOnly = fallbackResults.filter(item => !localeStemSet.has(getStem(item))) - // Merge: prefer locale results, fill gaps from fallback - const getStem = (r: Collections[T]) => (r as unknown as { stem: string }).stem - const localeStemSet = new Set(localeResults.map(getStem)) - const fallbackOnly = fallbackResults.filter(item => !localeStemSet.has(getStem(item))) + // When using the default ORDER BY (stem ASC), we can do a proper sorted merge. + // LIMITATION: when a custom ORDER BY is specified, we cannot interleave the + // two result sets correctly because that would require parsing the SQL ORDER BY + // clause and re-implementing the comparison in JS. Instead we concatenate + // locale items first, then fallback items — each group retains its DB order + // but the overall sequence may not match a single-query ORDER BY. + const merged = params.orderBy.length === 0 + ? mergeSortedArrays(localeResults, fallbackOnly, getStem) + : [...localeResults, ...fallbackOnly] - // When using the default ORDER BY (stem ASC), we can do a proper sorted merge - // because mergeSortedArrays compares by stem. - // LIMITATION: when a custom ORDER BY is specified, we cannot interleave the - // two result sets correctly because that would require parsing the SQL ORDER BY - // clause and re-implementing the comparison in JS. Instead we concatenate - // locale items first, then fallback items — each group retains its DB order - // but the overall sequence may not match a single-query ORDER BY. - const merged = params.orderBy.length === 0 - ? mergeSortedArrays(localeResults, fallbackOnly, getStem) - : [...localeResults, ...fallbackOnly] + // Apply offset then limit on the merged result + let result = merged + if (params.offset > 0) { + result = result.slice(params.offset) + } + const limit = opts.limit ?? (params.limit > 0 ? params.limit : 0) + if (limit > 0) { + result = result.slice(0, limit) + } - // Apply offset then limit on the merged result - let result = merged - if (params.offset > 0) { - result = result.slice(params.offset) - } - const limit = opts.limit ?? (params.limit > 0 ? params.limit : 0) - if (limit > 0) { - result = result.slice(0, limit) - } + // Strip internally-injected 'stem' if the caller didn't select it + // (unless it's needed by a count() call targeting that field) + if (stemInjected && opts.preserveField !== 'stem') { + return result.map((item) => { + const { stem: _, ...rest } = item as unknown as Record + return rest as Collections[T] + }) + } - // Strip internally-injected 'stem' if the caller didn't select it - // (unless it's needed by a count() call targeting that field) - if (stemInjected && opts.preserveField !== 'stem') { - return result.map((item) => { - const { stem: _, ...rest } = item as unknown as Record - return rest as Collections[T] - }) + return result as Collections[T][] + } + finally { + // Restore original selectedFields even on fetch errors so later calls aren't poisoned + params.selectedFields = savedFields } - - return result as Collections[T][] } - function buildQuery(opts: { count?: { field: string, distinct: boolean }, limit?: number, extraCondition?: string, noLimitOffset?: boolean } = {}) { + function buildQuery(opts: { + count?: { field: string, distinct: boolean } + limit?: number + extraCondition?: string + noLimitOffset?: boolean + autoLocale?: { condition?: string, fallback?: { locale: string, fallback: string } } + } = {}) { let query = 'SELECT ' if (opts?.count) { const countField = opts.count.field === '*' ? '*' : `"${opts.count.field.replace(/"/g, '')}"` @@ -294,6 +319,11 @@ export const collectionQueryBuilder = (collection: query += ` FROM ${tables[String(collection)]}` const conditions = [...params.conditions] + // Auto-locale condition (single-query path — fallback path handled in fetchWithLocaleFallback). + // Skip when extraCondition is set: that's the fallback sub-query which already pins its own locale. + if (opts.autoLocale?.condition && !opts.extraCondition) { + conditions.push(opts.autoLocale.condition) + } if (opts.extraCondition) { conditions.push(opts.extraCondition) } @@ -330,19 +360,25 @@ export const collectionQueryBuilder = (collection: /** * Merge two arrays that are both sorted by `stem` ASC (the default ORDER BY). - * Interleaves items using lexicographic comparison of their `stem` values. + * Interleaves items using **binary comparison** of their `stem` values to match + * SQLite's default BINARY collation (and PostgreSQL's "C" ordering of ASCII codepoints). + * + * We deliberately do NOT use `localeCompare`: that uses Unicode collation which + * orders `'a' < 'A'` (case-insensitive linguistic order), whereas the DB sorts + * `'A' (65) < 'a' (97)` (codepoint order). Using `localeCompare` would interleave + * the two result sets in a different order than the DB returned them. + * * Precondition: both arrays must be sorted by stem ASC — this function does NOT * handle arbitrary ORDER BY clauses (use concatenation for custom sorts). */ function mergeSortedArrays(a: T[], b: T[], getStem: (r: T) => string): T[] { - // Both arrays come from the DB with the same ORDER BY. - // Build a position map from array `a` stems to interleave `b` items correctly. - // Items in `b` whose stem falls between two `a` items get inserted at that position. const result: T[] = [] let ai = 0 let bi = 0 while (ai < a.length && bi < b.length) { - if (getStem(a[ai]!).localeCompare(getStem(b[bi]!)) <= 0) { + // Plain `<=` matches SQL BINARY/codepoint order; equal stems can't both occur + // here (we filter duplicates in the caller), but `<=` keeps `a` stable on ties. + if (getStem(a[ai]!) <= getStem(b[bi]!)) { result.push(a[ai]!) ai++ } diff --git a/src/types/index.ts b/src/types/index.ts index 4bb9814f3..dce7680de 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +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/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/utils/dev.ts b/src/utils/dev.ts index a0c41ca68..c62a7d00a 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -245,10 +245,18 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani } const collectionDump = manifest.dump[collection.name]! - // Use exact key match: look for the id as a complete SQL string literal ('key',) to avoid - // substring matches (e.g., 'team.yml' matching 'team.yml#fr') + // 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 INSERT + 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. const escapedKey = key.replace(/'/g, '\'\'') - const keyMatch = (item: string) => item.includes(`'${escapedKey}',`) || item.endsWith(`'${escapedKey}')`) + 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 diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 698594bdb..47ea51f39 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -45,6 +45,12 @@ export const defuByIndex = createDefu((obj, key, value) => { * 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. * + * `meta` semantics: the default locale keeps its full `meta` (minus the `i18n` key). + * Non-default locale items get a fresh `meta` of `{ ...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. Use a top-level field if you need a locale-varying value. + * * Note: this function mutates `parsedContent.meta` (removes the `i18n` key) and * sets `parsedContent.locale` if not already set. This is acceptable because the * source content is always consumed (inserted into DB) immediately after expansion. diff --git a/src/utils/templates.ts b/src/utils/templates.ts index def354ba9..912248f34 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' @@ -176,17 +176,22 @@ export const manifestTemplate = (manifest: Manifest) => ({ filename: moduleTemplates.manifest, getContents: ({ options }: { options: { manifest: Manifest } }) => { const collectionsMeta = options.manifest.collections.reduce((acc, collection) => { - // Stem prefix = source prefix only (collection name is stripped by describeId in path-meta.ts) - const sourcePrefix = collection.source?.[0]?.prefix || '' - const stemPrefix = sourcePrefix.replace(/^\/|\/$/g, '') - acc[collection.name] = { + // Stem prefix is used by `.stem()` to auto-resolve a leading source directory. + // We only emit it when ALL sources of a collection share 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/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index daabbad17..c487a71b8 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -408,5 +408,79 @@ describe('collectionQueryBuilder', () => { // 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: auto-applied locale used to be pushed to params.conditions on first + // execution, so a second .all() on the same builder would re-apply it (and any + // intervening .locale() call would produce a contradictory `loc=X AND loc=Y`). + const qb = collectionQueryBuilder(mockCollection, mockFetch, 'en') + + mockFetch.mockResolvedValueOnce([]) + await qb.all() // auto-locale = en (default), single-query path + expect(mockFetch).toHaveBeenLastCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC', + ) + + mockFetch.mockResolvedValueOnce([]) + await qb.all() // second call must not stack a duplicate locale condition + expect(mockFetch).toHaveBeenLastCalledWith( + 'articles', + 'SELECT * FROM _articles WHERE ("locale" = \'en\') ORDER BY stem ASC', + ) + + // An explicit .locale() now should 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('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 — 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') + + // Now run an unrelated query on a *new* builder using the same select set — + // verifies the test isolation; 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', + ) + }) + }) + + 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']) + }) }) }) From 73bc637fe0c89fa277770c6289a994e154e3c92f Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 03:06:51 +0200 Subject: [PATCH 53/67] feat: add locale fallback merging and collection locales query --- .../docs/4.utils/5.use-query-collection.md | 2 +- docs/content/docs/7.integrations/01.i18n.md | 14 ++++- pnpm-lock.yaml | 2 + src/runtime/client.ts | 44 +++++++------ src/runtime/internal/locales.ts | 16 ++++- src/runtime/internal/query.ts | 35 ++++++++++- src/runtime/server.ts | 13 ++-- src/utils/i18n.ts | 24 +++++--- test/unit/collectionQueryBuilder.test.ts | 29 +++++++++ test/unit/i18n.test.ts | 61 +++++++++++++++++++ 10 files changed, 196 insertions(+), 44 deletions(-) diff --git a/docs/content/docs/4.utils/5.use-query-collection.md b/docs/content/docs/4.utils/5.use-query-collection.md index 4615da9ef..b85a30c1b 100644 --- a/docs/content/docs/4.utils/5.use-query-collection.md +++ b/docs/content/docs/4.utils/5.use-query-collection.md @@ -46,7 +46,7 @@ The optional generic `R` overrides the return type. When omitted, the collection ### Terminal Methods -Terminal methods execute the query and return `AsyncData`: +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: - `.all()` — returns `AsyncData` - `.first()` — returns `AsyncData` diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index 4bef70b9b..c0aaf2504 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -11,6 +11,10 @@ seo: 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 +Auto-detection of the active locale on the server reads `event.context.nuxtI18n.detectLocale`, which is set by `@nuxtjs/i18n` **v10 or newer**. Earlier versions don't expose this context, so server-side auto-detection won't apply — use `.locale()` explicitly in your event handlers instead. +:: + ## Setup ::prose-steps @@ -161,7 +165,9 @@ Content paths are stored **without** the locale prefix (e.g., `/docs/getting-sta 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`. On the server, it is read from `event.context.nuxtI18n?.vueI18nOptions?.locale` (with a fallback to `event.context.nuxtI18n?.locale`). If `@nuxtjs/i18n` is not installed, no auto-detection happens and queries return rows from every locale unless you call `.locale()` explicitly. +**Detection paths.** On the client, the locale is read from `nuxtApp.$i18n.locale.value`. On the server, it is read from `event.context.nuxtI18n?.detectLocale` (set per request by `@nuxtjs/i18n` v10), with a fallback to `event.context.nuxtI18n?.vueI18nOptions?.locale` (the configured default) when detection didn't run. 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. :: ::warning @@ -230,7 +236,7 @@ console.log(item.meta._i18nSourceHash) // Hash of the default locale's translate ## CSP Configuration -If you use `nuxt-security` with Content Security Policy, add `'wasm-unsafe-eval'` to your `script-src` directive. Nuxt Content uses WebAssembly SQLite for client-side queries during locale switching: +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({ @@ -253,6 +259,10 @@ export default defineNuxtConfig({ **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). +**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 auto-detection relies on `event.context.nuxtI18n.detectLocale`, introduced in `@nuxtjs/i18n` **v10**. Earlier versions still work with explicit `.locale()` calls but won't pick up the active locale automatically. + ## Complete Examples You can see a complete working example: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a220ccb1c..893f48699 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,6 +392,8 @@ importers: test/fixtures/csv: {} + test/fixtures/i18n: {} + packages: '@ai-sdk/gateway@3.0.104': diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 2f6ba5521..6f6b6d779 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -1,5 +1,5 @@ import type { H3Event } from 'h3' -import { collectionQueryBuilder, collectionQueryGroup } from './internal/query' +import { buildGroup, collectionQueryBuilder, collectionQueryGroup } from './internal/query' import { generateNavigationTree } from './internal/navigation' import { generateItemSurround } from './internal/surround' import type { GenerateSearchSectionsOptions, SearchCollectionOptions, SearchResult } from './internal/search' @@ -10,6 +10,9 @@ import type { Collections, ContentLocaleEntry, ContentNavigationItem, Collection import type { AsyncData, NuxtError } from '#app' import type { MaybeRefOrGetter, Ref } from 'vue' import { computed, ref, toValue, tryUseNuxtApp, useAsyncData, watch } from '#imports' +// `useAsyncData`'s key is a getter returning a string; Nuxt treats getter keys +// as reactive (since Nuxt 3.17), so changes to the resolved key — e.g. when the +// locale ref changes — automatically refetch. No `watch:` plumbing required. export type { GenerateSearchSectionsOptions, SearchCollectionOptions, SearchResult } from './internal/search' @@ -23,10 +26,14 @@ interface ChainablePromise extends Promise(collection: T): CollectionQueryBuilder => { const nuxtApp = tryUseNuxtApp() const event = nuxtApp?.ssrContext?.event - // Auto-detect locale from @nuxtjs/i18n (client: $i18n.locale, SSR: event.context.nuxtI18n) - const detectedLocale = (nuxtApp?.$i18n as { locale?: { value?: string } })?.locale?.value - || (event?.context?.nuxtI18n as { vueI18nOptions?: { locale?: string } })?.vueI18nOptions?.locale - || (event?.context?.nuxtI18n as { locale?: string })?.locale + // Auto-detect locale: on the client we read $i18n.locale; on the server we read + // `event.context.nuxtI18n.detectLocale` (the per-request resolved locale set by + // @nuxtjs/i18n >= 10). `vueI18nOptions.locale` is the configured default and is + // only used if detection didn't run. + const i18nCtx = event?.context?.nuxtI18n as { detectLocale?: string, vueI18nOptions?: { locale?: string } } | undefined + const detectedLocale = (nuxtApp?.$i18n as { locale?: { value?: string } } | undefined)?.locale?.value + || i18nCtx?.detectLocale + || i18nCtx?.vueI18nOptions?.locale return collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql), detectedLocale) } @@ -46,7 +53,7 @@ export function queryCollectionLocales(collection: // Skip auto-locale: this helper needs ALL locale variants, not just the current one const event = tryUseNuxtApp()?.ssrContext?.event const qb = collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql)) - return generateCollectionLocales(qb, stem) + return generateCollectionLocales(qb, String(collection), stem) } /** @@ -73,8 +80,9 @@ export function useQueryCollection })?.locale - // Reactive locale for cache key and watch + 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 re-runs the handler when the locale ref changes. const localeValue = computed(() => i18nLocaleRef?.value || '') type Item = Collections[T] @@ -102,16 +110,18 @@ export function useQueryCollection) { + // Pre-build the group once just to derive a stable cache-key fragment. + // The same factory runs again inside `buildQuery()` when the actual query + // is executed — running it twice is fine because group factories are + // expected to be pure. const group = groupFactory(collectionQueryGroup(collection)) - const cond = (group as unknown as { _conditions: string[] })._conditions.join(' AND ') - keyParts.conditions.push(`and(${cond})`) + keyParts.conditions.push(`and${buildGroup(group, 'AND')}`) ops.push(qb => qb.andWhere(groupFactory)) return builder }, orWhere(groupFactory: QueryGroupFunction) { const group = groupFactory(collectionQueryGroup(collection)) - const cond = (group as unknown as { _conditions: string[] })._conditions.join(' OR ') - keyParts.conditions.push(`or(${cond})`) + keyParts.conditions.push(`or${buildGroup(group, 'OR')}`) ops.push(qb => qb.orWhere(groupFactory)) return builder }, @@ -157,21 +167,17 @@ export function useQueryCollection { - return useAsyncData(() => buildKey('all'), () => buildQuery().all(), { watch: watchSources() }) as AsyncData + return useAsyncData(() => buildKey('all'), () => buildQuery().all()) as AsyncData }, first(): AsyncData { - return useAsyncData(() => buildKey('first'), () => buildQuery().first(), { watch: watchSources() }) as AsyncData + return useAsyncData(() => buildKey('first'), () => buildQuery().first()) as AsyncData }, count(field?: keyof Item | '*', distinct?: boolean): AsyncData { const countKey = `count:${String(field ?? '*')}:${distinct ? 'd' : ''}` - return useAsyncData(() => buildKey(countKey), () => buildQuery().count(field, distinct), { watch: watchSources() }) as AsyncData + return useAsyncData(() => buildKey(countKey), () => buildQuery().count(field, distinct)) as AsyncData }, } - function watchSources() { - return !explicitLocale && i18nLocaleRef ? [i18nLocaleRef] : undefined - } - /** Rebuild a fresh query builder with all chained ops replayed. */ function buildQuery(): CollectionQueryBuilder { const qb = queryCollection(collection) diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts index 4a9ff481a..d4b287980 100644 --- a/src/runtime/internal/locales.ts +++ b/src/runtime/internal/locales.ts @@ -1,16 +1,26 @@ -import type { CollectionQueryBuilder, ContentLocaleEntry } from '@nuxt/content' +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`/`title` only exist on `page` collections). This keeps the + * payload small for data collections with large fields. */ export async function generateCollectionLocales>( queryBuilder: CollectionQueryBuilder, + collection: string, stem: string, ): Promise { - // No .select() — data collections lack path/title columns; SELECT * is safe here - // because ContentLocaleEntry marks path? and title? as optional. + const collectionFields = (manifestMeta as ManifestCollectionsMeta)[collection]?.fields ?? {} + const selectFields = LOCALE_ENTRY_FIELDS.filter(f => f in collectionFields) as Array + const items = await queryBuilder + .select(...selectFields) .stem(stem) .all() diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 8531fa7b7..1290f9412 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -2,11 +2,30 @@ import { withoutTrailingSlash } from 'ufo' 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 (e.g. for cache keys) 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 + * or value. Used to detect manual locale filters so auto-locale steps aside. + * + * Conditions are emitted by `collectionQueryGroup.where()` as strings that start + * with `"locale"` (the quoted column name). + */ +const referencesLocaleColumn = (conditions: string[]): boolean => + conditions.some(c => c.startsWith('"locale"')) + export const collectionQueryGroup = (collection: T): CollectionQueryGroup => { const conditions = [] as Array @@ -98,11 +117,19 @@ 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 don't 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 }, @@ -235,7 +262,9 @@ export const collectionQueryBuilder = (collection: preserveField?: string autoLocale?: { condition?: string, fallback?: { locale: string, fallback: string } } } = {}): Promise { - const fb = params.localeFallback || opts.autoLocale?.fallback! + // Callers gate on `params.localeFallback || opts.autoLocale?.fallback` being + // truthy, so one of them is always defined here — `||` collapses both branches. + const fb = (params.localeFallback || opts.autoLocale?.fallback) as { locale: string, fallback: string } const { locale, fallback } = fb // Ensure `stem` is always fetched — needed for merge-key deduplication. diff --git a/src/runtime/server.ts b/src/runtime/server.ts index 6f981233c..4012990d3 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -15,11 +15,12 @@ interface ChainablePromise extends Promise(event: H3Event, collection: T): CollectionQueryBuilder => { - // Auto-detect locale from @nuxtjs/i18n server context (resilient to different i18n versions) - const i18nCtx = event.context?.nuxtI18n as Record | undefined - const detectedLocale = (i18nCtx?.vueI18nOptions as { locale?: string })?.locale - || (i18nCtx?.locale as string) - || undefined + // Auto-detect locale from @nuxtjs/i18n server context (requires @nuxtjs/i18n >= 10). + // `detectLocale` is the per-request resolved locale; `vueI18nOptions.locale` is + // the configured default — used only as a last resort if detection didn't run + // (e.g. when `experimental.nitroContextDetection` is disabled). + const i18nCtx = event.context?.nuxtI18n as { detectLocale?: string, vueI18nOptions?: { locale?: string } } | undefined + const detectedLocale = i18nCtx?.detectLocale || i18nCtx?.vueI18nOptions?.locale return collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql), detectedLocale) } @@ -38,7 +39,7 @@ export function queryCollectionSearchSections(e export function queryCollectionLocales(event: H3Event, collection: T, stem: string): Promise { // Skip auto-locale: this helper needs ALL locale variants, not just the current one const qb = collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql)) - return generateCollectionLocales(qb, stem) + return generateCollectionLocales(qb, String(collection), stem) } function chainablePromise(event: H3Event, collection: T, fn: (qb: CollectionQueryBuilder) => Promise) { diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 47ea51f39..d6e2294b2 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -76,24 +76,28 @@ export function expandI18nData( parsedContent.locale = i18nConfig.defaultLocale } - // Compute source hash from default locale's translatable fields - const translatedFields = new Set(Object.values(i18nData).flatMap(Object.keys)) - const sourceFields: Record = {} - for (const field of translatedFields) { - sourceFields[field] = parsedContent[field] - } - const i18nSourceHash = hash(sourceFields) - const items: ParsedContentFile[] = [parsedContent] for (const [locale, overrides] of Object.entries(i18nData)) { if (locale === parsedContent.locale) continue + // Source hash is per-locale: based only on the default values of fields THIS + // locale actually translates. A field translated only in `de` must not enter + // the `fr` hash — otherwise a default change to that field would flip `fr`'s + // hash and signal a false "stale translation". + const sourceFields: Record = {} + for (const field of Object.keys(overrides)) { + sourceFields[field] = parsedContent[field] + } + const i18nSourceHash = hash(sourceFields) + // Deep merge preserves untranslated fields (routes, IDs, icons). // For page collections, body AST must not be deep-merged — replace it wholesale. + // Use `'body' in overrides` so explicit null/empty bodies still replace (rather + // than falling back to deep-merging the default AST in). const merged = defuByIndex(overrides, parsedContent) as ParsedContentFile - if (collectionType === 'page' && overrides.body) { - merged.body = overrides.body + if (collectionType === 'page' && 'body' in overrides) { + merged.body = overrides.body as ParsedContentFile['body'] } const localeItem: ParsedContentFile = { diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index c487a71b8..22018eef5 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -438,6 +438,35 @@ describe('collectionQueryBuilder', () => { ) }) + 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 — we detect any condition starting with `"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 — the next query would then SELECT an extra "stem" column. diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index 4f2dbdc51..f4dfa7807 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -517,5 +517,66 @@ describe('i18n', () => { expect(items1[1].meta._i18nSourceHash).not.toBe(items2[1].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`. `de` translates only `description`. + // Changing `description` between content1 and content2 must NOT change `fr`'s + // hash (since fr doesn't translate description) but MUST change `de`'s. + 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() + }) }) }) From 3c4fa0e197d7717c97a60c604e2efb7c383d61d1 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 03:06:51 +0200 Subject: [PATCH 54/67] feat: validate collection i18n config and harden query safety --- docs/content/docs/2.collections/1.define.md | 6 +++- .../docs/4.utils/5.use-query-collection.md | 10 +++--- docs/content/docs/7.integrations/01.i18n.md | 23 ++++++++----- src/module.ts | 7 ++-- src/runtime/internal/security.ts | 5 ++- src/utils/config.ts | 29 +++++++++++++++-- src/utils/dev.ts | 10 +++--- src/utils/i18n.ts | 27 +++++++++++++--- test/unit/assertSafeQuery.test.ts | 6 ++++ test/unit/i18n.test.ts | 32 +++++++++++++++++++ 10 files changed, 127 insertions(+), 28 deletions(-) diff --git a/docs/content/docs/2.collections/1.define.md b/docs/content/docs/2.collections/1.define.md index 60e4e07bd..a34ed0007 100644 --- a/docs/content/docs/2.collections/1.define.md +++ b/docs/content/docs/2.collections/1.define.md @@ -213,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`/`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/4.utils/5.use-query-collection.md b/docs/content/docs/4.utils/5.use-query-collection.md index b85a30c1b..d950a167e 100644 --- a/docs/content/docs/4.utils/5.use-query-collection.md +++ b/docs/content/docs/4.utils/5.use-query-collection.md @@ -46,11 +46,11 @@ The optional generic `R` overrides the return type. When omitted, the collection ### 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: +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()` — returns `AsyncData` -- `.first()` — returns `AsyncData` -- `.count(field?, distinct?)` — returns `AsyncData` +- `.all()` — returns `AsyncData` +- `.first()` — returns `AsyncData` +- `.count(field?, distinct?)` — returns `AsyncData` ## Locale Reactivity @@ -77,7 +77,7 @@ Use the generic parameter to override the return type when the collection's gene import type { CardItemType } from '#shared/types/components/card-item' const { data: cards } = await useQueryCollection('technologies').all() -// cards is Ref +// `cards` is `Ref` (per Nuxt's `useAsyncData` typing). ``` diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index c0aaf2504..820444fa1 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -140,24 +140,31 @@ When `@nuxtjs/i18n` is installed, `queryCollection` automatically detects the cu ```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. :: diff --git a/src/module.ts b/src/module.ts index bbb3970b4..97c3e42ed 100644 --- a/src/module.ts +++ b/src/module.ts @@ -380,11 +380,14 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio usedComponents.push(...parsedContent.__metadata.components) } - // i18n: expand inline translations to per-locale rows + // i18n: expand inline translations to per-locale rows. + // 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 is the SQL row's actual `id`. if (collection.i18n && (parsedContent?.meta as Record)?.i18n) { const expandedItems = expandI18nData(parsedContent, collection.i18n, collection.type) for (const item of expandedItems) { - const itemKey = item.locale ? `${keyInCollection}#${item.locale}` : keyInCollection + const itemKey = item.id as string const { queries: itemQueries, hash: itemHash } = generateCollectionInsert(collection, item) list.push([itemKey, itemQueries, itemHash]) } diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index 251a7a141..33c238c78 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -72,7 +72,10 @@ export function assertSafeQuery(sql: string, collection: string) { // ORDER BY (optional — COUNT queries omit it) if (orderBy && order) { const _order = (orderBy + ' ' + order).split(', ') - if (!_order.every(column => column.match(/^("[a-zA-Z_]+"|[a-zA-Z_]+) (ASC|DESC)$/))) { + // Column names must start with a letter/underscore but may contain digits + // afterwards (e.g. `h1`, `field_2024`, `version2`). 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') } } diff --git a/src/utils/config.ts b/src/utils/config.ts index ee6533014..1f3e4391e 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -118,11 +118,25 @@ function resolveI18nConfig(nuxt: Nuxt, collections: Record idx.columns.length === 2 && idx.columns[0] === 'locale' && idx.columns[1] === 'stem', + ) + if (!hasLocaleStemIndex) { + collection.indexes = [...(collection.indexes || []), { columns: ['locale', 'stem'] }] + } } else { logger.warn( @@ -133,3 +147,12 @@ function resolveI18nConfig(nuxt: Nuxt, collections: Record | undefined + return Boolean(props && 'locale' in props) +} diff --git a/src/utils/dev.ts b/src/utils/dev.ts index c62a7d00a..49b91616c 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -173,14 +173,16 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani const expandedItems = expandI18nData(parsed, collection.i18n, collection.type) for (const item of expandedItems) { - const itemKey = item.locale ? `${keyInCollection}#${item.locale}` : keyInCollection + // Use item.id directly as the dump/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) } - // Clean up the bare (un-suffixed) row in case i18n was just added to this file - await broadcast(collection, keyInCollection) - // Remove locale rows that are no longer in the i18n section for (const locale of collection.i18n.locales) { if (locale === sourceLocale || locale in i18nData) continue diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index d6e2294b2..968f8f6be 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -6,16 +6,35 @@ import type { ParsedContentFile } from '../types' /** * Custom defu that merges arrays by index (item-by-item) instead of concatenating. * Applied recursively to all nested arrays within merged objects. - * Used for inline i18n expansion: locale overrides merge with default locale items - * so untranslated fields (routes, IDs, icons, URLs) are preserved from the default. * - * In createDefu's merger: obj[key] = defaults (second arg), value = overrides (first arg). - * Override items take priority; default items fill gaps for missing fields. + * Used for inline i18n expansion: object-of-fields arrays (e.g. nav items, cards) + * deep-merge so untranslated fields (routes, IDs, icons, URLs) are preserved from + * the default. Scalar arrays (`tags: ['a','b','c']`) are replaced wholesale — + * pad-filling them with the default's tail is virtually never what authors want + * when they intentionally provide a shorter locale-specific list. + * + * Heuristic: if every element of the override array is a non-object, the array is + * treated as scalar and replaced. Otherwise it deep-merges by index, with the + * default's trailing items preserved for missing override slots. + * + * In createDefu's merger: `obj[key]` is the defaults (second arg), `value` is the + * overrides (first arg). Override items take priority; default items fill gaps. */ export const defuByIndex = createDefu((obj, key, value) => { if (Array.isArray(obj[key]) && Array.isArray(value)) { const defaultArr = obj[key] const overrideArr = value + + // Scalar override arrays replace wholesale (no pad-fill from default). + // An empty override array also short-circuits here — author explicitly cleared. + const isScalarOverride = (overrideArr as unknown[]).every( + (item: unknown) => typeof item !== 'object' || item === null, + ) + if (isScalarOverride) { + ;(obj as Record)[key as string] = [...overrideArr] + return true + } + const maxLen = Math.max(overrideArr.length, defaultArr.length) const result = [] for (let i = 0; i < maxLen; i++) { diff --git a/test/unit/assertSafeQuery.test.ts b/test/unit/assertSafeQuery.test.ts index 780b8a55b..8e13b1cbb 100644 --- a/test/unit/assertSafeQuery.test.ts +++ b/test/unit/assertSafeQuery.test.ts @@ -67,6 +67,12 @@ describe('decompressSQLDump', () => { // 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, } Object.entries(securityQueries).forEach(([query, isValid]) => { diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index f4dfa7807..38d0ffd82 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -421,6 +421,38 @@ describe('i18n', () => { 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', From 5d9e48ed93852e734cf242cfd6a2f19177182963 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 03:06:51 +0200 Subject: [PATCH 55/67] feat: auto-detect active locale on client and server --- .../docs/4.utils/5.use-query-collection.md | 20 ++- docs/content/docs/7.integrations/01.i18n.md | 6 + src/runtime/client.ts | 77 +++++++----- src/runtime/internal/i18n-detection.ts | 81 ++++++++++++ src/runtime/server.ts | 11 +- src/types/locales.ts | 20 +++ src/utils/i18n.ts | 3 +- test/i18n.test.ts | 18 +++ test/unit/i18nDetection.test.ts | 116 ++++++++++++++++++ 9 files changed, 310 insertions(+), 42 deletions(-) create mode 100644 src/runtime/internal/i18n-detection.ts create mode 100644 test/unit/i18nDetection.test.ts diff --git a/docs/content/docs/4.utils/5.use-query-collection.md b/docs/content/docs/4.utils/5.use-query-collection.md index d950a167e..574cfb795 100644 --- a/docs/content/docs/4.utils/5.use-query-collection.md +++ b/docs/content/docs/4.utils/5.use-query-collection.md @@ -48,9 +48,23 @@ The optional generic `R` overrides the return type. When omitted, the collection 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()` — returns `AsyncData` -- `.first()` — returns `AsyncData` -- `.count(field?, distinct?)` — returns `AsyncData` +- `.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 diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index 820444fa1..72546588a 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -131,6 +131,10 @@ i18n: Deep merge is used for all collection types — translated fields override, untranslated fields (routes, IDs, URLs) are preserved from the default. For **page collections**, the `body` field (Markdown AST) is replaced wholesale when the override provides it, preventing AST corruption from deep-merging. :: +::warning +**Do not declare a field named `i18n` in your collection schema.** The parser puts top-level non-schema fields onto `meta`, and inline-i18n expansion reads `meta.i18n`. A schema field of the same name promotes `i18n` to a regular column instead, and the translations will be ingested as plain data rather than expanded into per-locale rows. +:: + ## Querying Content ### Automatic Locale Detection @@ -175,6 +179,8 @@ For the default locale, a single `WHERE locale = ?` query is issued. For non-def **Detection paths.** On the client, the locale is read from `nuxtApp.$i18n.locale.value`. On the server, it is read from `event.context.nuxtI18n?.detectLocale` (set per request by `@nuxtjs/i18n` v10), with a fallback to `event.context.nuxtI18n?.vueI18nOptions?.locale` (the configured default) when detection didn't run. 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 diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 6f6b6d779..aafb2babe 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -1,18 +1,20 @@ import type { H3Event } from 'h3' -import { buildGroup, collectionQueryBuilder, collectionQueryGroup } from './internal/query' +import { buildGroup, collectionQueryBuilder, collectionQueryGroup, getGroupConditions } from './internal/query' import { generateNavigationTree } from './internal/navigation' import { generateItemSurround } from './internal/surround' import type { GenerateSearchSectionsOptions, SearchCollectionOptions, SearchResult } 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 { withoutTrailingSlash } from 'ufo' import type { Collections, ContentLocaleEntry, ContentNavigationItem, CollectionQueryBuilder, DatabaseAdapter, PageCollections, QueryGroupFunction, SQLOperator, SurroundOptions } from '@nuxt/content' -import type { AsyncData, NuxtError } from '#app' +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 returning a string; Nuxt treats getter keys -// as reactive (since Nuxt 3.17), so changes to the resolved key — e.g. when the -// locale ref changes — automatically refetch. No `watch:` plumbing required. +// `useAsyncData`'s key is a getter; Nuxt watches the resolved string and re-fetches +// when it changes (reactive keys, Nuxt 3.17+). Module compat is `>=4.1.0 || ^3.19.0`, +// so the locale-aware key change automatically triggers refetches without `watch:`. export type { GenerateSearchSectionsOptions, SearchCollectionOptions, SearchResult } from './internal/search' @@ -26,14 +28,8 @@ interface ChainablePromise extends Promise(collection: T): CollectionQueryBuilder => { const nuxtApp = tryUseNuxtApp() const event = nuxtApp?.ssrContext?.event - // Auto-detect locale: on the client we read $i18n.locale; on the server we read - // `event.context.nuxtI18n.detectLocale` (the per-request resolved locale set by - // @nuxtjs/i18n >= 10). `vueI18nOptions.locale` is the configured default and is - // only used if detection didn't run. - const i18nCtx = event?.context?.nuxtI18n as { detectLocale?: string, vueI18nOptions?: { locale?: string } } | undefined - const detectedLocale = (nuxtApp?.$i18n as { locale?: { value?: string } } | undefined)?.locale?.value - || i18nCtx?.detectLocale - || i18nCtx?.vueI18nOptions?.locale + // Detect from client first (live ref during SPA navigation), then from the SSR event context. + const detectedLocale = detectClientLocale(nuxtApp) || detectServerLocale(event) return collectionQueryBuilder(collection, (collection, sql) => executeContentQuery(event, collection, sql), detectedLocale) } @@ -64,9 +60,14 @@ export function queryCollectionLocales(collection: * * Must be called in a Vue component setup context (like useAsyncData, 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`, etc. + * * @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() @@ -105,7 +106,12 @@ export function useQueryCollection` fragment alongside + // the explicit condition. The underlying query builder already handles this + // (`referencesLocaleColumn` in query.ts) — we mirror it 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 }, @@ -115,12 +121,16 @@ export function useQueryCollection c.startsWith('"locale"'))) 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 (groupConditions.some(c => c.startsWith('"locale"'))) explicitLocale = true keyParts.conditions.push(`or${buildGroup(group, 'OR')}`) ops.push(qb => qb.orWhere(groupFactory)) return builder @@ -146,7 +156,10 @@ export function useQueryCollection qb.path(path)) return builder }, @@ -166,15 +179,15 @@ export function useQueryCollection qb.locale(locale, opts)) return builder }, - all(): AsyncData { - return useAsyncData(() => buildKey('all'), () => buildQuery().all()) as AsyncData + all(options?: AsyncDataOptions): AsyncData { + return useAsyncData(() => buildKey('all'), () => buildQuery().all() as Promise, options) as AsyncData }, - first(): AsyncData { - return useAsyncData(() => buildKey('first'), () => buildQuery().first()) as AsyncData + first(options?: AsyncDataOptions): AsyncData { + return useAsyncData(() => buildKey('first'), () => buildQuery().first() as Promise, options) as AsyncData }, - count(field?: keyof Item | '*', distinct?: boolean): 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)) as AsyncData + return useAsyncData(() => buildKey(countKey), () => buildQuery().count(field, distinct), options) as AsyncData }, } @@ -187,16 +200,18 @@ export function useQueryCollection= v10) + * attaches to H3 events. Kept narrow on purpose: we only read the two fields + * we need, and pinning a precise type would couple us to an upstream module + * that we declare 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. Set by `@nuxtjs/i18n >= 10` after route-locale-detect runs. */ + 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: `detectLocale` (per-request resolved) → `vueI18nOptions.locale` (configured default). + * Returns `undefined` when `@nuxtjs/i18n` isn't installed or its context hasn't been initialized + * (e.g. routes outside the i18n middleware, or prerender without detection). + */ +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` isn't installed. + * + * Typed as `unknown` because Nuxt's `NuxtApp` doesn't declare `$i18n` natively — + * it's a runtime plugin injection. A narrower parameter 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 shape must stay stable: Nuxt re-uses + * AsyncData entries by key, so changing this format 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/server.ts b/src/runtime/server.ts index 4012990d3..dec5f2b98 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -4,6 +4,7 @@ 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, ContentLocaleEntry, PageCollections, SurroundOptions, SQLOperator, QueryGroupFunction } from '@nuxt/content' @@ -15,13 +16,9 @@ interface ChainablePromise extends Promise(event: H3Event, collection: T): CollectionQueryBuilder => { - // Auto-detect locale from @nuxtjs/i18n server context (requires @nuxtjs/i18n >= 10). - // `detectLocale` is the per-request resolved locale; `vueI18nOptions.locale` is - // the configured default — used only as a last resort if detection didn't run - // (e.g. when `experimental.nitroContextDetection` is disabled). - const i18nCtx = event.context?.nuxtI18n as { detectLocale?: string, vueI18nOptions?: { locale?: string } } | undefined - const detectedLocale = i18nCtx?.detectLocale || i18nCtx?.vueI18nOptions?.locale - return collectionQueryBuilder(collection, (collection, sql) => fetchQuery(event, collection, sql), detectedLocale) + // Auto-detect from `@nuxtjs/i18n` server context — falls back to configured default + // when per-request detection didn't 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) { diff --git a/src/types/locales.ts b/src/types/locales.ts index 575a4af14..e4b6726de 100644 --- a/src/types/locales.ts +++ b/src/types/locales.ts @@ -10,3 +10,23 @@ export interface ContentLocaleEntry { /** 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 — Studio, custom translator pipelines — 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 based only + * on the default-locale values of fields THIS locale overrides — a change to a + * field that this locale doesn't translate won't 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/utils/i18n.ts b/src/utils/i18n.ts index 968f8f6be..b27433f35 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -1,6 +1,7 @@ import { createDefu } from 'defu' import { hash } from 'ohash' import type { CollectionI18nConfig } from '../types/collection' +import { I18N_SOURCE_HASH_FIELD } from '../types/locales' import type { ParsedContentFile } from '../types' /** @@ -123,7 +124,7 @@ export function expandI18nData( ...merged, id: `${parsedContent.id}#${locale}`, locale, - meta: { ...cleanMeta, _i18nSourceHash: i18nSourceHash }, + meta: { ...cleanMeta, [I18N_SOURCE_HASH_FIELD]: i18nSourceHash }, } items.push(localeItem) diff --git a/test/i18n.test.ts b/test/i18n.test.ts index 894f0ea9c..b5cd548f0 100644 --- a/test/i18n.test.ts +++ b/test/i18n.test.ts @@ -216,5 +216,23 @@ describe('i18n', async () => { 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` / `title: undefined` rather than blowing up on 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() + } + }) }) }) diff --git a/test/unit/i18nDetection.test.ts b/test/unit/i18nDetection.test.ts new file mode 100644 index 000000000..5a6777f07 --- /dev/null +++ b/test/unit/i18nDetection.test.ts @@ -0,0 +1,116 @@ +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 — @nuxtjs/i18n not installed or middleware didn't run. + 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 prerender / a route outside 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 behavior. + 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 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 (incl. equivalent normalization upstream) → equal keys + const a = buildUseQueryCollectionKey({ ...baseParts, conditions: ['path=/foo'] }) + const b = buildUseQueryCollectionKey({ ...baseParts, conditions: ['path=/foo'] }) + expect(a).toBe(b) + }) +}) From f93b11652cf940cb1e0e35bc777decb9a3077f28 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 03:06:51 +0200 Subject: [PATCH 56/67] feat: guard locales query against collections without i18n --- src/runtime/internal/locales.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts index d4b287980..288aaab3e 100644 --- a/src/runtime/internal/locales.ts +++ b/src/runtime/internal/locales.ts @@ -10,6 +10,11 @@ const LOCALE_ENTRY_FIELDS = ['locale', 'stem', 'path', 'title'] as const * Selects only the columns that map to `ContentLocaleEntry` (and that the collection * actually has — `path`/`title` only exist on `page` 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 — i.e. 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 here. */ export async function generateCollectionLocales>( queryBuilder: CollectionQueryBuilder, @@ -17,6 +22,15 @@ export async function generateCollectionLocales>( 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 From 8946203c7f5531b3062abfe991e53fbe185f9311 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 03:06:51 +0200 Subject: [PATCH 57/67] perf: parallelize locale and fallback queries --- docs/content/docs/7.integrations/01.i18n.md | 13 +++++--- src/module.ts | 14 ++++++--- src/runtime/internal/i18n-detection.ts | 25 ++++++++++++--- src/runtime/internal/query.ts | 34 ++++++++++++++++----- src/utils/collection.ts | 19 ++++++++++-- src/utils/config.ts | 10 +++--- test/unit/collectionQueryBuilder.test.ts | 32 +++++++++++++++++-- 7 files changed, 116 insertions(+), 31 deletions(-) diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index 72546588a..91d7cabaf 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -12,7 +12,7 @@ seo: 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 -Auto-detection of the active locale on the server reads `event.context.nuxtI18n.detectLocale`, which is set by `@nuxtjs/i18n` **v10 or newer**. Earlier versions don't expose this context, so server-side auto-detection won't apply — use `.locale()` explicitly in your event handlers instead. +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 @@ -176,7 +176,7 @@ Content paths are stored **without** the locale prefix (e.g., `/docs/getting-sta 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`. On the server, it is read from `event.context.nuxtI18n?.detectLocale` (set per request by `@nuxtjs/i18n` v10), with a fallback to `event.context.nuxtI18n?.vueI18nOptions?.locale` (the configured default) when detection didn't run. If `@nuxtjs/i18n` is not installed, no auto-detection happens and queries return rows from every locale unless you call `.locale()` explicitly. +**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. @@ -262,7 +262,10 @@ export default defineNuxtConfig({ }, routeRules: { '/__nuxt_content/**': { - csurf: false, // Exempt content API from CSRF + // 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, }, }, }) @@ -272,9 +275,11 @@ export default defineNuxtConfig({ **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 auto-detection relies on `event.context.nuxtI18n.detectLocale`, introduced in `@nuxtjs/i18n` **v10**. Earlier versions still work with explicit `.locale()` calls but won't pick up the active locale automatically. +**`@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 and direct hits on already-localized URLs both fall back to the configured default locale. Use explicit `.locale()` in server handlers for guaranteed per-request locale resolution. ## Complete Examples diff --git a/src/module.ts b/src/module.ts index 97c3e42ed..769125b5b 100644 --- a/src/module.ts +++ b/src/module.ts @@ -139,12 +139,16 @@ export default defineNuxtModule({ { 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: 'queryCollectionLocales', 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') }) diff --git a/src/runtime/internal/i18n-detection.ts b/src/runtime/internal/i18n-detection.ts index fc116b45c..71e2ab156 100644 --- a/src/runtime/internal/i18n-detection.ts +++ b/src/runtime/internal/i18n-detection.ts @@ -10,7 +10,19 @@ import type { H3Event } from 'h3' * https://github.com/nuxt-modules/i18n/blob/main/src/runtime/server/context.ts */ interface NuxtI18nServerContext { - /** Per-request resolved locale. Set by `@nuxtjs/i18n >= 10` after route-locale-detect runs. */ + /** + * Per-request resolved locale. Written by `@nuxtjs/i18n >= 10`'s Nitro plugin + * (`src/runtime/server/plugin.ts`) inside the `render:before` hook — 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 (e.g. `/fr/about` for a French + * user), this stays `undefined` and the fallback to `vueI18nOptions.locale` + * (the configured default) is what `detectServerLocale` ultimately returns. + * + * Practical upshot: 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 } @@ -19,9 +31,14 @@ interface NuxtI18nServerContext { /** * Resolve the active locale on the server from `@nuxtjs/i18n`'s event context. * - * Priority: `detectLocale` (per-request resolved) → `vueI18nOptions.locale` (configured default). - * Returns `undefined` when `@nuxtjs/i18n` isn't installed or its context hasn't been initialized - * (e.g. routes outside the i18n middleware, or prerender without detection). + * Priority: `detectLocale` (set only during server-side localized redirects) + * → `vueI18nOptions.locale` (the configured default). Returns `undefined` when + * `@nuxtjs/i18n` isn't installed or its context hasn't been initialized. + * + * Note: 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 diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 1290f9412..ddf05ea9e 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -145,6 +145,19 @@ export const collectionQueryBuilder = (collection: 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, + // which surfaces as a confusing "no such column" SQL error far from the + // user's call site. Warn at the source so the misuse is obvious. Manifest + // metadata is the source of truth here: `i18nConfig` is undefined exactly + // when the collection wasn't 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 } @@ -218,9 +231,14 @@ export const collectionQueryBuilder = (collection: params.limit = savedLimit } } + // `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 > 0 — 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) }, } @@ -279,13 +297,15 @@ export const collectionQueryBuilder = (collection: // Sub-queries fetch ALL matching rows (no limit/offset) — we apply those JS-side on the merged result. // Auto-locale's `condition` is intentionally NOT applied to sub-queries: we are overriding the locale // here, so passing the original autoLocale would double-filter. - const localeCondition = `("locale" = ${singleQuote(locale)})` - const localeQuery = buildQuery({ extraCondition: localeCondition, noLimitOffset: true }) - const localeResults = await fetch(collection, localeQuery).then(res => res || []) - - const fallbackCondition = `("locale" = ${singleQuote(fallback)})` - const fallbackQuery = buildQuery({ extraCondition: fallbackCondition, noLimitOffset: true }) - const fallbackResults = await fetch(collection, fallbackQuery).then(res => res || []) + // Both queries are independent, so issue them in parallel — halves perceived latency on the + // non-default-locale path. `Promise.all` short-circuits on any rejection, which is the + // desired behavior (the merge can't proceed without both result sets). + const localeQuery = buildQuery({ extraCondition: `("locale" = ${singleQuote(locale)})`, noLimitOffset: true }) + const fallbackQuery = buildQuery({ extraCondition: `("locale" = ${singleQuote(fallback)})`, noLimitOffset: true }) + const [localeResults, fallbackResults] = await Promise.all([ + fetch(collection, localeQuery).then(res => res || []), + fetch(collection, fallbackQuery).then(res => res || []), + ]) // Merge: prefer locale results, fill gaps from fallback const getStem = (r: Collections[T]) => (r as unknown as { stem: string }).stem diff --git a/src/utils/collection.ts b/src/utils/collection.ts index 79c7af06e..1a6662a1b 100644 --- a/src/utils/collection.ts +++ b/src/utils/collection.ts @@ -1,5 +1,5 @@ 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' @@ -12,6 +12,17 @@ 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', + ) +} + export function defineCollection(collection: Collection): DefinedCollection { let standardSchema: Draft07 = emptyStandardSchema @@ -36,9 +47,11 @@ export function defineCollection(collection: Collection): DefinedCollectio extendedSchema = mergeStandardSchema(metaStandardSchema, extendedSchema) - // Auto-add composite index on (locale, stem) for i18n collections + // Auto-add composite index on (locale, stem) for i18n collections. + // Skip when the user already declared an equivalent index — avoids a duplicate + // CREATE INDEX (which would survive when the user supplies a custom `name`). const indexes = collection.indexes ? [...collection.indexes] : [] - if (hasI18nConfig) { + if (hasI18nConfig && !hasLocaleStemIndex(indexes)) { indexes.push({ columns: ['locale', 'stem'] }) } diff --git a/src/utils/config.ts b/src/utils/config.ts index 1f3e4391e..3ba00b901 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -3,7 +3,7 @@ import { relative } from 'pathe' import { hasNuxtModule, useNuxt } from '@nuxt/kit' import type { Nuxt } from '@nuxt/schema' import type { CollectionI18nConfig, DefinedCollection, ModuleOptions } from '../types' -import { defineCollection, resolveCollections } from './collection' +import { defineCollection, hasLocaleStemIndex, resolveCollections } from './collection' import { localeStandardSchema, mergeStandardSchema } from './schema' import { getCollectionFieldsTypes } from '../runtime/internal/schema' import { logger } from './dev' @@ -130,11 +130,9 @@ function resolveI18nConfig(nuxt: Nuxt, collections: Record idx.columns.length === 2 && idx.columns[0] === 'locale' && idx.columns[1] === 'stem', - ) - if (!hasLocaleStemIndex) { + // 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'] }] } } diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index 22018eef5..cac107d45 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -271,16 +271,44 @@ describe('collectionQueryBuilder', () => { expect(results[1]).toEqual({ stem: 'b', locale: 'en' }) }) - it('count query omits ORDER BY', async () => { + it('count() ignores LIMIT carried over from .limit()', async () => { + // Regression: COUNT returns exactly one row; appending `LIMIT N` is at best + // misleading and combined with OFFSET it returns `[]` → `m[0].count` would throw. + mockFetch.mockResolvedValueOnce([{ count: 42 }]) const query = collectionQueryBuilder(mockCollection, mockFetch) - await query.count() + 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 From 948df1d7aaf1ef3b98f92666af4875f56f5bf46d Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 03:06:51 +0200 Subject: [PATCH 58/67] fix: prevent shared-state race in locale-fallback count --- src/runtime/client.ts | 8 +- src/runtime/internal/query.ts | 203 +++++++++++++---------- src/utils/collection.ts | 16 +- src/utils/config.ts | 9 +- test/unit/collectionQueryBuilder.test.ts | 105 ++++++++++++ 5 files changed, 253 insertions(+), 88 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index aafb2babe..470dc80bf 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -84,7 +84,13 @@ export function useQueryCollection } | undefined)?.locale // The locale value flows into the cache key. Because `useAsyncData`'s key is // a getter, Nuxt re-runs the handler when the locale ref changes. - const localeValue = computed(() => i18nLocaleRef?.value || '') + // + // During SSR `$i18n.locale` may not be populated (the client plugin hasn't run); + // fall back to the SSR event context so the server-rendered key matches the + // initial client key on hydration — otherwise the first client render would + // see a different key, miss the cached payload, and immediately refetch. + const ssrLocale = detectServerLocale(nuxtApp?.ssrContext?.event) + const localeValue = computed(() => i18nLocaleRef?.value || ssrLocale || '') type Item = Collections[T] // Use the consumer's type override if provided, otherwise the collection type diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index ddf05ea9e..d862a30f1 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -137,11 +137,15 @@ export const collectionQueryBuilder = (collection: return query.where('path', '=', withoutTrailingSlash(path)) }, stem(stem: string) { + // Normalize input so leading/trailing slashes from callers don't survive into + // the WHERE clause. The stored `stem` column never has them, and `stemPrefix` + // from the manifest is already normalized (see `templates.ts`). + const normalized = stem.replace(/^\/+|\/+$/g, '') // Resolve full stem by prepending the collection's source prefix if not already present. // Check segment boundary to avoid false matches (e.g. prefix "navigation" matching "navigation2/foo"). - const fullStem = stemPrefix && !(stem === stemPrefix || stem.startsWith(stemPrefix + '/')) - ? `${stemPrefix}/${stem}` - : stem + const fullStem = stemPrefix && !(normalized === stemPrefix || normalized.startsWith(stemPrefix + '/')) + ? `${stemPrefix}/${normalized}` + : normalized return query.where('stem', '=', fullStem) }, locale(locale: string, opts?: { fallback?: string }) { @@ -206,30 +210,28 @@ export const collectionQueryBuilder = (collection: async count(field: keyof Collections[T] | '*' = '*', distinct: boolean = false) { const autoLocale = resolveAutoLocale() if (params.localeFallback || autoLocale.fallback) { - // Ensure the counted field is fetched and bypass pagination for accurate counts. - // Save/restore in finally so an error in fetch doesn't leak state to later calls. + // Compute the effective field list via overrides — never mutate `params`, + // otherwise concurrent `Promise.all([qb.all(), qb.count()])` would race. const countField = field !== '*' ? String(field) : undefined - const savedFields = params.selectedFields - const savedOffset = params.offset - const savedLimit = params.limit - if (countField && savedFields.length > 0 && !savedFields.includes(field as keyof Collections[T])) { - params.selectedFields = [...savedFields, field as keyof Collections[T]] - } - params.offset = 0 - params.limit = 0 - try { - const res = await fetchWithLocaleFallback({ preserveField: countField, autoLocale }) - if (field === '*') return res.length - const values = res - .map(r => (r as unknown as Record)[String(field)]) - .filter(v => v !== null && v !== undefined) - return distinct ? new Set(values).size : values.length - } - finally { - params.selectedFields = savedFields - params.offset = savedOffset - params.limit = savedLimit - } + const fieldsOverride = countField + && params.selectedFields.length > 0 + && !params.selectedFields.includes(field as keyof Collections[T]) + ? [...params.selectedFields, field as keyof Collections[T]] + : undefined + + // `bypassPagination` makes the fallback path count the full match set rather + // than the visible page — `LIMIT 5 .count()` should still return the total. + 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) + return distinct ? new Set(values).size : values.length } // `noLimitOffset` is essential here: a COUNT query returns exactly one row, // so any `LIMIT/OFFSET` carried over from `.skip()`/`.limit()` either caps a @@ -258,7 +260,24 @@ export const collectionQueryBuilder = (collection: */ function resolveAutoLocale(): { condition?: string, fallback?: { locale: string, fallback: string } } { if (params.localeExplicitlySet || !i18nConfig || !detectedLocale) return {} - if (!i18nConfig.locales.includes(detectedLocale)) return {} + if (!i18nConfig.locales.includes(detectedLocale)) { + // BCP-47 tag mismatch (e.g. `@nuxtjs/i18n` returns `en-US` for a collection + // declaring only `en`) — silently skipping auto-locale would yield rows + // from EVERY locale, which is rarely what the user expects. Surface this + // at the call site rather than letting the consumer wonder why their + // French page renders 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 — ` + + 'returning 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)})` } } @@ -268,62 +287,78 @@ export const collectionQueryBuilder = (collection: /** * Two-query locale fallback: fetches locale-specific rows and default-locale rows, * then merges by stem (locale items take priority, fallback fills gaps). - * Internally injects 'stem' into selectedFields for merge-key deduplication, - * stripping it from results when the caller didn't explicitly select it. - * Accepts an optional limit override and a preserveField to keep for count operations. + * Internally injects 'stem' into the SELECT list for merge-key deduplication, then + * strips it from results when the caller didn't explicitly select it. + * + * All effective state is passed by argument and the function NEVER mutates + * `params` — that makes the builder safe to reuse across concurrent calls + * (e.g. `Promise.all([qb.all(), qb.count()])`). Previous mutate/restore-in-finally + * leaked partial state across the race window between concurrent terminals. * - * Note: when called from auto-locale path, params.localeFallback may be undefined - * and the fallback target comes from autoLocale.fallback instead. + * Note: when called from the auto-locale path, params.localeFallback may be + * undefined and the fallback pair comes from autoLocale.fallback instead. */ async function fetchWithLocaleFallback(opts: { limit?: number preserveField?: string autoLocale?: { condition?: string, fallback?: { locale: string, fallback: string } } + /** Override `params.selectedFields` for this call (e.g. count() adding the counted field). */ + fieldsOverride?: Array + /** Ignore `params.offset`/`params.limit` (used by `.count()` so paging doesn't truncate the count). */ + bypassPagination?: boolean } = {}): Promise { // Callers gate on `params.localeFallback || opts.autoLocale?.fallback` being // truthy, so one of them is always defined here — `||` collapses both branches. const fb = (params.localeFallback || opts.autoLocale?.fallback) as { locale: string, fallback: string } const { locale, fallback } = fb - // Ensure `stem` is always fetched — needed for merge-key deduplication. - // Track whether we injected it so we can strip it from results later. - const savedFields = params.selectedFields - const stemInjected = savedFields.length > 0 && !savedFields.includes('stem' as keyof Collections[T]) - if (stemInjected) { - params.selectedFields = [...savedFields, 'stem' as keyof Collections[T]] - } + // Ensure `stem` is in the SELECT list — needed for merge-key deduplication. + // Tracked via a local so we can strip it from results when the caller didn't 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 ALL matching rows (no limit/offset) — we apply those JS-side on the merged result. + // Auto-locale's `condition` is intentionally NOT applied to sub-queries: we are overriding the locale + // here, so passing the original autoLocale would double-filter. + // Both queries are independent, so issue them in parallel — halves perceived latency on the + // non-default-locale path. `Promise.all` short-circuits on any rejection, which is the + // desired behavior (the merge can't 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 || []), + ]) - try { - // Sub-queries fetch ALL matching rows (no limit/offset) — we apply those JS-side on the merged result. - // Auto-locale's `condition` is intentionally NOT applied to sub-queries: we are overriding the locale - // here, so passing the original autoLocale would double-filter. - // Both queries are independent, so issue them in parallel — halves perceived latency on the - // non-default-locale path. `Promise.all` short-circuits on any rejection, which is the - // desired behavior (the merge can't proceed without both result sets). - const localeQuery = buildQuery({ extraCondition: `("locale" = ${singleQuote(locale)})`, noLimitOffset: true }) - const fallbackQuery = buildQuery({ extraCondition: `("locale" = ${singleQuote(fallback)})`, noLimitOffset: true }) - const [localeResults, fallbackResults] = await Promise.all([ - fetch(collection, localeQuery).then(res => res || []), - fetch(collection, fallbackQuery).then(res => res || []), - ]) - - // Merge: prefer locale results, fill gaps from fallback - const getStem = (r: Collections[T]) => (r as unknown as { stem: string }).stem - const localeStemSet = new Set(localeResults.map(getStem)) - const fallbackOnly = fallbackResults.filter(item => !localeStemSet.has(getStem(item))) - - // When using the default ORDER BY (stem ASC), we can do a proper sorted merge. - // LIMITATION: when a custom ORDER BY is specified, we cannot interleave the - // two result sets correctly because that would require parsing the SQL ORDER BY - // clause and re-implementing the comparison in JS. Instead we concatenate - // locale items first, then fallback items — each group retains its DB order - // but the overall sequence may not match a single-query ORDER BY. - const merged = params.orderBy.length === 0 - ? mergeSortedArrays(localeResults, fallbackOnly, getStem) - : [...localeResults, ...fallbackOnly] - - // Apply offset then limit on the merged result - let result = merged + // Merge: prefer locale results, fill gaps from fallback + const getStem = (r: Collections[T]) => (r as unknown as { stem: string }).stem + const localeStemSet = new Set(localeResults.map(getStem)) + const fallbackOnly = fallbackResults.filter(item => !localeStemSet.has(getStem(item))) + + // When using the default ORDER BY (stem ASC), we can do a proper sorted merge. + // LIMITATION: when a custom ORDER BY is specified, we cannot interleave the + // two result sets correctly because that would require parsing the SQL ORDER BY + // clause and re-implementing the comparison in JS. Instead we concatenate + // locale items first, then fallback items — each group retains its DB order + // but the overall sequence may not match a single-query ORDER BY. + const merged = params.orderBy.length === 0 + ? mergeSortedArrays(localeResults, fallbackOnly, getStem) + : [...localeResults, ...fallbackOnly] + + // Apply offset then limit on the merged result + let result = merged + if (!opts.bypassPagination) { if (params.offset > 0) { result = result.slice(params.offset) } @@ -331,22 +366,18 @@ export const collectionQueryBuilder = (collection: if (limit > 0) { result = result.slice(0, limit) } - - // Strip internally-injected 'stem' if the caller didn't select it - // (unless it's needed by a count() call targeting that field) - if (stemInjected && opts.preserveField !== 'stem') { - return result.map((item) => { - const { stem: _, ...rest } = item as unknown as Record - return rest as Collections[T] - }) - } - - return result as Collections[T][] } - finally { - // Restore original selectedFields even on fetch errors so later calls aren't poisoned - params.selectedFields = savedFields + + // Strip internally-injected 'stem' if the caller didn't select it + // (unless it's needed by a count() call targeting that field) + if (stemInjected && opts.preserveField !== 'stem') { + return result.map((item) => { + const { stem: _, ...rest } = item as unknown as Record + return rest as Collections[T] + }) } + + return result as Collections[T][] } function buildQuery(opts: { @@ -355,6 +386,8 @@ export const collectionQueryBuilder = (collection: 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) { @@ -362,7 +395,7 @@ export const collectionQueryBuilder = (collection: 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)]}` diff --git a/src/utils/collection.ts b/src/utils/collection.ts index 1a6662a1b..305cfe103 100644 --- a/src/utils/collection.ts +++ b/src/utils/collection.ts @@ -41,8 +41,22 @@ export function defineCollection(collection: Collection): DefinedCollectio // Add locale field when i18n is fully configured (not `true` shorthand — // that gets resolved later in loadContentConfig via resolveI18nConfig) const hasI18nConfig = collection.i18n && collection.i18n !== true + // 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) + // 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) @@ -62,7 +76,7 @@ export function defineCollection(collection: Collection): DefinedCollectio extendedSchema: extendedSchema, fields: getCollectionFieldsTypes(extendedSchema), indexes, - i18n: collection.i18n, + i18n: resolvedI18n, } } diff --git a/src/utils/config.ts b/src/utils/config.ts index 3ba00b901..239c87cfd 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -106,8 +106,15 @@ function resolveI18nConfig(nuxt: Nuxt, collections: Record typeof l === 'string' ? l : l.code) + // `@nuxtjs/i18n` permits `defaultLocale` outside `locales` (it falls through + // at runtime), but for content filtering it's a footgun — files under + // `content//` would never path-detect. Force the invariant + // here so it can't silently desync. resolvedConfig = { - locales: i18nOptions.locales.map(l => typeof l === 'string' ? l : l.code), + locales: localeCodes.includes(i18nOptions.defaultLocale) + ? localeCodes + : [i18nOptions.defaultLocale, ...localeCodes], defaultLocale: i18nOptions.defaultLocale, } } diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index cac107d45..977c6842b 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -252,6 +252,34 @@ describe('collectionQueryBuilder', () => { ) }) + describe('.stem() input normalization', () => { + it('strips leading slashes from user input', async () => { + // Stored stems never carry a leading slash; tolerate the slash from callers. + 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 — 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, en has stems a, b, c — fallback should interleave a, b mockFetch @@ -518,6 +546,83 @@ describe('collectionQueryBuilder', () => { '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` + // / offset / limit in a save/mutate/restore-in-finally pattern. Under `Promise.all`, + // a second terminal could read the mutated state mid-flight. Now both terminals + // pass overrides into buildQuery and never touch `params`. + const qb = collectionQueryBuilder(mockCollection, mockFetch) + .select('title' as never, 'locale' as never) + .locale('fr', { fallback: 'en' }) + + // 4 calls expected: 2 from .all() (fr+en) and 2 from .count() (fr+en). + // The count() injects `title` into selectedFields; .all() must NOT see it. + 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 and (in dev) warns when detected locale is a BCP-47 tag not in collection.locales', async () => { + // `@nuxtjs/i18n` may return `en-US`; a collection declaring only `en` should + // silently skip auto-locale rather than producing rows from every locale. + // In dev mode, a console.warn surfaces the mismatch. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await collectionQueryBuilder(mockCollection, mockFetch, 'en-US').all() + // Either path: no WHERE("locale" = ...) appears in the query. + const sql = mockFetch.mock.lastCall![1] as string + expect(sql).not.toContain('"locale" =') + + // In dev, the warning must include both the offending tag and the skip hint. + if (import.meta.dev) { + expect(warn).toHaveBeenCalled() + const msg = warn.mock.calls[0]?.[0] as string + expect(msg).toContain('en-US') + expect(msg).toContain('Auto-locale filter skipped') + } + } + finally { + warn.mockRestore() + } + }) }) describe('mergeSortedArrays / locale-fallback ordering', () => { From 7da509b7c75e167c601b670b39add87c7b470d50 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 03:06:52 +0200 Subject: [PATCH 59/67] refactor: tidy implementation and clarify code comments --- src/module.ts | 10 +- src/runtime/client.ts | 75 +++++----- src/runtime/internal/i18n-detection.ts | 70 ++++----- src/runtime/internal/locales.ts | 21 +-- src/runtime/internal/query.ts | 180 ++++++++++++----------- src/runtime/internal/security.ts | 30 ++-- src/runtime/nitro.ts | 4 +- src/runtime/server.ts | 8 +- src/types/collection.ts | 9 +- src/types/locales.ts | 12 +- src/utils/collection.ts | 13 +- src/utils/config.ts | 8 +- src/utils/content/index.ts | 4 +- src/utils/dev.ts | 38 ++--- src/utils/i18n.ts | 76 +++++----- src/utils/templates.ts | 7 +- test/i18n.test.ts | 11 +- test/unit/assertSafeQuery.test.ts | 2 +- test/unit/collectionQueryBuilder.test.ts | 80 +++++----- test/unit/i18n.test.ts | 16 +- test/unit/i18nDetection.test.ts | 17 ++- 21 files changed, 381 insertions(+), 310 deletions(-) diff --git a/src/module.ts b/src/module.ts index 769125b5b..14bf7e6c0 100644 --- a/src/module.ts +++ b/src/module.ts @@ -140,9 +140,9 @@ export default defineNuxtModule({ { 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. + // 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/server') }, { name: 'queryCollectionSearchSections', from: resolver.resolve('./runtime/server') }, @@ -384,10 +384,10 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio usedComponents.push(...parsedContent.__metadata.components) } - // i18n: expand inline translations to per-locale rows. + // 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 is the SQL row's actual `id`. + // 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) for (const item of expandedItems) { diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 470dc80bf..e34a6cd09 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -12,9 +12,10 @@ import type { Collections, ContentLocaleEntry, ContentNavigationItem, Collection 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 re-fetches -// when it changes (reactive keys, Nuxt 3.17+). Module compat is `>=4.1.0 || ^3.19.0`, -// so the locale-aware key change automatically triggers refetches without `watch:`. +// `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 { GenerateSearchSectionsOptions, SearchCollectionOptions, SearchResult } from './internal/search' @@ -28,7 +29,8 @@ interface ChainablePromise extends Promise(collection: T): CollectionQueryBuilder => { const nuxtApp = tryUseNuxtApp() const event = nuxtApp?.ssrContext?.event - // Detect from client first (live ref during SPA navigation), then from the SSR event context. + // 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) } @@ -46,23 +48,26 @@ export function queryCollectionSearchSections(c } export function queryCollectionLocales(collection: T, stem: string): Promise { - // Skip auto-locale: this helper needs ALL locale variants, not just the current one + // 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 auto-wraps execution in useAsyncData - * with an auto-generated cache key. Locale is auto-detected from @nuxtjs/i18n - * and content automatically re-fetches when the locale changes. + * `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, useFetch). + * 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`, etc. + * `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() @@ -72,9 +77,10 @@ export function queryCollectionLocales(collection: export function useQueryCollection(collection: T) { const nuxtApp = tryUseNuxtApp() if (!nuxtApp) { - // useAsyncData would throw the same way; surface the cause earlier so users - // get a clear hint about *where* the call is misplaced rather than a generic - // "[nuxt] instance unavailable" trace from inside useAsyncData. + // `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, ' @@ -83,24 +89,26 @@ export function useQueryCollection } | undefined)?.locale // The locale value flows into the cache key. Because `useAsyncData`'s key is - // a getter, Nuxt re-runs the handler when the locale ref changes. + // a getter, Nuxt reruns the handler when the locale ref changes. // - // During SSR `$i18n.locale` may not be populated (the client plugin hasn't run); - // fall back to the SSR event context so the server-rendered key matches the - // initial client key on hydration — otherwise the first client render would - // see a different key, miss the cached payload, and immediately refetch. + // During SSR, `$i18n.locale` may not be populated (the client plugin has not + // run yet), so the value falls back to the SSR event context. This keeps the + // server-rendered key aligned with the initial client key on hydration. + // Without this fallback the first client render would see a different key, + // miss the cached payload, and immediately refetch. const ssrLocale = detectServerLocale(nuxtApp?.ssrContext?.event) const localeValue = computed(() => i18nLocaleRef?.value || ssrLocale || '') type Item = Collections[T] - // Use the consumer's type override if provided, otherwise the collection type + // Use the consumer's type override when provided, otherwise the collection type. type Result = [R] extends [never] ? Item : R - // Collect query chain operations to replay on each execution + // Recorded query-chain operations replayed on each execution. const ops: Array<(qb: CollectionQueryBuilder) => void> = [] let explicitLocale = false - // Track key-relevant params directly, which avoids creating a full query builder in buildKey + // Track key-relevant params directly to avoid building a full query builder + // inside `buildKey()`. const keyParts = { conditions: [] as string[], orderBy: [] as string[], @@ -112,20 +120,21 @@ export function useQueryCollection` fragment alongside - // the explicit condition. The underlying query builder already handles this - // (`referencesLocaleColumn` in query.ts) — we mirror it for key stability. + // 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 just to derive a stable cache-key fragment. - // The same factory runs again inside `buildQuery()` when the actual query - // is executed — running it twice is fine because group factories are - // expected to be pure. + // 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 (groupConditions.some(c => c.startsWith('"locale"'))) explicitLocale = true @@ -162,9 +171,9 @@ export function useQueryCollection qb.path(path)) return builder diff --git a/src/runtime/internal/i18n-detection.ts b/src/runtime/internal/i18n-detection.ts index 71e2ab156..d1dc41747 100644 --- a/src/runtime/internal/i18n-detection.ts +++ b/src/runtime/internal/i18n-detection.ts @@ -1,27 +1,29 @@ import type { H3Event } from 'h3' /** - * Minimal type view of the per-request context that `@nuxtjs/i18n` (>= v10) - * attaches to H3 events. Kept narrow on purpose: we only read the two fields - * we need, and pinning a precise type would couple us to an upstream module - * that we declare as a soft (optional) integration. + * 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 + * 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 >= 10`'s Nitro plugin - * (`src/runtime/server/plugin.ts`) inside the `render:before` hook — 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 (e.g. `/fr/about` for a French - * user), this stays `undefined` and the fallback to `vueI18nOptions.locale` - * (the configured default) is what `detectServerLocale` ultimately returns. + * 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`). * - * Practical upshot: 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. + * 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. */ @@ -31,14 +33,14 @@ interface NuxtI18nServerContext { /** * Resolve the active locale on the server from `@nuxtjs/i18n`'s event context. * - * Priority: `detectLocale` (set only during server-side localized redirects) - * → `vueI18nOptions.locale` (the configured default). Returns `undefined` when - * `@nuxtjs/i18n` isn't installed or its context hasn't been initialized. + * 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. * - * Note: 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. + * 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 @@ -47,11 +49,11 @@ export function detectServerLocale(event: H3Event | undefined): string | undefin /** * Resolve the active locale on the client from `nuxtApp.$i18n.locale` (a Vue ref). - * Returns `undefined` when `@nuxtjs/i18n` isn't installed. + * Returns `undefined` when `@nuxtjs/i18n` is not installed. * - * Typed as `unknown` because Nuxt's `NuxtApp` doesn't declare `$i18n` natively — - * it's a runtime plugin injection. A narrower parameter type would have no overlap - * with `NuxtApp` and force callers into an awkward cast. + * 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 @@ -59,13 +61,15 @@ export function detectClientLocale(nuxtApp: unknown): string | undefined { } /** - * Pure builder for `useQueryCollection`'s cache key. Extracted so it can be unit-tested - * without spinning up a Nuxt app instance. The shape must stay stable: Nuxt re-uses - * AsyncData entries by key, so changing this format invalidates user caches on upgrade. + * 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. + * `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 diff --git a/src/runtime/internal/locales.ts b/src/runtime/internal/locales.ts index 288aaab3e..d72966383 100644 --- a/src/runtime/internal/locales.ts +++ b/src/runtime/internal/locales.ts @@ -4,17 +4,20 @@ 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. + * 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`/`title` only exist on `page` collections). This keeps the - * payload small for data collections with large fields. + * 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 — i.e. 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 here. + * 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, diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index d862a30f1..1b33b8adc 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -4,8 +4,8 @@ import manifestMeta, { tables } from '#content/manifest' /** * Read the raw conditions accumulated on a group built by `collectionQueryGroup`. - * Exposed so other modules can serialize a group (e.g. for cache keys) without - * reaching into the internal `_conditions` field via a cast. + * 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 @@ -21,7 +21,7 @@ export const buildGroup = (group: CollectionQueryGr * or value. Used to detect manual locale filters so auto-locale steps aside. * * Conditions are emitted by `collectionQueryGroup.where()` as strings that start - * with `"locale"` (the quoted column name). + * with the quoted column name `"locale"`. */ const referencesLocaleColumn = (conditions: string[]): boolean => conditions.some(c => c.startsWith('"locale"')) @@ -89,8 +89,8 @@ export const collectionQueryGroup = (collection: T) } export const collectionQueryBuilder = (collection: T, fetch: (collection: T, sql: string) => Promise, detectedLocale?: string): CollectionQueryBuilder => { - // Read collection metadata from manifest. Cast through the shared typed view - // so the property shape stays in sync with templates.ts. + // 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 @@ -101,14 +101,13 @@ export const collectionQueryBuilder = (collection: offset: 0, limit: 0, orderBy: [] as Array, - // Count query count: { field: '' as keyof Collections[T] | '*', distinct: false, }, - // Locale fallback (handled via two queries + JS merge) + // Locale fallback runs as two queries merged in JS (see `fetchWithLocaleFallback`). localeFallback: undefined as { locale: string, fallback: string } | undefined, - // Track whether .locale() was called explicitly (exposed for cache key generation) + // Tracks whether `.locale()` was called explicitly. Surfaced to the cache key. localeExplicitlySet: false, } @@ -117,8 +116,8 @@ 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 don't combine into a contradictory WHERE clause. + // 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 } @@ -137,24 +136,24 @@ export const collectionQueryBuilder = (collection: return query.where('path', '=', withoutTrailingSlash(path)) }, stem(stem: string) { - // Normalize input so leading/trailing slashes from callers don't survive into - // the WHERE clause. The stored `stem` column never has them, and `stemPrefix` - // from the manifest is already normalized (see `templates.ts`). + // 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, '') - // Resolve full stem by prepending the collection's source prefix if not already present. - // Check segment boundary to avoid false matches (e.g. prefix "navigation" matching "navigation2/foo"). + // 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, - // which surfaces as a confusing "no such column" SQL error far from the - // user's call site. Warn at the source so the misuse is obvious. Manifest - // metadata is the source of truth here: `i18nConfig` is undefined exactly - // when the collection wasn't declared with `i18n`. + // 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)}): ` @@ -210,8 +209,8 @@ export const collectionQueryBuilder = (collection: async count(field: keyof Collections[T] | '*' = '*', distinct: boolean = false) { const autoLocale = resolveAutoLocale() if (params.localeFallback || autoLocale.fallback) { - // Compute the effective field list via overrides — never mutate `params`, - // otherwise concurrent `Promise.all([qb.all(), qb.count()])` would race. + // 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 @@ -219,8 +218,8 @@ export const collectionQueryBuilder = (collection: ? [...params.selectedFields, field as keyof Collections[T]] : undefined - // `bypassPagination` makes the fallback path count the full match set rather - // than the visible page — `LIMIT 5 .count()` should still return the total. + // `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, @@ -233,10 +232,10 @@ export const collectionQueryBuilder = (collection: .filter(v => v !== null && v !== undefined) return distinct ? new Set(values).size : values.length } - // `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 > 0 — slices - // past it and yields `[]`, making `m[0].count` throw a TypeError. + // `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, @@ -246,34 +245,36 @@ export const collectionQueryBuilder = (collection: } /** - * Compute the auto-locale effect for this execution **without mutating** persistent state. - * Safe for builder reuse: each terminal method re-resolves based on current params. + * 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. + * - `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. * - * Skips entirely when the collection has no i18n config, no locale was detected, - * the detected locale isn't in the configured list, or the user already called `.locale()`. + * 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 (e.g. `@nuxtjs/i18n` returns `en-US` for a collection - // declaring only `en`) — silently skipping auto-locale would yield rows - // from EVERY locale, which is rarely what the user expects. Surface this - // at the call site rather than letting the consumer wonder why their - // French page renders English content. + // 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 — ` - + 'returning 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.', + + `[${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 {} @@ -285,47 +286,51 @@ export const collectionQueryBuilder = (collection: } /** - * Two-query locale fallback: fetches locale-specific rows and default-locale rows, - * then merges by stem (locale items take priority, fallback fills gaps). - * Internally injects 'stem' into the SELECT list for merge-key deduplication, then - * strips it from results when the caller didn't explicitly select it. + * 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 and the function NEVER mutates - * `params` — that makes the builder safe to reuse across concurrent calls - * (e.g. `Promise.all([qb.all(), qb.count()])`). Previous mutate/restore-in-finally + * 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. * - * Note: when called from the auto-locale path, params.localeFallback may be - * undefined and the fallback pair comes from autoLocale.fallback instead. + * 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 (e.g. count() adding the counted field). */ + /** Override `params.selectedFields` for this call (used by `.count()` to add the counted field). */ fieldsOverride?: Array - /** Ignore `params.offset`/`params.limit` (used by `.count()` so paging doesn't truncate the count). */ + /** 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 — `||` collapses both branches. + // 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 - // Ensure `stem` is in the SELECT list — needed for merge-key deduplication. - // Tracked via a local so we can strip it from results when the caller didn't ask for it. + // 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 ALL matching rows (no limit/offset) — we apply those JS-side on the merged result. - // Auto-locale's `condition` is intentionally NOT applied to sub-queries: we are overriding the locale - // here, so passing the original autoLocale would double-filter. - // Both queries are independent, so issue them in parallel — halves perceived latency on the - // non-default-locale path. `Promise.all` short-circuits on any rejection, which is the - // desired behavior (the merge can't proceed without both result sets). + // 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, @@ -341,22 +346,22 @@ export const collectionQueryBuilder = (collection: fetch(collection, fallbackQuery).then(res => res || []), ]) - // Merge: prefer locale results, fill gaps from fallback + // 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))) - // When using the default ORDER BY (stem ASC), we can do a proper sorted merge. - // LIMITATION: when a custom ORDER BY is specified, we cannot interleave the - // two result sets correctly because that would require parsing the SQL ORDER BY - // clause and re-implementing the comparison in JS. Instead we concatenate - // locale items first, then fallback items — each group retains its DB order - // but the overall sequence may not match a single-query ORDER BY. + // Under the default `ORDER BY stem ASC` the two result sets can be interleaved + // with a proper sorted merge. A custom `.order()` cannot be honoured here + // without parsing the SQL ORDER BY clause and reimplementing the comparison in + // JS, so this path falls back to concatenating locale items first, then + // fallback items. Each group retains its database order, but the overall + // sequence may not match a single-query ORDER BY. const merged = params.orderBy.length === 0 ? mergeSortedArrays(localeResults, fallbackOnly, getStem) : [...localeResults, ...fallbackOnly] - // Apply offset then limit on the merged result + // Apply offset then limit on the merged result. let result = merged if (!opts.bypassPagination) { if (params.offset > 0) { @@ -368,8 +373,8 @@ export const collectionQueryBuilder = (collection: } } - // Strip internally-injected 'stem' if the caller didn't select it - // (unless it's needed by a count() call targeting that field) + // 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 @@ -401,8 +406,10 @@ export const collectionQueryBuilder = (collection: query += ` FROM ${tables[String(collection)]}` const conditions = [...params.conditions] - // Auto-locale condition (single-query path — fallback path handled in fetchWithLocaleFallback). - // Skip when extraCondition is set: that's the fallback sub-query which already pins its own locale. + // 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) } @@ -414,7 +421,8 @@ export const collectionQueryBuilder = (collection: query += ` WHERE ${conditions.join(' AND ')}` } - // Skip ORDER BY for COUNT queries (PostgreSQL rejects ORDER BY on aggregate without GROUP BY) + // 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(', ')}` @@ -445,21 +453,21 @@ export const collectionQueryBuilder = (collection: * Interleaves items using **binary comparison** of their `stem` values to match * SQLite's default BINARY collation (and PostgreSQL's "C" ordering of ASCII codepoints). * - * We deliberately do NOT use `localeCompare`: that uses Unicode collation which - * orders `'a' < 'A'` (case-insensitive linguistic order), whereas the DB sorts + * `localeCompare` is deliberately avoided. It uses Unicode collation, which orders + * `'a' < 'A'` (case-insensitive linguistic order), whereas the database sorts * `'A' (65) < 'a' (97)` (codepoint order). Using `localeCompare` would interleave - * the two result sets in a different order than the DB returned them. + * the two result sets in a different order than the database returned. * - * Precondition: both arrays must be sorted by stem ASC — this function does NOT - * handle arbitrary ORDER BY clauses (use concatenation for custom sorts). + * Both inputs must be sorted by `stem` ASC. This function does not handle + * arbitrary ORDER BY clauses, use concatenation for custom sorts. */ function mergeSortedArrays(a: T[], b: T[], getStem: (r: T) => string): T[] { const result: T[] = [] let ai = 0 let bi = 0 while (ai < a.length && bi < b.length) { - // Plain `<=` matches SQL BINARY/codepoint order; equal stems can't both occur - // here (we filter duplicates in the caller), but `<=` keeps `a` stable on ties. + // Plain `<=` matches SQL BINARY codepoint order. Equal stems cannot both occur + // here (the caller filters duplicates), but `<=` keeps `a` stable on ties. if (getStem(a[ai]!) <= getStem(b[bi]!)) { result.push(a[ai]!) ai++ diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index 33c238c78..c300a0cd8 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -1,6 +1,7 @@ const SQL_COMMANDS = /SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|\$/i -// Combines the upstream security tightening (anchored regex, required `as count` alias) -// with the feature additions (quoted column names, optional `ORDER BY` for count queries). +// Combines the upstream security tightening (anchored regex, required `as count` +// alias) with the feature additions (quoted column names, optional `ORDER BY` for +// count queries). 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+)?$/ @@ -69,12 +70,12 @@ export function assertSafeQuery(sql: string, collection: string) { } } - // ORDER BY (optional — COUNT queries omit it) + // ORDER BY is optional, since COUNT queries omit it. if (orderBy && order) { const _order = (orderBy + ' ' + order).split(', ') - // Column names must start with a letter/underscore but may contain digits - // afterwards (e.g. `h1`, `field_2024`, `version2`). Earlier upstream regex - // forbade digits entirely, which rejected legitimate user schema fields. + // 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') } @@ -94,7 +95,7 @@ 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 = '' @@ -105,27 +106,30 @@ function cleanupQuery(query: string, options: { removeString: boolean } = { remo if (inString) { if (char === stringFence) { if (nextChar === stringFence) { - // Doubled quote escape (e.g., '' inside a string) — skip both, stay in string + // Doubled-quote escape (for example `''` inside a string). Skip both + // characters and stay inside the string. if (!options?.removeString) { - result += char + char // preserve both quotes + // Preserve both quotes when not stripping the string content. + result += char + char } i++ continue } else { - // String closing quote + // Closing quote of the string literal. inString = false stringFence = '' } } - // Inside string: keep character when not removing strings + // Inside a string, keep each character when not removing strings. if (!options?.removeString) { result += char } continue } - // Not in string — opening quote starts string tracking regardless of removeString mode + // Outside a string, an opening quote starts string tracking regardless of + // `removeString` mode. if (char === '\'' || char === '"') { inString = true stringFence = char @@ -136,7 +140,7 @@ function cleanupQuery(query: string, options: { removeString: boolean } = { remo } if (char === '-' && nextChar === '-') { - // everything after this is a comment + // Line comment, everything after this is a comment. return result } diff --git a/src/runtime/nitro.ts b/src/runtime/nitro.ts index 13ec1b408..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. */ /** diff --git a/src/runtime/server.ts b/src/runtime/server.ts index dec5f2b98..cbbd60717 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -16,8 +16,9 @@ interface ChainablePromise extends Promise(event: H3Event, collection: T): CollectionQueryBuilder => { - // Auto-detect from `@nuxtjs/i18n` server context — falls back to configured default - // when per-request detection didn't run. See `internal/i18n-detection.ts` for the contract. + // 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)) } @@ -34,7 +35,8 @@ export function queryCollectionSearchSections(e } export function queryCollectionLocales(event: H3Event, collection: T, stem: string): Promise { - // Skip auto-locale: this helper needs ALL locale variants, not just the current one + // 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) } diff --git a/src/types/collection.ts b/src/types/collection.ts index cb88f8a89..e8a1069cd 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -115,7 +115,7 @@ export interface DefinedCollection { fields: Record indexes?: CollectionIndex[] /** - * `true` is the shorthand resolved from `@nuxtjs/i18n` in config loading. + * The `true` shorthand is resolved from `@nuxtjs/i18n` during config loading. * After resolution, this is always `CollectionI18nConfig | undefined`. */ i18n?: true | CollectionI18nConfig @@ -125,12 +125,13 @@ 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` — that's resolved before this point). + * Fully resolved i18n config. Never `true`, since the shorthand is resolved + * before this point. */ i18n?: CollectionI18nConfig } diff --git a/src/types/locales.ts b/src/types/locales.ts index e4b6726de..a2c9a69db 100644 --- a/src/types/locales.ts +++ b/src/types/locales.ts @@ -14,17 +14,19 @@ export interface ContentLocaleEntry { /** * 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 — Studio, custom translator pipelines — can - * detect outdated translations by comparing this hash across versions. + * 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 based only - * on the default-locale values of fields THIS locale overrides — a change to a - * field that this locale doesn't translate won't change its hash. + * 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. */ diff --git a/src/utils/collection.ts b/src/utils/collection.ts index 305cfe103..0fd2b9636 100644 --- a/src/utils/collection.ts +++ b/src/utils/collection.ts @@ -38,8 +38,8 @@ export function defineCollection(collection: Collection): DefinedCollectio extendedSchema = mergeStandardSchema(pageStandardSchema, extendedSchema) } - // Add locale field when i18n is fully configured (not `true` shorthand — - // that gets resolved later in loadContentConfig via resolveI18nConfig) + // 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). @@ -52,7 +52,7 @@ export function defineCollection(collection: Collection): DefinedCollectio 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 — ` + + `\`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] } @@ -61,9 +61,10 @@ export function defineCollection(collection: Collection): DefinedCollectio extendedSchema = mergeStandardSchema(metaStandardSchema, extendedSchema) - // Auto-add composite index on (locale, stem) for i18n collections. - // Skip when the user already declared an equivalent index — avoids a duplicate - // CREATE INDEX (which would survive when the user supplies a custom `name`). + // 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'] }) diff --git a/src/utils/config.ts b/src/utils/config.ts index 239c87cfd..ac34425be 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -107,10 +107,10 @@ function resolveI18nConfig(nuxt: Nuxt, collections: Record typeof l === 'string' ? l : l.code) - // `@nuxtjs/i18n` permits `defaultLocale` outside `locales` (it falls through - // at runtime), but for content filtering it's a footgun — files under - // `content//` would never path-detect. Force the invariant - // here so it can't silently desync. + // `@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 diff --git a/src/utils/content/index.ts b/src/utils/content/index.ts index 96eeae62e..b67760fdd 100644 --- a/src/utils/content/index.ts +++ b/src/utils/content/index.ts @@ -217,7 +217,9 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) } } - // i18n: detect locale from path prefix when collection has i18n configured + // Detect the locale from the path prefix when the collection has i18n + // configured. Strips the prefix from `path` and `stem` so each row is + // addressable by a single locale-agnostic key. if (collection.i18n && collectionKeys.includes('locale')) { const currentPath = String(result.path || pathMetaFields.path || '') const currentStem = String(result.stem || pathMetaFields.stem || '') diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 49b91616c..5aa793176 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -165,32 +165,34 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani const parsed: ParsedContentFile = JSON.parse(parsedContent) - // i18n: expand inline translations to per-locale DB rows + // 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 source locale before expandI18nData mutates parsed.locale + // 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) for (const item of expandedItems) { - // Use item.id directly as the dump/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`. + // 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 in the i18n section + // 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 removed + // Clean up stale locale variants if `i18n` was previously present but + // has now been removed from the file. if (collection.i18n) { for (const locale of collection.i18n.locales) { await broadcast(collection, `${keyInCollection}#${locale}`) @@ -248,12 +250,13 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani const collectionDump = manifest.dump[collection.name]! // 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 INSERT + 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. + // 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}',`) @@ -262,8 +265,9 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani const keyIndex = collectionDump.findIndex(keyMatch) const indexToUpdate = keyIndex !== -1 ? keyIndex : collectionDump.length - // Count all consecutive dump entries belonging to this key (large content splits - // into INSERT + UPDATE fragments that each reference the same key literal) + // 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++) { diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index b27433f35..b807d3e39 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -5,29 +5,32 @@ import { I18N_SOURCE_HASH_FIELD } from '../types/locales' import type { ParsedContentFile } from '../types' /** - * Custom defu that merges arrays by index (item-by-item) instead of concatenating. - * Applied recursively to all nested arrays within merged objects. + * 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: object-of-fields arrays (e.g. nav items, cards) + * 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. Scalar arrays (`tags: ['a','b','c']`) are replaced wholesale — - * pad-filling them with the default's tail is virtually never what authors want - * when they intentionally provide a shorter locale-specific list. + * the default. Scalar arrays (for example `tags: ['a', 'b', 'c']`) are replaced + * wholesale, since pad-filling them with the default's tail is virtually never + * what authors want when they intentionally provide a shorter locale-specific + * list. * - * Heuristic: if every element of the override array is a non-object, the array is - * treated as scalar and replaced. Otherwise it deep-merges by index, with the - * default's trailing items preserved for missing override slots. + * Heuristic, if every element of the override array is a non-object, the array + * is treated as scalar and replaced. Otherwise it deep-merges by index, with + * the default's trailing items preserved for missing override slots. * - * In createDefu's merger: `obj[key]` is the defaults (second arg), `value` is the - * overrides (first arg). Override items take priority; default items fill gaps. + * Inside `createDefu`'s merger, `obj[key]` is the defaults (second arg) and + * `value` is the overrides (first arg). Override items take priority and + * default items fill any gaps. */ export const defuByIndex = createDefu((obj, key, value) => { if (Array.isArray(obj[key]) && Array.isArray(value)) { const defaultArr = obj[key] const overrideArr = value - // Scalar override arrays replace wholesale (no pad-fill from default). - // An empty override array also short-circuits here — author explicitly cleared. + // Scalar override arrays replace wholesale (no pad-fill from the default). + // An empty override array also short-circuits here, since the author has + // explicitly cleared it. const isScalarOverride = (overrideArr as unknown[]).every( (item: unknown) => typeof item !== 'object' || item === null, ) @@ -58,22 +61,26 @@ export const defuByIndex = createDefu((obj, key, value) => { /** * Expand inline i18n data from a parsed content file into per-locale items. - * The default locale keeps the original content; non-default locales get a deep-merged - * copy where only overridden fields differ. Non-default items include `_i18nSourceHash` - * for tracking whether the source content has changed since translation. + * 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. + * 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. * - * `meta` semantics: the default locale keeps its full `meta` (minus the `i18n` key). - * Non-default locale items get a fresh `meta` of `{ ...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. Use a top-level field if you need a locale-varying value. + * `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. Use a top-level field when a locale-varying value is needed. * - * Note: this function mutates `parsedContent.meta` (removes the `i18n` key) and - * sets `parsedContent.locale` if not already set. This is acceptable because the - * source content is always consumed (inserted into DB) immediately after expansion. + * 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, @@ -101,20 +108,21 @@ export function expandI18nData( for (const [locale, overrides] of Object.entries(i18nData)) { if (locale === parsedContent.locale) continue - // Source hash is per-locale: based only on the default values of fields THIS - // locale actually translates. A field translated only in `de` must not enter - // the `fr` hash — otherwise a default change to that field would flip `fr`'s - // hash and signal a false "stale translation". + // The source hash is per-locale. It is computed only from the default values + // of fields that *this* locale actually translates. A field translated only + // in `de` must not enter the `fr` hash. Otherwise a default change to that + // field would flip `fr`'s hash and signal a false "stale translation". const sourceFields: Record = {} for (const field of Object.keys(overrides)) { sourceFields[field] = parsedContent[field] } const i18nSourceHash = hash(sourceFields) - // Deep merge preserves untranslated fields (routes, IDs, icons). - // For page collections, body AST must not be deep-merged — replace it wholesale. - // Use `'body' in overrides` so explicit null/empty bodies still replace (rather - // than falling back to deep-merging the default AST in). + // 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'] diff --git a/src/utils/templates.ts b/src/utils/templates.ts index 912248f34..c742e90c1 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -176,9 +176,10 @@ export const manifestTemplate = (manifest: Manifest) => ({ filename: moduleTemplates.manifest, getContents: ({ options }: { options: { manifest: Manifest } }) => { const collectionsMeta = options.manifest.collections.reduce((acc, collection) => { - // Stem prefix is used by `.stem()` to auto-resolve a leading source directory. - // We only emit it when ALL sources of a collection share the same normalized prefix — - // otherwise the heuristic would silently prepend the wrong directory for some files. + // 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))) diff --git a/test/i18n.test.ts b/test/i18n.test.ts index b5cd548f0..e2c8be499 100644 --- a/test/i18n.test.ts +++ b/test/i18n.test.ts @@ -26,7 +26,8 @@ describe('i18n', async () => { await setup({ rootDir: resolver.resolve('./fixtures/i18n'), dev: true, - port: 0, // Let OS assign a free port to avoid EADDRINUSE on CI + // Let the OS assign a free port to avoid `EADDRINUSE` on CI. + port: 0, }) describe('database', () => { @@ -218,10 +219,10 @@ describe('i18n', async () => { }) 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` / `title: undefined` rather than blowing up on a - // SQL "no such column" error. + // 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', ) diff --git a/test/unit/assertSafeQuery.test.ts b/test/unit/assertSafeQuery.test.ts index 8e13b1cbb..44703e30c 100644 --- a/test/unit/assertSafeQuery.test.ts +++ b/test/unit/assertSafeQuery.test.ts @@ -57,7 +57,7 @@ describe('decompressSQLDump', () => { // Escaped quotes in WHERE values should pass (not be treated as comments) 'SELECT * FROM _content_test WHERE ("title" = \'L\'\'été\') ORDER BY stem ASC': true, 'SELECT * FROM _content_test WHERE ("title" = \'it\'\'s\') ORDER BY stem ASC': true, - // Triple-quote edge case — should NOT bypass keyword detection + // 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, diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index 977c6842b..2ec02fb55 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -254,7 +254,8 @@ describe('collectionQueryBuilder', () => { describe('.stem() input normalization', () => { it('strips leading slashes from user input', async () => { - // Stored stems never carry a leading slash; tolerate the slash from callers. + // 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', @@ -263,7 +264,7 @@ describe('collectionQueryBuilder', () => { }) it('strips trailing slashes from user input', async () => { - // A trailing slash would never match the stored stem — collapse it silently. + // A trailing slash would never match the stored stem, so collapse it silently. await collectionQueryBuilder(mockCollection, mockFetch).stem('navbar/').all() expect(mockFetch).toHaveBeenLastCalledWith( 'articles', @@ -281,7 +282,8 @@ describe('collectionQueryBuilder', () => { }) it('locale fallback merges results in stem order', async () => { - // fr has stem c, en has stems a, b, c — fallback should interleave a, b + // `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' }]) @@ -300,8 +302,9 @@ describe('collectionQueryBuilder', () => { }) it('count() ignores LIMIT carried over from .limit()', async () => { - // Regression: COUNT returns exactly one row; appending `LIMIT N` is at best - // misleading and combined with OFFSET it returns `[]` → `m[0].count` would throw. + // 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() @@ -343,7 +346,8 @@ describe('collectionQueryBuilder', () => { .mockResolvedValueOnce([{ stem: 'a', locale: 'fr' }]) .mockResolvedValueOnce([{ stem: 'a', locale: 'en' }, { stem: 'b', locale: 'en' }]) - // Pass 'fr' as detectedLocale (3rd arg) — simulates what client.ts/server.ts do + // 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') @@ -372,7 +376,7 @@ describe('collectionQueryBuilder', () => { }) it('does not auto-apply locale when no detectedLocale is provided', async () => { - // No detectedLocale (undefined) — no auto-locale + // No `detectedLocale` means no auto-locale is applied. const query = collectionQueryBuilder(mockCollection, mockFetch) await query.all() @@ -384,7 +388,8 @@ describe('collectionQueryBuilder', () => { }) it('uses single query (no fallback) when detectedLocale equals defaultLocale', async () => { - // Default locale 'en' — should use a single WHERE, not two-query fallback + // 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() @@ -396,7 +401,8 @@ describe('collectionQueryBuilder', () => { }) it('rejects unknown detectedLocale values', async () => { - // 'xx' is not in i18nConfig.locales — should be ignored (no locale filter) + // `'xx'` is not in `i18nConfig.locales`, so it is ignored (no locale filter + // is added). const query = collectionQueryBuilder(mockCollection, mockFetch, 'xx') await query.all() @@ -466,26 +472,29 @@ describe('collectionQueryBuilder', () => { }) it('does not leak auto-locale into persistent conditions across calls', async () => { - // Regression: auto-applied locale used to be pushed to params.conditions on first - // execution, so a second .all() on the same builder would re-apply it (and any - // intervening .locale() call would produce a contradictory `loc=X AND loc=Y`). + // 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([]) - await qb.all() // auto-locale = en (default), single-query path + // 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([]) - await qb.all() // second call must not stack a duplicate locale condition + // 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() now should fully override the auto-locale on the next call + // An explicit `.locale()` must fully override the auto-locale on the next call. mockFetch.mockResolvedValueOnce([]) await qb.locale('de').all() expect(mockFetch).toHaveBeenLastCalledWith( @@ -495,9 +504,9 @@ describe('collectionQueryBuilder', () => { }) 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'`. + // 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() @@ -509,8 +518,9 @@ describe('collectionQueryBuilder', () => { }) 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 — we detect any condition starting with `"locale"`. + // 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')) @@ -524,8 +534,9 @@ describe('collectionQueryBuilder', () => { }) 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 — the next query would then SELECT an extra "stem" column. + // 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' }) @@ -533,8 +544,9 @@ describe('collectionQueryBuilder', () => { mockFetch.mockRejectedValueOnce(new Error('boom')) await expect(qb.all()).rejects.toThrow('boom') - // Now run an unrelated query on a *new* builder using the same select set — - // verifies the test isolation; the bug was about builder-internal mutation. + // 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) @@ -548,16 +560,18 @@ describe('collectionQueryBuilder', () => { }) 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` - // / offset / limit in a save/mutate/restore-in-finally pattern. Under `Promise.all`, - // a second terminal could read the mutated state mid-flight. Now both terminals - // pass overrides into buildQuery and never touch `params`. + // 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' }) - // 4 calls expected: 2 from .all() (fr+en) and 2 from .count() (fr+en). - // The count() injects `title` into selectedFields; .all() must NOT see it. + // 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' }]) @@ -601,9 +615,9 @@ describe('collectionQueryBuilder', () => { }) it('skips auto-locale and (in dev) warns when detected locale is a BCP-47 tag not in collection.locales', async () => { - // `@nuxtjs/i18n` may return `en-US`; a collection declaring only `en` should - // silently skip auto-locale rather than producing rows from every locale. - // In dev mode, a console.warn surfaces the mismatch. + // `@nuxtjs/i18n` may return `en-US`. A collection declaring only `en` + // should silently skip auto-locale rather than producing rows from every + // locale. In dev mode, a `console.warn` surfaces the mismatch. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { await collectionQueryBuilder(mockCollection, mockFetch, 'en-US').all() diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index 38d0ffd82..d4d9c8c7f 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -422,8 +422,9 @@ describe('i18n', () => { }) 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. + // 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'], @@ -551,9 +552,10 @@ describe('i18n', () => { }) it('source hash is per-locale: a field translated only in another locale does not affect this locale\'s hash', () => { - // `fr` translates only `title`. `de` translates only `description`. - // Changing `description` between content1 and content2 must NOT change `fr`'s - // hash (since fr doesn't translate description) but MUST change `de`'s. + // `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', @@ -593,8 +595,8 @@ describe('i18n', () => { 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. + // 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', diff --git a/test/unit/i18nDetection.test.ts b/test/unit/i18nDetection.test.ts index 5a6777f07..241db3b7c 100644 --- a/test/unit/i18nDetection.test.ts +++ b/test/unit/i18nDetection.test.ts @@ -12,7 +12,8 @@ describe('detectServerLocale', () => { }) it('returns undefined when event.context.nuxtI18n is absent', () => { - // Empty context — @nuxtjs/i18n not installed or middleware didn't run. + // 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() }) @@ -23,7 +24,8 @@ describe('detectServerLocale', () => { }) it('falls back to `vueI18nOptions.locale` when detection did not run', () => { - // Mirrors the case where prerender / a route outside i18n middleware lacks detectLocale. + // 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') }) @@ -67,8 +69,9 @@ describe('buildUseQueryCollectionKey', () => { }) 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 behavior. + // 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') }) @@ -93,7 +96,8 @@ describe('buildUseQueryCollectionKey', () => { selectedFields: ['title', 'date'], currentLocale: 'fr', }) - // Expected order: collection → conditions → locale → orderBy → offset → limit → selectedFields → method + // 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', @@ -108,7 +112,8 @@ describe('buildUseQueryCollectionKey', () => { }) it('returns identical keys for inputs that produce identical SQL', () => { - // Demonstrates the contract: equal inputs (incl. equivalent normalization upstream) → equal keys + // 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) From fdca90b6ec3527cbe995d33970c0162283c34113 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 21:06:07 +0200 Subject: [PATCH 60/67] fix: correct locale fallback ordering, counting, and detection --- src/runtime/internal/query.ts | 96 +++++++++++++++-------------------- 1 file changed, 40 insertions(+), 56 deletions(-) diff --git a/src/runtime/internal/query.ts b/src/runtime/internal/query.ts index 1b33b8adc..f4470d28b 100644 --- a/src/runtime/internal/query.ts +++ b/src/runtime/internal/query.ts @@ -17,14 +17,22 @@ export const buildGroup = (group: CollectionQueryGr } /** - * Match any condition that filters on the `locale` column, regardless of operator - * or value. Used to detect manual locale filters so auto-locale steps aside. + * 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. * - * Conditions are emitted by `collectionQueryGroup.where()` as strings that start - * with the quoted column name `"locale"`. + * 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. */ -const referencesLocaleColumn = (conditions: string[]): boolean => - conditions.some(c => c.startsWith('"locale"')) +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 @@ -230,7 +238,12 @@ export const collectionQueryBuilder = (collection: const values = res .map(r => (r as unknown as Record)[String(field)]) .filter(v => v !== null && v !== undefined) - return distinct ? new Set(values).size : values.length + 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 @@ -351,15 +364,26 @@ export const collectionQueryBuilder = (collection: 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 can be interleaved - // with a proper sorted merge. A custom `.order()` cannot be honoured here - // without parsing the SQL ORDER BY clause and reimplementing the comparison in - // JS, so this path falls back to concatenating locale items first, then - // fallback items. Each group retains its database order, but the overall - // sequence may not match a single-query ORDER BY. - const merged = params.orderBy.length === 0 - ? mergeSortedArrays(localeResults, fallbackOnly, getStem) - : [...localeResults, ...fallbackOnly] + // 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 @@ -448,46 +472,6 @@ export const collectionQueryBuilder = (collection: return query } -/** - * Merge two arrays that are both sorted by `stem` ASC (the default ORDER BY). - * Interleaves items using **binary comparison** of their `stem` values to match - * SQLite's default BINARY collation (and PostgreSQL's "C" ordering of ASCII codepoints). - * - * `localeCompare` is deliberately avoided. It uses Unicode collation, which orders - * `'a' < 'A'` (case-insensitive linguistic order), whereas the database sorts - * `'A' (65) < 'a' (97)` (codepoint order). Using `localeCompare` would interleave - * the two result sets in a different order than the database returned. - * - * Both inputs must be sorted by `stem` ASC. This function does not handle - * arbitrary ORDER BY clauses, use concatenation for custom sorts. - */ -function mergeSortedArrays(a: T[], b: T[], getStem: (r: T) => string): T[] { - const result: T[] = [] - let ai = 0 - let bi = 0 - while (ai < a.length && bi < b.length) { - // Plain `<=` matches SQL BINARY codepoint order. Equal stems cannot both occur - // here (the caller filters duplicates), but `<=` keeps `a` stable on ties. - if (getStem(a[ai]!) <= getStem(b[bi]!)) { - result.push(a[ai]!) - ai++ - } - else { - result.push(b[bi]!) - bi++ - } - } - while (ai < a.length) { - result.push(a[ai]!) - ai++ - } - while (bi < b.length) { - result.push(b[bi]!) - bi++ - } - return result -} - function singleQuote(value: unknown) { return `'${String(value).replace(/'/g, '\'\'')}'` } From 4b14f2f7f51b40a712e36c18e9f946a17999a691 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 21:06:08 +0200 Subject: [PATCH 61/67] fix: reject oversized queries and unsafe SQL functions --- src/runtime/internal/security.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index c300a0cd8..7bec8b49e 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -1,8 +1,18 @@ const SQL_COMMANDS = /SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|\$/i +// 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). -const SQL_COUNT_REGEX = /^COUNT\((DISTINCT )?("[a-z_]\w+"|[a-z_]\w+|\*)\) as count$/i +// 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+)?$/ /** @@ -19,6 +29,10 @@ 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') @@ -44,12 +58,12 @@ 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') } @@ -68,6 +82,9 @@ 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 is optional, since COUNT queries omit it. From 29f5a2f792d14c30c43eac8849901ecc1a4c55f8 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 21:06:15 +0200 Subject: [PATCH 62/67] fix: detect locale from stem and harden inline expansion --- src/module.ts | 2 +- src/utils/content/index.ts | 14 +-- src/utils/dev.ts | 10 +- src/utils/i18n.ts | 193 +++++++++++++++++++++++++------------ 4 files changed, 147 insertions(+), 72 deletions(-) diff --git a/src/module.ts b/src/module.ts index 14bf7e6c0..f73291b10 100644 --- a/src/module.ts +++ b/src/module.ts @@ -389,7 +389,7 @@ async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollectio // `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) + 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) diff --git a/src/utils/content/index.ts b/src/utils/content/index.ts index b67760fdd..218f1068a 100644 --- a/src/utils/content/index.ts +++ b/src/utils/content/index.ts @@ -217,13 +217,15 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) } } - // Detect the locale from the path prefix when the collection has i18n - // configured. Strips the prefix from `path` and `stem` so each row is - // addressable by a single locale-agnostic key. + // 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 currentPath = String(result.path || pathMetaFields.path || '') - const currentStem = String(result.stem || pathMetaFields.stem || '') - const detected = detectLocaleFromPath(currentPath, currentStem, collection.i18n) + 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')) { diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 5aa793176..90db390f0 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -171,7 +171,7 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani // 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) + 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 @@ -192,9 +192,11 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani } else { // Clean up stale locale variants if `i18n` was previously present but - // has now been removed from the file. + // 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}`) } } @@ -230,10 +232,12 @@ export function watchContents(nuxt: Nuxt, options: ModuleOptions, manifest: Mani await db.deleteDevelopmentCache(keyInCollection) - // Remove main row and all locale variant rows + // 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}`) } } diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index b807d3e39..e02017e8a 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -2,63 +2,90 @@ 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' /** - * Custom `defu` merger that combines arrays by index (item-by-item) instead of + * 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. Scalar arrays (for example `tags: ['a', 'b', 'c']`) are replaced - * wholesale, since pad-filling them with the default's tail is virtually never - * what authors want when they intentionally provide a shorter locale-specific - * list. - * - * Heuristic, if every element of the override array is a non-object, the array - * is treated as scalar and replaced. Otherwise it deep-merges by index, with - * the default's trailing items preserved for missing override slots. + * the default. * * Inside `createDefu`'s merger, `obj[key]` is the defaults (second arg) and - * `value` is the overrides (first arg). Override items take priority and - * default items fill any gaps. + * `value` is the overrides (first arg). */ export const defuByIndex = createDefu((obj, key, value) => { if (Array.isArray(obj[key]) && Array.isArray(value)) { - const defaultArr = obj[key] - const overrideArr = value - - // Scalar override arrays replace wholesale (no pad-fill from the default). - // An empty override array also short-circuits here, since the author has - // explicitly cleared it. - const isScalarOverride = (overrideArr as unknown[]).every( - (item: unknown) => typeof item !== 'object' || item === null, - ) - if (isScalarOverride) { - ;(obj as Record)[key as string] = [...overrideArr] - return true - } - - const maxLen = Math.max(overrideArr.length, defaultArr.length) - const result = [] - for (let i = 0; i < maxLen; i++) { - const overrideItem = overrideArr[i] - const defaultItem = defaultArr[i] - if (overrideItem !== undefined && defaultItem !== undefined - && typeof overrideItem === 'object' && overrideItem !== null - && typeof defaultItem === 'object' && defaultItem !== null) { - // Recursively merge with defuByIndex so nested arrays also merge by index - result.push(defuByIndex(overrideItem, defaultItem)) - } - else { - result.push(overrideItem !== undefined ? overrideItem : defaultItem) - } - } - ;(obj as Record)[key as string] = result + ;(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 @@ -70,12 +97,20 @@ export const defuByIndex = createDefu((obj, key, value) => { * wholesale rather than deep-merged, since `body` is a parsed markdown tree * that cannot be meaningfully merged. * - * `meta` semantics, the default locale keeps its full `meta` (minus the `i18n` + * 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. Use a top-level field when a locale-varying value is needed. + * (`_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 @@ -86,6 +121,7 @@ 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 @@ -105,16 +141,46 @@ export function expandI18nData( const items: ParsedContentFile[] = [parsedContent] - for (const [locale, overrides] of Object.entries(i18nData)) { + 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 fields that *this* locale actually translates. A field translated only - // in `de` must not enter the `fr` hash. Otherwise a default change to that - // field would flip `fr`'s hash and signal a false "stale translation". + // 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] = parsedContent[field] + sourceFields[field] = pickSourceByOverride(parsedContent[field], overrides[field]) } const i18nSourceHash = hash(sourceFields) @@ -142,31 +208,34 @@ export function expandI18nData( } /** - * Detect locale from the first path segment and strip the locale prefix - * from both path and stem. Returns default locale when no prefix matches. + * 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 pathParts = path.split('/').filter(Boolean) - const firstPart = pathParts[0] + const stemParts = stem.split('/').filter(Boolean) + const firstPart = stemParts[0] if (firstPart && i18nConfig.locales.includes(firstPart)) { - const pathWithoutLocale = '/' + pathParts.slice(1).join('/') + const newStem = stemParts.slice(1).join('/') - let newStem = stem - if (stem === firstPart) { - newStem = '' - } - else if (stem.startsWith(firstPart + '/')) { - newStem = stem.slice(firstPart.length + 1) - } + // 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: pathWithoutLocale === '/' ? '/' : pathWithoutLocale, + path: strippedPath === '/' || strippedPath === '' ? '/' : strippedPath, stem: newStem, } } From 5259b1f430659e0324547abed14206f498a814b5 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 21:06:15 +0200 Subject: [PATCH 63/67] fix: resolve i18n shorthand safely and normalize the locale field --- src/utils/collection.ts | 19 +++++++++++ src/utils/config.ts | 76 ++++++++++++++++++++++++----------------- 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/utils/collection.ts b/src/utils/collection.ts index 0fd2b9636..ba93eb712 100644 --- a/src/utils/collection.ts +++ b/src/utils/collection.ts @@ -23,6 +23,24 @@ export function hasLocaleStemIndex(indexes: CollectionIndex[] | undefined): bool ) } +/** + * 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 @@ -46,6 +64,7 @@ export function defineCollection(collection: Collection): DefinedCollectio 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 } diff --git a/src/utils/config.ts b/src/utils/config.ts index ac34425be..a0f03acf1 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -3,7 +3,7 @@ import { relative } from 'pathe' import { hasNuxtModule, useNuxt } from '@nuxt/kit' import type { Nuxt } from '@nuxt/schema' import type { CollectionI18nConfig, DefinedCollection, ModuleOptions } from '../types' -import { defineCollection, hasLocaleStemIndex, resolveCollections } from './collection' +import { defineCollection, hasLocaleStemIndex, normalizeLocaleField, resolveCollections } from './collection' import { localeStandardSchema, mergeStandardSchema } from './schema' import { getCollectionFieldsTypes } from '../runtime/internal/schema' import { logger } from './dev' @@ -85,10 +85,38 @@ export async function loadContentConfig(nuxt: Nuxt, options?: ModuleOptions) { 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 nuxt-i18n is not installed and a - * collection uses `i18n: true`, a warning is logged and i18n is disabled. + * 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 @@ -96,16 +124,18 @@ function resolveI18nConfig(nuxt: Nuxt, collections: Record - defaultLocale?: string - } - }).i18n + const i18nOptions = readI18nModuleOptions(nuxt) - if (i18nOptions?.locales?.length && i18nOptions.defaultLocale) { + 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 @@ -125,17 +155,10 @@ function resolveI18nConfig(nuxt: Nuxt, collections: Record | undefined - return Boolean(props && 'locale' in props) -} From 5f1e6317da004c29e410fc24e91764c58ef657a5 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 21:06:15 +0200 Subject: [PATCH 64/67] fix: emit per-collection locale and export the source-hash field --- src/types/collection.ts | 5 ----- src/utils/index.ts | 7 +++++++ src/utils/templates.ts | 11 ++++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/types/collection.ts b/src/types/collection.ts index e8a1069cd..de626cf62 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -152,11 +152,6 @@ export interface CollectionItemBase { stem: string extension: string meta: Record - /** - * Locale code for this content item. - * Only present when the collection has i18n enabled. - */ - locale?: string } export interface PageCollectionItemBase extends CollectionItemBase { diff --git a/src/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/templates.ts b/src/utils/templates.ts index c742e90c1..e72344c52 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -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}`) }), )), From bb6493bbe30e92258653eed2c922e80d0893b42c Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 21:06:16 +0200 Subject: [PATCH 65/67] perf: skip locale cache key for non-i18n collections --- src/runtime/client.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index e34a6cd09..5bba82db6 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -1,5 +1,5 @@ import type { H3Event } from 'h3' -import { buildGroup, collectionQueryBuilder, collectionQueryGroup, getGroupConditions } 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' @@ -8,7 +8,8 @@ import { generateCollectionLocales } from './internal/locales' import { buildUseQueryCollectionKey, detectClientLocale, detectServerLocale } from './internal/i18n-detection' import { fetchQuery } from './internal/api' import { withoutTrailingSlash } from 'ufo' -import type { Collections, ContentLocaleEntry, ContentNavigationItem, CollectionQueryBuilder, DatabaseAdapter, PageCollections, QueryGroupFunction, SQLOperator, SurroundOptions } from '@nuxt/content' +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' @@ -87,15 +88,19 @@ export function useQueryCollection } | 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. // - // During SSR, `$i18n.locale` may not be populated (the client plugin has not - // run yet), so the value falls back to the SSR event context. This keeps the - // server-rendered key aligned with the initial client key on hydration. - // Without this fallback the first client render would see a different key, - // miss the cached payload, and immediately refetch. + // 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 || '') @@ -137,7 +142,7 @@ export function useQueryCollection c.startsWith('"locale"'))) explicitLocale = true + if (referencesLocaleColumn(groupConditions)) explicitLocale = true keyParts.conditions.push(`and${buildGroup(group, 'AND')}`) ops.push(qb => qb.andWhere(groupFactory)) return builder @@ -145,7 +150,7 @@ export function useQueryCollection) { const group = groupFactory(collectionQueryGroup(collection)) const groupConditions = getGroupConditions(group) - if (groupConditions.some(c => c.startsWith('"locale"'))) explicitLocale = true + if (referencesLocaleColumn(groupConditions)) explicitLocale = true keyParts.conditions.push(`or${buildGroup(group, 'OR')}`) ops.push(qb => qb.orWhere(groupFactory)) return builder @@ -215,6 +220,11 @@ export function useQueryCollection Date: Fri, 12 Jun 2026 21:06:23 +0200 Subject: [PATCH 66/67] test: cover detection, expansion, query, and security fixes --- test/fixtures/i18n/content.config.ts | 10 + test/fixtures/i18n/content/notes/first.yml | 1 + .../i18n/server/api/content/blog-auto.get.ts | 14 ++ test/i18n.test.ts | 38 ++++ test/mock/content-manifest.ts | 6 + test/unit/assertSafeQuery.test.ts | 18 ++ test/unit/collectionQueryBuilder.test.ts | 171 +++++++++++++++--- test/unit/i18n.test.ts | 109 ++++++++++- 8 files changed, 343 insertions(+), 24 deletions(-) create mode 100644 test/fixtures/i18n/content/notes/first.yml create mode 100644 test/fixtures/i18n/server/api/content/blog-auto.get.ts diff --git a/test/fixtures/i18n/content.config.ts b/test/fixtures/i18n/content.config.ts index 676805d02..e2b354280 100644 --- a/test/fixtures/i18n/content.config.ts +++ b/test/fixtures/i18n/content.config.ts @@ -29,5 +29,15 @@ export default defineContentConfig({ 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/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/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/i18n.test.ts b/test/i18n.test.ts index e2c8be499..2bc974598 100644 --- a/test/i18n.test.ts +++ b/test/i18n.test.ts @@ -62,6 +62,16 @@ describe('i18n', async () => { 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)', () => { @@ -236,4 +246,32 @@ describe('i18n', async () => { } }) }) + + 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 44703e30c..e56a01330 100644 --- a/test/unit/assertSafeQuery.test.ts +++ b/test/unit/assertSafeQuery.test.ts @@ -73,6 +73,24 @@ describe('decompressSQLDump', () => { '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]) => { diff --git a/test/unit/collectionQueryBuilder.test.ts b/test/unit/collectionQueryBuilder.test.ts index 2ec02fb55..953e18140 100644 --- a/test/unit/collectionQueryBuilder.test.ts +++ b/test/unit/collectionQueryBuilder.test.ts @@ -5,6 +5,8 @@ import { collectionQueryBuilder } from '../../src/runtime/internal/query' vi.mock('#content/manifest', () => ({ tables: { articles: '_articles', + navigation: '_navigation', + plain: '_plain', }, default: { articles: { @@ -13,6 +15,20 @@ vi.mock('#content/manifest', () => ({ 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: '', + }, }, })) @@ -614,28 +630,139 @@ describe('collectionQueryBuilder', () => { expect(count).toBe(5) }) - it('skips auto-locale and (in dev) warns when detected locale is a BCP-47 tag not in collection.locales', async () => { - // `@nuxtjs/i18n` may return `en-US`. A collection declaring only `en` - // should silently skip auto-locale rather than producing rows from every - // locale. In dev mode, a `console.warn` surfaces the mismatch. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - await collectionQueryBuilder(mockCollection, mockFetch, 'en-US').all() - // Either path: no WHERE("locale" = ...) appears in the query. - const sql = mockFetch.mock.lastCall![1] as string - expect(sql).not.toContain('"locale" =') - - // In dev, the warning must include both the offending tag and the skip hint. - if (import.meta.dev) { - expect(warn).toHaveBeenCalled() - const msg = warn.mock.calls[0]?.[0] as string - expect(msg).toContain('en-US') - expect(msg).toContain('Auto-locale filter skipped') - } - } - finally { - warn.mockRestore() - } + 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) }) }) diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index d4d9c8c7f..7f7412393 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -236,9 +236,19 @@ describe('i18n', () => { expect(result).toMatchObject({ locale: 'en', path: '/blog/fr/post', stem: 'blog/fr/post' }) }) - it('leaves stem unchanged when it does not start with locale prefix', () => { + 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: 'fr', path: '/docs/guide', stem: 'docs/guide' }) + 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', () => { @@ -478,6 +488,74 @@ describe('i18n', () => { 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) + }) }) }) @@ -551,6 +629,33 @@ describe('i18n', () => { 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 From c9d8d869113c0a8c3f18a8f046fd1d4137bb2f7a Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Fri, 12 Jun 2026 21:06:23 +0200 Subject: [PATCH 67/67] docs: fix examples and document i18n behavior --- docs/content/docs/2.collections/1.define.md | 4 +- .../docs/4.utils/1.query-collection.md | 6 ++- ...ollection.md => 6.use-query-collection.md} | 8 ++- ...cales.md => 7.query-collection-locales.md} | 51 ++++++++++++++----- docs/content/docs/7.integrations/01.i18n.md | 38 +++++++++----- 5 files changed, 78 insertions(+), 29 deletions(-) rename docs/content/docs/4.utils/{5.use-query-collection.md => 6.use-query-collection.md} (87%) rename docs/content/docs/4.utils/{6.query-collection-locales.md => 7.query-collection-locales.md} (52%) diff --git a/docs/content/docs/2.collections/1.define.md b/docs/content/docs/2.collections/1.define.md index a34ed0007..6e6e2146b 100644 --- a/docs/content/docs/2.collections/1.define.md +++ b/docs/content/docs/2.collections/1.define.md @@ -218,8 +218,8 @@ type Collection = { schema?: ZodObject // Database indexes for query optimization indexes?: CollectionIndex[] - // Enable multi-language support. `true` reads `locales`/`defaultLocale` - // from the `@nuxtjs/i18n` module configuration; an object opts in + // 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 } } diff --git a/docs/content/docs/4.utils/1.query-collection.md b/docs/content/docs/4.utils/1.query-collection.md index 0eceed427..18aa6d972 100644 --- a/docs/content/docs/4.utils/1.query-collection.md +++ b/docs/content/docs/4.utils/1.query-collection.md @@ -240,7 +240,7 @@ queryCollection('docs').locale('fr', { fallback: 'en' }).all() ``` ::tip -When `@nuxtjs/i18n` is installed and the collection has `i18n` configured, locale filtering is applied automatically based on the current locale. You only need `.locale()` for explicit control. +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)` @@ -271,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/5.use-query-collection.md b/docs/content/docs/4.utils/6.use-query-collection.md similarity index 87% rename from docs/content/docs/4.utils/5.use-query-collection.md rename to docs/content/docs/4.utils/6.use-query-collection.md index 574cfb795..0dbe6bf0d 100644 --- a/docs/content/docs/4.utils/5.use-query-collection.md +++ b/docs/content/docs/4.utils/6.use-query-collection.md @@ -24,11 +24,17 @@ const { data: techs } = await useQueryCollection('technologies').all() ```ts function useQueryCollection( collection: T -): UseQueryCollectionBuilder +) ``` +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`: diff --git a/docs/content/docs/4.utils/6.query-collection-locales.md b/docs/content/docs/4.utils/7.query-collection-locales.md similarity index 52% rename from docs/content/docs/4.utils/6.query-collection-locales.md rename to docs/content/docs/4.utils/7.query-collection-locales.md index 63502905a..3a96d8d74 100644 --- a/docs/content/docs/4.utils/6.query-collection-locales.md +++ b/docs/content/docs/4.utils/7.query-collection-locales.md @@ -9,20 +9,33 @@ description: Query all locale variants of a content item for language switchers ```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. +`queryCollectionLocales` bypasses automatic locale filtering. It always returns all locale variants regardless of the current locale. :: ## API @@ -72,14 +85,20 @@ export default eventHandler(async (event) => { ```vue ``` @@ -87,14 +106,20 @@ const locales = page.value?.stem ```vue ``` diff --git a/docs/content/docs/7.integrations/01.i18n.md b/docs/content/docs/7.integrations/01.i18n.md index 91d7cabaf..7413cb532 100644 --- a/docs/content/docs/7.integrations/01.i18n.md +++ b/docs/content/docs/7.integrations/01.i18n.md @@ -63,7 +63,12 @@ export default defineContentConfig({ source: 'data/team.yml', schema: z.object({ name: z.string(), - role: 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'], @@ -123,12 +128,16 @@ i18n: ``` - Top-level fields serve as the **default locale** values -- Only translated fields go in the `i18n` section — untranslated fields are preserved automatically -- Arrays are merged **by index**: override items are matched with default items positionally, preserving untranslated fields like `route`, `url`, `icon` +- Only translated fields go in the `i18n` section. Untranslated fields are preserved automatically +- Arrays of objects are merged **by index**: override items are matched with default items positionally, preserving untranslated fields like `route`, `url`, `icon`. Arrays of plain values (strings, numbers) replace the default array wholesale, including an empty array, which clears the list - Nested objects and arrays within items are also merged recursively ::tip -Deep merge is used for all collection types — translated fields override, untranslated fields (routes, IDs, URLs) are preserved from the default. For **page collections**, the `body` field (Markdown AST) is replaced wholesale when the override provides it, preventing AST corruption from deep-merging. +Deep merge is used for all collection types. Translated fields override, and untranslated fields (routes, IDs, URLs) are preserved from the default. For **page collections**, the `body` field (Markdown AST) is replaced wholesale when the override provides it, preventing AST corruption from deep-merging. +:: + +::warning +**Inline translations only apply to fields declared in the collection schema.** A field that is not in the schema is stored on `meta`, and its translation is not persisted per locale. Declare every field you intend to translate in the collection schema. Identity fields (`id`, `stem`, `path`, `locale`, `extension`) cannot be overridden per locale. :: ::warning @@ -146,8 +155,8 @@ When `@nuxtjs/i18n` is installed, `queryCollection` automatically detects the cu const route = useRoute() const { locale } = useI18n() -// Strip locale prefix from route path — content paths don't include it. -// Works for any `@nuxtjs/i18n` routing strategy: a `prefix_except_default` +// Strip locale prefix from route path. Content paths do not include it. +// Works for any `@nuxtjs/i18n` routing strategy. A `prefix_except_default` // default-locale path won't start with `/{locale}` and short-circuits to `path`. const contentPath = computed(() => { const path = route.path @@ -184,7 +193,7 @@ The authoritative shape of the server-side i18n context is declared in [`nuxt-mo :: ::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. The merge assumes both arrays are sorted by `stem ASC` using the database's binary/codepoint collation (SQLite default, PostgreSQL `C`). If a custom `.order()` is set, items from the requested locale and items from the fallback locale are concatenated rather than interleaved. +**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 @@ -193,7 +202,7 @@ The `useQueryCollection` composable wraps `queryCollection` with `useAsyncData`, ```vue [pages/technologies.vue]