@l10nmonster/helpers-json 3.0.0-alpha.16 → 3.0.0-alpha.17

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.
Files changed (3) hide show
  1. package/i18next.js +103 -117
  2. package/package.json +1 -1
  3. package/utils.js +65 -0
package/i18next.js CHANGED
@@ -2,13 +2,16 @@
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
- import { flatten, unflatten } from 'flat';
5
+ import { unflatten } from 'flat';
6
6
  import { regex } from '@l10nmonster/core';
7
- import { flattenAndSplitResources, ARB_ANNOTATION_MARKER, arbPlaceholderHandler } from './utils.js';
7
+ import {
8
+ arbPlaceholderHandler,
9
+ parseResourceAnnotations,
10
+ } from './utils.js';
11
+
12
+ const ALL_PLURAL_FORMS = ['zero', 'one', 'two', 'few', 'many', 'other'];
13
+ const DEFAULT_SOURCE_PLURAL_FORMS = ['one', 'other'];
8
14
 
9
- const isArbAnnotations = e => e[0].split('.').some(segment => segment.startsWith(ARB_ANNOTATION_MARKER));
10
- const validPluralSuffixes = new Set(['one', 'other', 'zero', 'two', 'few', 'many']);
11
- const extractArbGroupsRegex = /(?<prefix>.+?\.)?@(?<key>[^.]+)\.(?<attribute>.+)/;
12
15
  const defaultArbAnnotationHandlers = {
13
16
  description: (_, data) => (data == null ? undefined : data),
14
17
  placeholders: (_, data) => (data == null ? undefined : arbPlaceholderHandler(data)),
@@ -16,139 +19,122 @@ const defaultArbAnnotationHandlers = {
16
19
  }
17
20
 
18
21
  /**
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" }
22
+ * Filter for i18next v4 JSON format.
23
+ * @see https://www.i18next.com/misc/json-format
50
24
  */
51
- function parseResourceAnnotations(resource, enableArbAnnotations, arbAnnotationHandlers) {
52
- if (!enableArbAnnotations) {
53
- return [ Object.entries(flatten(resource)), {} ]
54
- }
55
-
56
- const { res, notes } = flattenAndSplitResources([], resource)
57
- const parsedNotes = {}
58
- for (const [key, arbAnnotations] of Object.entries(notes)) {
59
- if (typeof arbAnnotations === "object") {
60
- const notes = []
61
- for (const [annotation, data] of Object.entries(arbAnnotations)) {
62
- const handler = arbAnnotationHandlers[annotation] ?? arbAnnotationHandlers.DEFAULT
63
- if (handler != null) {
64
- const val = handler(annotation, data)
65
- if (val !== undefined) {
66
- notes.push(val)
67
- }
68
- }
69
- }
70
- parsedNotes[key] = notes.join("\n")
71
- } else {
72
- parsedNotes[key] = arbAnnotations
73
- }
74
- }
75
- return [ Object.entries(res), parsedNotes ];
76
- }
77
-
78
25
  export class I18nextFilter {
26
+
27
+ /**
28
+ * @param {Object} [params] - Configuration options
29
+ * @param {boolean} [params.enableArbAnnotations=false] - Enable parsing of ARB-style annotations (@key objects)
30
+ * @param {boolean} [params.enableArrays=false] - Enable array syntax in generated output (vs object notation)
31
+ * @param {boolean} [params.emitArbAnnotations=false] - Emit ARB annotations in generated output
32
+ * @param {Object} [params.arbAnnotationHandlers] - Custom handlers for ARB annotation types.
33
+ * Each handler is a function(name, data) returning a string or undefined.
34
+ * Built-in handlers: description, placeholders, DEFAULT.
35
+ */
79
36
  constructor(params) {
80
37
  this.enableArbAnnotations = params?.enableArbAnnotations || false;
81
- this.enablePluralSuffixes = params?.enablePluralSuffixes || false;
82
38
  this.enableArrays = params?.enableArrays || false;
83
39
  this.emitArbAnnotations = params?.emitArbAnnotations || false;
84
40
  this.arbAnnotationHandlers = {
85
41
  ...defaultArbAnnotationHandlers,
86
42
  ...(params?.arbAnnotationHandlers ?? {})
87
- }
43
+ };
88
44
  }
89
45
 
90
- async parseResource({ resource }) {
91
- const response = {
92
- segments: []
46
+ async parseResource({ resource, sourcePluralForms, targetPluralForms }) {
47
+ const response = { segments: [] };
48
+ if (!resource) return response;
49
+
50
+ const unParsedResource = JSON.parse(resource);
51
+ const targetLangs = unParsedResource['@@targetLocales'];
52
+ Array.isArray(targetLangs) && (response.targetLangs = targetLangs);
53
+
54
+ const [parsedResource, notes] = parseResourceAnnotations(
55
+ unParsedResource,
56
+ this.enableArbAnnotations,
57
+ this.arbAnnotationHandlers,
58
+ );
59
+
60
+ // Use provided plural forms or defaults
61
+ const requiredSourceForms = sourcePluralForms ?? DEFAULT_SOURCE_PLURAL_FORMS;
62
+ const requiredTargetForms = targetPluralForms ?? ALL_PLURAL_FORMS;
63
+ const targetFormsSet = new Set(requiredTargetForms);
64
+
65
+ // Collect all segments and group potential plurals by baseKey
66
+ const potentialPluralGroups = new Map(); // baseKey -> Map(suffix -> segment)
67
+
68
+ for (const [key, value] of parsedResource) {
69
+ const seg = {
70
+ sid: key,
71
+ str: value,
72
+ ...(notes[key] && { notes: notes[key] }),
73
+ };
74
+ response.segments.push(seg);
75
+
76
+ // Check if key is a plural form (e.g., "key_one", "key_other")
77
+ const underscoreIdx = key.lastIndexOf('_');
78
+ if (underscoreIdx !== -1) {
79
+ const suffix = key.slice(underscoreIdx + 1);
80
+ if (targetFormsSet.has(suffix)) {
81
+ const baseKey = key.slice(0, underscoreIdx);
82
+ if (!potentialPluralGroups.has(baseKey)) {
83
+ potentialPluralGroups.set(baseKey, new Map());
84
+ }
85
+ potentialPluralGroups.get(baseKey).set(suffix, seg);
86
+ }
87
+ }
93
88
  }
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;
89
+
90
+ // For groups with all required forms: set pluralForm and generate missing forms
91
+ for (const [baseKey, forms] of potentialPluralGroups) {
92
+ const hasAllForms = requiredSourceForms.every(form => forms.has(form));
93
+ if (!hasAllForms) continue;
94
+
95
+ // Set pluralForm on existing segments
96
+ for (const [suffix, seg] of forms) {
97
+ seg.pluralForm = suffix;
98
+ }
99
+
100
+ // Generate missing forms from _other
101
+ const other = forms.get('other');
102
+ for (const suffix of requiredTargetForms) {
103
+ if (!forms.has(suffix)) {
104
+ response.segments.push({
105
+ sid: `${baseKey}_${suffix}`,
106
+ str: other.str,
107
+ pluralForm: suffix,
108
+ ...(other.notes && { notes: other.notes })
109
+ });
108
110
  }
109
- response.segments.push(seg);
110
111
  }
111
112
  }
112
113
  return response;
113
114
  }
114
115
 
115
- async translateResource({ resource, translator }) {
116
- let flatResource = flatten(JSON.parse(resource));
117
- for (const entry of Object.entries(flatResource)) {
118
- if (!this.enableArbAnnotations || !isArbAnnotations(entry)) {
119
- const translation = await translator(...entry);
120
- if (translation === null) {
121
- delete flatResource[entry[0]];
122
- } else {
123
- flatResource[entry[0]] = translation;
124
- // TODO: deal with pluralized forms as well
125
- }
116
+ /**
117
+ * Generate a resource file from segments
118
+ * @param {Object} params
119
+ * @param {Array} params.segments - Array of segment objects with sid, str, etc.
120
+ * @param {Function} params.translator - Translator function(seg) that returns translated string
121
+ * @param {Array} [params.targetPluralForms] - Array of plural forms required for target language
122
+ * @returns {Promise<string>} JSON string of the generated resource
123
+ */
124
+ async generateResource({ segments, translator, targetPluralForms }) {
125
+ const targetFormsSet = targetPluralForms ? new Set(targetPluralForms) : null;
126
+ const flatResource = {};
127
+ for (const seg of segments) {
128
+ // Skip plural forms not needed for target language
129
+ if (seg.pluralForm && targetFormsSet && !targetFormsSet.has(seg.pluralForm)) {
130
+ continue;
126
131
  }
127
- }
128
- if (this.enableArbAnnotations) {
129
- for (const entry of Object.entries(flatResource).filter(entry => isArbAnnotations(entry))) {
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];
149
- }
132
+ const translatedStr = await translator(seg);
133
+ if (translatedStr != null) {
134
+ flatResource[seg.sid] = translatedStr.str;
150
135
  }
151
136
  }
137
+
152
138
  return `${JSON.stringify(unflatten(flatResource, { object: !this.enableArrays }), null, 2)}\n`;
153
139
  }
154
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@l10nmonster/helpers-json",
3
- "version": "3.0.0-alpha.16",
3
+ "version": "3.0.0-alpha.17",
4
4
  "description": "Helpers to deal with JSON file formats",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/utils.js CHANGED
@@ -1,3 +1,8 @@
1
+ // Intentionally uses `== null` to handle undefined and null
2
+ /* eslint-disable no-eq-null, eqeqeq */
3
+
4
+ import { flatten } from 'flat';
5
+
1
6
  export const ARB_ANNOTATION_MARKER = "@";
2
7
  export const FLATTEN_SEPARATOR = ".";
3
8
 
@@ -105,3 +110,63 @@ export function arbPlaceholderHandler(placeholders) {
105
110
  }
106
111
  return phs.join("\n")
107
112
  }
113
+
114
+ /**
115
+ * @function parseResourceAnnotations
116
+ *
117
+ * @description
118
+ * Parse resource annotations according to the given configuration.
119
+ *
120
+ * @param {object} resource - The resource to parse.
121
+ * @param {boolean} enableArbAnnotations - Whether to enable annotations
122
+ * @param {object} arbAnnotationHandlers - An object mapping annotation names to a function which takes an annotation name and its value and returns a string.
123
+ *
124
+ * @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.
125
+ *
126
+ * @example
127
+ * const resource = {
128
+ * "key": "value",
129
+ * "@key": {
130
+ * "description": "description for key",
131
+ * "placeholders": {
132
+ * "placeholder": {
133
+ * "example": "example for placeholder",
134
+ * "description": "description for placeholder",
135
+ * }
136
+ * }
137
+ * }
138
+ * };
139
+ * const [segments, notes] = parseResourceAnnotations(resource, true, {
140
+ * description: (_, data) => (data == null ? undefined : data),
141
+ * placeholders: (_, data) => (data == null ? undefined : arbPlaceholderHandler(data)),
142
+ * DEFAULT: (name, data) => (data == null ? undefined : `${name}: ${data}`),
143
+ * });
144
+ * // segments is [["key", "value"]]
145
+ * // notes is { "key": "description for key\nplaceholder: example for placeholder - description for placeholder" }
146
+ */
147
+ export function parseResourceAnnotations(resource, enableArbAnnotations, arbAnnotationHandlers) {
148
+ if (!enableArbAnnotations) {
149
+ return [ Object.entries(flatten(resource)), {} ]
150
+ }
151
+
152
+ const { res, notes } = flattenAndSplitResources([], resource)
153
+ const parsedNotes = {}
154
+ for (const [key, arbAnnotations] of Object.entries(notes)) {
155
+ if (typeof arbAnnotations === "object") {
156
+ const notesList = []
157
+ for (const [annotation, data] of Object.entries(arbAnnotations)) {
158
+ const handler = arbAnnotationHandlers[annotation] ?? arbAnnotationHandlers.DEFAULT
159
+ if (handler != null) {
160
+ const val = handler(annotation, data)
161
+ if (val !== undefined) {
162
+ notesList.push(val)
163
+ }
164
+ }
165
+ }
166
+ parsedNotes[key] = notesList.join("\n")
167
+ } else {
168
+ parsedNotes[key] = arbAnnotations
169
+ }
170
+ }
171
+ return [ Object.entries(res), parsedNotes ];
172
+ }