@l10nmonster/helpers-android 3.0.0-alpha.9 → 3.0.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## @l10nmonster/helpers-android [3.0.2](https://public-github/l10nmonster/l10nmonster/compare/@l10nmonster/helpers-android@3.0.1...@l10nmonster/helpers-android@3.0.2) (2025-12-23)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * Improve type definitions and checks ([826b412](https://public-github/l10nmonster/l10nmonster/commit/826b412f0f7e761d404165a243b0c2b26c416ac1))
7
+
8
+
9
+
10
+
11
+
12
+ ### Dependencies
13
+
14
+ * **@l10nmonster/core:** upgraded to 3.1.1
15
+
16
+ ## @l10nmonster/helpers-android [3.0.1](https://public-github/l10nmonster/l10nmonster/compare/@l10nmonster/helpers-android@3.0.0...@l10nmonster/helpers-android@3.0.1) (2025-12-20)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * Add proper pluralization expansion support ([3d062bb](https://public-github/l10nmonster/l10nmonster/commit/3d062bbb3272c61e969b419a56a7a5e347ab96c6))
22
+ * Pluralization improvements ([5964250](https://public-github/l10nmonster/l10nmonster/commit/596425092c425cc8d6c312ef58509c4c3c537431))
23
+ * **server:** Fix cart cleanup ([9bbcab9](https://public-github/l10nmonster/l10nmonster/commit/9bbcab93e1fd20aeb09f59c828665159f091f37c))
24
+
1
25
  # Changelog
2
26
 
3
27
  All notable changes to this project will be documented in this file.
package/filter.js CHANGED
@@ -7,6 +7,8 @@ import { XMLParser, XMLBuilder } from 'fast-xml-parser';
7
7
  import { default as formatXml } from 'xml-formatter';
8
8
  import { logVerbose } from '@l10nmonster/core';
9
9
 
10
+ /** @typedef {import('@l10nmonster/core').ResourceFilter} ResourceFilter */
11
+
10
12
  function collapseTextNodes(node) {
11
13
  return node.map(e => e['#text']).join('').trim();
12
14
  }
@@ -19,6 +21,7 @@ function isTranslatableNode(resNode, str) {
19
21
 
20
22
  /**
21
23
  * Class representing an AndroidFilter for parsing and translating Android resource files.
24
+ * @implements {ResourceFilter}
22
25
  */
23
26
  export class AndroidXMLFilter {
24
27
 
@@ -35,9 +38,10 @@ export class AndroidXMLFilter {
35
38
  * Parse an Android resource file and extract translatable segments.
36
39
  * @param {Object} params - Parameters for parsing the resource.
37
40
  * @param {string} params.resource - The XML content of the Android resource file.
41
+ * @param {string[]} [params.targetPluralForms] - Array of plural forms required for target languages.
38
42
  * @returns {Promise<Object>} An object containing the extracted segments.
39
43
  */
40
- async parseResource({ resource }) {
44
+ async parseResource({ resource, targetPluralForms }) {
41
45
  const segments = [];
42
46
  const parsingOptions = {
43
47
  ignoreAttributes: false,
@@ -52,6 +56,7 @@ export class AndroidXMLFilter {
52
56
  parseTagValue: false,
53
57
  trimValues: true,
54
58
  };
59
+
55
60
  const parser = new XMLParser(parsingOptions);
56
61
  for (const rootNode of parser.parse(resource)) {
57
62
  if ('resources' in rootNode) {
@@ -71,17 +76,42 @@ export class AndroidXMLFilter {
71
76
  lastComment = null;
72
77
  }
73
78
  } else if ('plurals' in resNode) { // TODO: support string-array
79
+ const pluralName = resNode[':@'].name;
80
+ const pluralForms = new Map(); // quantity -> { seg, notes }
81
+ let pluralComment = lastComment;
82
+
83
+ // Collect existing plural forms
74
84
  for (const itemNode of resNode.plurals) {
75
85
  if ('#comment' in itemNode) {
76
- lastComment = itemNode['#comment'].map(e => e['#text']).join('').trim();
86
+ pluralComment = itemNode['#comment'].map(e => e['#text']).join('').trim();
77
87
  } else if ('item' in itemNode) {
88
+ const quantity = itemNode[':@'].quantity;
78
89
  const seg = {
79
- sid: `${resNode[':@'].name}_${itemNode[':@'].quantity}`,
80
- isSuffixPluralized: true,
90
+ sid: `${pluralName}_${quantity}`,
91
+ pluralForm: quantity,
81
92
  str: collapseTextNodes(itemNode.item)
82
93
  };
83
- lastComment && (seg.notes = lastComment);
84
- segments.push(seg);
94
+ pluralComment && (seg.notes = pluralComment);
95
+ pluralForms.set(quantity, seg);
96
+ }
97
+ }
98
+
99
+ // Android <plurals> element explicitly defines plural rules
100
+ // Expansion can happen as long as 'other' form is present
101
+ const otherForm = pluralForms.get('other');
102
+
103
+ // Add forms in natural plural order (existing or generated from 'other')
104
+ for (const form of targetPluralForms) {
105
+ if (pluralForms.has(form)) {
106
+ segments.push(pluralForms.get(form));
107
+ } else if (otherForm) {
108
+ // Generate missing form from 'other'
109
+ segments.push({
110
+ sid: `${pluralName}_${form}`,
111
+ pluralForm: form,
112
+ str: otherForm.str,
113
+ ...(otherForm.notes && { notes: otherForm.notes })
114
+ });
85
115
  }
86
116
  }
87
117
  lastComment = null;
@@ -102,9 +132,10 @@ export class AndroidXMLFilter {
102
132
  * @param {Object} params - Parameters for translating the resource.
103
133
  * @param {string} params.resource - The XML content of the Android resource file.
104
134
  * @param {Function} params.translator - A function that translates a string given its ID and source text.
135
+ * @param {string[]} [params.targetPluralForms] - Array of plural forms required for the target language.
105
136
  * @returns {Promise<string|null>} The translated XML content, or null if no translations were made.
106
137
  */
107
- async translateResource({ resource, translator }) {
138
+ async translateResource({ resource, translator, targetPluralForms }) {
108
139
  const parsingOptions = {
109
140
  ignoreAttributes: false,
110
141
  processEntities: true,
@@ -118,6 +149,7 @@ export class AndroidXMLFilter {
118
149
  parseTagValue: false,
119
150
  trimValues: true,
120
151
  };
152
+
121
153
  const parser = new XMLParser(parsingOptions);
122
154
  const parsedResource = parser.parse(resource);
123
155
  const nodesToDelete = [];
@@ -138,26 +170,51 @@ export class AndroidXMLFilter {
138
170
  } else {
139
171
  nodesToDelete.push(resNode);
140
172
  }
141
- } else if ('plurals' in resNode) { // TODO: deal with plurals of the target language, not the source
142
- let dropPlural = false;
143
- const itemNodesToDelete = []
173
+ } else if ('plurals' in resNode) {
174
+ const pluralName = resNode[':@'].name;
175
+
176
+ // Collect source plural forms
177
+ const sourceForms = new Map(); // quantity -> { text, itemNode }
144
178
  for (const itemNode of resNode.plurals) {
145
- if ('#comment' in itemNode) {
146
- itemNodesToDelete.push(itemNode)
147
- // eslint-disable-next-line no-continue
148
- continue;
179
+ if ('item' in itemNode) {
180
+ sourceForms.set(itemNode[':@'].quantity, {
181
+ text: collapseTextNodes(itemNode.item),
182
+ itemNode
183
+ });
184
+ }
185
+ }
186
+
187
+ // Get 'other' form for generating missing target forms
188
+ // Android <plurals> is explicitly a plural, so we can expand as long as 'other' exists
189
+ const otherForm = sourceForms.get('other');
190
+
191
+ // Build new plurals node with only required target forms in CLDR order
192
+ const newPluralItems = [];
193
+ let dropPlural = false;
194
+
195
+ for (const form of targetPluralForms) {
196
+ const sourceForm = sourceForms.get(form) ?? otherForm;
197
+ if (!sourceForm) {
198
+ // Can't generate this required form - no source and no fallback
199
+ dropPlural = true;
200
+ break;
149
201
  }
150
- const translation = await translator(`${resNode[':@'].name}_${itemNode[':@'].quantity}`, collapseTextNodes(itemNode.item));
202
+ const translation = await translator(`${pluralName}_${form}`, sourceForm.text);
151
203
  if (translation === undefined) {
152
204
  dropPlural = true;
153
- } else {
154
- itemNode.item = [ { '#text': translation } ];
205
+ break;
155
206
  }
207
+ newPluralItems.push({
208
+ item: [{ '#text': translation }],
209
+ ':@': { quantity: form }
210
+ });
156
211
  }
157
- resNode.plurals = resNode.plurals.filter(n => !itemNodesToDelete.includes(n))
158
- if (dropPlural) {
212
+
213
+ if (dropPlural || newPluralItems.length === 0) {
159
214
  nodesToDelete.push(resNode);
160
215
  } else {
216
+ // Replace plurals with new items containing only target forms
217
+ resNode.plurals = newPluralItems;
161
218
  translated++;
162
219
  }
163
220
  } else {
@@ -172,7 +229,8 @@ export class AndroidXMLFilter {
172
229
  }
173
230
  const builder = new XMLBuilder(parsingOptions);
174
231
  const roughXML = builder.build(parsedResource);
175
- // eslint-disable-next-line prefer-template
176
- return formatXml(roughXML, { collapseContent: true, indentation: this.indentation, lineSeparator: '\n' }) + '\n';
232
+
233
+ // @ts-ignore - xml-formatter types don't match actual module export
234
+ return `${formatXml(roughXML, { collapseContent: true, indentation: this.indentation, lineSeparator: '\n' })}\n`;
177
235
  }
178
236
  }
package/index.js CHANGED
@@ -5,6 +5,8 @@ const androidControlCharsToDecode = {
5
5
  n: '\n',
6
6
  t: '\t',
7
7
  };
8
+
9
+ /** @type {import('@l10nmonster/core').DecoderFunction} */
8
10
  export const escapesDecoder = regex.decoderMaker(
9
11
  'androidEscapesDecoder',
10
12
  /(?<node>\\(?<escapedChar>[@?\\'"])|\\(?<escapedControl>[nt])|\\u(?<codePoint>[0-9A-Za-z]{4}))/g,
@@ -16,7 +18,10 @@ export const escapesDecoder = regex.decoderMaker(
16
18
  )
17
19
  );
18
20
 
19
- // Android lint doesn't accept % but accepts %%. % should be replaced with '\u0025' but %% shouldn't
21
+ /**
22
+ * Android lint doesn't accept % but accepts %%. % should be replaced with '\u0025' but %% shouldn't
23
+ * @type {import('@l10nmonster/core').TextEncoderFunction}
24
+ */
20
25
  export const escapesEncoder = (str, flags = {}) => {
21
26
  let escapedStr = str.replaceAll(/[@\\'"]/g, '\\$&').replaceAll('\t', '\\t').replaceAll('\n', '\\n').replaceAll(/(?<!%)%(?!%)/g, '\\u0025');
22
27
  // eslint-disable-next-line prefer-template
@@ -26,9 +31,14 @@ export const escapesEncoder = (str, flags = {}) => {
26
31
  return escapedStr;
27
32
  };
28
33
 
34
+ /** @type {import('@l10nmonster/core').PartTransformer} */
35
+ // @ts-ignore - Part union type narrowing not supported by TypeScript in this pattern
29
36
  export const spaceCollapser = (parts) => parts.map(p => (p.t === 's' ? { ...p, v: p.v.replaceAll(/[ \f\n\r\t\v\u2028\u2029]+/g, ' ')} : p));
30
37
 
31
- // C-style placeholders (based on the ios one)
38
+ /**
39
+ * C-style placeholders (based on the ios one)
40
+ * @type {import('@l10nmonster/core').DecoderFunction}
41
+ */
32
42
  export const phDecoder = regex.decoderMaker(
33
43
  'iosPHDecoder',
34
44
  /(?<tag>%(?:\d\$)?[0#+-]?[0-9*]*\.?\d*[hl]{0,2}[jztL]?[diuoxXeEfgGaAcpsSn])/g,
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@l10nmonster/helpers-android",
3
- "version": "3.0.0-alpha.9",
3
+ "version": "3.0.2",
4
4
  "description": "Helpers to deal with Android file formats",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "scripts": {
8
- "test": "node --test"
8
+ "test": "node --test",
9
+ "typecheck": "tsc --noEmit"
9
10
  },
10
11
  "author": "Diego Lagunas",
11
12
  "license": "MIT",
@@ -14,6 +15,6 @@
14
15
  "xml-formatter": "^3"
15
16
  },
16
17
  "peerDependencies": {
17
- "@l10nmonster/core": "^3.0.0-alpha.0"
18
+ "@l10nmonster/core": "3.1.1"
18
19
  }
19
20
  }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../tsconfig.base.json",
3
+ "include": [
4
+ "*.js",
5
+ "**/*.js"
6
+ ],
7
+ "exclude": [
8
+ "node_modules",
9
+ "**/node_modules",
10
+ "test/**",
11
+ "tests/**",
12
+ "**/*.test.js",
13
+ "**/*.spec.js",
14
+ "dist/**",
15
+ "ui/**",
16
+ "types/**"
17
+ ]
18
+ }
package/.releaserc.json DELETED
@@ -1,31 +0,0 @@
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-android@${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-android@${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
29
- }
30
- ]
31
- }