From 081d47217683440ca2cce200edbe53389f87cf0a Mon Sep 17 00:00:00 2001 From: T <6601329+cookesan@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:13:11 -0300 Subject: [PATCH] feat: Add named substitutions to i18n (#2440) --- packages/i18n/README.md | 17 ++++ packages/i18n/bench/_locales/en/messages.json | 3 + packages/i18n/bench/background.ts | 9 ++ packages/i18n/src/__tests__/build.test.ts | 22 +++++ packages/i18n/src/__tests__/index.test.ts | 43 ++++++++++ packages/i18n/src/__tests__/types.test.ts | 55 +++++++++++++ packages/i18n/src/__tests__/utils.test.ts | 30 +++++++ packages/i18n/src/build.ts | 18 +++- packages/i18n/src/index.ts | 56 +++++++++---- packages/i18n/src/types.ts | 82 +++++++++++++++++-- packages/i18n/src/utils.ts | 23 +++++- 11 files changed, 334 insertions(+), 24 deletions(-) diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 179b057e..2c206986 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -173,6 +173,23 @@ dollars: $$$1 i18n.t('dollars', ['1.00']); // "$1.00" ``` +#### Named Substitutions + +You can also use named substitutions with `{key}` placeholders: + +```yml +welcome: Hello {name}, welcome to {appName}! +``` + +```ts +i18n.t('welcome', { + name: 'Ada', + appName: 'WXT', +}); // "Hello Ada, welcome to WXT!" +``` + +Missing keys are left unchanged in the returned message. + ### Plural Forms > [!WARNING] diff --git a/packages/i18n/bench/_locales/en/messages.json b/packages/i18n/bench/_locales/en/messages.json index 6488bd0d..4522b344 100644 --- a/packages/i18n/bench/_locales/en/messages.json +++ b/packages/i18n/bench/_locales/en/messages.json @@ -5,6 +5,9 @@ "substitution": { "message": "Includes one substitution: $1" }, + "namedSubstitution": { + "message": "Includes one named substitution: {value}" + }, "singular": { "message": "There is $1 item" }, diff --git a/packages/i18n/bench/background.ts b/packages/i18n/bench/background.ts index 1cf9f973..c892335f 100644 --- a/packages/i18n/bench/background.ts +++ b/packages/i18n/bench/background.ts @@ -36,6 +36,15 @@ setTimeout(() => { await runBench(substitution); } + { + const key = 'namedSubstitution'; + const value = 'test'; + const namedSubstitution = new Bench({ name: 'Named substitution' }) + .add('Vanilla', () => void chrome.i18n.getMessage(key)) + .add('WXT I18n', () => void i18n.t(key, { value })); + await runBench(namedSubstitution); + } + { const vanillaPluralKey = 'plural'; const wxtKey = 'wxtPlural'; diff --git a/packages/i18n/src/__tests__/build.test.ts b/packages/i18n/src/__tests__/build.test.ts index ef05be0c..8f1839c8 100644 --- a/packages/i18n/src/__tests__/build.test.ts +++ b/packages/i18n/src/__tests__/build.test.ts @@ -22,6 +22,7 @@ describe('Built Tools', () => { const fileText = stringifyYAML({ simple: 'example', sub: 'Hello $1', + named: 'Hello {name} from {tool}', nested: { example: 'This is nested', array: ['One', 'Two'], @@ -33,11 +34,19 @@ describe('Built Tools', () => { message: 'test 2', description: 'test', }, + chromeNamed: { + message: 'test {value}', + description: 'test', + }, plural0: { 0: 'Zero items', 1: 'One item', n: '$1 items', }, + pluralNamed: { + 1: 'One {item}', + n: '$1 {item}', + }, plural1: { 1: 'One item', n: '$1 items', @@ -68,6 +77,9 @@ describe('Built Tools', () => { "sub": { "message": "Hello $1" }, + "named": { + "message": "Hello {name} from {tool}" + }, "nested_example": { "message": "This is nested" }, @@ -84,9 +96,16 @@ describe('Built Tools', () => { "message": "test 2", "description": "test" }, + "chromeNamed": { + "message": "test {value}", + "description": "test" + }, "plural0": { "message": "Zero items | One item | $1 items" }, + "pluralNamed": { + "message": "One {item} | $1 {item}" + }, "plural1": { "message": "One item | $1 items" }, @@ -103,12 +122,15 @@ describe('Built Tools', () => { "export type GeneratedI18nStructure = { "simple": { substitutions: 0, plural: false }; "sub": { substitutions: 1, plural: false }; + "named": { substitutions: 0, plural: false, namedSubstitutions: ["name","tool"] }; "nested.example": { substitutions: 0, plural: false }; "nested.array.0": { substitutions: 0, plural: false }; "nested.array.1": { substitutions: 0, plural: false }; "nested.notChrome.message": { substitutions: 0, plural: false }; "chrome": { substitutions: 0, plural: false }; + "chromeNamed": { substitutions: 0, plural: false, namedSubstitutions: ["value"] }; "plural0": { substitutions: 1, plural: true }; + "pluralNamed": { substitutions: 1, plural: true, namedSubstitutions: ["item"] }; "plural1": { substitutions: 1, plural: true }; "pluralN": { substitutions: 1, plural: true }; "pluralSub": { substitutions: 2, plural: true }; diff --git a/packages/i18n/src/__tests__/index.test.ts b/packages/i18n/src/__tests__/index.test.ts index 11ed8d60..49480642 100644 --- a/packages/i18n/src/__tests__/index.test.ts +++ b/packages/i18n/src/__tests__/index.test.ts @@ -68,4 +68,47 @@ describe('createI18n', () => { i18n.t('key', 3, ['custom']); expect(getMessageMock).toBeCalledWith('key', ['custom']); }); + + it('should replace named substitutions', () => { + const i18n = createI18n(); + getMessageMock.mockReturnValue('My name is {name}, and I use {tool}.'); + + const actual = i18n.t('key', { + name: 'Ada', + tool: 'WXT', + }); + + expect(actual).toBe('My name is Ada, and I use WXT.'); + expect(getMessageMock).toBeCalledTimes(1); + expect(getMessageMock).toBeCalledWith('key'); + }); + + it('should leave unknown named substitutions unchanged', () => { + const i18n = createI18n(); + getMessageMock.mockReturnValue('Hello {name} from {team}.'); + + const actual = i18n.t('key', { name: 'Ada' }); + + expect(actual).toBe('Hello Ada from {team}.'); + }); + + it('should combine positional and named substitutions', () => { + const i18n = createI18n(); + getMessageMock.mockReturnValue('Hello Ada from {team}.'); + + const actual = i18n.t('key', ['Ada'], { team: 'WXT' }); + + expect(actual).toBe('Hello Ada from WXT.'); + expect(getMessageMock).toBeCalledWith('key', ['Ada']); + }); + + it('should replace named substitutions after pluralization', () => { + const i18n = createI18n(); + getMessageMock.mockReturnValue('One {item} | Multiple {item}'); + + const actual = i18n.t('key', 2, { item: 'files' }); + + expect(actual).toBe('Multiple files'); + expect(getMessageMock).toBeCalledWith('key', ['2']); + }); }); diff --git a/packages/i18n/src/__tests__/types.test.ts b/packages/i18n/src/__tests__/types.test.ts index c3695590..6a4101b0 100644 --- a/packages/i18n/src/__tests__/types.test.ts +++ b/packages/i18n/src/__tests__/types.test.ts @@ -39,9 +39,29 @@ describe('I18n Types', () => { simple: { plural: false; substitutions: 0 }; simpleSub1: { plural: false; substitutions: 1 }; simpleSub2: { plural: false; substitutions: 2 }; + named: { + plural: false; + substitutions: 0; + namedSubstitutions: ['name', 'tool']; + }; + mixed: { + plural: false; + substitutions: 1; + namedSubstitutions: ['team']; + }; plural: { plural: true; substitutions: 0 }; pluralSub1: { plural: true; substitutions: 1 }; pluralSub2: { plural: true; substitutions: 2 }; + pluralNamed: { + plural: true; + substitutions: 1; + namedSubstitutions: ['item']; + }; + pluralMixed: { + plural: true; + substitutions: 2; + namedSubstitutions: ['item']; + }; }; const i18n = createI18n(); @@ -76,6 +96,22 @@ describe('I18n Types', () => { // @ts-expect-error i18n.t('simpleSub2', n); + i18n.t('named', { name: 'Ada', tool: 'WXT' }); + // @ts-expect-error + i18n.t('named'); + // @ts-expect-error + i18n.t('named', { name: 'Ada' }); + // @ts-expect-error + i18n.t('named', ['Ada', 'WXT']); + + i18n.t('mixed', ['Ada'], { team: 'WXT' }); + // @ts-expect-error + i18n.t('mixed', { team: 'WXT' }); + // @ts-expect-error + i18n.t('mixed', ['Ada']); + // @ts-expect-error + i18n.t('mixed', ['Ada'], {}); + i18n.t('plural', n); // @ts-expect-error i18n.t('plural'); @@ -109,6 +145,25 @@ describe('I18n Types', () => { i18n.t('pluralSub2', n, ['one', 'two', 'three']); // @ts-expect-error i18n.t('pluralSub2', n); + + i18n.t('pluralNamed', n, { item: 'files' }); + i18n.t('pluralNamed', n, ['files'], { item: 'files' }); + // @ts-expect-error + i18n.t('pluralNamed', n); + // @ts-expect-error + i18n.t('pluralNamed', n, {}); + // @ts-expect-error + i18n.t('pluralNamed', n, ['files']); + + i18n.t('pluralMixed', n, ['1', 'files'], { item: 'reports' }); + // @ts-expect-error + i18n.t('pluralMixed', n, { item: 'reports' }); + // @ts-expect-error + i18n.t('pluralMixed', n, ['1', 'files']); + // @ts-expect-error + i18n.t('pluralMixed', n, ['1'], { item: 'reports' }); + // @ts-expect-error + i18n.t('pluralMixed', n, ['1', 'files'], {}); }); }); }); diff --git a/packages/i18n/src/__tests__/utils.test.ts b/packages/i18n/src/__tests__/utils.test.ts index 141d9a2f..cddc9cf1 100644 --- a/packages/i18n/src/__tests__/utils.test.ts +++ b/packages/i18n/src/__tests__/utils.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from 'vitest'; import { ChromeMessage } from '../build'; import { + applyNamedSubstitutions, applyChromeMessagePlaceholders, + getNamedSubstitutionNames, getSubstitutionCount, standardizeLocale, } from '../utils'; @@ -42,6 +44,34 @@ describe('Utils', () => { }); }); + describe('applyNamedSubstitutions', () => { + it('should replace named substitutions', () => { + expect( + applyNamedSubstitutions('Hello {name}, welcome to {tool}', { + name: 'Ada', + tool: 'WXT', + }), + ).toBe('Hello Ada, welcome to WXT'); + }); + + it('should leave missing substitutions unchanged', () => { + expect(applyNamedSubstitutions('Hello {name}', {})).toBe('Hello {name}'); + }); + }); + + describe('getNamedSubstitutionNames', () => { + it('should return unique substitution names in order', () => { + expect(getNamedSubstitutionNames('{name} uses {tool}: {name}')).toEqual([ + 'name', + 'tool', + ]); + }); + + it('should return an empty array when no named substitutions are present', () => { + expect(getNamedSubstitutionNames('Hello $1')).toEqual([]); + }); + }); + describe('getSubstitutionCount', () => { it('should return the last substitution present in the message', () => { expect(getSubstitutionCount('I like $1, but I like $2 better')).toBe(2); diff --git a/packages/i18n/src/build.ts b/packages/i18n/src/build.ts index 4213a405..15920a36 100644 --- a/packages/i18n/src/build.ts +++ b/packages/i18n/src/build.ts @@ -8,7 +8,11 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { parseYAML, parseJSON5, parseTOML } from 'confbox'; import { dirname, extname } from 'node:path'; -import { applyChromeMessagePlaceholders, getSubstitutionCount } from './utils'; +import { + applyChromeMessagePlaceholders, + getNamedSubstitutionNames, + getSubstitutionCount, +} from './utils'; export { SUPPORTED_LOCALES } from './supported-locales'; @@ -38,6 +42,7 @@ export type MessagesObject = Record; export interface ParsedBaseMessage { key: string[]; substitutions: number; + namedSubstitutions: string[]; } export interface ParsedChromeMessage extends ParsedBaseMessage, ChromeMessage { @@ -167,11 +172,13 @@ function _parseMessagesObject( case 'symbol': { const message = String(object); const substitutions = getSubstitutionCount(message); + const namedSubstitutions = getNamedSubstitutionNames(message); return [ { type: 'simple', key: path, substitutions, + namedSubstitutions, message, }, ]; @@ -191,11 +198,13 @@ function _parseMessagesObject( if (isPluralMessage(object)) { const message = Object.values(object).join('|'); const substitutions = getSubstitutionCount(message); + const namedSubstitutions = getNamedSubstitutionNames(message); return [ { type: 'plural', key: path, substitutions, + namedSubstitutions, plurals: object, }, ]; @@ -204,11 +213,13 @@ function _parseMessagesObject( if (depth === 1 && isChromeMessage(object)) { const message = applyChromeMessagePlaceholders(object); const substitutions = getSubstitutionCount(message); + const namedSubstitutions = getNamedSubstitutionNames(message); return [ { type: 'chrome', key: path, substitutions, + namedSubstitutions, ...object, }, ]; @@ -247,6 +258,11 @@ export function generateTypeText(messages: ParsedMessage[]): string { `substitutions: ${message.substitutions}`, `plural: ${message.type === 'plural'}`, ]; + if (message.namedSubstitutions.length > 0) { + features.push( + `namedSubstitutions: ${JSON.stringify(message.namedSubstitutions)}`, + ); + } return ` "${key}": { ${features.join(', ')} };`; }; diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 6ca53566..8d80cc76 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1,6 +1,13 @@ /** @module @wxt-dev/i18n */ -import { I18nStructure, I18n, Substitution, UntypedI18n } from './types'; +import type { + I18nStructure, + I18n, + NamedSubstitutions, + Substitution, + UntypedI18n, +} from './types'; import { browser } from '@wxt-dev/browser'; +import { applyNamedSubstitutions } from './utils'; export function createI18n(): UntypedI18n; export function createI18n(): I18n; @@ -8,6 +15,7 @@ export function createI18n(): UntypedI18n { const t: UntypedI18n['t'] = (key: string, ...args: unknown[]) => { // Resolve args let sub: Substitution[] | undefined; + let namedSub: NamedSubstitutions | undefined; let count: number | undefined; args.forEach((arg, i) => { if (arg == null) { @@ -16,9 +24,11 @@ export function createI18n(): UntypedI18n { count = arg; } else if (Array.isArray(arg)) { sub = arg; + } else if (isNamedSubstitutions(arg)) { + namedSub = arg; } else { throw Error( - `Unknown argument at index ${i}. Must be a number for pluralization, substitution array, or options object.`, + `Unknown argument at index ${i}. Must be a number for pluralization, substitution array, or named substitution object.`, ); } }); @@ -40,24 +50,36 @@ export function createI18n(): UntypedI18n { if (!message) { console.warn(`[i18n] Message not found: "${key}"`); } - if (count == null) return message; - // Apply pluralization - const plural = message.split(' | '); - switch (plural.length) { - // "n items" - case 1: - return plural[0]; - // "1 item | n items" - case 2: - return plural[count === 1 ? 0 : 1]; - // "0 items | 1 item | n items" - case 3: - return plural[count === 0 || count === 1 ? count : 2]; - default: - throw Error('Unknown plural formatting'); + if (count != null) { + // Apply pluralization + const plural = message.split(' | '); + switch (plural.length) { + // "n items" + case 1: + message = plural[0]; + break; + // "1 item | n items" + case 2: + message = plural[count === 1 ? 0 : 1]; + break; + // "0 items | 1 item | n items" + case 3: + message = plural[count === 0 || count === 1 ? count : 2]; + break; + default: + throw Error('Unknown plural formatting'); + } } + + return namedSub == null + ? message + : applyNamedSubstitutions(message, namedSub); }; return { t }; } + +function isNamedSubstitutions(value: unknown): value is NamedSubstitutions { + return typeof value === 'object' && value != null; +} diff --git a/packages/i18n/src/types.ts b/packages/i18n/src/types.ts index fcdbe142..aec993cc 100644 --- a/packages/i18n/src/types.ts +++ b/packages/i18n/src/types.ts @@ -1,6 +1,7 @@ export interface I18nFeatures { plural: boolean; substitutions: SubstitutionCount; + namedSubstitutions?: readonly string[]; } export interface UntypedI18n { @@ -10,8 +11,21 @@ export interface UntypedI18n { export type UntypedTFunction = { (key: string): string; (key: string, substitutions: Substitution[]): string; + (key: string, substitutions: NamedSubstitutions): string; + ( + key: string, + substitutions: Substitution[], + namedSubstitutions: NamedSubstitutions, + ): string; (key: string, n: number): string; (key: string, n: number, substitutions: Substitution[]): string; + (key: string, n: number, substitutions: NamedSubstitutions): string; + ( + key: string, + n: number, + substitutions: Substitution[], + namedSubstitutions: NamedSubstitutions, + ): string; }; export type I18nStructure = Record; @@ -20,6 +34,12 @@ export interface I18n { t: TFunction; } +type HasNamedSubstitutions = T extends { + namedSubstitutions: readonly string[]; +} + ? true + : false; + // prettier-ignore export type SubstitutionTuple = T extends 1 ? [$1: Substitution] @@ -37,22 +57,43 @@ export type TFunction = { // Non-plural, no substitutions ( // prettier-ignore - key: K & { [P in keyof T]: T[P] extends { plural: false; substitutions: 0 } ? P : never; }[keyof T], + key: K & { [P in keyof T]: T[P] extends { plural: false; substitutions: 0 } ? HasNamedSubstitutions extends true ? never : P : never; }[keyof T], ): string; // Non-plural with substitutions ( // prettier-ignore - key: K & { [P in keyof T]: T[P] extends { plural: false; substitutions: SubstitutionCount } ? P : never; }[keyof T], + key: K & { [P in keyof T]: T[P] extends { plural: false; substitutions: SubstitutionCount } ? HasNamedSubstitutions extends true ? never : P : never; }[keyof T], substitutions: T[K] extends I18nFeatures ? SubstitutionTuple : never, ): string; + // Non-plural with named substitutions + ( + // prettier-ignore + key: K & { [P in keyof T]: T[P] extends { plural: false; substitutions: 0; namedSubstitutions: readonly string[] } ? P : never; }[keyof T], + substitutions: T[K] extends I18nFeatures + ? NamedSubstitutionsFor + : never, + ): string; + + // Non-plural with positional and named substitutions + ( + // prettier-ignore + key: K & { [P in keyof T]: T[P] extends { plural: false; substitutions: PositiveSubstitutionCount; namedSubstitutions: readonly string[] } ? P : never; }[keyof T], + substitutions: T[K] extends I18nFeatures + ? SubstitutionTuple + : never, + namedSubstitutions: T[K] extends I18nFeatures + ? NamedSubstitutionsFor + : never, + ): string; + // Plural with 1 substitution ( // prettier-ignore - key: K & { [P in keyof T]: T[P] extends { plural: true; substitutions: 1 } ? P : never; }[keyof T], + key: K & { [P in keyof T]: T[P] extends { plural: true; substitutions: 1 } ? HasNamedSubstitutions extends true ? never : P : never; }[keyof T], n: number, substitutions?: SubstitutionTuple<1>, ): string; @@ -60,21 +101,52 @@ export type TFunction = { // Plural without substitutions ( // prettier-ignore - key: K & { [P in keyof T]: T[P] extends { plural: true; substitutions: 0 | 1 } ? P : never; }[keyof T], + key: K & { [P in keyof T]: T[P] extends { plural: true; substitutions: 0 | 1 } ? HasNamedSubstitutions extends true ? never : P : never; }[keyof T], n: number, ): string; // Plural with substitutions ( // prettier-ignore - key: K & { [P in keyof T]: T[P] extends { plural: true; substitutions: SubstitutionCount } ? P : never; }[keyof T], + key: K & { [P in keyof T]: T[P] extends { plural: true; substitutions: SubstitutionCount } ? HasNamedSubstitutions extends true ? never : P : never; }[keyof T], n: number, substitutions: T[K] extends I18nFeatures ? SubstitutionTuple : never, ): string; + + // Plural with named substitutions + ( + // prettier-ignore + key: K & { [P in keyof T]: T[P] extends { plural: true; substitutions: 0 | 1; namedSubstitutions: readonly string[] } ? P : never; }[keyof T], + n: number, + substitutions: T[K] extends I18nFeatures + ? NamedSubstitutionsFor + : never, + ): string; + + // Plural with positional and named substitutions + ( + // prettier-ignore + key: K & { [P in keyof T]: T[P] extends { plural: true; substitutions: PositiveSubstitutionCount; namedSubstitutions: readonly string[] } ? P : never; }[keyof T], + n: number, + substitutions: T[K] extends I18nFeatures + ? SubstitutionTuple + : never, + namedSubstitutions: T[K] extends I18nFeatures + ? NamedSubstitutionsFor + : never, + ): string; }; export type Substitution = string | number; +export type NamedSubstitutions = Record; type SubstitutionCount = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; +type PositiveSubstitutionCount = Exclude; + +type NamedSubstitutionsFor = T extends { + namedSubstitutions: readonly string[]; +} + ? { [K in T['namedSubstitutions'][number]]: Substitution } + : never; diff --git a/packages/i18n/src/utils.ts b/packages/i18n/src/utils.ts index 6856c79a..1c1f8a4d 100644 --- a/packages/i18n/src/utils.ts +++ b/packages/i18n/src/utils.ts @@ -1,4 +1,7 @@ -import { ChromeMessage } from './build'; +import type { ChromeMessage } from './build'; +import type { NamedSubstitutions } from './types'; + +const NAMED_SUBSTITUTION_RE = /\{([A-Za-z0-9_]+)\}/g; export function applyChromeMessagePlaceholders(message: ChromeMessage): string { if (message.placeholders == null) return message.message; @@ -11,6 +14,24 @@ export function applyChromeMessagePlaceholders(message: ChromeMessage): string { ); } +export function applyNamedSubstitutions( + message: string, + substitutions: NamedSubstitutions, +): string { + return message.replace(NAMED_SUBSTITUTION_RE, (match, key: string) => { + return Object.prototype.hasOwnProperty.call(substitutions, key) + ? String(substitutions[key]) + : match; + }); +} + +export function getNamedSubstitutionNames(message: string): string[] { + return Array.from( + message.matchAll(NAMED_SUBSTITUTION_RE), + ([, name]) => name, + ).filter((name, i, names) => names.indexOf(name) === i); +} + export function getSubstitutionCount(message: string): number { return ( 1 +