Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,417 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: i18n (i18n-completion)
**Package**: `@suaveplan/i18n`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Ergonomic translate options bag
`MessageCatalog.t(key, options?: TranslateOptions)` SHALL accept an optional second argument that bundles the most common translation concerns into a single call. `options` SHALL support `count: number`, `context: string`, `defaultValue: string`, `values: Record<string, unknown>`, `escapeHtml?: boolean`, and `formatters?: Record<string, ValueFormatter>`. When `count` is present, the catalog SHALL invoke `PluralizationEngine.resolve` and select the matching plural branch; when `context` is present, the catalog SHALL look up `${key}_${context}` first, falling back to `key`. The `count` value SHALL be auto-injected into `options.values` so plural strings can use `{count}` directly.
#### Scenario: count selects plural branch
- **GIVEN** catalog `{ en: { item: { one: '{count} item', other: '{count} items' } } }`
- **WHEN** `t('item', { count: 3 })` is called
- **THEN** the result equals `'3 items'`
#### Scenario: context selector falls back to base key
- **GIVEN** catalog `{ en: { greeting: 'Hi', greeting_male: 'Hello sir' } }`
- **WHEN** `t('greeting', { context: 'female' })` is called
- **THEN** the result equals `'Hi'` (greeting_female absent → falls back to base)
#### Scenario: defaultValue used on miss
- **GIVEN** catalog without key `'foo'`
- **WHEN** `t('foo', { defaultValue: 'Bar' })` is called
- **THEN** the result equals `'Bar'`
#### Scenario: values interpolated alongside count
- **GIVEN** catalog `{ en: { item: { one: '{name} has {count} item', other: '{name} has {count} items' } } }`
- **WHEN** `t('item', { count: 2, values: { name: 'Alice' } })` is called
- **THEN** the result equals `'Alice has 2 items'`
**Verified by:** `packages/i18n/i18n/src/core/catalog/translate.test.ts::"count selects plural branch"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"context selector falls back to base key"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"defaultValue used on miss"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"values interpolated alongside count"`
---
### Requirement: Nested-key references in source strings
When a source string contains `$t(other.key)` or `$t(ns:other.key)`, the catalog SHALL recursively resolve the referenced key and substitute its value in place. Cycles SHALL be detected via a maximum nesting depth of 4 (configurable via `nestingDepth` option). Exceeding the depth SHALL throw `I18nError(I18N.NESTING_DEPTH_EXCEEDED)` with `context.path` listing the chain.
#### Scenario: nested reference resolves
- **GIVEN** catalog `{ en: { common: { cancel: 'Cancel' }, dialog: { close: 'Close — $t(common:cancel)' } } }`
- **WHEN** `t('dialog:close')` is called
- **THEN** the result equals `'Close — Cancel'`
#### Scenario: cycle throws NESTING_DEPTH_EXCEEDED
- **GIVEN** catalog `{ en: { a: '$t(b)', b: '$t(a)' } }`
- **WHEN** `t('a')` is called
- **THEN** it throws `I18nError(I18N.NESTING_DEPTH_EXCEEDED)` with `context.path` containing `['a', 'b', 'a', 'b', 'a']`
**Verified by:** `packages/i18n/i18n/src/core/catalog/translate.test.ts::"nested reference resolves"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"cycle throws NESTING_DEPTH_EXCEEDED"`
---
### Requirement: ICU selectordinal node
`MessageParser` SHALL recognise `{var, selectordinal, one {…} two {…} few {…} other {…}}` as a node type distinct from `plural`. At render time, the renderer SHALL invoke the supplied `pluralSelect(count, 'ordinal')` callback (or `PluralizationEngine.getCategory(count, locale, true)` when wired through `MessageCatalog.t`).
#### Scenario: selectordinal selects 'few' for English '3'
- **GIVEN** message `'{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place'`
- **WHEN** parsed and rendered with `{ place: 3 }` under `en` locale
- **THEN** the result equals `'3rd place'`
**Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"selectordinal selects 'few' for English '3'"`
---
### Requirement: ICU typed-argument support
`MessageParser` SHALL recognise typed-argument forms `{name, type[, style]}` where `type` is one of `date`, `time`, `number`. At render time, the renderer SHALL route through the canonical `formatDate` / `formatTime` / `formatNumber` helpers from `@suaveplan/i18n/formatting/*`, threading the active locale. Style strings SHALL accept the standard ICU subset:
- `date`: `short`, `medium`, `long`, `full`
- `time`: `short`, `medium`, `long`, `full`
- `number`: `decimal`, `percent`, `currency/<ISO 4217 code>`, `compact`, `scientific`, `engineering`
#### Scenario: typed currency argument formats per locale
- **GIVEN** message `'{amount, number, currency/USD}'`
- **WHEN** parsed and rendered with `{ amount: 1234.5 }` under `en-US` locale
- **THEN** the result equals `'$1,234.50'`
#### Scenario: typed date argument formats per style
- **GIVEN** message `'{at, date, medium}'`
- **WHEN** parsed and rendered with `{ at: new Date(Date.UTC(2026, 4, 4)) }` under `en-GB` locale
- **THEN** the result equals `'4 May 2026'`
#### Scenario: unknown type rejects at parse time
- **GIVEN** message `'{x, magic, foo}'`
- **WHEN** parsed
- **THEN** `MessageParser.validate(message)` returns an error with code `'UNKNOWN_ARG_TYPE'` and `field === 'magic'`
**Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"typed currency argument formats per locale"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"typed date argument formats per style"` AND `packages/i18n/i18n/src/parser/message/message-validate.test.ts::"unknown type rejects at validate"`
---
### Requirement: ICU apostrophe quoting
`MessageParser` SHALL implement the ICU MessageFormat apostrophe quoting rule:
- A standalone apostrophe followed by a `{`, `}`, or another apostrophe begins a quoted run; the quoted run ends at the next single apostrophe.
- Two consecutive apostrophes (`''`) render as a single literal apostrophe.
- Apostrophes outside this rule render literally.
#### Scenario: doubled apostrophe renders as single
- **GIVEN** message `"it''s here"`
- **WHEN** parsed and rendered with `{}` values
- **THEN** the result equals `"it's here"`
#### Scenario: quoted run preserves braces
- **GIVEN** message `"'{literal}' is {actual}"`
- **WHEN** parsed and rendered with `{ actual: 'X' }`
- **THEN** the result equals `'{literal} is X'`
#### Scenario: standalone apostrophe is literal
- **GIVEN** message `"don't"`
- **WHEN** parsed and rendered
- **THEN** the result equals `"don't"`
**Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"doubled apostrophe renders as single"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"quoted run preserves braces"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"standalone apostrophe is literal"`
---
### Requirement: Rich-text / tag interpolation
`MessageParser` SHALL recognise XML-style open/close tag pairs (`<tag>...</tag>`) inside source messages and emit a `TagNode` with `name: string` and `children: MessageNode[]`. `MessageParser.format(message, values)` SHALL accept a value `name: (chunks: string) => string` to render the tag content inline. `MessageParser.formatChunks<T>(message, values, chunkValues)` SHALL return `Array<string | T>` for framework consumers, where `chunkValues[name]: (chunks: T[]) => T` (e.g. `{ bold: (chunks) => <b>{chunks}</b> }`).
#### Scenario: tag formats inline as string
- **GIVEN** message `'Hello <bold>world</bold>!'`
- **WHEN** `format(message, { bold: (chunks) =>`**${chunks}**`})`
- **THEN** the result equals `'Hello **world**!'`
#### Scenario: nested tag formats correctly
- **GIVEN** message `'<a>read <b>more</b> here</a>'`
- **WHEN** `format(message, { a: (c) =>`[${c}]`, b: (c) => `*${c}*`})`
- **THEN** the result equals `'[read *more* here]'`
#### Scenario: formatChunks returns mixed string + framework-node array
- **GIVEN** message `'Hello <bold>world</bold>!'`
- **WHEN** `formatChunks(message, {}, { bold: (c) => ({ tag: 'b', children: c }) })`
- **THEN** the result deep-equals `['Hello ', { tag: 'b', children: ['world'] }, '!']`
**Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"tag formats inline as string"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"nested tag formats correctly"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"formatChunks returns mixed array"`
---
### Requirement: I18nInstance facade and plugin lifecycle
The package SHALL expose `createI18n(options) → I18nInstance` that composes `MessageCatalog`, `InterpolationEngine`, `PluralizationEngine`, `MessageParser`, `LocaleResolver`, and a plugin registry. `I18nInstance.use(plugin: I18nPlugin)` SHALL register the plugin (calling `install(i18n)` synchronously or awaiting it when async). `I18nInstance.dispose()` SHALL call `dispose()` on every registered plugin in reverse-registration order.
#### Scenario: plugin install runs once on registration
- **GIVEN** an `I18nInstance` and a plugin whose `install` increments a counter
- **WHEN** `i18n.use(plugin)` is called once
- **THEN** the counter is 1 (not 0, not 2)
#### Scenario: dispose runs in reverse order
- **GIVEN** plugins A, B, C registered in that order, each pushing their name into a shared array on `dispose`
- **WHEN** `i18n.dispose()` is called
- **THEN** the shared array equals `['C', 'B', 'A']`
#### Scenario: duplicate plugin names rejected
- **GIVEN** an `I18nInstance` with a plugin named `'http-backend'` already registered
- **WHEN** another plugin with the same name is registered
- **THEN** it throws `I18nError(I18N.PLUGIN_ALREADY_INSTALLED)` with `context.name === 'http-backend'`
**Verified by:** `packages/i18n/i18n/src/core/plugin/plugin.test.ts::"install runs once"` AND `packages/i18n/i18n/src/core/plugin/plugin.test.ts::"dispose runs in reverse order"` AND `packages/i18n/i18n/src/core/plugin/plugin.test.ts::"duplicate plugin name rejected"`
---
### Requirement: Event bus topics
`I18nInstance` SHALL emit five canonical event topics via an internal `@suaveplan/events` bus exposed as `i18n.events`:
- `languageChanged` — `{ from: string; to: string }` — emitted by `i18n.changeLanguage(locale)`
- `loaded` — `{ locale: string; namespace: string; backend?: string }` — emitted on every catalog `set` / `mergeMessages` / backend read
- `missingKey` — `{ locale: string; namespace: string; key: string; defaultValue?: string }` — emitted on every miss before `MissingKeyHandler` is invoked
- `keyAdded` — `{ locale: string; namespace: string; key: string; value: string }` — emitted on `saveMissing` / HMR / backend write-through
- `pluralRuleAdded` — `{ locale: string }` — emitted by `PluralizationEngine.registerRule`
#### Scenario: changeLanguage emits languageChanged
- **WHEN** `i18n.changeLanguage('fr')` is called from an `en` locale
- **THEN** the bus emits `languageChanged` with `{ from: 'en', to: 'fr' }` exactly once
#### Scenario: missing key emits missingKey before handler
- **WHEN** `t('absent.key')` is called and a `MissingKeyHandler` is registered
- **THEN** the bus emits `missingKey` with the lookup payload AND the handler is invoked AFTER the emission
**Verified by:** `packages/i18n/i18n/src/core/plugin/events.test.ts::"changeLanguage emits languageChanged"` AND `packages/i18n/i18n/src/core/plugin/events.test.ts::"missingKey emitted before handler"`
---
### Requirement: List, display-name, and number-helper formatters
The package SHALL expose:
- `formatList(values: string[], locale: string, options?: ListFormatOptions)` wrapping `Intl.ListFormat` with cached instance per `(locale, options-hash)`
- `formatDisplayName(code: string, locale: string, options: DisplayNameOptions)` wrapping `Intl.DisplayNames`
- `formatPercent(value: number, locale: string, options?)` — pre-bound `formatNumber({ style: 'percent' })`
- `formatCompact(value: number, locale: string, options?)` — pre-bound `formatNumber({ notation: 'compact' })`
- `formatUnit(value: number, unit: string, locale: string, options?)` — pre-bound `formatNumber({ style: 'unit', unit })`
Each function SHALL emit a sample on the relevant `i18n.format.<name>.duration_ms` histogram.
#### Scenario: list conjunction joins with locale-specific connector
- **WHEN** `formatList(['apples', 'oranges', 'bananas'], 'en-US', { type: 'conjunction' })` is called
- **THEN** the result equals `'apples, oranges, and bananas'`
#### Scenario: display-name returns localised region name
- **WHEN** `formatDisplayName('GB', 'en-US', { type: 'region' })` is called
- **THEN** the result equals `'United Kingdom'`
#### Scenario: compact notation for thousands
- **WHEN** `formatCompact(1234, 'en-US')` is called
- **THEN** the result equals `'1.2K'`
#### Scenario: unit formatting
- **WHEN** `formatUnit(12, 'kilometer-per-hour', 'en-US')` is called
- **THEN** the result equals `'12 km/h'`
**Verified by:** `packages/i18n/i18n/src/formatting/list/list.test.ts::"list conjunction joins"` AND `packages/i18n/i18n/src/formatting/display-name/display-name.test.ts::"region name localised"` AND `packages/i18n/i18n/src/formatting/number/number-helpers.test.ts::"compact notation for thousands"` AND `packages/i18n/i18n/src/formatting/number/number-helpers.test.ts::"unit formatting"`
---
### Requirement: Pluralization engine prefers Intl.PluralRules
`PluralizationEngine.getRule(locale)` SHALL select rules in this order: (1) explicitly registered override rule, (2) `Intl.PluralRules(locale)` when supported by the runtime, (3) hand-written rule registry, (4) `defaultPluralRule`. The hand-written rules SHALL remain registered and SHALL be used when `Intl.PluralRules` is unavailable. This decouples the package from CLDR drift.
#### Scenario: registered override wins over Intl
- **GIVEN** a custom rule registered for `en` that always returns `'few'`
- **WHEN** `getRule('en')(5)` is called
- **THEN** the result equals `'few'` (override wins, Intl skipped)
#### Scenario: Intl.PluralRules used when no override
- **GIVEN** no override registered for `pl`
- **WHEN** `getCategory(2, 'pl')` is called on a runtime where `Intl.PluralRules` is supported
- **THEN** the result equals `Intl.PluralRules('pl').select(2)` (i.e. `'few'`)
**Verified by:** `packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"override wins over Intl"` AND `packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"Intl used when no override"`
---
### Requirement: Locale-aware first-day-of-week
`getDayNames(locale, style?)` SHALL consult `new Intl.Locale(locale).weekInfo.firstDay` (1 = Monday, 7 = Sunday) to determine the array's starting weekday. When `weekInfo` is unavailable on the runtime (older Node, older browsers), the function SHALL fall back to Monday-start (`firstDay: 1`).
#### Scenario: en-US starts on Sunday
- **WHEN** `getDayNames('en-US', 'short')` is called on a runtime where `weekInfo` is available
- **THEN** the first element equals `'Sun'`
#### Scenario: en-GB starts on Monday
- **WHEN** `getDayNames('en-GB', 'short')` is called
- **THEN** the first element equals `'Mon'`
**Verified by:** `packages/i18n/i18n/src/formatting/date/date.test.ts::"en-US starts on Sunday"` AND `packages/i18n/i18n/src/formatting/date/date.test.ts::"en-GB starts on Monday"`
---
### Requirement: Accept-Language preserves script casing
`parseAcceptLanguage(header)` SHALL preserve the BCP-47 case convention of script subtags: language → lowercase, script → title-case, region → uppercase. The current implementation lowercases the entire tag and is a bug.
#### Scenario: zh-Hant-TW round-trips with original casing
- **WHEN** `parseAcceptLanguage('zh-Hant-TW;q=0.9')` is called
- **THEN** the first entry's `code` equals `'zh-Hant-TW'` (script title-case, region upper-case, language lower-case)
**Verified by:** `packages/i18n/i18n/src/locale/negotiation/negotiation.test.ts::"zh-Hant-TW round-trips with original casing"`
---
### Requirement: Pseudo-localization post-processor
The package SHALL expose `pseudoLocalize(text, options?)` and a corresponding `pseudoLocalizationPlugin(options?)` that registers as a post-processor. Three modes SHALL be supported via `mode: 'accent' | 'bracket' | 'expand'`:
- `accent` (default): replaces ASCII letters with diacritic equivalents (`Welcome` → `Wéłçömé`)
- `bracket`: surrounds the string with `[!! ... !!]` markers
- `expand`: pads the string by ~30% with extra characters to test layout overflow
#### Scenario: accent mode produces diacritic equivalents
- **WHEN** `pseudoLocalize('Welcome', { mode: 'accent' })` is called
- **THEN** the result is non-empty AND every original ASCII letter is replaced with a non-ASCII counterpart
#### Scenario: bracket mode surrounds the string
- **WHEN** `pseudoLocalize('Welcome', { mode: 'bracket' })` is called
- **THEN** the result equals `'[!! Welcome !!]'`
#### Scenario: post-processor plugin transforms t() output
- **GIVEN** an `I18nInstance` with `pseudoLocalizationPlugin({ mode: 'bracket' })` registered
- **WHEN** `t('greeting')` returns `'Welcome'` from the catalog
- **THEN** the final return value equals `'[!! Welcome !!]'`
**Verified by:** `packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"accent mode produces diacritic equivalents"` AND `packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"bracket mode surrounds string"` AND `packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"plugin transforms t() output"`
---
### Requirement: Segmenter and collator wrappers
The package SHALL expose `formatSegment(text, locale, granularity?)` returning an array of `{ segment: string; index: number; isWordLike?: boolean }` via `Intl.Segmenter`, and `compareLocale(a, b, locale, options?)` wrapping `Intl.Collator` with cached instance per `(locale, options-hash)`.
#### Scenario: word segmentation in Japanese
- **WHEN** `formatSegment('東京は晴れです', 'ja-JP', 'word')` is called on a runtime with `Intl.Segmenter`
- **THEN** the result has at least one segment with `isWordLike: true` and `segment === '東京'`
#### Scenario: collator sorts case-insensitively
- **WHEN** `['B', 'a', 'C'].sort((a, b) => compareLocale(a, b, 'en-US', { sensitivity: 'base' }))`
- **THEN** the result equals `['a', 'B', 'C']`
**Verified by:** `packages/i18n/i18n/src/locale/segment/segment.test.ts::"word segmentation in Japanese"` AND `packages/i18n/i18n/src/locale/collator/collator.test.ts::"sorts case-insensitively"`
---
### Requirement: OTel meter instruments wired into production paths
Every existing instrument declared in `src/observability/metrics.ts` (`i18n.translate.total`, `i18n.translate.duration_ms`, `i18n.locale.resolve.total`) SHALL be incremented / observed from the relevant production code paths. Two additional instruments SHALL be added:
- Histogram `i18n.interpolation.duration_ms` — emitted from `InterpolationEngine.render` and `MessageParser.format`
- Counter `i18n.missing_key.total` — labels: `locale`, `namespace`; incremented from `MessageCatalog.get` whenever the lookup falls through to the missing-key handler or returns `undefined`
#### Scenario: get records translate.total{outcome=hit}
- **WHEN** `MessageCatalog.get('present.key')` resolves a value with the in-memory `MeterProvider` registered
- **THEN** exactly one sample is recorded under `i18n.translate.total` with attribute `outcome: 'hit'` AND one sample under `i18n.translate.duration_ms`
#### Scenario: locale resolve records strategy
- **WHEN** `LocaleResolver.resolve('fr-FR')` returns via `'language'` strategy
- **THEN** `i18n.locale.resolve.total` records a sample with attribute `strategy: 'language'`
#### Scenario: missing key increments counter
- **WHEN** `MessageCatalog.get('absent.key')` returns `undefined`
- **THEN** `i18n.missing_key.total` records a sample with `{ locale: 'en', namespace: '' }`
**Verified by:** `packages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts::"translate.total records hit"` AND `packages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts::"locale.resolve.total records strategy"` AND `packages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts::"missing_key.total increments on miss"`
---
### Requirement: Missing-key reporter plugin
The package SHALL expose `createMissingKeyReporter(options?)` returning an `I18nPlugin` that subscribes to the `missingKey` event. By default it logs at `warn` via `@suaveplan/logger`. When `options.onMissing` is supplied, the plugin SHALL invoke it with the event payload. When the catalog is configured with `saveMissing: true` and a backend that implements `save`, the reporter MAY (opt-in via `persistMissing: true`) call `catalog.saveMissing(locale, key, defaultValue)`.
#### Scenario: missing key logs warn by default
- **GIVEN** a logger spy and `i18n.use(createMissingKeyReporter({ logger }))`
- **WHEN** `t('absent.key')` is called
- **THEN** the logger received exactly one `warn` call mentioning `'absent.key'`
#### Scenario: persistMissing routes to backend
- **GIVEN** `saveMissing: true`, a `MemoryBackend`, and `createMissingKeyReporter({ persistMissing: true })`
- **WHEN** `t('new.key', { defaultValue: 'New' })` is called for a missing key
- **THEN** the backend's `save` was called once with the merged record containing `'new.key': 'New'`
**Verified by:** `packages/i18n/i18n/src/post-processors/missing-key-reporter.test.ts::"warn logged by default"` AND `packages/i18n/i18n/src/post-processors/missing-key-reporter.test.ts::"persistMissing routes to backend"`
## MODIFIED Requirements
### I18N-05 — Pluralization engine contract
`PluralizationEngine` MUST:
- Maintain a `PluralRuleRegistry` that callers can write to via `registerRule(locale, rule)`. Built-in hand-written rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, Welsh, and `defaultPluralRule` SHALL remain registered as overrides.
- `.getRule(locale)` selects in this order: (1) explicit override registered via `registerRule`, (2) `Intl.PluralRules(locale)` when the runtime supports it, (3) the hand-written rule registry, (4) `defaultPluralRule`.
- `.resolve(count, locale, messages, opts?)` MUST call the resolved rule, fall back to the base-language rule (strip region), then fall back to `defaultPluralRule`; return `PluralResolveResult { category: PluralCategory; value: string }`.
- `PluralCategory` MUST be `'zero' | 'one' | 'two' | 'few' | 'many' | 'other'`.
- In strict mode, throw `I18nError(I18N.PLURAL_RULE_NOT_FOUND)` when no rule exists for the locale and its base language and `Intl.PluralRules` is unavailable.
#### Scenario: Intl.PluralRules used when no override
- **GIVEN** no override registered for `'pl'`
- **WHEN** `getCategory(2, 'pl')` is called
- **THEN** the result equals `Intl.PluralRules('pl').select(2)` (`'few'`)
#### Scenario: hand-written rule used when Intl unavailable
- **GIVEN** a stubbed runtime where `Intl.PluralRules` throws on construction
- **WHEN** `getCategory(2, 'pl')` is called
- **THEN** the result is computed by the registered `polishPlural` function
**Verified by:** `packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"Intl used when no override"` AND `packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"hand-written used when Intl unavailable"`
<!-- markdownlint-enable MD024 -->