diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index 0a79a58..a38a9a1 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -91,6 +91,11 @@ const spanish = await resource.getTranslation('es'); Translations preserve each message's `format`, so an MF1 or `NONE` message stays MF1 or `NONE` after translation unless the translation overrides it. +Call `getTranslation` with the project's `pseudoLocale` (for example `'en-XA'`) to +get a pseudo-localized resource for UI testing. Literal text is transformed while +MF1/MF2 placeholders and syntax are preserved according to each message's +`format`. + ## Next steps - Read [`README.md`](./README.md) for the complete API, serialization rules, diff --git a/README.md b/README.md index 9ee835d..dc77cf6 100644 --- a/README.md +++ b/README.md @@ -215,16 +215,24 @@ So for `getTranslation('zh-HK')` with chain `['zh', 'zh-Hant', 'zh-HK']`, you ge ### Pseudo Localization -When `getTranslation` is called with the project's `pseudoLocale` (e.g. `en-XA`), it returns a new resource with pseudolocalized message values—useful for testing UI layout and finding hardcoded strings without loading translation files: +When `getTranslation` is called with the project's `pseudoLocale` (e.g. `en-XA`), it returns a new resource with pseudolocalized message values—useful for testing UI layout and finding hardcoded strings without loading translation files. Each message is handled according to its resolved `format`: + +- **MF2** — only literal text parts are transformed; expressions like `{$name}` stay intact. +- **MF1** — only human-readable content is transformed; ICU placeholders, plural/select structure, `#`, and formatter styles (e.g. `::currency/EUR`) stay intact. +- **NONE** — the whole string is transformed. ```javascript // Request pseudolocalized messages (project locales.pseudoLocale is 'en-XA') const pseudoResource = await resource.getTranslation('en-XA'); -// Message values are transformed: "Hi, {name}" → "Ħī, {name}" -// Variables and message-format syntax are preserved; only literal text is pseudolocalized +// MF1 example: literals are accented; syntax is preserved +// "{count, plural, one {# file} other {# files}}" +// → "{count, plural, one {# ƒīŀḗ} other {# ƒīŀḗş}}" +const files = pseudoResource.get('files')?.format({ count: 2 }); + +// MF2 example: "Hello, {$name}!" → "Ħḗŀŀǿ, {$name}!" const greeting = pseudoResource.get('sampleKey2')?.format({ name: 'Alice' }); -// Result: "Ħī, Alice" +// Result includes accented "Hello" with Alice substituted for {$name} ``` ### Working with Attributes and Notes @@ -293,7 +301,7 @@ const data = resource.getData(); - `add(key: string, value: string, attributes?: MsgAttributes, notes?: MsgNote[]): MsgResource` - Add a message - `addNote(type: NoteTypes, content: string): MsgResource` - Add a note (returns `this` for chaining) - `translate(data: MsgResourceData): MsgResource` - Create a translated version -- `getTranslation(lang: string): Promise` - Load and apply translations. When `lang` matches the project's `pseudoLocale`, returns a resource with pseudolocalized message values instead of loading from the loader. +- `getTranslation(lang?: string): Promise` - Load and apply translations. When `lang` matches the project's `pseudoLocale`, returns a resource with message values pseudo-localized according to each message's resolved `format` (`MF1` / `MF2` / `NONE`) instead of loading from the loader. When `lang` is omitted, returns a clone of the current resource. - `getProject(): MsgProject` - Returns the project instance associated with the resource - `getData(stripNotes?: boolean): MsgResourceData` - Get resource data. Message objects in the output omit `attributes` when they match the resource's attributes (to avoid redundancy). The resource's `format` is omitted when it equals the project's, and a message's `format` is omitted when it equals the resource's - `toJSON(stripNotes?: boolean): string` - Serialize to JSON @@ -326,6 +334,14 @@ const data = resource.getData(); - `MsgFormat` - `'MF1' | 'MF2' | 'NONE'`; the formatting syntax for a message. - `MsgAttributes` - `{ lang?: string; dir?: string; dnt?: boolean; format?: MsgFormat }`. +### Pseudo-localization helpers + +Exported from `@worldware/msg` for advanced use (also used internally by `MsgResource.getTranslation`): + +- `pseudoLocalize(source, format, options?)` - Dispatch by `MsgFormat` +- `pseudoLocalizeMF1(source, options?)` / `pseudoLocalizeMF2(source, options?)` / `pseudoLocalizeNone(source, options?)` - Format-specific helpers +- `PseudoLocalizeOptions` - `{ strategy?: 'accented' | 'bidi' }` + ## Development ```bash diff --git a/package-lock.json b/package-lock.json index e1b608d..80c04b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@messageformat/icu-messageformat-1": "^0.12.0", + "@messageformat/parser": "^5.1.1", "messageformat": "^4.0.0", "pseudo-localization": "^2.4.0" }, @@ -4809,6 +4810,7 @@ }, "node_modules/npm/node_modules/@gar/promise-retry": { "version": "1.0.3", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -4817,6 +4819,7 @@ }, "node_modules/npm/node_modules/@isaacs/fs-minipass": { "version": "4.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -4828,11 +4831,13 @@ }, "node_modules/npm/node_modules/@isaacs/string-locale-compare": { "version": "1.1.0", + "dev": true, "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/agent": { "version": "4.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -4848,6 +4853,7 @@ }, "node_modules/npm/node_modules/@npmcli/arborist": { "version": "9.9.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -4895,6 +4901,7 @@ }, "node_modules/npm/node_modules/@npmcli/config": { "version": "10.12.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -4913,6 +4920,7 @@ }, "node_modules/npm/node_modules/@npmcli/fs": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -4924,6 +4932,7 @@ }, "node_modules/npm/node_modules/@npmcli/git": { "version": "7.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -4942,6 +4951,7 @@ }, "node_modules/npm/node_modules/@npmcli/installed-package-contents": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -4957,6 +4967,7 @@ }, "node_modules/npm/node_modules/@npmcli/map-workspaces": { "version": "5.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -4971,6 +4982,7 @@ }, "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { "version": "9.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -4986,6 +4998,7 @@ }, "node_modules/npm/node_modules/@npmcli/name-from-folder": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -4994,6 +5007,7 @@ }, "node_modules/npm/node_modules/@npmcli/node-gyp": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -5002,6 +5016,7 @@ }, "node_modules/npm/node_modules/@npmcli/package-json": { "version": "7.0.5", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5019,6 +5034,7 @@ }, "node_modules/npm/node_modules/@npmcli/promise-spawn": { "version": "9.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5030,6 +5046,7 @@ }, "node_modules/npm/node_modules/@npmcli/query": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5041,6 +5058,7 @@ }, "node_modules/npm/node_modules/@npmcli/redact": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -5049,6 +5067,7 @@ }, "node_modules/npm/node_modules/@npmcli/run-script": { "version": "10.0.4", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5064,6 +5083,7 @@ }, "node_modules/npm/node_modules/@sigstore/bundle": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -5075,6 +5095,7 @@ }, "node_modules/npm/node_modules/@sigstore/core": { "version": "3.2.1", + "dev": true, "inBundle": true, "license": "Apache-2.0", "engines": { @@ -5083,6 +5104,7 @@ }, "node_modules/npm/node_modules/@sigstore/protobuf-specs": { "version": "0.5.1", + "dev": true, "inBundle": true, "license": "Apache-2.0", "engines": { @@ -5091,6 +5113,7 @@ }, "node_modules/npm/node_modules/@sigstore/sign": { "version": "4.1.1", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -5107,6 +5130,7 @@ }, "node_modules/npm/node_modules/@sigstore/tuf": { "version": "4.0.2", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -5119,6 +5143,7 @@ }, "node_modules/npm/node_modules/@sigstore/verify": { "version": "3.1.1", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -5132,6 +5157,7 @@ }, "node_modules/npm/node_modules/@tufjs/canonical-json": { "version": "2.0.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -5140,6 +5166,7 @@ }, "node_modules/npm/node_modules/@tufjs/models": { "version": "4.1.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -5152,6 +5179,7 @@ }, "node_modules/npm/node_modules/abbrev": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -5160,6 +5188,7 @@ }, "node_modules/npm/node_modules/agent-base": { "version": "7.1.4", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -5168,16 +5197,19 @@ }, "node_modules/npm/node_modules/aproba": { "version": "2.1.0", + "dev": true, "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/archy": { "version": "1.0.0", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/balanced-match": { "version": "4.0.4", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -5186,6 +5218,7 @@ }, "node_modules/npm/node_modules/bin-links": { "version": "6.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5201,6 +5234,7 @@ }, "node_modules/npm/node_modules/binary-extensions": { "version": "3.1.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -5212,6 +5246,7 @@ }, "node_modules/npm/node_modules/brace-expansion": { "version": "5.0.7", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -5223,6 +5258,7 @@ }, "node_modules/npm/node_modules/cacache": { "version": "20.0.4", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5243,6 +5279,7 @@ }, "node_modules/npm/node_modules/chalk": { "version": "5.6.2", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -5254,6 +5291,7 @@ }, "node_modules/npm/node_modules/chownr": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "engines": { @@ -5262,6 +5300,7 @@ }, "node_modules/npm/node_modules/ci-info": { "version": "4.4.0", + "dev": true, "funding": [ { "type": "github", @@ -5276,6 +5315,7 @@ }, "node_modules/npm/node_modules/cidr-regex": { "version": "5.0.5", + "dev": true, "inBundle": true, "license": "BSD-2-Clause", "engines": { @@ -5284,6 +5324,7 @@ }, "node_modules/npm/node_modules/cmd-shim": { "version": "8.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -5292,6 +5333,7 @@ }, "node_modules/npm/node_modules/common-ancestor-path": { "version": "2.0.0", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "engines": { @@ -5300,6 +5342,7 @@ }, "node_modules/npm/node_modules/cssesc": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "MIT", "bin": { @@ -5311,6 +5354,7 @@ }, "node_modules/npm/node_modules/debug": { "version": "4.4.3", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -5327,6 +5371,7 @@ }, "node_modules/npm/node_modules/diff": { "version": "8.0.4", + "dev": true, "inBundle": true, "license": "BSD-3-Clause", "engines": { @@ -5335,6 +5380,7 @@ }, "node_modules/npm/node_modules/env-paths": { "version": "2.2.1", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -5343,11 +5389,13 @@ }, "node_modules/npm/node_modules/exponential-backoff": { "version": "3.1.3", + "dev": true, "inBundle": true, "license": "Apache-2.0" }, "node_modules/npm/node_modules/fastest-levenshtein": { "version": "1.0.16", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -5356,6 +5404,7 @@ }, "node_modules/npm/node_modules/fs-minipass": { "version": "3.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5367,6 +5416,7 @@ }, "node_modules/npm/node_modules/glob": { "version": "13.0.6", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -5383,11 +5433,13 @@ }, "node_modules/npm/node_modules/graceful-fs": { "version": "4.2.11", + "dev": true, "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { "version": "9.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5399,11 +5451,13 @@ }, "node_modules/npm/node_modules/http-cache-semantics": { "version": "4.2.0", + "dev": true, "inBundle": true, "license": "BSD-2-Clause" }, "node_modules/npm/node_modules/http-proxy-agent": { "version": "7.0.2", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -5416,6 +5470,7 @@ }, "node_modules/npm/node_modules/https-proxy-agent": { "version": "7.0.6", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -5428,6 +5483,7 @@ }, "node_modules/npm/node_modules/iconv-lite": { "version": "0.7.2", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -5444,6 +5500,7 @@ }, "node_modules/npm/node_modules/ignore-walk": { "version": "8.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5455,6 +5512,7 @@ }, "node_modules/npm/node_modules/ini": { "version": "6.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -5463,6 +5521,7 @@ }, "node_modules/npm/node_modules/init-package-json": { "version": "8.2.5", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5479,6 +5538,7 @@ }, "node_modules/npm/node_modules/ip-address": { "version": "10.2.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -5487,6 +5547,7 @@ }, "node_modules/npm/node_modules/is-cidr": { "version": "6.0.4", + "dev": true, "inBundle": true, "license": "BSD-2-Clause", "dependencies": { @@ -5498,6 +5559,7 @@ }, "node_modules/npm/node_modules/isexe": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "engines": { @@ -5506,6 +5568,7 @@ }, "node_modules/npm/node_modules/json-parse-even-better-errors": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -5514,6 +5577,7 @@ }, "node_modules/npm/node_modules/json-stringify-nice": { "version": "1.1.4", + "dev": true, "inBundle": true, "license": "ISC", "funding": { @@ -5522,6 +5586,7 @@ }, "node_modules/npm/node_modules/jsonparse": { "version": "1.3.1", + "dev": true, "engines": [ "node >= 0.2.0" ], @@ -5530,16 +5595,19 @@ }, "node_modules/npm/node_modules/just-diff": { "version": "6.0.2", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/just-diff-apply": { "version": "5.5.0", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/libnpmaccess": { "version": "10.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5552,6 +5620,7 @@ }, "node_modules/npm/node_modules/libnpmdiff": { "version": "8.1.11", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5570,6 +5639,7 @@ }, "node_modules/npm/node_modules/libnpmexec": { "version": "10.3.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5592,6 +5662,7 @@ }, "node_modules/npm/node_modules/libnpmfund": { "version": "7.0.25", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5603,6 +5674,7 @@ }, "node_modules/npm/node_modules/libnpmorg": { "version": "8.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5615,6 +5687,7 @@ }, "node_modules/npm/node_modules/libnpmpack": { "version": "9.1.11", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5629,6 +5702,7 @@ }, "node_modules/npm/node_modules/libnpmpublish": { "version": "11.2.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5647,6 +5721,7 @@ }, "node_modules/npm/node_modules/libnpmsearch": { "version": "9.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5658,6 +5733,7 @@ }, "node_modules/npm/node_modules/libnpmteam": { "version": "8.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5670,6 +5746,7 @@ }, "node_modules/npm/node_modules/libnpmversion": { "version": "8.0.4", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5685,6 +5762,7 @@ }, "node_modules/npm/node_modules/lru-cache": { "version": "11.5.1", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "engines": { @@ -5693,6 +5771,7 @@ }, "node_modules/npm/node_modules/make-fetch-happen": { "version": "15.0.6", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5715,6 +5794,7 @@ }, "node_modules/npm/node_modules/minimatch": { "version": "10.2.5", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -5729,6 +5809,7 @@ }, "node_modules/npm/node_modules/minipass": { "version": "7.1.3", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "engines": { @@ -5737,6 +5818,7 @@ }, "node_modules/npm/node_modules/minipass-collect": { "version": "2.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5748,6 +5830,7 @@ }, "node_modules/npm/node_modules/minipass-fetch": { "version": "5.0.2", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -5764,6 +5847,7 @@ }, "node_modules/npm/node_modules/minipass-flush": { "version": "1.0.6", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -5775,6 +5859,7 @@ }, "node_modules/npm/node_modules/minipass-pipeline": { "version": "1.2.4", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5786,6 +5871,7 @@ }, "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { "version": "3.3.6", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5797,11 +5883,13 @@ }, "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/minipass-sized": { "version": "2.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5813,6 +5901,7 @@ }, "node_modules/npm/node_modules/minizlib": { "version": "3.1.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -5824,11 +5913,13 @@ }, "node_modules/npm/node_modules/ms": { "version": "2.1.3", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/mute-stream": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -5837,6 +5928,7 @@ }, "node_modules/npm/node_modules/negotiator": { "version": "1.0.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -5845,6 +5937,7 @@ }, "node_modules/npm/node_modules/node-gyp": { "version": "12.4.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -5868,6 +5961,7 @@ }, "node_modules/npm/node_modules/nopt": { "version": "9.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5882,6 +5976,7 @@ }, "node_modules/npm/node_modules/npm-audit-report": { "version": "7.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -5890,6 +5985,7 @@ }, "node_modules/npm/node_modules/npm-bundled": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5901,6 +5997,7 @@ }, "node_modules/npm/node_modules/npm-install-checks": { "version": "8.0.0", + "dev": true, "inBundle": true, "license": "BSD-2-Clause", "dependencies": { @@ -5912,6 +6009,7 @@ }, "node_modules/npm/node_modules/npm-normalize-package-bin": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -5920,6 +6018,7 @@ }, "node_modules/npm/node_modules/npm-package-arg": { "version": "13.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5934,6 +6033,7 @@ }, "node_modules/npm/node_modules/npm-packlist": { "version": "10.0.4", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5946,6 +6046,7 @@ }, "node_modules/npm/node_modules/npm-pick-manifest": { "version": "11.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5960,6 +6061,7 @@ }, "node_modules/npm/node_modules/npm-profile": { "version": "12.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5972,6 +6074,7 @@ }, "node_modules/npm/node_modules/npm-registry-fetch": { "version": "19.1.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -5990,6 +6093,7 @@ }, "node_modules/npm/node_modules/npm-user-validate": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "BSD-2-Clause", "engines": { @@ -5998,6 +6102,7 @@ }, "node_modules/npm/node_modules/p-map": { "version": "7.0.4", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -6009,6 +6114,7 @@ }, "node_modules/npm/node_modules/pacote": { "version": "21.5.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -6039,6 +6145,7 @@ }, "node_modules/npm/node_modules/parse-conflict-json": { "version": "5.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -6052,6 +6159,7 @@ }, "node_modules/npm/node_modules/path-scurry": { "version": "2.0.2", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -6067,6 +6175,7 @@ }, "node_modules/npm/node_modules/postcss-selector-parser": { "version": "7.1.4", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -6079,6 +6188,7 @@ }, "node_modules/npm/node_modules/proc-log": { "version": "6.1.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -6087,6 +6197,7 @@ }, "node_modules/npm/node_modules/proggy": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -6095,6 +6206,7 @@ }, "node_modules/npm/node_modules/promise-all-reject-late": { "version": "1.0.1", + "dev": true, "inBundle": true, "license": "ISC", "funding": { @@ -6103,6 +6215,7 @@ }, "node_modules/npm/node_modules/promise-call-limit": { "version": "3.0.2", + "dev": true, "inBundle": true, "license": "ISC", "funding": { @@ -6111,6 +6224,7 @@ }, "node_modules/npm/node_modules/promzard": { "version": "3.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -6122,6 +6236,7 @@ }, "node_modules/npm/node_modules/qrcode-terminal": { "version": "0.12.0", + "dev": true, "inBundle": true, "bin": { "qrcode-terminal": "bin/qrcode-terminal.js" @@ -6129,6 +6244,7 @@ }, "node_modules/npm/node_modules/read": { "version": "5.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -6140,6 +6256,7 @@ }, "node_modules/npm/node_modules/read-cmd-shim": { "version": "6.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -6148,12 +6265,14 @@ }, "node_modules/npm/node_modules/safer-buffer": { "version": "2.1.2", + "dev": true, "inBundle": true, "license": "MIT", "optional": true }, "node_modules/npm/node_modules/semver": { "version": "7.8.5", + "dev": true, "inBundle": true, "license": "ISC", "bin": { @@ -6165,6 +6284,7 @@ }, "node_modules/npm/node_modules/signal-exit": { "version": "4.1.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -6176,6 +6296,7 @@ }, "node_modules/npm/node_modules/sigstore": { "version": "4.1.1", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -6192,6 +6313,7 @@ }, "node_modules/npm/node_modules/smart-buffer": { "version": "4.2.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -6201,6 +6323,7 @@ }, "node_modules/npm/node_modules/socks": { "version": "2.8.9", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -6214,6 +6337,7 @@ }, "node_modules/npm/node_modules/socks-proxy-agent": { "version": "8.0.5", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -6227,11 +6351,13 @@ }, "node_modules/npm/node_modules/spdx-exceptions": { "version": "2.5.0", + "dev": true, "inBundle": true, "license": "CC-BY-3.0" }, "node_modules/npm/node_modules/spdx-expression-parse": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -6241,11 +6367,13 @@ }, "node_modules/npm/node_modules/spdx-license-ids": { "version": "3.0.23", + "dev": true, "inBundle": true, "license": "CC0-1.0" }, "node_modules/npm/node_modules/ssri": { "version": "13.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -6257,6 +6385,7 @@ }, "node_modules/npm/node_modules/supports-color": { "version": "10.2.2", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -6268,6 +6397,7 @@ }, "node_modules/npm/node_modules/tar": { "version": "7.5.19", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -6283,16 +6413,19 @@ }, "node_modules/npm/node_modules/text-table": { "version": "0.2.0", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/tiny-relative-date": { "version": "2.0.2", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/tinyglobby": { "version": "0.2.17", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -6308,6 +6441,7 @@ }, "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { "version": "6.5.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -6324,6 +6458,7 @@ }, "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.4", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -6335,6 +6470,7 @@ }, "node_modules/npm/node_modules/treeverse": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -6343,6 +6479,7 @@ }, "node_modules/npm/node_modules/tuf-js": { "version": "4.1.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -6356,6 +6493,7 @@ }, "node_modules/npm/node_modules/undici": { "version": "6.27.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -6364,11 +6502,13 @@ }, "node_modules/npm/node_modules/util-deprecate": { "version": "1.0.2", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/validate-npm-package-name": { "version": "7.0.2", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -6377,6 +6517,7 @@ }, "node_modules/npm/node_modules/walk-up-path": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -6385,6 +6526,7 @@ }, "node_modules/npm/node_modules/which": { "version": "6.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -6399,6 +6541,7 @@ }, "node_modules/npm/node_modules/write-file-atomic": { "version": "7.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -6410,6 +6553,7 @@ }, "node_modules/npm/node_modules/yallist": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "engines": { diff --git a/package.json b/package.json index bed4eb6..413e527 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@messageformat/icu-messageformat-1": "^0.12.0", + "@messageformat/parser": "^5.1.1", "messageformat": "^4.0.0", "pseudo-localization": "^2.4.0" }, diff --git a/src/classes/MsgResource/MsgResource.ts b/src/classes/MsgResource/MsgResource.ts index 50c4b46..4f301dc 100644 --- a/src/classes/MsgResource/MsgResource.ts +++ b/src/classes/MsgResource/MsgResource.ts @@ -1,8 +1,7 @@ -import { parseMessage, stringifyMessage, visit } from "messageformat"; -import { localize } from "pseudo-localization"; import { type MsgMessageData, MsgMessage } from "../MsgMessage/MsgMessage.js"; import { DEFAULT_ATTRIBUTES, MSG_DEFAULT_FORMAT, MsgInterface, type MsgAttributes, type MsgNote, type NoteTypes } from "../MsgInterface/MsgInterface.js"; import { MsgProject } from "../MsgProject/MsgProject.js"; +import { pseudoLocalize } from "../../lib/pseudo-localize.js"; /** * Plain data used to create or describe a {@link MsgResource}. @@ -90,29 +89,6 @@ export class MsgResource extends Map implements MsgInterface return attributes.format ?? MSG_DEFAULT_FORMAT; } - /** - * Pseudo-localizes an MF2 message by localizing only literal text parts. - */ - private pseudoLocalizeMF2( - source: string, - options?: { strategy?: "accented" | "bidi" } - ): string { - const msg = parseMessage(source); - - visit(msg, { - pattern: (pattern) => { - for (let i = 0; i < pattern.length; i++) { - const part = pattern[i]; - if (typeof part === "string") { - pattern[i] = localize(part, options); - } - } - }, - }); - - return stringifyMessage(msg); - } - /** Locale and formatting metadata for this resource. */ public get attributes() { return this._attributes; @@ -255,7 +231,11 @@ export class MsgResource extends Map implements MsgInterface * Loads and applies translations for a locale via the project loader. * * When `lang` is the project's pseudo-locale, returns a pseudo-localized - * copy without calling the loader. + * copy without calling the loader. Each message is transformed according to + * its resolved `format` (`MF1`, `MF2`, or `NONE`): only literal text is + * accented; placeholders and message-format syntax are preserved. + * + * When `lang` is omitted, returns a clone of the current resource. * * @throws {Error} When the locale is unsupported or its language chain is empty. */ @@ -269,19 +249,17 @@ export class MsgResource extends Map implements MsgInterface const project = this._project; const pseudoLocale = project.locales.pseudoLocale; if (lang === pseudoLocale) { + const messages = [...this.values()].map((msg) => ({ + key: msg.key, + value: pseudoLocalize(msg.value, this.resolveFormat(msg.attributes)), + attributes: { ...msg.attributes, lang: pseudoLocale } + })); const pseudolocalizedData: MsgResourceData = { title: this.title, attributes: { ...this.attributes, lang: pseudoLocale }, notes: this.notes.length > 0 ? this.notes : undefined, - messages: [] + messages }; - this.forEach(msg => { - pseudolocalizedData.messages!.push({ - key: msg.key, - value: this.pseudoLocalizeMF2(msg.value), - attributes: { ...this.attributes, lang: pseudoLocale } - }); - }); return this.translate(pseudolocalizedData); } diff --git a/src/lib/index.ts b/src/lib/index.ts index afbf9b6..109eaa1 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,4 +1,5 @@ /** - * Barrel export for the lang utils. + * Barrel export for library utilities. */ -export * from './lang-utils.js'; \ No newline at end of file +export * from './lang-utils.js'; +export * from './pseudo-localize.js'; diff --git a/src/lib/pseudo-localize.ts b/src/lib/pseudo-localize.ts new file mode 100644 index 0000000..a37c0bc --- /dev/null +++ b/src/lib/pseudo-localize.ts @@ -0,0 +1,142 @@ +import { parseMessage, stringifyMessage, visit } from "messageformat"; +import { parse, type Token } from "@messageformat/parser"; +import { localize } from "pseudo-localization"; +import type { MsgFormat } from "../classes/MsgInterface/MsgInterface.js"; + +/** + * Options forwarded to `pseudo-localization`'s `localize`. + */ +export type PseudoLocalizeOptions = { + strategy?: "accented" | "bidi"; +}; + +/** A literal span in an MF1 source string eligible for pseudo-localization. */ +type Mf1ContentSpan = { + offset: number; + length: number; + value: string; +}; + +/** + * Pseudo-localizes an MF2 message by localizing only literal text parts. + */ +export function pseudoLocalizeMF2( + source: string, + options?: PseudoLocalizeOptions +): string { + const msg = parseMessage(source); + + visit(msg, { + pattern: (pattern) => { + for (let i = 0; i < pattern.length; i++) { + const part = pattern[i]; + if (typeof part === "string") { + pattern[i] = localize(part, options); + } + } + }, + }); + + return stringifyMessage(msg); +} + +/** + * Collects human-readable `content` tokens from an MF1 AST. + * + * Content inside function `param` (arg styles like `::currency/EUR`) is skipped + * so formatter options stay intact. The `inFunctionParam` flag is preserved when + * descending into nested select/plural tokens under a param. + */ +function collectMf1ContentTokens( + tokens: Token[], + into: Mf1ContentSpan[] = [], + inFunctionParam = false +): Mf1ContentSpan[] { + for (const token of tokens) { + switch (token.type) { + case "content": + if (!inFunctionParam && token.value.length > 0) { + into.push({ + offset: token.ctx.offset, + length: token.value.length, + value: token.value, + }); + } + break; + case "plural": + case "select": + case "selectordinal": + for (const selectCase of token.cases) { + collectMf1ContentTokens(selectCase.tokens, into, inFunctionParam); + } + break; + case "function": + if (token.param) { + collectMf1ContentTokens(token.param, into, true); + } + break; + case "argument": + case "octothorpe": + break; + } + } + return into; +} + +/** + * Pseudo-localizes an MF1 (ICU MessageFormat 1) message by localizing only + * human-readable content tokens, preserving placeholders and syntax. + * + * Parses with `@messageformat/parser`, then rebuilds the source with localized + * content spans so ICU structure round-trips unchanged. + */ +export function pseudoLocalizeMF1( + source: string, + options?: PseudoLocalizeOptions +): string { + const spans = collectMf1ContentTokens(parse(source)); + if (spans.length === 0) { + return source; + } + + spans.sort((a, b) => a.offset - b.offset); + + let result = ""; + let cursor = 0; + for (const span of spans) { + result += source.slice(cursor, span.offset); + result += localize(span.value, options); + cursor = span.offset + span.length; + } + result += source.slice(cursor); + return result; +} + +/** + * Pseudo-localizes an unformatted (`NONE`) string in full. + */ +export function pseudoLocalizeNone( + source: string, + options?: PseudoLocalizeOptions +): string { + return localize(source, options); +} + +/** + * Pseudo-localizes a message value according to its resolved {@link MsgFormat}. + */ +export function pseudoLocalize( + source: string, + format: MsgFormat, + options?: PseudoLocalizeOptions +): string { + switch (format) { + case "MF1": + return pseudoLocalizeMF1(source, options); + case "NONE": + return pseudoLocalizeNone(source, options); + case "MF2": + default: + return pseudoLocalizeMF2(source, options); + } +} diff --git a/src/tests/MsgResource.test.ts b/src/tests/MsgResource.test.ts index 60fbf12..d5aaa1b 100644 --- a/src/tests/MsgResource.test.ts +++ b/src/tests/MsgResource.test.ts @@ -562,6 +562,24 @@ describe('MsgResource tests', () => { expect(translated.get('test-2')?.value).toBe('这是测试 2'); }); + test('MsgResource: "getTranslation" method without lang clones the resource', async () => { + const project = MsgProject.create(testProjectData); + const resource = MsgResource.create({ + title: 'TestResource', + attributes: { lang: 'en', dir: 'ltr' }, + messages: [ + { key: 'test-1', value: 'This is test 1' } + ] + }, project); + + const cloned = await resource.getTranslation(); + + expect(cloned).not.toBe(resource); + expect(cloned.title).toBe('TestResource'); + expect(cloned.attributes.lang).toBe('en'); + expect(cloned.get('test-1')?.value).toBe('This is test 1'); + }); + test('MsgResource: "getTranslation" method with language chain', async () => { const project = MsgProject.create(testProjectData); const resource = MsgResource.create({ @@ -721,6 +739,80 @@ describe('MsgResource tests', () => { expect(value?.startsWith('Ħ') || value?.includes('ŀ')).toBe(true); // pseudolocalized }); + test('MsgResource: "getTranslation" method with pseudoLocale handles MF1 plural messages', async () => { + const project = MsgProject.create({ + ...testProjectData, + project: { ...testProjectData.project, format: 'MF1' } + }); + const resource = MsgResource.create({ + title: 'TestResource', + attributes: { lang: 'en', dir: 'ltr' }, + messages: [ + { key: 'files', value: '{count, plural, one {# file} other {# files}}' } + ] + }, project); + + const translated = await resource.getTranslation('zxx'); + const value = translated.get('files')?.value; + + expect(value).toBeDefined(); + expect(value).toContain('{count, plural,'); + expect(value).toContain('#'); + expect(value).not.toBe('{count, plural, one {# file} other {# files}}'); + expect(value!.includes('file')).toBe(false); + // Still formats as MF1 after pseudo-localization + expect(translated.get('files')?.format({ count: 1 })).toMatch(/1/); + expect(translated.get('files')?.format({ count: 2 })).toMatch(/2/); + }); + + test('MsgResource: "getTranslation" method with pseudoLocale handles NONE messages', async () => { + const project = MsgProject.create(testProjectData); + const resource = MsgResource.create({ + title: 'TestResource', + attributes: { lang: 'en', dir: 'ltr' }, + messages: [ + { key: 'plain', value: 'Hello world', attributes: { format: 'NONE' } } + ] + }, project); + + const translated = await resource.getTranslation('zxx'); + const value = translated.get('plain')?.value; + + expect(value).toBeDefined(); + expect(value).not.toBe('Hello world'); + expect(value?.startsWith('Ħ') || value?.includes('ŀ')).toBe(true); + expect(translated.get('plain')?.attributes.format).toBe('NONE'); + }); + + test('MsgResource: "getTranslation" method with pseudoLocale handles mixed formats', async () => { + const project = MsgProject.create({ + ...testProjectData, + project: { ...testProjectData.project, format: 'MF1' } + }); + const resource = MsgResource.create({ + title: 'TestResource', + attributes: { lang: 'en', dir: 'ltr' }, + messages: [ + { key: 'files', value: '{count, plural, one {# file} other {# files}}' }, + { key: 'hi', value: 'Hello, {$name}!', attributes: { format: 'MF2' } }, + { key: 'plain', value: 'Just text', attributes: { format: 'NONE' } } + ] + }, project); + + const translated = await resource.getTranslation('zxx'); + + const files = translated.get('files')?.value; + expect(files).toContain('{count, plural,'); + expect(files).not.toBe('{count, plural, one {# file} other {# files}}'); + + const hi = translated.get('hi')?.value; + expect(hi).toContain('{$name}'); + expect(hi).not.toBe('Hello, {$name}!'); + + const plain = translated.get('plain')?.value; + expect(plain).not.toBe('Just text'); + }); + test('MsgResource: "getTranslation" method throws error for unsupported locale', async () => { const project = MsgProject.create(testProjectData); const resource = MsgResource.create({ diff --git a/src/tests/format.integration.test.ts b/src/tests/format.integration.test.ts index e2fecdf..516e0c0 100644 --- a/src/tests/format.integration.test.ts +++ b/src/tests/format.integration.test.ts @@ -96,6 +96,40 @@ describe('format integration: mixed MF1/MF2/NONE within one project', () => { expect(formatted).toContain('3'); expect(formatted).toContain('个文件'); }); + + test('pseudoLocale localizes mixed formats and formatting still works', async () => { + const project = MsgProject.create(projectData('MF1')); + const resource = MsgResource.create({ + title: 'Home', + attributes: { lang: 'en', dir: 'ltr' }, + messages: [ + { key: 'files', value: '{count, plural, one {# file} other {# files}}' }, + { key: 'hi', value: 'Hello, {$name}!', attributes: { format: 'MF2' } }, + { key: 'raw', value: 'Just text', attributes: { format: 'NONE' } } + ] + }, project); + + const pseudo = await resource.getTranslation('en-XA'); + + expect(pseudo.attributes.lang).toBe('en-XA'); + expect(pseudo.get('files')?.attributes.format).toBe('MF1'); + expect(pseudo.get('hi')?.attributes.format).toBe('MF2'); + expect(pseudo.get('raw')?.attributes.format).toBe('NONE'); + + const files = pseudo.get('files')!; + expect(files.value).not.toContain('file'); + expect(files.format({ count: 1 })).toMatch(/1/); + expect(files.format({ count: 2 })).toMatch(/2/); + + const hi = pseudo.get('hi')!; + expect(hi.value).toContain('{$name}'); + expect(hi.format({ name: 'Ada' })).toContain('Ada'); + expect(hi.format({ name: 'Ada' })).not.toContain('Hello'); + + const raw = pseudo.get('raw')!; + expect(raw.value).not.toBe('Just text'); + expect(raw.format({})).toBe(raw.value); + }); }); describe('format coverage: default fallbacks', () => { diff --git a/src/tests/pseudo-localize.test.ts b/src/tests/pseudo-localize.test.ts new file mode 100644 index 0000000..8797969 --- /dev/null +++ b/src/tests/pseudo-localize.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest'; +import { + pseudoLocalize, + pseudoLocalizeMF1, + pseudoLocalizeMF2, + pseudoLocalizeNone +} from '../lib/pseudo-localize.js'; + +describe('pseudoLocalize helpers', () => { + test('pseudoLocalizeMF2 localizes literals and preserves variables', () => { + const result = pseudoLocalizeMF2('Hello, {$name}!'); + expect(result).toContain('{$name}'); + expect(result).not.toBe('Hello, {$name}!'); + expect(result.startsWith('Ħ') || result.includes('ŀ')).toBe(true); + }); + + test('pseudoLocalizeNone localizes the whole string', () => { + const result = pseudoLocalizeNone('Hello world'); + expect(result).not.toBe('Hello world'); + expect(result.startsWith('Ħ') || result.includes('ŀ')).toBe(true); + }); + + test('pseudoLocalizeMF1 localizes content and preserves simple placeholders', () => { + const result = pseudoLocalizeMF1('Hello, {name}!'); + expect(result).toContain('{name}'); + expect(result).not.toBe('Hello, {name}!'); + expect(result.startsWith('Ħ') || result.includes('ŀ')).toBe(true); + }); + + test('pseudoLocalizeMF1 localizes plural arm text and preserves ICU structure', () => { + const source = '{count, plural, one {# file} other {# files}}'; + const result = pseudoLocalizeMF1(source); + + expect(result).toContain('{count, plural,'); + expect(result).toContain('one {'); + expect(result).toContain('other {'); + expect(result).toContain('#'); + expect(result).not.toBe(source); + // Literal "file" / "files" should be accented; # and keys stay intact. + expect(result).toMatch(/#\s+\S+/); + expect(result.includes('file') || result.includes('files')).toBe(false); + }); + + test('pseudoLocalizeMF1 localizes select arm text and preserves keys', () => { + const source = '{gender, select, male {He liked this} female {She liked this} other {They liked this}}'; + const result = pseudoLocalizeMF1(source); + + expect(result).toContain('{gender, select,'); + expect(result).toContain('male {'); + expect(result).toContain('female {'); + expect(result).toContain('other {'); + expect(result).not.toBe(source); + expect(result.includes('liked')).toBe(false); + }); + + test('pseudoLocalizeMF1 does not localize function arg styles', () => { + const source = 'Total: {amount, number, ::currency/EUR}'; + const result = pseudoLocalizeMF1(source); + + expect(result).toContain('{amount, number, ::currency/EUR}'); + expect(result).not.toBe(source); + expect(result.startsWith('Ŧ') || result.includes('ǿ')).toBe(true); + }); + + test('pseudoLocalizeMF1 returns source unchanged when there is no content', () => { + expect(pseudoLocalizeMF1('{name}')).toBe('{name}'); + expect(pseudoLocalizeMF1('')).toBe(''); + }); + + test('pseudoLocalize dispatches by format', () => { + const mf2 = pseudoLocalize('Hi {$n}', 'MF2'); + expect(mf2).toContain('{$n}'); + expect(mf2).not.toBe('Hi {$n}'); + + const mf1 = pseudoLocalize('Hi {n}', 'MF1'); + expect(mf1).toContain('{n}'); + expect(mf1).not.toBe('Hi {n}'); + + expect(pseudoLocalize('plain', 'NONE')).not.toBe('plain'); + }); +});