Skip to content

Commit 65d72d1

Browse files
s00dcursoragent
andcommitted
fix(core): chain custom plural null to defaultPlural
Document that Nuxt plural must be a self-contained function; file-path plural is not supported (#241). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 268a9a4 commit 65d72d1

11 files changed

Lines changed: 98 additions & 42 deletions

File tree

docs/api/module-options.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Where translation files live and how keys are resolved.
6060
| `translationDir` | `string` | `'locales'` | Path to the directory containing translation JSON files, relative to the project root. |
6161
| `disableWatcher` | `boolean` | `false` | Disable the file watcher that auto-creates missing translation files in development mode. |
6262
| `routesLocaleLinks` | `{ [key: string]: string }` | `{}` | Map route names to other route names to share the same translation files. For example, `{ 'about-us': 'about' }` means the `about-us` page will use translations from the `about` page instead of its own. |
63-
| `plural` | `string \| PluralFunc` | `built-in pluralization (singular/plural by count)` | Custom pluralization function or a path to a file exporting one. When a string path is provided, the file is imported at build time. The function receives `(key, count, params, locale, getter)` and should return the correct plural form as a string, or `null` to fall back to the built-in logic. |
63+
| `plural` | `PluralFunc` | `built-in pluralization (form index by count)` | Custom pluralization function. Receives `(key, count, params, locale, getter)` and should return the selected plural form as a string, or `null`/`undefined` to fall back to the built-in `defaultPlural` logic (so you can override only some locales). For the Nuxt module the function is serialized with `.toString()` into `.nuxt/i18n.plural.mjs` — it must be self-contained (no imports / outer scope). A file path string is **not** supported. |
6464
| `disablePageLocales` | `boolean` | `false` | Disable per-page translation files. When `true`, only global translations (`{locale}.json`) are loaded; page-specific files (`pages/{page}/{locale}.json`) are not generated or loaded. |
6565
| `fallbackLocale` | `string` | `undefined (no fallback; returns the raw key)` | Global fallback locale code. When a translation key is missing in the active locale, the module looks it up in this locale before returning the key itself. |
6666

docs/guide/configuration.md

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ The sections below explain how they work together; the
5050
| [`disableWatcher`](/api/module-options) | `boolean` | `false` | Disable the file watcher that auto-creates missing translation files in development mode. |
5151
| [`types`](/api/module-options) | `boolean` | `true` | Generate TypeScript type declarations for `useI18n`, `$t`, and related helpers based on the translation keys in your default locale files. |
5252
| [`routesLocaleLinks`](/api/module-options) | `{ [key: string]: string }` | `{}` | Map route names to other route names to share the same translation files. |
53-
| [`plural`](/api/module-options) | `string \| PluralFunc` | `built-in pluralization (singular/plural by count)` | Custom pluralization function or a path to a file exporting one. |
53+
| [`plural`](/api/module-options) | `PluralFunc` | `built-in pluralization (form index by count)` | Custom pluralization function. |
5454
| [`disablePageLocales`](/api/module-options) | `boolean` | `false` | Disable per-page translation files. |
5555
| [`fallbackLocale`](/api/module-options) | `string` | `undefined (no fallback; returns the raw key)` | Global fallback locale code. |
5656
| [`localeCookie`](/api/module-options) | `string \| null` | `null` | Cookie name for persisting the user's locale preference across sessions. |
@@ -640,12 +640,16 @@ autoDetectPath: '*' // On all routes (use with caution)
640640

641641
<!-- generated:option:plural — do not edit; run `pnpm run docs:generate` -->
642642

643-
**Type** `string \| PluralFunc` · **Default** `built-in pluralization (singular/plural by count)`
643+
**Type** `PluralFunc` · **Default** `built-in pluralization (form index by count)`
644644

645-
Custom pluralization function or a path to a file exporting one.
646-
When a string path is provided, the file is imported at build time.
647-
The function receives `(key, count, params, locale, getter)` and should return
648-
the correct plural form as a string, or `null` to fall back to the built-in logic.
645+
Custom pluralization function.
646+
Receives `(key, count, params, locale, getter)` and should return the selected
647+
plural form as a string, or `null`/`undefined` to fall back to the built-in
648+
`defaultPlural` logic (so you can override only some locales).
649+
650+
For the Nuxt module the function is serialized with `.toString()` into
651+
`.nuxt/i18n.plural.mjs` — it must be self-contained (no imports / outer scope).
652+
A file path string is **not** supported.
649653

650654
<!-- /generated:option:plural -->
651655

@@ -678,8 +682,9 @@ For languages with complex pluralization rules (e.g., Russian, Arabic, Polish),
678682
The function is serialized via `.toString()` and injected into a virtual module at build time. This means:
679683

680684
- **Must use `function` keyword** — NOT shorthand method syntax, NOT arrow functions with external references
681-
- **No imports or external references** — the function must be fully self-contained
685+
- **No imports or external references** — the function must be fully self-contained (a file path like `plural: '~/i18n/plural.ts'` is **not** supported)
682686
- **No TypeScript-only syntax** that doesn't survive `.toString()` (type annotations are fine in `nuxt.config.ts` because Nuxt strips them)
687+
- Returning `null` / `undefined` falls back to the built-in `defaultPlural` (useful for per-locale overrides)
683688
:::
684689

685690
**Example: Russian pluralization** (4 forms: zero, one, few, many):
@@ -755,36 +760,39 @@ plural: function (key, count, params, _locale, t) {
755760

756761
##### Per-locale pluralization
757762

758-
If different locales need different plural rules, use the `locale` parameter:
763+
If different locales need different plural rules, branch on `locale` and return `null`
764+
for locales you do not customize — the built-in `defaultPlural` handles the rest:
759765

760766
```typescript
761767
plural: function (key, count, _params, locale, t) {
768+
// Only override Slavic locales; en/de/… keep the built-in rules
769+
if (locale !== 'ru' && locale !== 'uk') return null
770+
762771
const translation = t(key)
763-
if (!translation) return key
772+
if (!translation) return null
764773

765774
const forms = translation.toString().split('|').map(function (s) { return s.trim() })
766-
767-
// Russian/Ukrainian plural rules
768-
if (locale === 'ru' || locale === 'uk') {
769-
let idx
770-
if (count === 0) {
771-
idx = 0
772-
} else {
773-
const mod10 = count % 10
774-
const mod100 = count % 100
775-
if (mod10 === 1 && mod100 !== 11) idx = 1
776-
else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) idx = 2
777-
else idx = 3
778-
}
779-
if (idx >= forms.length) idx = forms.length - 1
780-
return (forms[idx] || '').replace('{count}', String(count))
775+
let idx
776+
if (count === 0) {
777+
idx = 0
778+
} else {
779+
const mod10 = count % 10
780+
const mod100 = count % 100
781+
if (mod10 === 1 && mod100 !== 11) idx = 1
782+
else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) idx = 2
783+
else idx = 3
781784
}
782-
783-
// Default: English-like (index-based)
784-
const idx = count < forms.length ? count : forms.length - 1
785+
if (idx >= forms.length) idx = forms.length - 1
785786
return (forms[idx] || '').replace('{count}', String(count))
786787
}
787788
```
789+
790+
::: warning No module-path `plural`
791+
`plural: '~/i18n/plural.ts'` (or any string path) is **not** supported. The Nuxt module
792+
serializes the function with `.toString()` into a virtual file; external imports and
793+
helpers from another module are stripped and will be `undefined` at runtime. Keep the
794+
function fully inlined in `nuxt.config.ts` (see the danger note above).
795+
:::
788796
#### `localeCookie`
789797

790798
<!-- generated:option:localeCookie — do not edit; run `pnpm run docs:generate` -->

docs/news/index.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ outline: 'deep'
66

77
# News
88

9+
## Unreleased — custom `plural` falls back to default (#241)
10+
11+
**Date**: 2026-07-31
12+
13+
A custom `plural` that returns `null`/`undefined` now chains to the built-in `defaultPlural`, so you can override only some locales (e.g. Slavic) without reimplementing English-style rules. File-path `plural: '~/…'` is **not** supported — the function must stay self-contained in `nuxt.config` (serialized via `.toString()`).
14+
915
## Unreleased — hreflang from `iso`, not routing `code` (#243)
1016

1117
**Date**: 2026-07-31

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@i18n-micro/core",
3-
"version": "1.3.10",
3+
"version": "1.3.11",
44
"description": "Core utilities for translations, formatting, and locale routing in Nuxt I18n Micro.",
55
"keywords": [
66
"formatting",

packages/core/src/base.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,15 @@ export abstract class BaseI18n {
4343
datetimeFormats: options.datetimeFormats,
4444
}
4545
this.formatter = new FormatService(formatOptions)
46-
this.pluralFunc = options.plural || defaultPlural
46+
// Custom plural returning null/undefined chains to defaultPlural (#241).
47+
// Skip the wrapper when the caller already passed the built-in (module default).
48+
this.pluralFunc =
49+
options.plural && options.plural !== defaultPlural
50+
? (key, count, params, locale, getter) => {
51+
const custom = options.plural!(key, count, params, locale, getter)
52+
return custom ?? defaultPlural(key, count, params, locale, getter)
53+
}
54+
: defaultPlural
4755
this.missingWarn = options.missingWarn ?? true
4856
this.missingHandler = options.missingHandler
4957
this.getCustomMissingHandler = options.getCustomMissingHandler

packages/core/tests/base.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,36 @@ describe('BaseI18n', () => {
221221
// This happens when translation exists but is empty or invalid.
222222
expect(i18n.tc('missing.key', 1, 'Default')).toBe('missing.key')
223223
})
224+
225+
test('#241: custom plural null should fall back to defaultPlural', () => {
226+
// Override only for ru; return null elsewhere — should use built-in form selection for en
227+
const customPlural: PluralFunc = (key, count, params, locale, getter) => {
228+
if (locale !== 'ru') return null
229+
const translation = getter(key, params)
230+
if (typeof translation !== 'string') return null
231+
return `ru:${translation.split('|')[0]}`
232+
}
233+
234+
const i18n = new TestI18n('en', 'en', 'index', { plural: customPlural })
235+
// key ≠ forms, so falling back to `key` cannot accidentally pass
236+
// defaultPlural indexes by count: 0→form0, 1→form1, 2+→last
237+
i18n['helper'].loadTranslations('en', { items: 'none|one|many' })
238+
239+
expect(i18n.tc('items', 0)).toBe('none')
240+
expect(i18n.tc('items', 1)).toBe('one')
241+
expect(i18n.tc('items', 5)).toBe('many')
242+
243+
i18n.setLocale('ru')
244+
i18n['helper'].loadTranslations('ru', { items: 'нет|один|мало|много' })
245+
expect(i18n.tc('items', 1)).toBe('ru:нет')
246+
})
247+
248+
test('#241: custom plural can still return an explicit string (no accidental default)', () => {
249+
const customPlural: PluralFunc = () => 'forced'
250+
const i18n = new TestI18n('en', 'en', 'index', { plural: customPlural })
251+
i18n['helper'].loadTranslations('en', { items: 'none|one|many' })
252+
expect(i18n.tc('items', 1)).toBe('forced')
253+
})
224254
})
225255

226256
describe('tn() method', () => {

packages/types/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@i18n-micro/types",
3-
"version": "1.2.9",
3+
"version": "1.2.10",
44
"description": "TypeScript definitions for the Nuxt I18n Micro ecosystem.",
55
"keywords": [
66
"i18n",

packages/types/src/index.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -337,13 +337,17 @@ export interface ModuleOptions {
337337
routesLocaleLinks?: { [key: string]: string }
338338

339339
/**
340-
* Custom pluralization function or a path to a file exporting one.
341-
* When a string path is provided, the file is imported at build time.
342-
* The function receives `(key, count, params, locale, getter)` and should return
343-
* the correct plural form as a string, or `null` to fall back to the built-in logic.
344-
* @default built-in pluralization (singular/plural by count)
340+
* Custom pluralization function.
341+
* Receives `(key, count, params, locale, getter)` and should return the selected
342+
* plural form as a string, or `null`/`undefined` to fall back to the built-in
343+
* `defaultPlural` logic (so you can override only some locales).
344+
*
345+
* For the Nuxt module the function is serialized with `.toString()` into
346+
* `.nuxt/i18n.plural.mjs` — it must be self-contained (no imports / outer scope).
347+
* A file path string is **not** supported.
348+
* @default built-in pluralization (form index by count)
345349
*/
346-
plural?: string | PluralFunc
350+
plural?: PluralFunc
347351

348352
/**
349353
* Disable per-page translation files.

scripts/api-surface/i18n-micro__preact.api.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1342,7 +1342,7 @@ member ModuleOptions.missingWarn?: boolean | undefined
13421342
member ModuleOptions.noPrefixRedirect?: boolean | undefined
13431343
member ModuleOptions.numberFormats?: Record<string, Record<string, Intl.NumberFormatOptions>> | undefined
13441344
member ModuleOptions.plugin?: boolean | undefined
1345-
member ModuleOptions.plural?: string | PluralFunc | undefined
1345+
member ModuleOptions.plural?: PluralFunc | undefined
13461346
member ModuleOptions.redirects?: boolean | undefined
13471347
member ModuleOptions.routeDisableMeta?: Record<string, boolean | string[]> | undefined
13481348
member ModuleOptions.routeLocales?: Record<string, string[]> | undefined

scripts/api-surface/i18n-micro__solid.api.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ member ModuleOptions.missingWarn?: boolean | undefined
9797
member ModuleOptions.noPrefixRedirect?: boolean | undefined
9898
member ModuleOptions.numberFormats?: Record<string, Record<string, Intl.NumberFormatOptions>> | undefined
9999
member ModuleOptions.plugin?: boolean | undefined
100-
member ModuleOptions.plural?: string | PluralFunc | undefined
100+
member ModuleOptions.plural?: PluralFunc | undefined
101101
member ModuleOptions.redirects?: boolean | undefined
102102
member ModuleOptions.routeDisableMeta?: Record<string, boolean | string[]> | undefined
103103
member ModuleOptions.routeLocales?: Record<string, string[]> | undefined

0 commit comments

Comments
 (0)