@oracle/oraclejet-icu-l10n 16.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/Bundler.d.ts +46 -0
- package/Bundler.js +312 -0
- package/PatternCompiler.d.ts +83 -0
- package/PatternCompiler.js +253 -0
- package/README.md +321 -0
- package/l10nBundleBuilder.js +88 -0
- package/package.json +30 -0
- package/test/bundleTypeTest.ts +40 -0
- package/test/cliTest.js +48 -0
- package/test/jest.config.js +9 -0
- package/test/l10nTest.js +492 -0
- package/test/resources/CustomMessageType.d.ts +6 -0
- package/test/resources/custom-hooks-no-def.js +3 -0
- package/test/resources/custom-hooks.js +8 -0
- package/test/resources/escape-util.js +3 -0
- package/test/resources/nls/app-strings-legacy-keys.json +5 -0
- package/test/resources/nls/app-strings-x.json +4 -0
- package/test/resources/nls/app-strings.json +48 -0
- package/test/resources/nls/azb/app-strings.json +3 -0
- package/test/resources/nls/azb-AZ/app-strings.json +3 -0
- package/test/resources/nls/ro-MD/app-strings.json +3 -0
- package/test/resources/nls/ru/app-strings.json +4 -0
- package/test/resources/nls/ru-RU/app-strings-x.json +3 -0
- package/test/resources/nls/ru-RU/app-strings.json +3 -0
- package/test/resources/nls/zh/app-strings.json +3 -0
- package/test/resources/nls/zh-Hans/app-strings.json +2 -0
- package/test/resources/nls/zh-Hans/extra-app-strings-x.json +3 -0
- package/test/resources/nls/zh-Hans-CN/app-strings.json +2 -0
- package/test/resources/nls-bad-metadata/app-strings-bad-root.json +11 -0
- package/test/resources/nls-extra-keys/README.md +3 -0
- package/test/resources/nls-extra-keys/app-bundle-bad.json +4 -0
- package/test/resources/nls-extra-keys/en/app-bundle-bad.json +3 -0
- package/tsconfig.json +7 -0
package/Bundler.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile the message bundle in ICU format. This function starts with the "root"
|
|
3
|
+
* message bundle and attempts to discover bundles for all available locales by
|
|
4
|
+
* traversing the root bundle's directory and looking for locale directory names.
|
|
5
|
+
* Any file found under locale directories whose names match the root bundle will
|
|
6
|
+
* also be compiled.
|
|
7
|
+
* When creating the output file in the given targetDir, the locale directory
|
|
8
|
+
* structure from the source will be recreated.
|
|
9
|
+
* @param {object} props The properties with which to build the bundle
|
|
10
|
+
* @param {string} props.rootDir The path to the root message bundle
|
|
11
|
+
* @param {string} props.bundleName The name of the bundle file
|
|
12
|
+
* @param {string} props.locale The locale of the root message bundle
|
|
13
|
+
* @param {string} props.outDir The target directory where the compiled root message
|
|
14
|
+
* @param {string=} props.module The type of module to produce -- 'esm', 'amd', or 'legacy-amd'
|
|
15
|
+
* @param {string=} props.exportType The type of export to produce -- 'default' or 'named'
|
|
16
|
+
* @param {boolean=} props.override Indicates the bundle is an override, and only the root
|
|
17
|
+
* locale and those explicitly stated in [--supportedLocales] will be built.
|
|
18
|
+
* @param {string[]=} props.additionalLocales An array of additional locales to build
|
|
19
|
+
* @param {string=} hooks A path to the custom hooks file
|
|
20
|
+
*/
|
|
21
|
+
export function build({
|
|
22
|
+
rootDir,
|
|
23
|
+
bundleName,
|
|
24
|
+
locale,
|
|
25
|
+
outDir,
|
|
26
|
+
module,
|
|
27
|
+
exportType,
|
|
28
|
+
override,
|
|
29
|
+
additionalLocales,
|
|
30
|
+
hooks
|
|
31
|
+
}: {
|
|
32
|
+
rootDir: string;
|
|
33
|
+
bundleName: string;
|
|
34
|
+
locale: string;
|
|
35
|
+
outDir: string;
|
|
36
|
+
module?: string | undefined;
|
|
37
|
+
exportType?: string | undefined;
|
|
38
|
+
override?: boolean | undefined;
|
|
39
|
+
additionalLocales?: string[] | undefined;
|
|
40
|
+
}): void;
|
|
41
|
+
/**
|
|
42
|
+
* Test if a given directory name (base name only) is an NLS directory.
|
|
43
|
+
* @param {string} name The directory name
|
|
44
|
+
* @return True if NLS directory, false otherwise
|
|
45
|
+
*/
|
|
46
|
+
export function isNlsDir(name: string): boolean;
|
package/Bundler.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
const fsx = require('fs-extra');
|
|
2
|
+
const glob = require('glob');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const PatternCompiler = require('./PatternCompiler');
|
|
6
|
+
const ts = require('typescript');
|
|
7
|
+
const pkg = require('./package.json');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Traverse the message bundle and compile all strings.
|
|
11
|
+
* @param {PatternCompiler} compiler The instance of the PatternCompiler
|
|
12
|
+
* @param {Object} bundle The message bundle
|
|
13
|
+
* @return {Object} A message bundle whose strings are wrapped by functions
|
|
14
|
+
* generated from the PatternCompiler.
|
|
15
|
+
* @private
|
|
16
|
+
*/
|
|
17
|
+
function traverse(compiler, bundle) {
|
|
18
|
+
const metadata = {};
|
|
19
|
+
// sort so that metadata '@' keys are processed first
|
|
20
|
+
const output = Object.keys(bundle).sort().reduce((accum, key) => {
|
|
21
|
+
if (key.match(/^@/)) {
|
|
22
|
+
metadata[key.substring(1)] = getMetadataParamTypes(bundle[key]);
|
|
23
|
+
} else {
|
|
24
|
+
const value = bundle[key];
|
|
25
|
+
if (typeof value !== 'string') {
|
|
26
|
+
throw Error(`"${key}" value must be a string`);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
...accum,
|
|
30
|
+
[key]: compiler.compile(value, metadata[key])
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return accum;
|
|
34
|
+
}, {});
|
|
35
|
+
return output;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getMetadataParamTypes(metadata) {
|
|
39
|
+
return Object.keys(metadata.placeholders || {}).reduce((accum, key) => (
|
|
40
|
+
{
|
|
41
|
+
...accum,
|
|
42
|
+
[key]: metadata.placeholders?.[key].type
|
|
43
|
+
}
|
|
44
|
+
), {});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the bundle as an object from the given filepath
|
|
49
|
+
* @param {string} filepath
|
|
50
|
+
* @return {object} The bundle from the file
|
|
51
|
+
* @private
|
|
52
|
+
*/
|
|
53
|
+
function getBundle(filepath) {
|
|
54
|
+
let bundle;
|
|
55
|
+
if (fsx.existsSync(filepath)) {
|
|
56
|
+
const bundleContents = fsx.readFileSync(filepath);
|
|
57
|
+
bundle = JSON.parse(bundleContents);
|
|
58
|
+
}
|
|
59
|
+
return bundle;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Process the given bundle for the given locale and write the compiled contents
|
|
64
|
+
* to the given file path
|
|
65
|
+
* @param {string} rootBundleFile The path to the root bundle JSON file
|
|
66
|
+
* @param {string} bundleId The id of the bundle
|
|
67
|
+
* @param {object} bundle The message bundle
|
|
68
|
+
* @param {string} locale The locale of the bundle
|
|
69
|
+
* @param {string} targetFile The path to the target file where the contents will
|
|
70
|
+
* be written.
|
|
71
|
+
* @param {string} exportType The type of export to produce, either 'default' or 'named'
|
|
72
|
+
* @param {boolean?} withBundleType Whether to export the bundle type definition
|
|
73
|
+
* @private
|
|
74
|
+
*/
|
|
75
|
+
function convertBundle(
|
|
76
|
+
rootBundleFile,
|
|
77
|
+
bundleId,
|
|
78
|
+
bundle,
|
|
79
|
+
locale,
|
|
80
|
+
targetFile,
|
|
81
|
+
exportType,
|
|
82
|
+
withBundleType
|
|
83
|
+
) {
|
|
84
|
+
const compiler = new PatternCompiler(locale);
|
|
85
|
+
let processed;
|
|
86
|
+
try {
|
|
87
|
+
processed = traverse(compiler, bundle);
|
|
88
|
+
} catch (ex) {
|
|
89
|
+
throw Error(`While processing ${locale}/${bundleId}:\n${ex.message}`);
|
|
90
|
+
}
|
|
91
|
+
// apply hooks and type imports
|
|
92
|
+
const convertor = customHooks.convertor;
|
|
93
|
+
const typeImport = customHooks.typeImport
|
|
94
|
+
? Object.keys(customHooks.typeImport).map((k) => customHooks.typeImport[k])[0]
|
|
95
|
+
: '';
|
|
96
|
+
const otherImports = customHooks.otherImports ? [customHooks.otherImports] : [];
|
|
97
|
+
const valueType = typeImport ? Object.keys(customHooks.typeImport)[0] : '';
|
|
98
|
+
const translations = Object.keys(processed).map((messageKey) => {
|
|
99
|
+
const entry = processed[messageKey];
|
|
100
|
+
const translation = entry.formatter || '""';
|
|
101
|
+
// Param types for TS
|
|
102
|
+
const params = Object.keys(entry.paramTypes).map(
|
|
103
|
+
(paramKey) => `${paramKey}:${entry.paramTypes[paramKey]}`
|
|
104
|
+
);
|
|
105
|
+
const paramString = params.length ? `p: {${params.join(',')}}` : '';
|
|
106
|
+
const paramVar = params.length ? 'p' : undefined;
|
|
107
|
+
const output = convertor
|
|
108
|
+
? `convert({bundleId:"${bundleId}",id:"${messageKey}",params:${paramVar},translation:${translation}})`
|
|
109
|
+
: translation;
|
|
110
|
+
return [messageKey, `(${paramString})${valueType && ':' + valueType} => ${output}`];
|
|
111
|
+
});
|
|
112
|
+
const targetDir = path.dirname(targetFile);
|
|
113
|
+
fsx.ensureDirSync(targetDir);
|
|
114
|
+
fsx.writeFileSync(
|
|
115
|
+
targetFile,
|
|
116
|
+
generateContent(
|
|
117
|
+
rootBundleFile,
|
|
118
|
+
otherImports.concat(typeImport),
|
|
119
|
+
translations,
|
|
120
|
+
withBundleType,
|
|
121
|
+
convertor,
|
|
122
|
+
exportType
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function generateContent(
|
|
128
|
+
rootBundleFile,
|
|
129
|
+
imports,
|
|
130
|
+
translations,
|
|
131
|
+
withBundleType,
|
|
132
|
+
convertor,
|
|
133
|
+
exportType
|
|
134
|
+
) {
|
|
135
|
+
return `// This file is auto-generated by ${pkg.name} from ${rootBundleFile}
|
|
136
|
+
// and should not be edited by hand. Update the JSON file and rerun the build to regenerate this content.
|
|
137
|
+
${imports.join('\n')}
|
|
138
|
+
|
|
139
|
+
${
|
|
140
|
+
(convertor &&
|
|
141
|
+
`
|
|
142
|
+
type ParamsType = {
|
|
143
|
+
bundleId: string,
|
|
144
|
+
id: string,
|
|
145
|
+
params: { [key:string]: any } | undefined,
|
|
146
|
+
translation: string
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const convert = ${convertor}`) ||
|
|
150
|
+
''
|
|
151
|
+
}
|
|
152
|
+
const bundle = {
|
|
153
|
+
${translations.map((t) => ` "${t[0]}": ${t[1]}`).join(',\n')}
|
|
154
|
+
};
|
|
155
|
+
${
|
|
156
|
+
(exportType === 'default' && `export default bundle;`) ||
|
|
157
|
+
`${translations.map((t) => `export const ${t[0]} = bundle.${t[0]}`).join(';\n')}`
|
|
158
|
+
}
|
|
159
|
+
${withBundleType ? 'export type BundleType = typeof bundle;' : ''}
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Test if a given directory name (base name only) is an NLS directory.
|
|
165
|
+
* @param {string} name The directory name
|
|
166
|
+
* @return True if NLS directory, false otherwise
|
|
167
|
+
*/
|
|
168
|
+
function isNlsDir(name) {
|
|
169
|
+
return (name.match(/^[a-z]{2,3}$/i) || name.match(/^[a-z]{2,3}-.+/)) && !name.match(/\.\w+$/);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function transpile(files, module) {
|
|
173
|
+
const moduleMap = {
|
|
174
|
+
amd: ts.ModuleKind.AMD,
|
|
175
|
+
'legacy-amd': ts.ModuleKind.AMD,
|
|
176
|
+
esm: ts.ModuleKind.ES2020
|
|
177
|
+
};
|
|
178
|
+
const program = ts.createProgram(files, {
|
|
179
|
+
module: moduleMap[module],
|
|
180
|
+
target: 'ES2021',
|
|
181
|
+
strict: true
|
|
182
|
+
});
|
|
183
|
+
program.emit();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function convertToLegacyAmd(files) {
|
|
187
|
+
// Replace default export with top-level keys
|
|
188
|
+
const amdDefaultExport = 'exports.default = bundle;';
|
|
189
|
+
files.forEach((file) => {
|
|
190
|
+
const contents = fsx.readFileSync(file).toString();
|
|
191
|
+
if (contents.indexOf(amdDefaultExport) === -1) {
|
|
192
|
+
throw Error(`No default export found in ${file}`);
|
|
193
|
+
}
|
|
194
|
+
fsx.writeFileSync(file, contents.replace(amdDefaultExport, 'Object.assign(exports, bundle);'));
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let customHooks = {};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Compile the message bundle in ICU format. This function starts with the "root"
|
|
202
|
+
* message bundle and attempts to discover bundles for all available locales by
|
|
203
|
+
* traversing the root bundle's directory and looking for locale directory names.
|
|
204
|
+
* Any file found under locale directories whose names match the root bundle will
|
|
205
|
+
* also be compiled.
|
|
206
|
+
* When creating the output file in the given targetDir, the locale directory
|
|
207
|
+
* structure from the source will be recreated.
|
|
208
|
+
* @param {object} props The properties with which to build the bundle
|
|
209
|
+
* @param {string} props.rootDir The path to the root message bundle
|
|
210
|
+
* @param {string} props.bundleName The name of the bundle file
|
|
211
|
+
* @param {string} props.locale The locale of the root message bundle
|
|
212
|
+
* @param {string} props.outDir The target directory where the compiled root message
|
|
213
|
+
* @param {string=} props.module The type of module to produce -- 'esm', 'amd', or 'legacy-amd'
|
|
214
|
+
* @param {string=} props.exportType The type of export to produce -- 'default' or 'named'
|
|
215
|
+
* @param {boolean=} props.override Indicates the bundle is an override, and only the root
|
|
216
|
+
* locale and those explicitly stated in [--supportedLocales] will be built.
|
|
217
|
+
* @param {string[]=} props.additionalLocales An array of additional locales to build
|
|
218
|
+
* @param {string=} hooks A path to the custom hooks file
|
|
219
|
+
*/
|
|
220
|
+
function build({
|
|
221
|
+
rootDir,
|
|
222
|
+
bundleName,
|
|
223
|
+
locale,
|
|
224
|
+
outDir,
|
|
225
|
+
module,
|
|
226
|
+
exportType = 'default',
|
|
227
|
+
override,
|
|
228
|
+
additionalLocales = [],
|
|
229
|
+
hooks
|
|
230
|
+
}) {
|
|
231
|
+
const jsonRegEx = /\.json$/;
|
|
232
|
+
if (!jsonRegEx.test(bundleName)) {
|
|
233
|
+
throw Error(`${bundleName} must be a JSON file`);
|
|
234
|
+
}
|
|
235
|
+
const isLegacyAmd = module === 'legacy-amd';
|
|
236
|
+
const targetBundleName = bundleName.replace(jsonRegEx, '.ts');
|
|
237
|
+
// Merge in supported locales to build. If physical dirs don't exist, they'll
|
|
238
|
+
// inherit the root translations
|
|
239
|
+
const supportedLocales = [
|
|
240
|
+
...new Set(
|
|
241
|
+
// for override bundles, only look in root and additionalLocales folders
|
|
242
|
+
(override ? [] : fsx.readdirSync(rootDir).filter(isNlsDir)).concat(additionalLocales).sort()
|
|
243
|
+
)
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
customHooks = hooks ? require(hooks) : {};
|
|
247
|
+
|
|
248
|
+
// Traverse root bundle
|
|
249
|
+
const rootBundleFile = path.relative(process.cwd(), path.join(rootDir, bundleName));
|
|
250
|
+
const rootBundle = getBundle(rootBundleFile);
|
|
251
|
+
let rootBundleKeys;
|
|
252
|
+
if (rootBundle) {
|
|
253
|
+
convertBundle(
|
|
254
|
+
rootBundleFile,
|
|
255
|
+
bundleName,
|
|
256
|
+
rootBundle,
|
|
257
|
+
locale,
|
|
258
|
+
path.join(outDir, targetBundleName),
|
|
259
|
+
isLegacyAmd ? 'default' : exportType,
|
|
260
|
+
true
|
|
261
|
+
);
|
|
262
|
+
rootBundleKeys = new Set(Object.keys(rootBundle));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
supportedLocales.forEach((locale) => {
|
|
266
|
+
// Combine all levels into single bundle, starting with rootBundle
|
|
267
|
+
const combinedBundle = Object.assign({}, rootBundle);
|
|
268
|
+
const localeParts = locale.split('-');
|
|
269
|
+
for (let i = 0, len = localeParts.length; i < len; i++) {
|
|
270
|
+
// Build segment from region (0) to index
|
|
271
|
+
let segment = localeParts.slice(0, i + 1).join('-');
|
|
272
|
+
const perBundlePath = path.join(rootDir, segment, bundleName);
|
|
273
|
+
const perBundle = getBundle(perBundlePath);
|
|
274
|
+
Object.assign(combinedBundle, perBundle);
|
|
275
|
+
// combinedBundle should not have keys not in rootBundle
|
|
276
|
+
if (rootBundleKeys) {
|
|
277
|
+
const extraKeys = Object.keys(combinedBundle).filter((k) => !rootBundleKeys.has(k));
|
|
278
|
+
if (extraKeys.length) {
|
|
279
|
+
throw Error(`${perBundlePath} has keys not found in ${rootBundleFile}: ${extraKeys}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
convertBundle(
|
|
285
|
+
rootBundleFile,
|
|
286
|
+
bundleName,
|
|
287
|
+
combinedBundle,
|
|
288
|
+
locale,
|
|
289
|
+
path.join(outDir, locale, targetBundleName),
|
|
290
|
+
isLegacyAmd ? 'default' : exportType
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
!override && fsx.writeFileSync(
|
|
295
|
+
path.join(outDir, 'supportedLocales.ts'),
|
|
296
|
+
`export default ${JSON.stringify(supportedLocales)};\n`
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (module) {
|
|
300
|
+
const transpileFiles = glob.sync(`${path.resolve(outDir)}/**/*.ts`);
|
|
301
|
+
transpile(transpileFiles, module);
|
|
302
|
+
|
|
303
|
+
if (module === 'legacy-amd') {
|
|
304
|
+
convertToLegacyAmd(glob.sync(`${outDir}/**/${bundleName.replace(jsonRegEx, '')}.js`));
|
|
305
|
+
transpileFiles.forEach(fsx.removeSync);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
module.exports = {
|
|
310
|
+
build,
|
|
311
|
+
isNlsDir
|
|
312
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export = PatternCompiler;
|
|
2
|
+
declare class PatternCompiler {
|
|
3
|
+
constructor(locale: any);
|
|
4
|
+
_locale: any;
|
|
5
|
+
compile(pattern: any): {
|
|
6
|
+
paramTypes: any;
|
|
7
|
+
formatter: any;
|
|
8
|
+
};
|
|
9
|
+
_processParts(ast: any, paramAccumulator: any, currentPluralNode: any): any;
|
|
10
|
+
_getNumberFormat(node: any, paramAccumulator: any): string;
|
|
11
|
+
_getDateTimeFormat(node: any, type: any, paramAccumulator: any): string;
|
|
12
|
+
_getSelectFormat(node: any, paramAccumulator: any): string;
|
|
13
|
+
_getPluralFormat(node: any, paramAccumulator: any): string;
|
|
14
|
+
_getPoundFormat(node: any, paramAccumulator: any): string;
|
|
15
|
+
_getSelectOptions(
|
|
16
|
+
node: any,
|
|
17
|
+
paramAccumulator: any,
|
|
18
|
+
pluralNode: any
|
|
19
|
+
): {
|
|
20
|
+
opts: any[];
|
|
21
|
+
other: string;
|
|
22
|
+
exactPlurals: any[];
|
|
23
|
+
};
|
|
24
|
+
_getParameterExpression(node: any, paramAccumulator: any, type: any): string;
|
|
25
|
+
_stringifyOptions(obj: any): any;
|
|
26
|
+
_getFormatOpts(): {
|
|
27
|
+
number: {
|
|
28
|
+
currency: {
|
|
29
|
+
style: string;
|
|
30
|
+
};
|
|
31
|
+
percent: {
|
|
32
|
+
style: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
date: {
|
|
36
|
+
short: {
|
|
37
|
+
month: string;
|
|
38
|
+
day: string;
|
|
39
|
+
year: string;
|
|
40
|
+
};
|
|
41
|
+
medium: {
|
|
42
|
+
month: string;
|
|
43
|
+
day: string;
|
|
44
|
+
year: string;
|
|
45
|
+
};
|
|
46
|
+
long: {
|
|
47
|
+
month: string;
|
|
48
|
+
day: string;
|
|
49
|
+
year: string;
|
|
50
|
+
};
|
|
51
|
+
full: {
|
|
52
|
+
weekday: string;
|
|
53
|
+
month: string;
|
|
54
|
+
day: string;
|
|
55
|
+
year: string;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
time: {
|
|
59
|
+
short: {
|
|
60
|
+
hour: string;
|
|
61
|
+
minute: string;
|
|
62
|
+
};
|
|
63
|
+
medium: {
|
|
64
|
+
hour: string;
|
|
65
|
+
minute: string;
|
|
66
|
+
second: string;
|
|
67
|
+
};
|
|
68
|
+
long: {
|
|
69
|
+
hour: string;
|
|
70
|
+
minute: string;
|
|
71
|
+
second: string;
|
|
72
|
+
timeZoneName: string;
|
|
73
|
+
};
|
|
74
|
+
full: {
|
|
75
|
+
hour: string;
|
|
76
|
+
minute: string;
|
|
77
|
+
second: string;
|
|
78
|
+
timeZoneName: string;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
_getLanguage(): any;
|
|
83
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
const Parser = require('@formatjs/icu-messageformat-parser');
|
|
2
|
+
|
|
3
|
+
module.exports = class PatternCompiler {
|
|
4
|
+
constructor(locale) {
|
|
5
|
+
this._locale = locale;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
compile(pattern, paramTypes = {}) {
|
|
9
|
+
// set ignioreTag flag, so that < and > characters are treated as regular characters that do not
|
|
10
|
+
// require escaping
|
|
11
|
+
const ast = Parser.parse(pattern, {ignoreTag: true});
|
|
12
|
+
const paramAccumulator = Object.create(null);
|
|
13
|
+
const combinedParts = this._processParts(ast, paramAccumulator);
|
|
14
|
+
return {paramTypes: combineParams(paramAccumulator, paramTypes), formatter: combinedParts};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_processParts(ast, paramAccumulator, currentPluralNode) {
|
|
18
|
+
if (!ast) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const acc = [];
|
|
22
|
+
return ast.reduce(
|
|
23
|
+
(acc, node) => {
|
|
24
|
+
switch (node.type) {
|
|
25
|
+
case 0: // raw text
|
|
26
|
+
acc.push(`${JSON.stringify(node.value)}`);
|
|
27
|
+
break;
|
|
28
|
+
case 1: // argument
|
|
29
|
+
acc.push(this._getParameterExpression(node, paramAccumulator));
|
|
30
|
+
break;
|
|
31
|
+
case 2: // number
|
|
32
|
+
acc.push(this._getNumberFormat(node, paramAccumulator));
|
|
33
|
+
break;
|
|
34
|
+
case 3: // date
|
|
35
|
+
acc.push(this._getDateTimeFormat(node, 'date', paramAccumulator));
|
|
36
|
+
break;
|
|
37
|
+
case 4: // time
|
|
38
|
+
acc.push(this._getDateTimeFormat(node, 'time', paramAccumulator));
|
|
39
|
+
break;
|
|
40
|
+
case 5: // select
|
|
41
|
+
acc.push(this._getSelectFormat(node, paramAccumulator));
|
|
42
|
+
break;
|
|
43
|
+
case 6: // plural
|
|
44
|
+
acc.push(this._getPluralFormat(node, paramAccumulator));
|
|
45
|
+
break;
|
|
46
|
+
case 7: // pound
|
|
47
|
+
acc.push(this._getPoundFormat(currentPluralNode, paramAccumulator));
|
|
48
|
+
break;
|
|
49
|
+
default:
|
|
50
|
+
throw new Error('Unsupported token type: ' + node.type);
|
|
51
|
+
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return acc;
|
|
55
|
+
|
|
56
|
+
},
|
|
57
|
+
acc
|
|
58
|
+
).join('+');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_getNumberFormat(node, paramAccumulator) {
|
|
62
|
+
let opts;
|
|
63
|
+
const style = node.style;
|
|
64
|
+
if (typeof style === 'string') {
|
|
65
|
+
opts = this._getFormatOpts().number[style];
|
|
66
|
+
} else if (Parser.isNumberSkeleton(style)) {
|
|
67
|
+
opts = style.parsedOptions;
|
|
68
|
+
}
|
|
69
|
+
return `new Intl.NumberFormat("${this._locale}",${this._stringifyOptions(opts)}).
|
|
70
|
+
format(${this._getParameterExpression(node, paramAccumulator, 'number')})`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_getDateTimeFormat(node, type, paramAccumulator) {
|
|
74
|
+
let opts;
|
|
75
|
+
const style = node.style;
|
|
76
|
+
if (typeof style === 'string') {
|
|
77
|
+
opts = this._getFormatOpts()[type][style];
|
|
78
|
+
} else if (Parser.isDateTimeSkeleton(style)) {
|
|
79
|
+
opts = style.parsedOptions;
|
|
80
|
+
}
|
|
81
|
+
return `new Intl.DateTimeFormat("${this._locale}",${this._stringifyOptions(opts)}).
|
|
82
|
+
format(new Date(${this._getParameterExpression(node, paramAccumulator, 'string')}))`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_getSelectFormat(node, paramAccumulator) {
|
|
86
|
+
const {opts, other} = this._getSelectOptions(node, paramAccumulator);
|
|
87
|
+
// The generated code will look like the following:
|
|
88
|
+
// {male: "he said", female: "she said"}[param]||{"they said"}
|
|
89
|
+
return `({${opts.join(',')}}[${this._getParameterExpression(node, paramAccumulator)}]${other})`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_getPluralFormat(node, paramAccumulator) {
|
|
93
|
+
const {opts, other, exactPlurals} = this._getSelectOptions(node, paramAccumulator, node);
|
|
94
|
+
const offset = node.offset !== 0 ? `-(${node.offset})` : '';
|
|
95
|
+
const num = this._getParameterExpression(node, paramAccumulator, 'number');
|
|
96
|
+
|
|
97
|
+
const pluralMatch = opts.length === 0 ? 'undefined' :
|
|
98
|
+
`({${opts.join(',')}} as Partial<Record<Intl.LDMLPluralRule, string>>)[new Intl.PluralRules('${this._locale}').select(${num}${offset})]`;
|
|
99
|
+
const exactMatch =
|
|
100
|
+
exactPlurals.length > 0 ? `{${exactPlurals.join(',')}}[${num}]||` : '';
|
|
101
|
+
|
|
102
|
+
// The generated code will look like the following:
|
|
103
|
+
// {0: "message for zero"}[pluralNum]||
|
|
104
|
+
// {one: "one message", many: "many message"}[new Intl.PluralRules(loc).select(pluralNum-offset)]||
|
|
105
|
+
// {"default message"}
|
|
106
|
+
return `(${exactMatch}${pluralMatch}${other})`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_getPoundFormat(node, paramAccumulator) {
|
|
110
|
+
if (!node) {
|
|
111
|
+
throw new Error("Unexpected pound roken outside of the plural rule!");
|
|
112
|
+
}
|
|
113
|
+
const offset = node.offset !== 0 ? `-${node.offset}` : '';
|
|
114
|
+
const num = this._getParameterExpression(node, paramAccumulator, 'number');
|
|
115
|
+
return `new Intl.NumberFormat("${this._locale}").format(${num}${offset})`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_getSelectOptions(node, paramAccumulator, pluralNode) {
|
|
119
|
+
const options = node.options;
|
|
120
|
+
const opts = []; // regular options
|
|
121
|
+
let other = ''; // default option
|
|
122
|
+
const exactPlurals = []; // exact plural values
|
|
123
|
+
Object.keys(options).forEach(key => {
|
|
124
|
+
const opt = options[key];
|
|
125
|
+
if (key === 'other') {
|
|
126
|
+
other = `||${this._processParts(opt.value, paramAccumulator, pluralNode)}`;
|
|
127
|
+
} else {
|
|
128
|
+
if (pluralNode && key.length > 1 && key.startsWith('=')) {
|
|
129
|
+
exactPlurals.push(`${key.substring(1)}:${this._processParts(opt.value, paramAccumulator, pluralNode)}`);
|
|
130
|
+
} else {
|
|
131
|
+
opts.push(`"${key}":${this._processParts(opt.value, paramAccumulator, pluralNode)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return {opts, other, exactPlurals};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_getParameterExpression(node, paramAccumulator, type) {
|
|
140
|
+
const paramName = node.value;
|
|
141
|
+
paramAccumulator[paramName] = type || 'string';
|
|
142
|
+
// no need to escape parameter name since it is supposed to be a valid identifier or number
|
|
143
|
+
return `p["${paramName}"]`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_stringifyOptions(obj) {
|
|
147
|
+
if (obj === undefined)
|
|
148
|
+
return obj;
|
|
149
|
+
const inner = Object.keys(obj)
|
|
150
|
+
.map(key => `${key}:${JSON.stringify(obj[key])}`)
|
|
151
|
+
.join(',');
|
|
152
|
+
return `{${inner}}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_getFormatOpts() {
|
|
156
|
+
return {
|
|
157
|
+
number: {
|
|
158
|
+
currency: {
|
|
159
|
+
style: 'currency',
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
percent: {
|
|
163
|
+
style: 'percent',
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
date: {
|
|
168
|
+
short: {
|
|
169
|
+
month: 'numeric',
|
|
170
|
+
day: 'numeric',
|
|
171
|
+
year: '2-digit',
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
medium: {
|
|
175
|
+
month: 'short',
|
|
176
|
+
day: 'numeric',
|
|
177
|
+
year: 'numeric',
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
long: {
|
|
181
|
+
month: 'long',
|
|
182
|
+
day: 'numeric',
|
|
183
|
+
year: 'numeric',
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
full: {
|
|
187
|
+
weekday: 'long',
|
|
188
|
+
month: 'long',
|
|
189
|
+
day: 'numeric',
|
|
190
|
+
year: 'numeric',
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
time: {
|
|
195
|
+
short: {
|
|
196
|
+
hour: 'numeric',
|
|
197
|
+
minute: 'numeric',
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
medium: {
|
|
201
|
+
hour: 'numeric',
|
|
202
|
+
minute: 'numeric',
|
|
203
|
+
second: 'numeric',
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
long: {
|
|
207
|
+
hour: 'numeric',
|
|
208
|
+
minute: 'numeric',
|
|
209
|
+
second: 'numeric',
|
|
210
|
+
timeZoneName: 'short',
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
full: {
|
|
214
|
+
hour: 'numeric',
|
|
215
|
+
minute: 'numeric',
|
|
216
|
+
second: 'numeric',
|
|
217
|
+
timeZoneName: 'short',
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
_getLanguage() {
|
|
223
|
+
return this._locale.split('-')[0];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Combine params from newParams into origParams, but throw exception if existing type differs
|
|
229
|
+
* @param {*} origParams
|
|
230
|
+
* @param {*} newParams
|
|
231
|
+
* @return A combined object of params
|
|
232
|
+
*/
|
|
233
|
+
function combineParams(origParams, newParams) {
|
|
234
|
+
const typeMap = {
|
|
235
|
+
'date': 'string',
|
|
236
|
+
'number': 'number',
|
|
237
|
+
'plural': 'number',
|
|
238
|
+
'select': 'string',
|
|
239
|
+
'text': 'string'
|
|
240
|
+
}
|
|
241
|
+
return Object.keys(newParams).reduce((accum, key) => {
|
|
242
|
+
const newType = typeMap[newParams[key]] || newParams[key];
|
|
243
|
+
if (key in accum && newType !== undefined && accum[key] !== newType) {
|
|
244
|
+
throw Error(`Parameter '${key}' defined as type '${accum[key]}' in usage, but ${newType} in metadata`);
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
...accum,
|
|
248
|
+
[key]: newType
|
|
249
|
+
}
|
|
250
|
+
}, origParams);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|