Skip to content

feat: native i18n support for collections#3753

Open
JonathanXDR wants to merge 51 commits intonuxt:mainfrom
JonathanXDR:feature/better-i18n
Open

feat: native i18n support for collections#3753
JonathanXDR wants to merge 51 commits intonuxt:mainfrom
JonathanXDR:feature/better-i18n

Conversation

@JonathanXDR
Copy link
Copy Markdown

@JonathanXDR JonathanXDR commented Mar 29, 2026

🔗 Linked issue

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Adds native i18n support to @nuxt/content, so collections are defined once with i18n: true and content files use an inline i18n section for translations. Only translated strings are specified, everything else is preserved from the default locale. queryCollection automatically detects the current locale from @nuxtjs/i18n and returns locale-resolved content with zero configuration.

Features

  • Define collections once: i18n: true auto-detects locales from @nuxtjs/i18n, or pass { locales, defaultLocale } explicitly
  • Inline translations: YAML/JSON i18n section with per-locale overrides; arrays merged by index via defuByIndex (preserves untranslated fields at every nesting level)
  • Automatic locale detection: queryCollection reads the current locale from @nuxtjs/i18n transparently (client: $i18n.locale, server: event.context.nuxtI18n); default locale uses single query, non-default uses two-query fallback merge
  • useQueryCollection composable: wraps queryCollection + useAsyncData with auto cache keys, locale-reactive re-fetching, and generic type override
  • queryCollectionLocales: returns all locale variants for a content item (for language switchers and hreflang SEO)
  • .stem() convenience method: query data collections by filename without knowing the source directory prefix
  • .locale() explicit override: manual locale control with optional fallback
  • Translator change tracking: non-default locale items include _i18nSourceHash for detecting outdated translations
  • HMR support: inline i18n expansion in dev mode with stale locale cleanup on file save

Security

  • assertSafeQuery rejects newlines (prevents multi-statement injection)
  • COUNT() field names are quoted (prevents field injection)
  • cleanupQuery string parser rewritten with proper state machine (fixes triple-quote bypass)

Known Limitations

The following items were requested in #3579 but are not included in this PR.

  • Nuxt Studio integration: Language select and translation helper UI
  • DeepL/Google Translate integration: Automatic translation of content fields
  • Translatable slugs with different filenames: When locales use different filenames (e.g., en/products.md vs de/produkte.md), queryCollectionLocales cannot link them because the stems differ. Same-filename content across locale directories works. This requires coordination with @nuxtjs/i18n (see nuxt-modules/i18n#3028).
  • Locale-specific field visibility: Fields can be added per locale via the i18n section, but there is no mechanism to hide a default-locale field for a specific locale.

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

Tests

  • 24 unit tests: inline expansion, path detection, source hash, defuByIndex edge cases
  • 24 unit tests: query builder: locale, stem, fallback, auto-locale, count
  • 36 unit tests: assertSafeQuery security validation
  • 17 integration tests: full i18n fixture with path-based + inline content

Documentation

  • Rewrote i18n integration guide with full setup, inline translations, and all new APIs
  • Added useQueryCollection and queryCollectionLocales utility docs
  • Added .locale() and .stem() to query-collection API reference
  • Added i18n option to collection definition docs
  • Added inline i18n examples to YAML and JSON file format docs

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 29, 2026

npm i https://pkg.pr.new/@nuxt/content@3753

commit: 61c15ba

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/content/docs/7.integrations/01.i18n.md`:
- Around line 132-137: The docs example uses route.path directly with
queryCollection('docs').path(route.path) but .path() expects normalized paths
without locale prefixes; update the example to normalize the path before calling
.path()—either derive the path from a slug (e.g., use the page slug) or strip
the locale prefix from route.path (detect leading `/${locale}/` and remove it)
so queryCollection('docs').path(normalizedPath) matches stored document paths;
update the sample in the <script setup> block (where useRoute(), route.path,
useAsyncData, and queryCollection('docs').path are referenced).

In `@src/module.ts`:
- Around line 410-414: The page-collection branch uses a shallow spread which
drops nested default fields; instead use defuByIndex for page frontmatter
merging (same as for data) but ensure the body AST is replace-only: build merged
via defuByIndex(overrides, defaultItem) (or the equivalent helper), then
explicitly set merged.body = overrides.body ?? defaultItem.body to avoid deep
AST merges; also add the same helper/logic in src/utils/dev.ts so HMR uses
identical merge behavior.

In `@src/utils/dev.ts`:
- Line 164: The call to insertDevelopmentCache in src/utils/dev.ts passes
arguments as (id, value, checksum) but the shared declaration in
src/types/database.ts still lists (id, checksum, parsedContent); update the
shared signature to match the new call order and names (e.g.,
insertDevelopmentCache(id: string, value: string, checksum: string)) so all
callers and adapters use the same parameter order and types, and update any
implementing adapters or tests that implement insertDevelopmentCache to match
the corrected parameter order and names.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b1dd7791-3af9-4f2e-ae75-c37c967d513c

📥 Commits

Reviewing files that changed from the base of the PR and between da8bf59 and a576ca0.

📒 Files selected for processing (9)
  • docs/content/docs/2.collections/1.define.md
  • docs/content/docs/4.utils/6.query-collection-locales.md
  • docs/content/docs/7.integrations/01.i18n.md
  • src/module.ts
  • src/runtime/client.ts
  • src/runtime/internal/security.ts
  • src/utils/content/index.ts
  • src/utils/dev.ts
  • src/utils/i18n.ts
✅ Files skipped from review due to trivial changes (2)
  • docs/content/docs/2.collections/1.define.md
  • docs/content/docs/4.utils/6.query-collection-locales.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/utils/i18n.ts
  • src/runtime/internal/security.ts
  • src/utils/content/index.ts
  • src/runtime/client.ts

Comment thread docs/content/docs/7.integrations/01.i18n.md
Comment thread src/module.ts Outdated
Comment thread src/utils/dev.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
src/utils/dev.ts (1)

267-270: The key matching pattern may miss certain INSERT statement formats.

The current pattern checks for '${escapedKey}', or strings ending with '${escapedKey}'). This works for typical INSERT statements where id is either followed by a comma or is the last value in the VALUES clause.

However, if the generated SQL format changes or if there are additional clauses after the VALUES, this could fail to match. Consider using a more robust regex pattern:

-    const keyIndex = collectionDump.findIndex(item => item.includes(`'${escapedKey}',`) || item.endsWith(`'${escapedKey}')`))
+    const keyIndex = collectionDump.findIndex(item => {
+      const pattern = new RegExp(`'${escapedKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'(?:,|\\))`)
+      return pattern.test(item)
+    })

This would match the key as a complete SQL string literal followed by either a comma or closing parenthesis.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/dev.ts` around lines 267 - 270, The current key matching uses
includes/endsWith on escapedKey and can miss formats; replace the boolean
expression in collectionDump.findIndex (where keyIndex is computed) with a
robust RegExp test that matches the SQL string literal exactly: build a regex
that matches the single-quoted escapedKey as a whole token followed by optional
whitespace and either a comma or a closing parenthesis (e.g., using a lookahead
like '(?=\\s*(,|\\))') and use regex.test(item); ensure you still escape single
quotes in escapedKey and also escape any regex-special characters before
embedding it in the RegExp; update the keyIndex computation in the same function
(where escapedKey and collectionDump.findIndex are used).
docs/content/docs/7.integrations/01.i18n.md (2)

232-236: Consider adding a brief rationale for the CSRF exemption.

The route rule at line 234 exempts the /__nuxt_content/** API from CSRF protection. While the comment states "Exempt content API from CSRF," a brief sentence explaining why this is safe (e.g., the API is read-only, or uses alternative authentication) would help users understand the security implications.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/7.integrations/01.i18n.md` around lines 232 - 236, Add a
short rationale next to the routeRules entry explaining why the CSRF exemption
for '/__nuxt_content/**' is safe; update the comment near routeRules: {
'/__nuxt_content/**': { csurf: false, } } to mention the specific reason (e.g.,
the endpoint is read-only/public content, uses token-based or other auth, or is
served only to trusted clients) so readers understand the security implications
and when this exemption is acceptable.

180-190: Document the ORDER BY limitation when using locale fallback.

When .locale(locale, { fallback }) is combined with .order(), the merged result concatenates locale-specific items before fallback items rather than interleaving them by the ORDER BY field. Users should be aware of this limitation to avoid unexpected ordering in their queries. Based on learnings, this is a documented, acknowledged limitation: implementing a JS comparator from SQL ORDER BY strings is not feasible without a SQL parser. The workaround is to avoid combining .order() with .locale(locale, { fallback }), or accept that locale items appear before fallback items in the result.

📝 Suggested documentation addition

Add a note or warning after line 190:

::warning
When combining `.locale()` with fallback and `.order()`, the result concatenates locale-specific items before fallback items rather than interleaving by the ORDER BY field. Avoid using `.order()` with locale fallback if you need precise ordering across both sets.
::
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/7.integrations/01.i18n.md` around lines 180 - 190, Add a
warning to the Locale docs explaining the known ORDER BY limitation: when using
CollectionQuery.locale(locale, { fallback }) together with .order(), results are
concatenated with all locale-specific items first and fallback items after (not
interleaved by the ORDER BY field); update the section near the examples
referencing .locale() and .order() to include this note and suggest the
workarounds (avoid combining .order() with locale fallback, or accept
locale-first ordering). Mention the limitation is acknowledged and that
implementing a JS comparator from SQL ORDER BY strings isn’t feasible without a
SQL parser.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/content/docs/7.integrations/01.i18n.md`:
- Around line 122-124: Update the i18n docs tip to state that for page
collections the body field is replaced entirely (not shallow-spread merged);
specifically mention that the code path where collection.type === 'page' &&
overrides.body assigns merged.body = overrides.body replaces the body AST
wholesale while other fields still use deep merge, so only the body is swapped
to preserve AST structure.

In `@src/runtime/internal/query.ts`:
- Around line 164-173: The count() implementation must ignore pagination in both
paths: in the locale-fallback branch (function fetchWithLocaleFallback/variable
merged) compute counts from the full merged result before any slicing so that
limit()/skip() do not affect the tally, and in the single-query branch ensure
the aggregation query built for count removes any LIMIT/OFFSET clauses (do not
append params.limit or params.skip to the aggregate SQL) so
skip(...).limit(...).count() cannot offset the count row; update the logic in
count() (and the same pattern in the other affected ranges) to explicitly
build/execute a pagination-free count query or use the un-sliced merged data for
counting.
- Around line 167-173: The fallback branch in count(field) relies on the
caller's projection and thus can return undefined values; update the
fetchWithLocaleFallback call inside count (and the similar block at the other
occurrence) to request an explicit projection that includes the document stem
plus the counted field (instead of reusing params.selectedFields or
preserveField alone). Concretely, when field !== '*' build a
selectedFields/preserveField payload that contains the stem identifier and
String(field) together so the fetch returns the counted field even if the caller
previously narrowed the .select(); keep the existing distinct/new Set logic
unchanged.
- Around line 316-325: The mergeSortedArrays function uses
getStem(...).localeCompare(...) which applies locale collation and can differ
from SQLite's BINARY ordering; replace that with a SQL-compatible binary
comparator that compares stems by their raw UTF-8 bytes (or by code-unit
ordering) so ordering matches SQLite's ORDER BY stem ASC (case- and
accent-sensitive, byte-order). Create a helper comparator (e.g.,
compareStemsBinary(stemA, stemB) that uses Buffer.from(stem, 'utf8') with
Buffer.compare or an explicit code-unit loop) and use it everywhere inside
mergeSortedArrays (including the while loop comparisons and any tie-break logic)
instead of localeCompare to ensure identical ordering to the DB.

In `@src/types/database.ts`:
- Line 20: Update the interface signature for insertDevelopmentCache to return
Promise<void> instead of void to match its async implementation; locate the
insertDevelopmentCache declaration in the types file and change its return type
to Promise<void> so it aligns with the async function implementation in the
codebase.

---

Nitpick comments:
In `@docs/content/docs/7.integrations/01.i18n.md`:
- Around line 232-236: Add a short rationale next to the routeRules entry
explaining why the CSRF exemption for '/__nuxt_content/**' is safe; update the
comment near routeRules: { '/__nuxt_content/**': { csurf: false, } } to mention
the specific reason (e.g., the endpoint is read-only/public content, uses
token-based or other auth, or is served only to trusted clients) so readers
understand the security implications and when this exemption is acceptable.
- Around line 180-190: Add a warning to the Locale docs explaining the known
ORDER BY limitation: when using CollectionQuery.locale(locale, { fallback })
together with .order(), results are concatenated with all locale-specific items
first and fallback items after (not interleaved by the ORDER BY field); update
the section near the examples referencing .locale() and .order() to include this
note and suggest the workarounds (avoid combining .order() with locale fallback,
or accept locale-first ordering). Mention the limitation is acknowledged and
that implementing a JS comparator from SQL ORDER BY strings isn’t feasible
without a SQL parser.

In `@src/utils/dev.ts`:
- Around line 267-270: The current key matching uses includes/endsWith on
escapedKey and can miss formats; replace the boolean expression in
collectionDump.findIndex (where keyIndex is computed) with a robust RegExp test
that matches the SQL string literal exactly: build a regex that matches the
single-quoted escapedKey as a whole token followed by optional whitespace and
either a comma or a closing parenthesis (e.g., using a lookahead like
'(?=\\s*(,|\\))') and use regex.test(item); ensure you still escape single
quotes in escapedKey and also escape any regex-special characters before
embedding it in the RegExp; update the keyIndex computation in the same function
(where escapedKey and collectionDump.findIndex are used).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5e3e84bd-1b29-4691-ba86-f25dbd23b765

📥 Commits

Reviewing files that changed from the base of the PR and between a576ca0 and a44b89e.

📒 Files selected for processing (5)
  • docs/content/docs/7.integrations/01.i18n.md
  • src/module.ts
  • src/runtime/internal/query.ts
  • src/types/database.ts
  • src/utils/dev.ts

Comment thread docs/content/docs/7.integrations/01.i18n.md
Comment thread src/runtime/internal/query.ts
Comment thread src/runtime/internal/query.ts Outdated
Comment thread src/runtime/internal/query.ts
Comment thread src/types/database.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/runtime/client.ts`:
- Around line 20-27: The SSR locale detection in queryCollection currently
checks nuxtApp.$i18n.locale.value and
event.context.nuxtI18n.vueI18nOptions.locale but misses the direct
event.context.nuxtI18n.locale fallback present in server code; update the
detectedLocale expression inside queryCollection to include ||
(event?.context?.nuxtI18n as { locale?: string })?.locale as the final fallback
so collectionQueryBuilder/executeContentQuery receive the same locale resolution
as on the server.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f90c0cde-aa5a-4421-9c38-41f350620d41

📥 Commits

Reviewing files that changed from the base of the PR and between a44b89e and aaf195d.

📒 Files selected for processing (1)
  • src/runtime/client.ts

Comment thread src/runtime/client.ts
@JonathanXDR
Copy link
Copy Markdown
Author

@farnabaz This PR is ready for review when you have a moment. It adds native i18n support for collections (inline translations, auto-locale detection, useQueryCollection composable). Thanks!

Copy link
Copy Markdown
Contributor

@edimitchel edimitchel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing work! Great to see this very i18n full-picture feature ! Here few comments, some are nitpick

Comment thread docs/content/docs/7.integrations/01.i18n.md
Comment thread src/runtime/internal/locales.ts Outdated
Comment thread test/unit/i18n.test.ts Outdated
Comment thread test/unit/i18n.test.ts Outdated
Comment thread test/unit/i18n.test.ts Outdated
Comment thread test/unit/i18n.test.ts Outdated
Comment thread test/unit/i18n.test.ts Outdated
Comment thread test/unit/i18n.test.ts Outdated
Comment thread src/runtime/internal/query.ts
Comment thread src/utils/dev.ts
@JonathanXDR JonathanXDR requested a review from edimitchel March 31, 2026 12:05
Comment thread src/runtime/internal/locales.ts Outdated
@JonathanXDR JonathanXDR requested a review from edimitchel April 3, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support i18n inline translations

3 participants