@l10nmonster/helpers-json 3.0.0-alpha.9 → 3.0.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/.releaserc.json CHANGED
@@ -20,7 +20,7 @@
20
20
  },
21
21
  {
22
22
  "path": "@semantic-release/npm",
23
- "npmPublish": true
23
+ "npmPublish": false
24
24
  },
25
25
  {
26
26
  "path": "@semantic-release/git",
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## @l10nmonster/helpers-json [3.0.1](https://public-github/l10nmonster/l10nmonster/compare/@l10nmonster/helpers-json@3.0.0...@l10nmonster/helpers-json@3.0.1) (2025-12-20)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * Add proper pluralization expansion support ([3d062bb](https://public-github/l10nmonster/l10nmonster/commit/3d062bbb3272c61e969b419a56a7a5e347ab96c6))
7
+ * Pluralization improvements ([5964250](https://public-github/l10nmonster/l10nmonster/commit/596425092c425cc8d6c312ef58509c4c3c537431))
8
+ * **server:** Fix cart cleanup ([9bbcab9](https://public-github/l10nmonster/l10nmonster/commit/9bbcab93e1fd20aeb09f59c828665159f091f37c))
9
+
1
10
  # Changelog
2
11
 
3
12
  All notable changes to this project will be documented in this file.
package/i18next.js CHANGED
@@ -2,13 +2,13 @@
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';
8
11
 
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
12
  const defaultArbAnnotationHandlers = {
13
13
  description: (_, data) => (data == null ? undefined : data),
14
14
  placeholders: (_, data) => (data == null ? undefined : arbPlaceholderHandler(data)),
@@ -16,139 +16,207 @@ const defaultArbAnnotationHandlers = {
16
16
  }
17
17
 
18
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" }
19
+ * Filter for i18next v4 JSON format.
20
+ * @see https://www.i18next.com/misc/json-format
50
21
  */
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
22
  export class I18nextFilter {
23
+
24
+ /**
25
+ * @param {Object} [params] - Configuration options
26
+ * @param {boolean} [params.enableArbAnnotations=false] - Enable parsing of ARB-style annotations (@key objects)
27
+ * @param {boolean} [params.enableArrays=false] - Enable array syntax in generated output (vs object notation)
28
+ * @param {boolean} [params.emitArbAnnotations=false] - Emit ARB annotations in generated output
29
+ * @param {Object} [params.arbAnnotationHandlers] - Custom handlers for ARB annotation types.
30
+ * Each handler is a function(name, data) returning a string or undefined.
31
+ * Built-in handlers: description, placeholders, DEFAULT.
32
+ */
79
33
  constructor(params) {
80
34
  this.enableArbAnnotations = params?.enableArbAnnotations || false;
81
- this.enablePluralSuffixes = params?.enablePluralSuffixes || false;
82
35
  this.enableArrays = params?.enableArrays || false;
83
36
  this.emitArbAnnotations = params?.emitArbAnnotations || false;
84
37
  this.arbAnnotationHandlers = {
85
38
  ...defaultArbAnnotationHandlers,
86
39
  ...(params?.arbAnnotationHandlers ?? {})
87
- }
40
+ };
88
41
  }
89
42
 
90
- async parseResource({ resource }) {
91
- const response = {
92
- segments: []
43
+ async parseResource({ resource, sourcePluralForms, targetPluralForms }) {
44
+ const response = { segments: [] };
45
+ if (!resource) return response;
46
+
47
+ const unParsedResource = JSON.parse(resource);
48
+ const targetLangs = unParsedResource['@@targetLocales'];
49
+ Array.isArray(targetLangs) && (response.targetLangs = targetLangs);
50
+
51
+ const [parsedResource, notes] = parseResourceAnnotations(
52
+ unParsedResource,
53
+ this.enableArbAnnotations,
54
+ this.arbAnnotationHandlers,
55
+ );
56
+
57
+ const targetFormsSet = new Set(targetPluralForms);
58
+
59
+ // Collect all segments and group potential plurals by baseKey
60
+ const potentialPluralGroups = new Map(); // baseKey -> Map(suffix -> segment)
61
+
62
+ for (const [key, value] of parsedResource) {
63
+ const seg = {
64
+ sid: key,
65
+ str: value,
66
+ ...(notes[key] && { notes: notes[key] }),
67
+ };
68
+ response.segments.push(seg);
69
+
70
+ // Check if key is a plural form (e.g., "key_one", "key_other")
71
+ const underscoreIdx = key.lastIndexOf('_');
72
+ if (underscoreIdx !== -1) {
73
+ const suffix = key.slice(underscoreIdx + 1);
74
+ if (targetFormsSet.has(suffix)) {
75
+ const baseKey = key.slice(0, underscoreIdx);
76
+ if (!potentialPluralGroups.has(baseKey)) {
77
+ potentialPluralGroups.set(baseKey, new Map());
78
+ }
79
+ potentialPluralGroups.get(baseKey).set(suffix, seg);
80
+ }
81
+ }
93
82
  }
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;
83
+
84
+ // For groups with all required forms: set pluralForm and generate missing forms
85
+ // We need to reorder segments so all forms of a plural group are together in CLDR order
86
+ const pluralGroupsToReorder = new Map(); // baseKey -> { firstIndex, forms: Map(suffix -> seg) }
87
+
88
+ for (const [baseKey, forms] of potentialPluralGroups) {
89
+ const hasAllForms = sourcePluralForms.every(form => forms.has(form));
90
+ if (!hasAllForms) continue;
91
+
92
+ // Find the first index of this plural group in segments
93
+ let firstIndex = -1;
94
+ for (let i = 0; i < response.segments.length; i++) {
95
+ if (forms.has(response.segments[i].sid?.split('_').pop()) &&
96
+ response.segments[i].sid?.startsWith(baseKey + '_')) {
97
+ firstIndex = i;
98
+ break;
108
99
  }
109
- response.segments.push(seg);
110
100
  }
101
+
102
+ // Set pluralForm on existing segments
103
+ for (const [suffix, seg] of forms) {
104
+ seg.pluralForm = suffix;
105
+ }
106
+
107
+ // Generate missing forms from _other
108
+ const other = forms.get('other');
109
+ for (const suffix of targetPluralForms) {
110
+ if (!forms.has(suffix)) {
111
+ forms.set(suffix, {
112
+ sid: `${baseKey}_${suffix}`,
113
+ str: other.str,
114
+ pluralForm: suffix,
115
+ ...(other.notes && { notes: other.notes })
116
+ });
117
+ }
118
+ }
119
+
120
+ pluralGroupsToReorder.set(baseKey, { firstIndex, forms });
121
+ }
122
+
123
+ // Rebuild segments with plural groups in correct order
124
+ if (pluralGroupsToReorder.size > 0) {
125
+ const newSegments = [];
126
+ const processedPluralKeys = new Set();
127
+
128
+ for (const seg of response.segments) {
129
+ const underscoreIdx = seg.sid.lastIndexOf('_');
130
+ const suffix = underscoreIdx !== -1 ? seg.sid.slice(underscoreIdx + 1) : null;
131
+ const baseKey = underscoreIdx !== -1 ? seg.sid.slice(0, underscoreIdx) : null;
132
+
133
+ if (baseKey && pluralGroupsToReorder.has(baseKey) && !processedPluralKeys.has(baseKey)) {
134
+ // Insert all forms of this plural group in CLDR order
135
+ processedPluralKeys.add(baseKey);
136
+ const { forms } = pluralGroupsToReorder.get(baseKey);
137
+ for (const form of targetPluralForms) {
138
+ if (forms.has(form)) {
139
+ newSegments.push(forms.get(form));
140
+ }
141
+ }
142
+ } else if (!baseKey || !pluralGroupsToReorder.has(baseKey)) {
143
+ // Non-plural segment
144
+ newSegments.push(seg);
145
+ }
146
+ // Skip plural segments that were already added via the group
147
+ }
148
+
149
+ response.segments = newSegments;
111
150
  }
151
+
112
152
  return response;
113
153
  }
114
154
 
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
155
+ /**
156
+ * Generate a resource file from segments
157
+ * @param {Object} params
158
+ * @param {Array} params.segments - Array of segment objects with sid, str, etc.
159
+ * @param {Function} params.translator - Translator function(seg) that returns translated string
160
+ * @param {Array} [params.targetPluralForms] - Array of plural forms required for target language
161
+ * @returns {Promise<string>} JSON string of the generated resource
162
+ */
163
+ async generateResource({ segments, translator, targetPluralForms }) {
164
+ const targetFormsSet = targetPluralForms ? new Set(targetPluralForms) : null;
165
+
166
+ // Collect translations
167
+ const translations = new Map(); // sid -> translatedStr
168
+ const pluralGroups = new Map(); // baseKey -> Set of sids
169
+
170
+ for (const seg of segments) {
171
+ // Skip plural forms not needed for target language
172
+ if (seg.pluralForm && targetFormsSet && !targetFormsSet.has(seg.pluralForm)) {
173
+ continue;
174
+ }
175
+ const translatedStr = await translator(seg);
176
+ if (translatedStr != null) {
177
+ translations.set(seg.sid, translatedStr.str);
178
+
179
+ // Track plural groups
180
+ if (seg.pluralForm) {
181
+ const underscoreIdx = seg.sid.lastIndexOf('_');
182
+ if (underscoreIdx !== -1) {
183
+ const baseKey = seg.sid.slice(0, underscoreIdx);
184
+ if (!pluralGroups.has(baseKey)) {
185
+ pluralGroups.set(baseKey, new Set());
186
+ }
187
+ pluralGroups.get(baseKey).add(seg.sid);
188
+ }
125
189
  }
126
190
  }
127
191
  }
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];
192
+
193
+ // Build flatResource with plural forms grouped and ordered
194
+ const flatResource = {};
195
+ const processedPluralKeys = new Set();
196
+
197
+ for (const seg of segments) {
198
+ if (!translations.has(seg.sid)) continue;
199
+
200
+ if (seg.pluralForm) {
201
+ const underscoreIdx = seg.sid.lastIndexOf('_');
202
+ const baseKey = underscoreIdx !== -1 ? seg.sid.slice(0, underscoreIdx) : null;
203
+
204
+ if (baseKey && pluralGroups.has(baseKey) && !processedPluralKeys.has(baseKey)) {
205
+ // Output all forms of this plural group in CLDR order
206
+ processedPluralKeys.add(baseKey);
207
+ for (const form of targetPluralForms) {
208
+ const sid = `${baseKey}_${form}`;
209
+ if (translations.has(sid)) {
210
+ flatResource[sid] = translations.get(sid);
211
+ }
145
212
  }
146
- } else {
147
- // No regex match, can't determine corresponding translation, so delete
148
- delete flatResource[key];
149
213
  }
214
+ // Skip individual plural forms - they were added via the group
215
+ } else {
216
+ flatResource[seg.sid] = translations.get(seg.sid);
150
217
  }
151
218
  }
219
+
152
220
  return `${JSON.stringify(unflatten(flatResource, { object: !this.enableArrays }), null, 2)}\n`;
153
221
  }
154
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@l10nmonster/helpers-json",
3
- "version": "3.0.0-alpha.9",
3
+ "version": "3.0.1",
4
4
  "description": "Helpers to deal with JSON file formats",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -13,7 +13,7 @@
13
13
  "flat": "^6"
14
14
  },
15
15
  "peerDependencies": {
16
- "@l10nmonster/core": "^3.0.0-alpha.0"
16
+ "@l10nmonster/core": "3.1.0"
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=22.11.0"
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
+ }