From 5e292e34780a2412e3362098758790375d2b4cc0 Mon Sep 17 00:00:00 2001 From: Jonathan Russ Date: Sat, 28 Mar 2026 14:57:51 +0100 Subject: [PATCH 01/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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, } }) }