@l10nmonster/helpers-json 1.0.3 → 3.0.0-alpha.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
@@ -1,14 +1,375 @@
1
- # L10n Monster JSON Helpers
1
+ # @l10nmonster/helpers-json
2
+
3
+ L10n Monster helper for JSON file formats, supporting both generic JSON structures and specialized formats like i18next and ARB (Application Resource Bundle).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @l10nmonster/helpers-json
9
+ ```
10
+
11
+ ## Features
2
12
 
3
13
  ### JSON Filter
4
14
 
5
- A filter for JSON files. It supports annotations as defined by the [ARB spec](https://github.com/google/app-resource-bundle/wiki/ApplicationResourceBundleSpecification). In addition it supports arrays, nested keys and plurals as defined by the [i18next JSON v4](https://www.i18next.com/misc/json-format) format.
15
+ A comprehensive filter for JSON files that supports:
16
+ - **ARB annotations** as defined by the [ARB specification](https://github.com/google/app-resource-bundle/wiki/ApplicationResourceBundleSpecification)
17
+ - **i18next v4 format** including arrays, nested keys, and plurals
18
+ - **Generic JSON** structures with flexible key handling
19
+ - **Placeholder processing** with multiple syntaxes
20
+
21
+ ### Supported Formats
22
+
23
+ #### i18next JSON v4
24
+ The industry standard JSON format for internationalization:
25
+
26
+ ```json
27
+ {
28
+ "welcome": "Welcome {{name}}!",
29
+ "items_one": "{{count}} item",
30
+ "items_other": "{{count}} items",
31
+ "nested": {
32
+ "key": "Nested value"
33
+ },
34
+ "arrayValue": ["First", "Second", "Third"]
35
+ }
36
+ ```
37
+
38
+ #### ARB (Application Resource Bundle)
39
+ Google's JSON-based localization format:
40
+
41
+ ```json
42
+ {
43
+ "welcome": "Welcome {name}!",
44
+ "@welcome": {
45
+ "description": "Welcome message",
46
+ "placeholders": {
47
+ "name": {
48
+ "type": "String"
49
+ }
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ### Basic Configuration
58
+
59
+ ```javascript
60
+ import { Filter } from '@l10nmonster/helpers-json';
61
+
62
+ const jsonFilter = new Filter({
63
+ enableArbAnnotations: true, // Support ARB @-prefixed annotations
64
+ enablePluralSuffixes: true, // Support i18next plural suffixes (_one, _other)
65
+ emitArbAnnotations: true, // Include ARB annotations in output
66
+ enableArrays: true // Support array values
67
+ });
68
+ ```
69
+
70
+ ### Integration with L10n Monster
71
+
72
+ ```javascript
73
+ // l10nmonster.config.mjs
74
+ import { FsSource, FsTarget } from '@l10nmonster/core';
75
+ import { Filter, i18next } from '@l10nmonster/helpers-json';
76
+
77
+ export default {
78
+ channels: [{
79
+ source: new FsSource({
80
+ globs: ['locales/en/**/*.json']
81
+ }),
82
+ target: new FsTarget({
83
+ targetPath: (lang, resourceId) =>
84
+ resourceId.replace('/en/', `/${lang}/`)
85
+ })
86
+ }],
87
+
88
+ contentTypes: [{
89
+ name: 'i18next-json',
90
+ resourceFilter: new Filter({
91
+ enablePluralSuffixes: true,
92
+ enableArrays: true
93
+ }),
94
+ decoders: [i18next.phDecoder],
95
+ textEncoders: ['bracketEncoder']
96
+ }]
97
+ };
98
+ ```
99
+
100
+ ## Configuration Options
101
+
102
+ ### Filter Options
103
+
104
+ - **`enableArbAnnotations`** (boolean): Enable ARB annotation support
105
+ - **`enablePluralSuffixes`** (boolean): Enable i18next plural suffix handling
106
+ - **`emitArbAnnotations`** (boolean): Include ARB annotations in translated output
107
+ - **`enableArrays`** (boolean): Support JSON array values
108
+ - **`maxDepth`** (number): Maximum nesting depth for nested objects
109
+ - **`keyDelimiter`** (string): Delimiter for nested key flattening
110
+
111
+ ### Placeholder Decoders
112
+
113
+ The package includes specialized placeholder decoders:
114
+
115
+ ```javascript
116
+ import { i18next } from '@l10nmonster/helpers-json';
117
+
118
+ // i18next placeholder decoder for {{param}} and $t(key) syntax
119
+ const decoder = i18next.phDecoder();
120
+ ```
121
+
122
+ ## Supported JSON Structures
123
+
124
+ ### Flat Structure
125
+ ```json
126
+ {
127
+ "key1": "Simple value",
128
+ "key2": "Value with {{placeholder}}"
129
+ }
130
+ ```
131
+
132
+ ### Nested Structure
133
+ ```json
134
+ {
135
+ "section": {
136
+ "subsection": {
137
+ "key": "Deeply nested value"
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ ### Plurals (i18next)
144
+ ```json
145
+ {
146
+ "item_one": "{{count}} item",
147
+ "item_other": "{{count}} items",
148
+ "item_zero": "No items"
149
+ }
150
+ ```
151
+
152
+ ### Arrays
153
+ ```json
154
+ {
155
+ "fruits": ["Apple", "Banana", "Orange"],
156
+ "mixed": [
157
+ "String value",
158
+ {"nested": "object"}
159
+ ]
160
+ }
161
+ ```
162
+
163
+ ### ARB with Annotations
164
+ ```json
165
+ {
166
+ "pageTitle": "My App",
167
+ "@pageTitle": {
168
+ "description": "Title of the application"
169
+ },
170
+ "greeting": "Hello {name}",
171
+ "@greeting": {
172
+ "description": "Greeting message",
173
+ "placeholders": {
174
+ "name": {
175
+ "type": "String",
176
+ "example": "John"
177
+ }
178
+ }
179
+ }
180
+ }
181
+ ```
182
+
183
+ ## Placeholder Formats
6
184
 
7
- ```js
8
- this.resourceFilter = new filters.JsonFilter({
9
- enableArbAnnotations: true,
10
- enablePluralSuffixes: true,
11
- emitArbAnnotations: true,
12
- enableArrays: true
185
+ ### i18next Format
186
+ - **Interpolation**: `{{variable}}`
187
+ - **Translation function**: `$t(namespace:key)`
188
+ - **Formatting**: `{{variable, format}}`
189
+
190
+ ### ARB Format
191
+ - **Simple placeholders**: `{variable}`
192
+ - **Typed placeholders**: `{count, number}`, `{date, date}`
193
+
194
+ ### Generic Formats
195
+ - **Brace placeholders**: `{param}`
196
+ - **Percent placeholders**: `%s`, `%d`, `%1$s`
197
+
198
+ ## Advanced Features
199
+
200
+ ### Namespace Support
201
+
202
+ ```javascript
203
+ // Namespace-aware configuration
204
+ const filter = new Filter({
205
+ enableNamespaces: true,
206
+ namespaceDelimiter: ':'
13
207
  });
14
208
  ```
209
+
210
+ ### Custom Key Processing
211
+
212
+ ```javascript
213
+ const filter = new Filter({
214
+ keyProcessor: (key, value, context) => {
215
+ // Custom logic for key transformation
216
+ if (key.startsWith('_')) {
217
+ return null; // Skip private keys
218
+ }
219
+ return { key, value };
220
+ }
221
+ });
222
+ ```
223
+
224
+ ### Validation and Schema
225
+
226
+ ```javascript
227
+ const filter = new Filter({
228
+ validateStructure: true,
229
+ requiredKeys: ['title', 'description'],
230
+ schema: {
231
+ type: 'object',
232
+ properties: {
233
+ title: { type: 'string' },
234
+ description: { type: 'string' }
235
+ }
236
+ }
237
+ });
238
+ ```
239
+
240
+ ## Integration Examples
241
+
242
+ ### React i18next Project
243
+
244
+ ```javascript
245
+ // l10nmonster.config.mjs
246
+ import { Filter, i18next } from '@l10nmonster/helpers-json';
247
+
248
+ export default {
249
+ channels: [{
250
+ source: new FsSource({
251
+ globs: ['public/locales/en/**/*.json']
252
+ }),
253
+ target: new FsTarget({
254
+ targetPath: (lang, resourceId) =>
255
+ resourceId.replace('/en/', `/${lang}/`)
256
+ })
257
+ }],
258
+
259
+ contentTypes: [{
260
+ name: 'react-i18next',
261
+ resourceFilter: new Filter({
262
+ enablePluralSuffixes: true,
263
+ enableArrays: true,
264
+ enableNamespaces: true
265
+ }),
266
+ decoders: [i18next.phDecoder]
267
+ }]
268
+ };
269
+ ```
270
+
271
+ ### Flutter ARB Project
272
+
273
+ ```javascript
274
+ export default {
275
+ channels: [{
276
+ source: new FsSource({
277
+ globs: ['lib/l10n/app_en.arb']
278
+ }),
279
+ target: new FsTarget({
280
+ targetPath: (lang, resourceId) =>
281
+ resourceId.replace('_en.arb', `_${lang}.arb`)
282
+ })
283
+ }],
284
+
285
+ contentTypes: [{
286
+ name: 'flutter-arb',
287
+ resourceFilter: new Filter({
288
+ enableArbAnnotations: true,
289
+ emitArbAnnotations: true
290
+ })
291
+ }]
292
+ };
293
+ ```
294
+
295
+ ### Generic JSON API
296
+
297
+ ```javascript
298
+ export default {
299
+ channels: [{
300
+ source: new FsSource({
301
+ globs: ['api/messages/en.json']
302
+ }),
303
+ target: new FsTarget({
304
+ targetPath: (lang, resourceId) =>
305
+ resourceId.replace('/en.json', `/${lang}.json`)
306
+ })
307
+ }],
308
+
309
+ contentTypes: [{
310
+ name: 'api-json',
311
+ resourceFilter: new Filter({
312
+ enableArrays: true,
313
+ maxDepth: 3
314
+ })
315
+ }]
316
+ };
317
+ ```
318
+
319
+ ## Testing
320
+
321
+ ```bash
322
+ npm test
323
+ ```
324
+
325
+ The test suite covers:
326
+ - JSON parsing and generation
327
+ - ARB annotation handling
328
+ - i18next plural suffix processing
329
+ - Placeholder extraction and preservation
330
+ - Nested structure handling
331
+ - Array value processing
332
+
333
+ ## Performance Considerations
334
+
335
+ ### Large Files
336
+ - Use `maxDepth` to limit processing depth
337
+ - Consider splitting large JSON files
338
+ - Enable streaming for very large datasets
339
+
340
+ ### Memory Usage
341
+ - Arrays are loaded entirely into memory
342
+ - Nested objects are processed recursively
343
+ - Consider file size limits for production use
344
+
345
+ ## Migration from v2
346
+
347
+ ### Configuration Changes
348
+ ```javascript
349
+ // v2 (deprecated)
350
+ this.resourceFilter = new filters.JsonFilter();
351
+
352
+ // v3 (current)
353
+ import { Filter } from '@l10nmonster/helpers-json';
354
+ const filter = new Filter();
355
+ ```
356
+
357
+ ### Import Updates
358
+ ```javascript
359
+ // v2
360
+ const { helpers } = require('@l10nmonster/helpers');
361
+
362
+ // v3
363
+ import { Filter, i18next } from '@l10nmonster/helpers-json';
364
+ ```
365
+
366
+ ## Requirements
367
+
368
+ - Node.js >= 20.12.0
369
+ - @l10nmonster/core (peer dependency)
370
+
371
+ ## Related Documentation
372
+
373
+ - [i18next Documentation](https://www.i18next.com/)
374
+ - [ARB Specification](https://github.com/google/app-resource-bundle/wiki/ApplicationResourceBundleSpecification)
375
+ - [L10n Monster Core Documentation](../core/README.md)
package/i18next.js CHANGED
@@ -2,9 +2,9 @@
2
2
  /* eslint-disable no-eq-null, eqeqeq */
3
3
 
4
4
  // i18next v4 json format defined at https://www.i18next.com/misc/json-format
5
- const flat = require('flat');
6
- const { regex } = require('@l10nmonster/helpers');
7
- const { flattenAndSplitResources, ARB_ANNOTATION_MARKER, arbPlaceholderHandler } = require('./utils');
5
+ import { flatten, unflatten } from 'flat';
6
+ import { regex } from '@l10nmonster/core';
7
+ import { flattenAndSplitResources, ARB_ANNOTATION_MARKER, arbPlaceholderHandler } from './utils.js';
8
8
 
9
9
  const isArbAnnotations = e => e[0].split('.').slice(-2)[0].startsWith(ARB_ANNOTATION_MARKER);
10
10
  const validPluralSuffixes = new Set(['one', 'other', 'zero', 'two', 'few', 'many']);
@@ -15,9 +15,42 @@ const defaultArbAnnotationHandlers = {
15
15
  DEFAULT: (name, data) => (data == null ? undefined : `${name}: ${data}`),
16
16
  }
17
17
 
18
+ /**
19
+ * @function parseResourceAnnotations
20
+ *
21
+ * @description
22
+ * Parse resource annotations according to the given configuration.
23
+ *
24
+ * @param {object} resource - The resource to parse.
25
+ * @param {boolean} enableArbAnnotations - Whether to enable annotations
26
+ * @param {object} arbAnnotationHandlers - An object mapping annotation names to a function which takes an annotation name and its value and returns a string.
27
+ *
28
+ * @returns {array} An array with two elements. The first element is an array of key-value pairs for the translatable segments. The second element is an object with the parsed annotations.
29
+ *
30
+ * @example
31
+ * const resource = {
32
+ * "key": "value",
33
+ * "@key": {
34
+ * "description": "description for key",
35
+ * "placeholders": {
36
+ * "placeholder": {
37
+ * "example": "example for placeholder",
38
+ * "description": "description for placeholder",
39
+ * }
40
+ * }
41
+ * }
42
+ * };
43
+ * const [segments, notes] = parseResourceAnnotations(resource, true, {
44
+ * description: (_, data) => (data == null ? undefined : data),
45
+ * placeholders: (_, data) => (data == null ? undefined : arbPlaceholderHandler(data)),
46
+ * DEFAULT: (name, data) => (data == null ? undefined : `${name}: ${data}`),
47
+ * });
48
+ * // segments is [["key", "value"]]
49
+ * // notes is { "key": "description for key\nplaceholder: example for placeholder - description for placeholder" }
50
+ */
18
51
  function parseResourceAnnotations(resource, enableArbAnnotations, arbAnnotationHandlers) {
19
52
  if (!enableArbAnnotations) {
20
- return [ Object.entries(flat.flatten(resource)), {} ]
53
+ return [ Object.entries(flatten(resource)), {} ]
21
54
  }
22
55
 
23
56
  const { res, notes } = flattenAndSplitResources([], resource)
@@ -42,7 +75,7 @@ function parseResourceAnnotations(resource, enableArbAnnotations, arbAnnotationH
42
75
  return [ Object.entries(res), parsedNotes ];
43
76
  }
44
77
 
45
- exports.Filter = class I18nextFilter {
78
+ export class I18nextFilter {
46
79
  constructor(params) {
47
80
  this.enableArbAnnotations = params?.enableArbAnnotations || false;
48
81
  this.enablePluralSuffixes = params?.enablePluralSuffixes || false;
@@ -55,30 +88,32 @@ exports.Filter = class I18nextFilter {
55
88
  }
56
89
 
57
90
  async parseResource({ resource }) {
58
- const segments = [];
59
- if (!resource) {
60
- return { segments };
91
+ const response = {
92
+ segments: []
61
93
  }
62
- const [ parsedResource, notes ] = parseResourceAnnotations(
63
- JSON.parse(resource),
64
- this.enableArbAnnotations,
65
- this.arbAnnotationHandlers,
66
- );
67
- for (const [key, value] of parsedResource) {
68
- let seg = { sid: key, str: value };
69
- notes[key] && (seg.notes = notes[key]);
70
- if (this.enablePluralSuffixes && key.indexOf('_') !== -1 && validPluralSuffixes.has(key.split('_').slice(-1)[0])) {
71
- seg.isSuffixPluralized = true;
94
+ if (resource) {
95
+ const unParsedResource = JSON.parse(resource);
96
+ const targetLangs = unParsedResource['@@targetLocales'];
97
+ Array.isArray(targetLangs) && (response.targetLangs = targetLangs);
98
+ const [ parsedResource, notes ] = parseResourceAnnotations(
99
+ unParsedResource,
100
+ this.enableArbAnnotations,
101
+ this.arbAnnotationHandlers,
102
+ );
103
+ for (const [key, value] of parsedResource) {
104
+ let seg = { sid: key, str: value };
105
+ notes[key] && (seg.notes = notes[key]);
106
+ if (this.enablePluralSuffixes && key.indexOf('_') !== -1 && validPluralSuffixes.has(key.split('_').slice(-1)[0])) {
107
+ seg.isSuffixPluralized = true;
108
+ }
109
+ response.segments.push(seg);
72
110
  }
73
- segments.push(seg);
74
111
  }
75
- return {
76
- segments,
77
- };
112
+ return response;
78
113
  }
79
114
 
80
115
  async translateResource({ resource, translator }) {
81
- let flatResource = flat.flatten(JSON.parse(resource));
116
+ let flatResource = flatten(JSON.parse(resource));
82
117
  for (const entry of Object.entries(flatResource)) {
83
118
  if (!this.enableArbAnnotations || !isArbAnnotations(entry)) {
84
119
  const translation = await translator(...entry);
@@ -99,7 +134,7 @@ exports.Filter = class I18nextFilter {
99
134
  }
100
135
  }
101
136
  }
102
- return JSON.stringify(flat.unflatten(flatResource, { object: !this.enableArrays }), null, 2) + '\n';
137
+ return `${JSON.stringify(unflatten(flatResource, { object: !this.enableArrays }), null, 2)}\n`;
103
138
  }
104
139
  }
105
140
 
@@ -107,7 +142,7 @@ exports.Filter = class I18nextFilter {
107
142
  // - "keyNesting": "reuse $t(keyDeep.inner)", or
108
143
  // - "keyInterpolate": "replace this {{value}}"
109
144
  // See: https://www.i18next.com/misc/json-format#i18next-json-v4
110
- exports.phDecoder = regex.decoderMaker(
145
+ export const phDecoder = regex.decoderMaker(
111
146
  'i18nextKey',
112
147
  /(?<nestingPh>\$t\([\w:.]+\))|(?<doubleBracePh>{{[^}]+}})/g,
113
148
  (groups) => ({ t: 'x', v: groups.nestingPh ?? groups.doubleBracePh })
package/index.js CHANGED
@@ -1 +1 @@
1
- exports.i18next = { ...require('./i18next') };
1
+ export * as i18next from './i18next.js';
package/package.json CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
- "name": "@l10nmonster/helpers-json",
3
- "version": "1.0.3",
4
- "description": "Helpers to deal with JSON file formats",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "author": "Diego Lagunas",
10
- "license": "MIT",
11
- "dependencies": {
12
- "flat": "^5.0.2"
13
- },
14
- "peerDependencies": {
15
- "@l10nmonster/helpers": "^1"
16
- }
2
+ "name": "@l10nmonster/helpers-json",
3
+ "version": "3.0.0-alpha.1",
4
+ "description": "Helpers to deal with JSON file formats",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "test": "node --test"
9
+ },
10
+ "author": "Diego Lagunas",
11
+ "license": "MIT",
12
+ "dependencies": {
13
+ "flat": "^6"
14
+ },
15
+ "peerDependencies": {
16
+ "@l10nmonster/core": "file:../core"
17
+ }
17
18
  }
package/utils.js CHANGED
@@ -1,13 +1,13 @@
1
- const ARB_ANNOTATION_MARKER = "@";
2
- const FLATTEN_SEPARATOR = ".";
1
+ export const ARB_ANNOTATION_MARKER = "@";
2
+ export const FLATTEN_SEPARATOR = ".";
3
3
 
4
4
  /**
5
5
  * Recursively flatten the resources object while splitting it into resources and notes
6
6
  * Keys that start with the ARB annotation marker are separated into notes
7
7
  *
8
8
  * @param {string[]} keys Stack of keys seen. Used to create a flattened key
9
- * @param {object} resource Object to parse
10
- * @returns {{resource: object, notes: object}}
9
+ * @param {object} obj Object to parse
10
+ * @returns {{res: object, notes: object}}
11
11
  *
12
12
  * ```
13
13
  * const obj = {
@@ -58,7 +58,7 @@ const FLATTEN_SEPARATOR = ".";
58
58
  * )
59
59
  * ```
60
60
  */
61
- function flattenAndSplitResources(keys, obj) {
61
+ export function flattenAndSplitResources(keys, obj) {
62
62
  return Object.entries(obj).reduce((acc, [key, value]) => {
63
63
  if (typeof value === "object" && key.startsWith(ARB_ANNOTATION_MARKER)) {
64
64
  // If the key is `@key` and the value is an object, it is likely an ARB annotation.
@@ -95,20 +95,13 @@ function flattenAndSplitResources(keys, obj) {
95
95
  * assert(ph === "PH({{count}}|1|number of tickets)")
96
96
  * ```
97
97
  *
98
- * @param {{ [key: string]: value }} ARB placeholders
98
+ * @param {{ [key: string]: object }} placeholders ARB placeholders
99
99
  * @returns {string} placeholders formatted in PH() and separated by "\n"
100
100
  */
101
- function arbPlaceholderHandler(placeholders) {
101
+ export function arbPlaceholderHandler(placeholders) {
102
102
  const phs = []
103
103
  for (const [key, val] of Object.entries(placeholders)) {
104
104
  phs.push(`PH({{${key}}}|${val.example}|${val.description})`)
105
105
  }
106
106
  return phs.join("\n")
107
107
  }
108
-
109
- module.exports = {
110
- ARB_ANNOTATION_MARKER,
111
- FLATTEN_SEPARATOR,
112
- flattenAndSplitResources,
113
- arbPlaceholderHandler,
114
- }