@l10nmonster/helpers-json 1.0.4 → 3.0.0-alpha.10

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.
@@ -0,0 +1,31 @@
1
+ {
2
+ "branches": [
3
+ "main",
4
+ {
5
+ "name": "next",
6
+ "prerelease": "alpha"
7
+ },
8
+ {
9
+ "name": "beta",
10
+ "prerelease": "beta"
11
+ }
12
+ ],
13
+ "tagFormat": "@l10nmonster/helpers-json@${version}",
14
+ "plugins": [
15
+ "@semantic-release/commit-analyzer",
16
+ "@semantic-release/release-notes-generator",
17
+ {
18
+ "path": "@semantic-release/changelog",
19
+ "changelogFile": "CHANGELOG.md"
20
+ },
21
+ {
22
+ "path": "@semantic-release/npm",
23
+ "npmPublish": true
24
+ },
25
+ {
26
+ "path": "@semantic-release/git",
27
+ "assets": ["CHANGELOG.md", "package.json"],
28
+ "message": "chore(release): @l10nmonster/helpers-json@${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
29
+ }
30
+ ]
31
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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 >= 22.11.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,22 +2,55 @@
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
- const isArbAnnotations = e => e[0].split('.').slice(-2)[0].startsWith(ARB_ANNOTATION_MARKER);
9
+ const isArbAnnotations = e => e[0].split('.').some(segment => segment.startsWith(ARB_ANNOTATION_MARKER));
10
10
  const validPluralSuffixes = new Set(['one', 'other', 'zero', 'two', 'few', 'many']);
11
- const extractArbGroupsRegex = /(?<prefix>.+?\.)?@(?<key>\S+)\.(?<attribute>\S+)/;
11
+ const extractArbGroupsRegex = /(?<prefix>.+?\.)?@(?<key>[^.]+)\.(?<attribute>.+)/;
12
12
  const defaultArbAnnotationHandlers = {
13
13
  description: (_, data) => (data == null ? undefined : data),
14
14
  placeholders: (_, data) => (data == null ? undefined : arbPlaceholderHandler(data)),
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;
@@ -80,11 +113,11 @@ exports.Filter = class I18nextFilter {
80
113
  }
81
114
 
82
115
  async translateResource({ resource, translator }) {
83
- let flatResource = flat.flatten(JSON.parse(resource));
116
+ let flatResource = flatten(JSON.parse(resource));
84
117
  for (const entry of Object.entries(flatResource)) {
85
118
  if (!this.enableArbAnnotations || !isArbAnnotations(entry)) {
86
119
  const translation = await translator(...entry);
87
- if (translation === undefined) {
120
+ if (translation === null) {
88
121
  delete flatResource[entry[0]];
89
122
  } else {
90
123
  flatResource[entry[0]] = translation;
@@ -94,14 +127,29 @@ exports.Filter = class I18nextFilter {
94
127
  }
95
128
  if (this.enableArbAnnotations) {
96
129
  for (const entry of Object.entries(flatResource).filter(entry => isArbAnnotations(entry))) {
97
- const arbGroups = extractArbGroupsRegex.exec(entry[0]).groups;
98
- const sid = `${arbGroups.prefix ?? ''}${arbGroups.key}`;
99
- if (!this.emitArbAnnotations || !flatResource[sid]) {
100
- delete flatResource[entry[0]];
130
+ const [key, value] = entry;
131
+
132
+ // Always delete if not emitting annotations
133
+ if (!this.emitArbAnnotations) {
134
+ delete flatResource[key];
135
+ continue;
136
+ }
137
+
138
+ // Only keep if regex matches and corresponding translation exists and is not null
139
+ const match = extractArbGroupsRegex.exec(key);
140
+ if (match?.groups) {
141
+ const { prefix = '', key: arbKey, attribute } = match.groups;
142
+ const sid = `${prefix}${arbKey}`;
143
+ if (!Object.prototype.hasOwnProperty.call(flatResource, sid) || flatResource[sid] == null) {
144
+ delete flatResource[key];
145
+ }
146
+ } else {
147
+ // No regex match, can't determine corresponding translation, so delete
148
+ delete flatResource[key];
101
149
  }
102
150
  }
103
151
  }
104
- return JSON.stringify(flat.unflatten(flatResource, { object: !this.enableArrays }), null, 2) + '\n';
152
+ return `${JSON.stringify(unflatten(flatResource, { object: !this.enableArrays }), null, 2)}\n`;
105
153
  }
106
154
  }
107
155
 
@@ -109,7 +157,7 @@ exports.Filter = class I18nextFilter {
109
157
  // - "keyNesting": "reuse $t(keyDeep.inner)", or
110
158
  // - "keyInterpolate": "replace this {{value}}"
111
159
  // See: https://www.i18next.com/misc/json-format#i18next-json-v4
112
- exports.phDecoder = regex.decoderMaker(
160
+ export const phDecoder = regex.decoderMaker(
113
161
  'i18nextKey',
114
162
  /(?<nestingPh>\$t\([\w:.]+\))|(?<doubleBracePh>{{[^}]+}})/g,
115
163
  (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,21 @@
1
1
  {
2
- "name": "@l10nmonster/helpers-json",
3
- "version": "1.0.4",
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.10",
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": "^3.0.0-alpha.0"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.11.0"
20
+ }
17
21
  }
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
- }