@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 +24 -0
- package/filter.js +79 -21
- package/index.js +12 -2
- package/package.json +4 -3
- package/tsconfig.json +18 -0
- package/.releaserc.json +0 -31
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
|
-
|
|
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: `${
|
|
80
|
-
|
|
90
|
+
sid: `${pluralName}_${quantity}`,
|
|
91
|
+
pluralForm: quantity,
|
|
81
92
|
str: collapseTextNodes(itemNode.item)
|
|
82
93
|
};
|
|
83
|
-
|
|
84
|
-
|
|
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) {
|
|
142
|
-
|
|
143
|
-
|
|
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 ('
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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(`${
|
|
202
|
+
const translation = await translator(`${pluralName}_${form}`, sourceForm.text);
|
|
151
203
|
if (translation === undefined) {
|
|
152
204
|
dropPlural = true;
|
|
153
|
-
|
|
154
|
-
itemNode.item = [ { '#text': translation } ];
|
|
205
|
+
break;
|
|
155
206
|
}
|
|
207
|
+
newPluralItems.push({
|
|
208
|
+
item: [{ '#text': translation }],
|
|
209
|
+
':@': { quantity: form }
|
|
210
|
+
});
|
|
156
211
|
}
|
|
157
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
}
|