@l10nmonster/helpers-android 1.0.0 → 3.0.0-alpha.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.
Files changed (4) hide show
  1. package/README.md +235 -11
  2. package/filter.js +48 -14
  3. package/index.js +6 -7
  4. package/package.json +17 -16
package/README.md CHANGED
@@ -1,18 +1,242 @@
1
- # L10n Monster Android Helpers
1
+ # @l10nmonster/helpers-android
2
2
 
3
- |Module|Export|Description|
3
+ L10n Monster helper for Android XML resource files, providing filters, decoders, and encoders for Android app localization.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @l10nmonster/helpers-android
9
+ ```
10
+
11
+ ## Components
12
+
13
+ |Component|Export|Description|
4
14
  |---|---|---|
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.|
15
+ |**Filter**|`Filter`|Resource filter for Android XML files|
16
+ |**Decoders**|||
17
+ ||`escapesDecoder`|Decoder for escaped chars like `\n` and `\u00a0`|
18
+ ||`spaceCollapser`|Decoder to convert multiple whitespace into single space|
19
+ ||`phDecoder`|Decoder for `%d` style placeholders|
20
+ |**Encoders**|||
21
+ ||`escapesEncoder`|Encoder for escaped chars as required by Android|
22
+
23
+ ## Usage
10
24
 
25
+ ### Android XML Filter
11
26
 
12
- ```js
13
- this.resourceFilter = new android.Filter({
14
- comment: 'pre',
27
+ ```javascript
28
+ import { Filter } from '@l10nmonster/helpers-android';
29
+
30
+ const androidFilter = new Filter({
31
+ comment: 'pre' // Options: 'pre', 'post', 'right'
15
32
  });
16
33
  ```
17
34
 
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).
35
+ The filter processes Android XML resource files (`strings.xml`, `plurals.xml`, etc.) and handles:
36
+ - String resources with proper escaping
37
+ - Plural forms (quantity="one", "other", etc.)
38
+ - Comments and developer notes positioning
39
+ - CDATA sections for complex text
40
+ - Resource attributes (`translatable`, `formatted`, etc.)
41
+
42
+ ### Comment Positioning
43
+
44
+ - **`pre`**: Places developer comments before the string element
45
+ - **`post`**: Places comments after the string element
46
+ - **`right`**: Places comments on the same line as the string
47
+
48
+ ### Decoders and Encoders
49
+
50
+ ```javascript
51
+ import {
52
+ escapesDecoder,
53
+ spaceCollapser,
54
+ phDecoder,
55
+ escapesEncoder
56
+ } from '@l10nmonster/helpers-android';
57
+
58
+ // Use in content type configuration
59
+ const contentType = {
60
+ name: 'android-strings',
61
+ resourceFilter: androidFilter,
62
+ decoders: [escapesDecoder, spaceCollapser, phDecoder],
63
+ textEncoders: [escapesEncoder]
64
+ };
65
+ ```
66
+
67
+ ## Supported Android Features
68
+
69
+ ### String Resources
70
+ - Basic string elements: `<string name="key">value</string>`
71
+ - Formatted strings with placeholders: `%s`, `%d`, `%1$s`
72
+ - Non-translatable strings: `translatable="false"`
73
+ - Formatted attribute: `formatted="false"`
74
+
75
+ ### Plurals
76
+ - Quantity-based plurals: `zero`, `one`, `two`, `few`, `many`, `other`
77
+ - Proper plural form handling per Android guidelines
78
+
79
+ ### Text Formatting
80
+ - Escape sequences: `\n`, `\t`, `\u0020`, etc.
81
+ - HTML tags in strings (preserved as placeholders)
82
+ - CDATA sections for complex markup
83
+ - Apostrophe and quote escaping
84
+
85
+ ### XML Features
86
+ - XML entities (`&lt;`, `&gt;`, `&amp;`, etc.)
87
+ - Comments and developer notes
88
+ - Resource arrays (basic support)
89
+
90
+ ## Configuration Examples
91
+
92
+ ### Basic Android Project
93
+
94
+ ```javascript
95
+ // l10nmonster.config.mjs
96
+ import { FsSource, FsTarget } from '@l10nmonster/core';
97
+ import { Filter } from '@l10nmonster/helpers-android';
98
+
99
+ export default {
100
+ channels: [{
101
+ source: new FsSource({
102
+ globs: ['app/src/main/res/values/strings.xml']
103
+ }),
104
+ target: new FsTarget({
105
+ targetPath: (lang, resourceId) =>
106
+ resourceId.replace('/values/', `/values-${lang}/`)
107
+ })
108
+ }],
109
+
110
+ contentTypes: [{
111
+ name: 'android-xml',
112
+ resourceFilter: new Filter({ comment: 'pre' })
113
+ }]
114
+ };
115
+ ```
116
+
117
+ ### Multi-Module Android Project
118
+
119
+ ```javascript
120
+ export default {
121
+ channels: [{
122
+ source: new FsSource({
123
+ globs: [
124
+ 'app/src/main/res/values/strings.xml',
125
+ 'feature-*/src/main/res/values/strings.xml',
126
+ 'library-*/src/main/res/values/strings.xml'
127
+ ]
128
+ }),
129
+ target: new FsTarget({
130
+ targetPath: (lang, resourceId) => {
131
+ // Handle different module structures
132
+ if (resourceId.includes('/values/')) {
133
+ return resourceId.replace('/values/', `/values-${lang}/`);
134
+ }
135
+ return resourceId;
136
+ }
137
+ })
138
+ }]
139
+ };
140
+ ```
141
+
142
+ ## File Structure Support
143
+
144
+ L10n Monster supports standard Android resource directory structure:
145
+
146
+ ```
147
+ app/src/main/res/
148
+ ├── values/ # Default (English) strings
149
+ │ ├── strings.xml
150
+ │ ├── plurals.xml
151
+ │ └── arrays.xml
152
+ ├── values-es/ # Spanish translations
153
+ │ └── strings.xml
154
+ ├── values-zh-rCN/ # Chinese (China) translations
155
+ │ └── strings.xml
156
+ └── values-b+es+419/ # Spanish (Latin America) - BCP 47
157
+ └── strings.xml
158
+ ```
159
+
160
+ ## Android-Specific Features
161
+
162
+ ### Placeholder Handling
163
+
164
+ The helper properly handles Android string formatting:
165
+
166
+ ```xml
167
+ <!-- Source -->
168
+ <string name="welcome">Hello %1$s, you have %2$d messages</string>
169
+
170
+ <!-- Maintains placeholder order and type -->
171
+ <string name="welcome">Hola %1$s, tienes %2$d mensajes</string>
172
+ ```
173
+
174
+ ### Escape Sequence Processing
175
+
176
+ ```xml
177
+ <!-- Input -->
178
+ <string name="multiline">First line\nSecond line\tTabbed</string>
179
+
180
+ <!-- Properly escaped output -->
181
+ <string name="multiline">Primera línea\nSegunda línea\tTabulada</string>
182
+ ```
183
+
184
+ ### Plural Forms
185
+
186
+ ```xml
187
+ <!-- Source -->
188
+ <plurals name="items">
189
+ <item quantity="one">%d item</item>
190
+ <item quantity="other">%d items</item>
191
+ </plurals>
192
+
193
+ <!-- Translated with proper quantity handling -->
194
+ <plurals name="items">
195
+ <item quantity="one">%d elemento</item>
196
+ <item quantity="other">%d elementos</item>
197
+ </plurals>
198
+ ```
199
+
200
+ ## Testing
201
+
202
+ ```bash
203
+ npm test
204
+ ```
205
+
206
+ The test suite covers:
207
+ - XML parsing and generation
208
+ - Escape sequence handling
209
+ - Placeholder preservation
210
+ - Plural form processing
211
+ - Comment positioning
212
+ - CDATA section handling
213
+
214
+ ## Integration with L10n Monster
215
+
216
+ This helper integrates seamlessly with L10n Monster's provider system:
217
+
218
+ ```javascript
219
+ import { providers } from '@l10nmonster/core';
220
+ import { GptAgent } from '@l10nmonster/helpers-openai';
221
+
222
+ export default {
223
+ providers: [{
224
+ id: 'ai-translator',
225
+ provider: new GptAgent({
226
+ model: 'gpt-4',
227
+ systemPrompt: 'Translate Android app strings, preserving all placeholders and formatting.'
228
+ })
229
+ }]
230
+ };
231
+ ```
232
+
233
+ ## Requirements
234
+
235
+ - Node.js >= 20.12.0
236
+ - @l10nmonster/core (peer dependency)
237
+
238
+ ## Related Documentation
239
+
240
+ - [Android String Resources](https://developer.android.com/guide/topics/resources/string-resource)
241
+ - [Android Localization](https://developer.android.com/guide/topics/resources/localization)
242
+ - [L10n Monster Core Documentation](../core/README.md)
package/filter.js CHANGED
@@ -3,8 +3,9 @@
3
3
  // and preserve the source but the downside is that we miss automatic CDATA handling, whitespace trimming, entity management.
4
4
  // TODO: double quotes are meant to preserve newlines but we treat double quotes like CDATA (which doesn't)
5
5
 
6
- const { XMLParser, XMLBuilder } = require('fast-xml-parser');
7
- const xmlFormatter = require('xml-formatter');
6
+ import { XMLParser, XMLBuilder } from 'fast-xml-parser';
7
+ import { default as formatXml } from 'xml-formatter';
8
+ import { logVerbose } from '@l10nmonster/core';
8
9
 
9
10
  function collapseTextNodes(node) {
10
11
  return node.map(e => e['#text']).join('').trim();
@@ -16,11 +17,26 @@ function isTranslatableNode(resNode, str) {
16
17
  return resNode[':@'].translatable !== 'false' && !resNode[':@']['/'] && !resourceReferenceRegex.test(str);
17
18
  }
18
19
 
19
- module.exports = class AndroidFilter {
20
- constructor({ indentation }) {
20
+ /**
21
+ * Class representing an AndroidFilter for parsing and translating Android resource files.
22
+ */
23
+ export class AndroidXMLFilter {
24
+
25
+ /**
26
+ * Create an AndroidXMLFilter.
27
+ * @param {Object} [options] - Configuration options for the filter.
28
+ * @param {string} [options.indentation='\t'] - The indentation character(s) to use in the output XML.
29
+ */
30
+ constructor({ indentation } = {}) {
21
31
  this.indentation = indentation || '\t';
22
32
  }
23
33
 
34
+ /**
35
+ * Parse an Android resource file and extract translatable segments.
36
+ * @param {Object} params - Parameters for parsing the resource.
37
+ * @param {string} params.resource - The XML content of the Android resource file.
38
+ * @returns {Promise<Object>} An object containing the extracted segments.
39
+ */
24
40
  async parseResource({ resource }) {
25
41
  const segments = [];
26
42
  const parsingOptions = {
@@ -56,18 +72,22 @@ module.exports = class AndroidFilter {
56
72
  }
57
73
  } else if ('plurals' in resNode) { // TODO: support string-array
58
74
  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);
75
+ if ('#comment' in itemNode) {
76
+ lastComment = itemNode['#comment'].map(e => e['#text']).join('').trim();
77
+ } else if ('item' in itemNode) {
78
+ const seg = {
79
+ sid: `${resNode[':@'].name}_${itemNode[':@'].quantity}`,
80
+ isSuffixPluralized: true,
81
+ str: collapseTextNodes(itemNode.item)
82
+ };
83
+ lastComment && (seg.notes = lastComment);
84
+ segments.push(seg);
85
+ }
66
86
  }
67
87
  lastComment = null;
68
88
  } else {
69
- l10nmonster.logger.verbose(`Unexpected child node in resources`);
70
- l10nmonster.logger.verbose(JSON.stringify(resNode));
89
+ logVerbose`Unexpected child node in resources`;
90
+ logVerbose`${JSON.stringify(resNode)}`;
71
91
  }
72
92
  }
73
93
  }
@@ -77,6 +97,13 @@ module.exports = class AndroidFilter {
77
97
  };
78
98
  }
79
99
 
100
+ /**
101
+ * Translate an Android resource file using the provided translator function.
102
+ * @param {Object} params - Parameters for translating the resource.
103
+ * @param {string} params.resource - The XML content of the Android resource file.
104
+ * @param {Function} params.translator - A function that translates a string given its ID and source text.
105
+ * @returns {Promise<string|null>} The translated XML content, or null if no translations were made.
106
+ */
80
107
  async translateResource({ resource, translator }) {
81
108
  const parsingOptions = {
82
109
  ignoreAttributes: false,
@@ -113,7 +140,13 @@ module.exports = class AndroidFilter {
113
140
  }
114
141
  } else if ('plurals' in resNode) { // TODO: deal with plurals of the target language, not the source
115
142
  let dropPlural = false;
143
+ const itemNodesToDelete = []
116
144
  for (const itemNode of resNode.plurals) {
145
+ if ('#comment' in itemNode) {
146
+ itemNodesToDelete.push(itemNode)
147
+ // eslint-disable-next-line no-continue
148
+ continue;
149
+ }
117
150
  const translation = await translator(`${resNode[':@'].name}_${itemNode[':@'].quantity}`, collapseTextNodes(itemNode.item));
118
151
  if (translation === undefined) {
119
152
  dropPlural = true;
@@ -121,6 +154,7 @@ module.exports = class AndroidFilter {
121
154
  itemNode.item = [ { '#text': translation } ];
122
155
  }
123
156
  }
157
+ resNode.plurals = resNode.plurals.filter(n => !itemNodesToDelete.includes(n))
124
158
  if (dropPlural) {
125
159
  nodesToDelete.push(resNode);
126
160
  } else {
@@ -139,6 +173,6 @@ module.exports = class AndroidFilter {
139
173
  const builder = new XMLBuilder(parsingOptions);
140
174
  const roughXML = builder.build(parsedResource);
141
175
  // eslint-disable-next-line prefer-template
142
- return xmlFormatter(roughXML, { collapseContent: true, indentation: this.indentation, lineSeparator: '\n' }) + '\n';
176
+ return formatXml(roughXML, { collapseContent: true, indentation: this.indentation, lineSeparator: '\n' }) + '\n';
143
177
  }
144
178
  }
package/index.js CHANGED
@@ -1,12 +1,11 @@
1
- const { regex } = require('@l10nmonster/helpers');
2
-
3
- exports.Filter = require('./filter');
1
+ import { regex } from '@l10nmonster/core';
2
+ export { AndroidXMLFilter } from './filter.js';
4
3
 
5
4
  const androidControlCharsToDecode = {
6
5
  n: '\n',
7
6
  t: '\t',
8
7
  };
9
- exports.escapesDecoder = regex.decoderMaker(
8
+ export const escapesDecoder = regex.decoderMaker(
10
9
  'androidEscapesDecoder',
11
10
  /(?<node>\\(?<escapedChar>[@?\\'"])|\\(?<escapedControl>[nt])|\\u(?<codePoint>[0-9A-Za-z]{4}))/g,
12
11
  (groups) => (groups.escapedChar ??
@@ -18,7 +17,7 @@ exports.escapesDecoder = regex.decoderMaker(
18
17
  );
19
18
 
20
19
  // Android lint doesn't accept % but accepts %%. % should be replaced with '\u0025' but %% shouldn't
21
- exports.escapesEncoder = (str, flags = {}) => {
20
+ export const escapesEncoder = (str, flags = {}) => {
22
21
  let escapedStr = str.replaceAll(/[@\\'"]/g, '\\$&').replaceAll('\t', '\\t').replaceAll('\n', '\\n').replaceAll(/(?<!%)%(?!%)/g, '\\u0025');
23
22
  // eslint-disable-next-line prefer-template
24
23
  flags.isFirst && escapedStr[0] === ' ' && (escapedStr = '\\u0020' + escapedStr.substring(1));
@@ -27,10 +26,10 @@ exports.escapesEncoder = (str, flags = {}) => {
27
26
  return escapedStr;
28
27
  };
29
28
 
30
- exports.spaceCollapser = (parts) => parts.map(p => (p.t === 's' ? { ...p, v: p.v.replaceAll(/[ \f\n\r\t\v\u2028\u2029]+/g, ' ')} : p));
29
+ export const spaceCollapser = (parts) => parts.map(p => (p.t === 's' ? { ...p, v: p.v.replaceAll(/[ \f\n\r\t\v\u2028\u2029]+/g, ' ')} : p));
31
30
 
32
31
  // C-style placeholders (based on the ios one)
33
- exports.phDecoder = regex.decoderMaker(
32
+ export const phDecoder = regex.decoderMaker(
34
33
  'iosPHDecoder',
35
34
  /(?<tag>%(?:\d\$)?[0#+-]?[0-9*]*\.?\d*[hl]{0,2}[jztL]?[diuoxXeEfgGaAcpsSn])/g,
36
35
  (groups) => ({ t: 'x', v: groups.tag })
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
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
- }
2
+ "name": "@l10nmonster/helpers-android",
3
+ "version": "3.0.0-alpha.1",
4
+ "description": "Helpers to deal with Android file formats",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "test": "node --test"
9
+ },
10
+ "author": "Diego Lagunas",
11
+ "license": "MIT",
12
+ "dependencies": {
13
+ "fast-xml-parser": "^5",
14
+ "xml-formatter": "^3"
15
+ },
16
+ "peerDependencies": {
17
+ "@l10nmonster/core": "file:../core"
18
+ }
18
19
  }