@worldware/msg 0.6.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,7 @@ A TypeScript library for managing internationalization (i18n) messages with supp
8
8
 
9
9
  - **Message Management**: Organize messages into resources with keys and values
10
10
  - **Translation Loading**: Load translations from external sources via customizable loaders
11
+ - **Pseudo Localization**: Request a pseudolocalized resource for UI testing via `getTranslation(pseudoLocale)`
11
12
  - **Message Formatting**: Format messages with parameters using MessageFormat 2 (MF2) syntax
12
13
  - **Attributes & Notes**: Attach metadata (language, direction, do-not-translate flags) and notes to messages
13
14
  - **Project Configuration**: Configure projects with locale settings and translation loaders
@@ -25,6 +26,7 @@ npm install @worldware/msg
25
26
  A project configuration that defines:
26
27
  - Project name and version
27
28
  - Source and target locales (with language fallback chains)
29
+ - Pseudo locale (for pseudolocalized output via `getTranslation`)
28
30
  - A translation loader function
29
31
 
30
32
  ### MsgResource
@@ -48,15 +50,30 @@ An individual message with:
48
50
 
49
51
  ### Basic Setup
50
52
 
53
+ The following example matches the ES module output of the **msg-cli** `create project` command—a typical project file that loads translations from JSON under a translations directory:
54
+
51
55
  ```typescript
52
- import { MsgProject, MsgResource } from '@worldware/msg';
56
+ import { MsgProject } from '@worldware/msg';
57
+
58
+ const TRANSLATION_IMPORT_PATH = '../l10n/translations';
59
+ const loader = async (project, title, language) => {
60
+ const path = `${TRANSLATION_IMPORT_PATH}/${project}/${language}/${title}.json`;
61
+ try {
62
+ const module = await import(path, { with: { type: 'json' } });
63
+ return module.default;
64
+ } catch (error) {
65
+ console.warn(`Translations for locale ${language} could not be loaded.`, error);
66
+ return {
67
+ title,
68
+ attributes: { lang: language, dir: '' },
69
+ notes: [],
70
+ messages: []
71
+ };
72
+ }
73
+ };
53
74
 
54
- // Create a project configuration
55
- const project = MsgProject.create({
56
- project: {
57
- name: 'my-app',
58
- version: 1
59
- },
75
+ export default MsgProject.create({
76
+ project: { name: 'my-app', version: 1 },
60
77
  locales: {
61
78
  sourceLocale: 'en',
62
79
  pseudoLocale: 'en-XA',
@@ -64,18 +81,15 @@ const project = MsgProject.create({
64
81
  'en': ['en'],
65
82
  'es': ['es'],
66
83
  'fr': ['fr'],
67
- 'fr-CA': ['fr', 'fr-CA'] // Falls back to 'fr' if 'fr-CA' not available
84
+ 'fr-CA': ['fr', 'fr-CA']
68
85
  }
69
86
  },
70
- loader: async (project, title, lang) => {
71
- // Custom loader to fetch translation data
72
- const path = `./translations/${project}/${lang}/${title}.json`;
73
- const data = await import(path);
74
- return data;
75
- }
87
+ loader
76
88
  });
77
89
  ```
78
90
 
91
+ When using this in your app, import the default export as your project and pass it to `MsgResource.create` (see below).
92
+
79
93
  ### Creating a Resource
80
94
 
81
95
  ```typescript
@@ -89,7 +103,7 @@ const resource = MsgResource.create({
89
103
  messages: [
90
104
  {
91
105
  key: 'greeting',
92
- value: 'Hello, {name}!'
106
+ value: 'Hello, {$name}!'
93
107
  },
94
108
  {
95
109
  key: 'welcome',
@@ -99,7 +113,7 @@ const resource = MsgResource.create({
99
113
  }, project);
100
114
 
101
115
  // Or add messages programmatically
102
- resource.add('goodbye', 'Goodbye, {name}!', {
116
+ resource.add('goodbye', 'Goodbye, {$name}!', {
103
117
  lang: 'en',
104
118
  dir: 'ltr'
105
119
  });
@@ -124,11 +138,38 @@ const spanishResource = await resource.getTranslation('es');
124
138
  // falling back to the source messages for missing translations
125
139
  ```
126
140
 
141
+ ### Language fallbacks and translation layering
142
+
143
+ The project's `targetLocales` maps each requested locale to a **fallback chain**: an array of locale codes ordered from least specific to most specific (e.g. base language first, then region-specific). For example, `'zh-HK': ['zh', 'zh-Hant', 'zh-HK']` means that when you request `zh-HK`, the chain is first `zh`, then `zh-Hant`, then `zh-HK`. You can get the chain for any locale with `project.getTargetLocale(locale)`.
144
+
145
+ When you call `resource.getTranslation(locale)`:
146
+
147
+ 1. The **source resource** (the resource you called it on) is the base.
148
+ 2. For each locale in that locale's chain, the project **loader** is called to load that locale's translation data.
149
+ 3. Each loaded dataset is **layered** onto the current result: messages in the new data add or override by key; keys missing in the new layer keep the value from the previous layer.
150
+ 4. The final resource is the result after all layers have been applied.
151
+
152
+ So for `getTranslation('zh-HK')` with chain `['zh', 'zh-Hant', 'zh-HK']`, you get: source → then zh overlay → then zh-Hant overlay → then zh-HK overlay. Later entries in the chain override earlier ones for the same key; missing keys fall back to the previous layer (and ultimately to the source).
153
+
154
+ ### Pseudo Localization
155
+
156
+ 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:
157
+
158
+ ```typescript
159
+ // Request pseudolocalized messages (project locales.pseudoLocale is 'en-XA')
160
+ const pseudoResource = await resource.getTranslation('en-XA');
161
+
162
+ // Message values are transformed: "Hello, {$name}!" → "Ħḗḗŀŀǿǿ, {$name}!"
163
+ // Variables and MF2 syntax are preserved; only literal text is pseudolocalized
164
+ const greeting = pseudoResource.get('greeting')?.format({ name: 'Alice' });
165
+ // Result: "Ħḗḗŀŀǿǿ, Alice!"
166
+ ```
167
+
127
168
  ### Working with Attributes and Notes
128
169
 
129
170
  ```typescript
130
171
  // Add notes to messages
131
- resource.add('complex-message', 'This is a complex message', {
172
+ resource.add('complex-message', 'You have {$count} items', {
132
173
  lang: 'en',
133
174
  dir: 'ltr',
134
175
  dnt: false // do-not-translate flag
@@ -188,7 +229,7 @@ const data = resource.getData();
188
229
  **Methods:**
189
230
  - `add(key: string, value: string, attributes?: MsgAttributes, notes?: MsgNote[]): MsgResource` - Add a message
190
231
  - `translate(data: MsgResourceData): MsgResource` - Create a translated version
191
- - `getTranslation(lang: string): Promise<MsgResource>` - Load and apply translations
232
+ - `getTranslation(lang: string): Promise<MsgResource>` - Load and apply translations. When `lang` matches the project's `pseudoLocale`, returns a resource with pseudolocalized message values instead of loading from the loader.
192
233
  - `getProject(): MsgProject` - Returns the project instance associated with the resource
193
234
  - `getData(stripNotes?: boolean): MsgResourceData` - Get resource data. Message objects in the output omit `attributes` when they match the resource's attributes (to avoid redundancy)
194
235
  - `toJSON(stripNotes?: boolean): string` - Serialize to JSON
@@ -6,6 +6,8 @@ import {
6
6
  } from "./chunk-QWBDIKQK.mjs";
7
7
 
8
8
  // src/classes/MsgResource/MsgResource.ts
9
+ import { parseMessage, stringifyMessage, visit } from "messageformat";
10
+ import { localize } from "pseudo-localization";
9
11
  var MsgResource = class _MsgResource extends Map {
10
12
  _attributes = {};
11
13
  _notes = [];
@@ -38,6 +40,20 @@ var MsgResource = class _MsgResource extends Map {
38
40
  const msg = message.attributes;
39
41
  return res.lang === msg.lang && res.dir === msg.dir && res.dnt === msg.dnt;
40
42
  }
43
+ pseudoLocalizeMF2(source, options) {
44
+ const msg = parseMessage(source);
45
+ visit(msg, {
46
+ pattern: (pattern) => {
47
+ for (let i = 0; i < pattern.length; i++) {
48
+ const part = pattern[i];
49
+ if (typeof part === "string") {
50
+ pattern[i] = localize(part, options);
51
+ }
52
+ }
53
+ }
54
+ });
55
+ return stringifyMessage(msg);
56
+ }
41
57
  get attributes() {
42
58
  return this._attributes;
43
59
  }
@@ -104,6 +120,23 @@ var MsgResource = class _MsgResource extends Map {
104
120
  }
105
121
  async getTranslation(lang) {
106
122
  const project = this._project;
123
+ const pseudoLocale = project.locales.pseudoLocale;
124
+ if (lang === pseudoLocale) {
125
+ const pseudolocalizedData = {
126
+ title: this.title,
127
+ attributes: { ...this.attributes, lang: pseudoLocale },
128
+ notes: this.notes.length > 0 ? this.notes : void 0,
129
+ messages: []
130
+ };
131
+ this.forEach((msg) => {
132
+ pseudolocalizedData.messages.push({
133
+ key: msg.key,
134
+ value: this.pseudoLocalizeMF2(msg.value),
135
+ attributes: { ...this.attributes, lang: pseudoLocale }
136
+ });
137
+ });
138
+ return this.translate(pseudolocalizedData);
139
+ }
107
140
  const languageChain = project.getTargetLocale(lang);
108
141
  if (!languageChain) {
109
142
  throw new Error("Unsupported locale for resource.");
@@ -46,6 +46,7 @@ declare class MsgResource extends Map<string, MsgMessage> implements MsgInterfac
46
46
  static create(data: MsgResourceData, project: MsgProject): MsgResource;
47
47
  private constructor();
48
48
  private hasMatchingAttributes;
49
+ private pseudoLocalizeMF2;
49
50
  get attributes(): MsgAttributes;
50
51
  set attributes(attributes: MsgAttributes);
51
52
  get notes(): MsgNote[];
@@ -46,6 +46,7 @@ declare class MsgResource extends Map<string, MsgMessage> implements MsgInterfac
46
46
  static create(data: MsgResourceData, project: MsgProject): MsgResource;
47
47
  private constructor();
48
48
  private hasMatchingAttributes;
49
+ private pseudoLocalizeMF2;
49
50
  get attributes(): MsgAttributes;
50
51
  set attributes(attributes: MsgAttributes);
51
52
  get notes(): MsgNote[];
@@ -23,6 +23,8 @@ __export(MsgResource_exports, {
23
23
  MsgResource: () => MsgResource
24
24
  });
25
25
  module.exports = __toCommonJS(MsgResource_exports);
26
+ var import_messageformat2 = require("messageformat");
27
+ var import_pseudo_localization = require("pseudo-localization");
26
28
 
27
29
  // src/classes/MsgMessage/MsgMessage.ts
28
30
  var import_messageformat = require("messageformat");
@@ -130,6 +132,20 @@ var MsgResource = class _MsgResource extends Map {
130
132
  const msg = message.attributes;
131
133
  return res.lang === msg.lang && res.dir === msg.dir && res.dnt === msg.dnt;
132
134
  }
135
+ pseudoLocalizeMF2(source, options) {
136
+ const msg = (0, import_messageformat2.parseMessage)(source);
137
+ (0, import_messageformat2.visit)(msg, {
138
+ pattern: (pattern) => {
139
+ for (let i = 0; i < pattern.length; i++) {
140
+ const part = pattern[i];
141
+ if (typeof part === "string") {
142
+ pattern[i] = (0, import_pseudo_localization.localize)(part, options);
143
+ }
144
+ }
145
+ }
146
+ });
147
+ return (0, import_messageformat2.stringifyMessage)(msg);
148
+ }
133
149
  get attributes() {
134
150
  return this._attributes;
135
151
  }
@@ -196,6 +212,23 @@ var MsgResource = class _MsgResource extends Map {
196
212
  }
197
213
  async getTranslation(lang) {
198
214
  const project = this._project;
215
+ const pseudoLocale = project.locales.pseudoLocale;
216
+ if (lang === pseudoLocale) {
217
+ const pseudolocalizedData = {
218
+ title: this.title,
219
+ attributes: { ...this.attributes, lang: pseudoLocale },
220
+ notes: this.notes.length > 0 ? this.notes : void 0,
221
+ messages: []
222
+ };
223
+ this.forEach((msg) => {
224
+ pseudolocalizedData.messages.push({
225
+ key: msg.key,
226
+ value: this.pseudoLocalizeMF2(msg.value),
227
+ attributes: { ...this.attributes, lang: pseudoLocale }
228
+ });
229
+ });
230
+ return this.translate(pseudolocalizedData);
231
+ }
199
232
  const languageChain = project.getTargetLocale(lang);
200
233
  if (!languageChain) {
201
234
  throw new Error("Unsupported locale for resource.");
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  MsgResource
3
- } from "../../chunk-AWSZFTAJ.mjs";
3
+ } from "../../chunk-2QUOYESW.mjs";
4
4
  import "../../chunk-WUDKNZV2.mjs";
5
5
  import "../../chunk-QWBDIKQK.mjs";
6
6
  export {
@@ -100,6 +100,8 @@ var MsgMessage = class _MsgMessage {
100
100
  };
101
101
 
102
102
  // src/classes/MsgResource/MsgResource.ts
103
+ var import_messageformat2 = require("messageformat");
104
+ var import_pseudo_localization = require("pseudo-localization");
103
105
  var MsgResource = class _MsgResource extends Map {
104
106
  _attributes = {};
105
107
  _notes = [];
@@ -132,6 +134,20 @@ var MsgResource = class _MsgResource extends Map {
132
134
  const msg = message.attributes;
133
135
  return res.lang === msg.lang && res.dir === msg.dir && res.dnt === msg.dnt;
134
136
  }
137
+ pseudoLocalizeMF2(source, options) {
138
+ const msg = (0, import_messageformat2.parseMessage)(source);
139
+ (0, import_messageformat2.visit)(msg, {
140
+ pattern: (pattern) => {
141
+ for (let i = 0; i < pattern.length; i++) {
142
+ const part = pattern[i];
143
+ if (typeof part === "string") {
144
+ pattern[i] = (0, import_pseudo_localization.localize)(part, options);
145
+ }
146
+ }
147
+ }
148
+ });
149
+ return (0, import_messageformat2.stringifyMessage)(msg);
150
+ }
135
151
  get attributes() {
136
152
  return this._attributes;
137
153
  }
@@ -198,6 +214,23 @@ var MsgResource = class _MsgResource extends Map {
198
214
  }
199
215
  async getTranslation(lang) {
200
216
  const project = this._project;
217
+ const pseudoLocale = project.locales.pseudoLocale;
218
+ if (lang === pseudoLocale) {
219
+ const pseudolocalizedData = {
220
+ title: this.title,
221
+ attributes: { ...this.attributes, lang: pseudoLocale },
222
+ notes: this.notes.length > 0 ? this.notes : void 0,
223
+ messages: []
224
+ };
225
+ this.forEach((msg) => {
226
+ pseudolocalizedData.messages.push({
227
+ key: msg.key,
228
+ value: this.pseudoLocalizeMF2(msg.value),
229
+ attributes: { ...this.attributes, lang: pseudoLocale }
230
+ });
231
+ });
232
+ return this.translate(pseudolocalizedData);
233
+ }
201
234
  const languageChain = project.getTargetLocale(lang);
202
235
  if (!languageChain) {
203
236
  throw new Error("Unsupported locale for resource.");
@@ -4,7 +4,7 @@ import {
4
4
  } from "../chunk-XS43NAP2.mjs";
5
5
  import {
6
6
  MsgResource
7
- } from "../chunk-AWSZFTAJ.mjs";
7
+ } from "../chunk-2QUOYESW.mjs";
8
8
  import {
9
9
  MsgMessage
10
10
  } from "../chunk-WUDKNZV2.mjs";
package/dist/index.cjs CHANGED
@@ -100,6 +100,8 @@ var MsgMessage = class _MsgMessage {
100
100
  };
101
101
 
102
102
  // src/classes/MsgResource/MsgResource.ts
103
+ var import_messageformat2 = require("messageformat");
104
+ var import_pseudo_localization = require("pseudo-localization");
103
105
  var MsgResource = class _MsgResource extends Map {
104
106
  _attributes = {};
105
107
  _notes = [];
@@ -132,6 +134,20 @@ var MsgResource = class _MsgResource extends Map {
132
134
  const msg = message.attributes;
133
135
  return res.lang === msg.lang && res.dir === msg.dir && res.dnt === msg.dnt;
134
136
  }
137
+ pseudoLocalizeMF2(source, options) {
138
+ const msg = (0, import_messageformat2.parseMessage)(source);
139
+ (0, import_messageformat2.visit)(msg, {
140
+ pattern: (pattern) => {
141
+ for (let i = 0; i < pattern.length; i++) {
142
+ const part = pattern[i];
143
+ if (typeof part === "string") {
144
+ pattern[i] = (0, import_pseudo_localization.localize)(part, options);
145
+ }
146
+ }
147
+ }
148
+ });
149
+ return (0, import_messageformat2.stringifyMessage)(msg);
150
+ }
135
151
  get attributes() {
136
152
  return this._attributes;
137
153
  }
@@ -198,6 +214,23 @@ var MsgResource = class _MsgResource extends Map {
198
214
  }
199
215
  async getTranslation(lang) {
200
216
  const project = this._project;
217
+ const pseudoLocale = project.locales.pseudoLocale;
218
+ if (lang === pseudoLocale) {
219
+ const pseudolocalizedData = {
220
+ title: this.title,
221
+ attributes: { ...this.attributes, lang: pseudoLocale },
222
+ notes: this.notes.length > 0 ? this.notes : void 0,
223
+ messages: []
224
+ };
225
+ this.forEach((msg) => {
226
+ pseudolocalizedData.messages.push({
227
+ key: msg.key,
228
+ value: this.pseudoLocalizeMF2(msg.value),
229
+ attributes: { ...this.attributes, lang: pseudoLocale }
230
+ });
231
+ });
232
+ return this.translate(pseudolocalizedData);
233
+ }
201
234
  const languageChain = project.getTargetLocale(lang);
202
235
  if (!languageChain) {
203
236
  throw new Error("Unsupported locale for resource.");
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-XS43NAP2.mjs";
5
5
  import {
6
6
  MsgResource
7
- } from "./chunk-AWSZFTAJ.mjs";
7
+ } from "./chunk-2QUOYESW.mjs";
8
8
  import {
9
9
  MsgMessage
10
10
  } from "./chunk-WUDKNZV2.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worldware/msg",
3
- "version": "0.6.4",
3
+ "version": "0.7.1",
4
4
  "description": "Message localization tooling",
5
5
  "license": "MIT",
6
6
  "author": "Joel Sahleen",
@@ -38,7 +38,8 @@
38
38
  "vitest": "^4.0.15"
39
39
  },
40
40
  "dependencies": {
41
- "messageformat": "4.0.0-10"
41
+ "messageformat": "4.0.0-10",
42
+ "pseudo-localization": "^2.4.0"
42
43
  },
43
44
  "scripts": {
44
45
  "build": "tsup",