feat: Add named substitutions to i18n (#2440)

This commit is contained in:
T
2026-06-28 16:13:11 -03:00
committed by GitHub
parent d7f8ebec39
commit 081d472176
11 changed files with 334 additions and 24 deletions
+17
View File
@@ -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]
@@ -5,6 +5,9 @@
"substitution": {
"message": "Includes one substitution: $1"
},
"namedSubstitution": {
"message": "Includes one named substitution: {value}"
},
"singular": {
"message": "There is $1 item"
},
+9
View File
@@ -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';
+22
View File
@@ -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 };
+43
View File
@@ -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']);
});
});
+55
View File
@@ -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<MyStructure>();
@@ -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'], {});
});
});
});
+30
View File
@@ -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);
+17 -1
View File
@@ -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<string, Message>;
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(', ')} };`;
};
+39 -17
View File
@@ -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<T extends I18nStructure>(): I18n<T>;
@@ -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;
}
+77 -5
View File
@@ -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<string, I18nFeatures>;
@@ -20,6 +34,12 @@ export interface I18n<T extends I18nStructure> {
t: TFunction<T>;
}
type HasNamedSubstitutions<T extends I18nFeatures> = T extends {
namedSubstitutions: readonly string[];
}
? true
: false;
// prettier-ignore
export type SubstitutionTuple<T extends SubstitutionCount> =
T extends 1 ? [$1: Substitution]
@@ -37,22 +57,43 @@ export type TFunction<T extends I18nStructure> = {
// Non-plural, no substitutions
<K extends keyof T>(
// 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<T[P]> extends true ? never : P : never; }[keyof T],
): string;
// Non-plural with substitutions
<K extends keyof T>(
// 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<T[P]> extends true ? never : P : never; }[keyof T],
substitutions: T[K] extends I18nFeatures
? SubstitutionTuple<T[K]['substitutions']>
: never,
): string;
// Non-plural with named substitutions
<K extends keyof T>(
// 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<T[K]>
: never,
): string;
// Non-plural with positional and named substitutions
<K extends keyof T>(
// 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<T[K]['substitutions']>
: never,
namedSubstitutions: T[K] extends I18nFeatures
? NamedSubstitutionsFor<T[K]>
: never,
): string;
// Plural with 1 substitution
<K extends keyof T>(
// 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<T[P]> extends true ? never : P : never; }[keyof T],
n: number,
substitutions?: SubstitutionTuple<1>,
): string;
@@ -60,21 +101,52 @@ export type TFunction<T extends I18nStructure> = {
// Plural without substitutions
<K extends keyof T>(
// 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<T[P]> extends true ? never : P : never; }[keyof T],
n: number,
): string;
// Plural with substitutions
<K extends keyof T>(
// 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<T[P]> extends true ? never : P : never; }[keyof T],
n: number,
substitutions: T[K] extends I18nFeatures
? SubstitutionTuple<T[K]['substitutions']>
: never,
): string;
// Plural with named substitutions
<K extends keyof T>(
// 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<T[K]>
: never,
): string;
// Plural with positional and named substitutions
<K extends keyof T>(
// 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<T[K]['substitutions']>
: never,
namedSubstitutions: T[K] extends I18nFeatures
? NamedSubstitutionsFor<T[K]>
: never,
): string;
};
export type Substitution = string | number;
export type NamedSubstitutions = Record<string, Substitution>;
type SubstitutionCount = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type PositiveSubstitutionCount = Exclude<SubstitutionCount, 0>;
type NamedSubstitutionsFor<T extends I18nFeatures> = T extends {
namedSubstitutions: readonly string[];
}
? { [K in T['namedSubstitutions'][number]]: Substitution }
: never;
+22 -1
View File
@@ -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 +