@l10nmonster/helpers-android 1.0.0
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/README.md +18 -0
- package/filter.js +144 -0
- package/index.js +37 -0
- package/package.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# L10n Monster Android Helpers
|
|
2
|
+
|
|
3
|
+
|Module|Export|Description|
|
|
4
|
+
|---|---|---|
|
|
5
|
+
|`helpers-android`|`Filter`|Filter for Android xml files.|
|
|
6
|
+
|`helpers-android`|`escapesDecoder`|Decoder for escaped chars like `\n` and `\u00a0`.|
|
|
7
|
+
|`helpers-android`|`spaceCollapser`|Decoder to convert multiple whitespace into a single space.|
|
|
8
|
+
|`helpers-android`|`phDecoder`|Decoder for `%d` style placeholders.|
|
|
9
|
+
|`helpers-android`|`escapesEncoder`|Encoder for escaped chars as required by Android.|
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
this.resourceFilter = new android.Filter({
|
|
14
|
+
comment: 'pre',
|
|
15
|
+
});
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A filter for XML files used in Android apps. The `comment` property specifies whether developer notes are placed before, after, or on the same line (`pre`, `post`, `right` respectively).
|
package/filter.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Check https://developer.android.com/guide/topics/resources/string-resource#FormattingAndStyling
|
|
2
|
+
// Currently XML parsing is disabled for <string> and <item>. This is to make it easier to inject translations
|
|
3
|
+
// and preserve the source but the downside is that we miss automatic CDATA handling, whitespace trimming, entity management.
|
|
4
|
+
// TODO: double quotes are meant to preserve newlines but we treat double quotes like CDATA (which doesn't)
|
|
5
|
+
|
|
6
|
+
const { XMLParser, XMLBuilder } = require('fast-xml-parser');
|
|
7
|
+
const xmlFormatter = require('xml-formatter');
|
|
8
|
+
|
|
9
|
+
function collapseTextNodes(node) {
|
|
10
|
+
return node.map(e => e['#text']).join('').trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const resourceReferenceRegex=/^@(?:[0-9A-Za-z_$]+:)?[0-9A-Za-z_$]+\/[0-9A-Za-z_$]+$/;
|
|
14
|
+
|
|
15
|
+
function isTranslatableNode(resNode, str) {
|
|
16
|
+
return resNode[':@'].translatable !== 'false' && !resNode[':@']['/'] && !resourceReferenceRegex.test(str);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = class AndroidFilter {
|
|
20
|
+
constructor({ indentation }) {
|
|
21
|
+
this.indentation = indentation || '\t';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async parseResource({ resource }) {
|
|
25
|
+
const segments = [];
|
|
26
|
+
const parsingOptions = {
|
|
27
|
+
ignoreAttributes: false,
|
|
28
|
+
processEntities: true,
|
|
29
|
+
htmlEntities: true,
|
|
30
|
+
allowBooleanAttributes: true,
|
|
31
|
+
alwaysCreateTextNode: true,
|
|
32
|
+
attributeNamePrefix : '',
|
|
33
|
+
commentPropName: "#comment",
|
|
34
|
+
preserveOrder: true,
|
|
35
|
+
stopNodes: ["*.string", "*.item"],
|
|
36
|
+
parseTagValue: false,
|
|
37
|
+
trimValues: true,
|
|
38
|
+
};
|
|
39
|
+
const parser = new XMLParser(parsingOptions);
|
|
40
|
+
for (const rootNode of parser.parse(resource)) {
|
|
41
|
+
if ('resources' in rootNode) {
|
|
42
|
+
let lastComment;
|
|
43
|
+
for (const resNode of rootNode.resources) {
|
|
44
|
+
if ('#comment' in resNode) {
|
|
45
|
+
lastComment = resNode['#comment'].map(e => e['#text']).join('').trim();
|
|
46
|
+
} else if ('string' in resNode) {
|
|
47
|
+
const str = collapseTextNodes(resNode.string);
|
|
48
|
+
if (isTranslatableNode(resNode, str)) {
|
|
49
|
+
const seg = {
|
|
50
|
+
sid: resNode[':@'].name,
|
|
51
|
+
str,
|
|
52
|
+
};
|
|
53
|
+
lastComment && (seg.notes = lastComment);
|
|
54
|
+
segments.push(seg);
|
|
55
|
+
lastComment = null;
|
|
56
|
+
}
|
|
57
|
+
} else if ('plurals' in resNode) { // TODO: support string-array
|
|
58
|
+
for (const itemNode of resNode.plurals) {
|
|
59
|
+
const seg = {
|
|
60
|
+
sid: `${resNode[':@'].name}_${itemNode[':@'].quantity}`,
|
|
61
|
+
isSuffixPluralized: true,
|
|
62
|
+
str: collapseTextNodes(itemNode.item)
|
|
63
|
+
};
|
|
64
|
+
lastComment && (seg.notes = lastComment);
|
|
65
|
+
segments.push(seg);
|
|
66
|
+
}
|
|
67
|
+
lastComment = null;
|
|
68
|
+
} else {
|
|
69
|
+
l10nmonster.logger.verbose(`Unexpected child node in resources`);
|
|
70
|
+
l10nmonster.logger.verbose(JSON.stringify(resNode));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
segments,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async translateResource({ resource, translator }) {
|
|
81
|
+
const parsingOptions = {
|
|
82
|
+
ignoreAttributes: false,
|
|
83
|
+
processEntities: true,
|
|
84
|
+
htmlEntities: true,
|
|
85
|
+
allowBooleanAttributes: true,
|
|
86
|
+
alwaysCreateTextNode: true,
|
|
87
|
+
attributeNamePrefix : '',
|
|
88
|
+
commentPropName: "#comment",
|
|
89
|
+
preserveOrder: true,
|
|
90
|
+
stopNodes: ["*.string", "*.item"],
|
|
91
|
+
parseTagValue: false,
|
|
92
|
+
trimValues: true,
|
|
93
|
+
};
|
|
94
|
+
const parser = new XMLParser(parsingOptions);
|
|
95
|
+
const parsedResource = parser.parse(resource);
|
|
96
|
+
const nodesToDelete = [];
|
|
97
|
+
let translated = 0;
|
|
98
|
+
for (const rootNode of parsedResource) {
|
|
99
|
+
if ('resources' in rootNode) {
|
|
100
|
+
for (const resNode of rootNode.resources) {
|
|
101
|
+
if ('string' in resNode) {
|
|
102
|
+
const str = collapseTextNodes(resNode.string);
|
|
103
|
+
if (isTranslatableNode(resNode, str)) {
|
|
104
|
+
const translation = await translator(resNode[':@'].name, str);
|
|
105
|
+
if (translation === undefined) {
|
|
106
|
+
nodesToDelete.push(resNode);
|
|
107
|
+
} else {
|
|
108
|
+
translated++;
|
|
109
|
+
resNode.string = [ { '#text': translation } ];
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
nodesToDelete.push(resNode);
|
|
113
|
+
}
|
|
114
|
+
} else if ('plurals' in resNode) { // TODO: deal with plurals of the target language, not the source
|
|
115
|
+
let dropPlural = false;
|
|
116
|
+
for (const itemNode of resNode.plurals) {
|
|
117
|
+
const translation = await translator(`${resNode[':@'].name}_${itemNode[':@'].quantity}`, collapseTextNodes(itemNode.item));
|
|
118
|
+
if (translation === undefined) {
|
|
119
|
+
dropPlural = true;
|
|
120
|
+
} else {
|
|
121
|
+
itemNode.item = [ { '#text': translation } ];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (dropPlural) {
|
|
125
|
+
nodesToDelete.push(resNode);
|
|
126
|
+
} else {
|
|
127
|
+
translated++;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
nodesToDelete.push(resNode); // drop other nodes because of https://github.com/NaturalIntelligence/fast-xml-parser/issues/435
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
rootNode.resources = rootNode.resources.filter(n => !nodesToDelete.includes(n));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (translated === 0) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const builder = new XMLBuilder(parsingOptions);
|
|
140
|
+
const roughXML = builder.build(parsedResource);
|
|
141
|
+
// eslint-disable-next-line prefer-template
|
|
142
|
+
return xmlFormatter(roughXML, { collapseContent: true, indentation: this.indentation, lineSeparator: '\n' }) + '\n';
|
|
143
|
+
}
|
|
144
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { regex } = require('@l10nmonster/helpers');
|
|
2
|
+
|
|
3
|
+
exports.Filter = require('./filter');
|
|
4
|
+
|
|
5
|
+
const androidControlCharsToDecode = {
|
|
6
|
+
n: '\n',
|
|
7
|
+
t: '\t',
|
|
8
|
+
};
|
|
9
|
+
exports.escapesDecoder = regex.decoderMaker(
|
|
10
|
+
'androidEscapesDecoder',
|
|
11
|
+
/(?<node>\\(?<escapedChar>[@?\\'"])|\\(?<escapedControl>[nt])|\\u(?<codePoint>[0-9A-Za-z]{4}))/g,
|
|
12
|
+
(groups) => (groups.escapedChar ??
|
|
13
|
+
(groups.escapedControl ?
|
|
14
|
+
(androidControlCharsToDecode[groups.escapedControl] ?? `\\${groups.escapedControl}`) :
|
|
15
|
+
String.fromCharCode(parseInt(groups.codePoint, 16))
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Android lint doesn't accept % but accepts %%. % should be replaced with '\u0025' but %% shouldn't
|
|
21
|
+
exports.escapesEncoder = (str, flags = {}) => {
|
|
22
|
+
let escapedStr = str.replaceAll(/[@\\'"]/g, '\\$&').replaceAll('\t', '\\t').replaceAll('\n', '\\n').replaceAll(/(?<!%)%(?!%)/g, '\\u0025');
|
|
23
|
+
// eslint-disable-next-line prefer-template
|
|
24
|
+
flags.isFirst && escapedStr[0] === ' ' && (escapedStr = '\\u0020' + escapedStr.substring(1));
|
|
25
|
+
// eslint-disable-next-line prefer-template
|
|
26
|
+
flags.isLast && escapedStr.length > 0 && escapedStr[escapedStr.length - 1] === ' ' && (escapedStr = escapedStr.substring(0, escapedStr.length - 1) + '\\u0020');
|
|
27
|
+
return escapedStr;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
exports.spaceCollapser = (parts) => parts.map(p => (p.t === 's' ? { ...p, v: p.v.replaceAll(/[ \f\n\r\t\v\u2028\u2029]+/g, ' ')} : p));
|
|
31
|
+
|
|
32
|
+
// C-style placeholders (based on the ios one)
|
|
33
|
+
exports.phDecoder = regex.decoderMaker(
|
|
34
|
+
'iosPHDecoder',
|
|
35
|
+
/(?<tag>%(?:\d\$)?[0#+-]?[0-9*]*\.?\d*[hl]{0,2}[jztL]?[diuoxXeEfgGaAcpsSn])/g,
|
|
36
|
+
(groups) => ({ t: 'x', v: groups.tag })
|
|
37
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@l10nmonster/helpers-android",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Helpers to deal with Android 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
|
+
"fast-xml-parser": "^4.0.3",
|
|
13
|
+
"xml-formatter": "^3"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@l10nmonster/helpers": "^1"
|
|
17
|
+
}
|
|
18
|
+
}
|