@vocab/core 1.0.4 → 1.1.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.
@@ -1,11 +1,13 @@
1
1
  import { existsSync, promises } from 'fs';
2
2
  import path from 'path';
3
- import { parse, isSelectElement, isTagElement, isArgumentElement, isNumberElement, isPluralElement, isDateElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
3
+ import { TYPE, parse, isSelectElement, isTagElement, isArgumentElement, isNumberElement, isPluralElement, isDateElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
4
4
  import prettier from 'prettier';
5
5
  import chokidar from 'chokidar';
6
6
  import chalk from 'chalk';
7
7
  import debug from 'debug';
8
8
  import glob from 'fast-glob';
9
+ import IntlMessageFormat from 'intl-messageformat';
10
+ import { printAST } from '@formatjs/icu-messageformat-parser/printer';
9
11
  import findUp from 'find-up';
10
12
  import Validator from 'fastest-validator';
11
13
 
@@ -93,6 +95,71 @@ function getTranslationMessages(translations) {
93
95
  return mapValues(translations, v => v.message);
94
96
  }
95
97
 
98
+ function generateLanguageFromTranslations({
99
+ baseTranslations,
100
+ generator
101
+ }) {
102
+ if (!generator.transformElement && !generator.transformMessage) {
103
+ return baseTranslations;
104
+ }
105
+
106
+ const translationKeys = Object.keys(baseTranslations);
107
+ const generatedTranslations = {};
108
+
109
+ for (const translationKey of translationKeys) {
110
+ const translation = baseTranslations[translationKey];
111
+ let transformedMessage = translation.message;
112
+
113
+ if (generator.transformElement) {
114
+ const messageAst = new IntlMessageFormat(translation.message).getAst();
115
+ const transformedAst = messageAst.map(transformMessageFormatElement(generator.transformElement));
116
+ transformedMessage = printAST(transformedAst);
117
+ }
118
+
119
+ if (generator.transformMessage) {
120
+ transformedMessage = generator.transformMessage(transformedMessage);
121
+ }
122
+
123
+ generatedTranslations[translationKey] = {
124
+ message: transformedMessage
125
+ };
126
+ }
127
+
128
+ return generatedTranslations;
129
+ }
130
+
131
+ function transformMessageFormatElement(transformElement) {
132
+ return messageFormatElement => {
133
+ const transformedMessageFormatElement = { ...messageFormatElement
134
+ };
135
+
136
+ switch (transformedMessageFormatElement.type) {
137
+ case TYPE.literal:
138
+ const transformedValue = transformElement(transformedMessageFormatElement.value);
139
+ transformedMessageFormatElement.value = transformedValue;
140
+ break;
141
+
142
+ case TYPE.select:
143
+ case TYPE.plural:
144
+ const transformedOptions = { ...transformedMessageFormatElement.options
145
+ };
146
+
147
+ for (const key of Object.keys(transformedOptions)) {
148
+ transformedOptions[key].value = transformedOptions[key].value.map(transformMessageFormatElement(transformElement));
149
+ }
150
+
151
+ break;
152
+
153
+ case TYPE.tag:
154
+ const transformedChildren = transformedMessageFormatElement.children.map(transformMessageFormatElement(transformElement));
155
+ transformedMessageFormatElement.children = transformedChildren;
156
+ break;
157
+ }
158
+
159
+ return transformedMessageFormatElement;
160
+ };
161
+ }
162
+
96
163
  function getUniqueKey(key, namespace) {
97
164
  return `${key}.${namespace}`;
98
165
  }
@@ -221,17 +288,17 @@ function getTranslationsFromFile(translations, {
221
288
 
222
289
  for (const [translationKey, translation] of Object.entries(keys)) {
223
290
  if (typeof translation === 'string') {
224
- printValidationError(`Found string for a translation "${translationKey}" in ${filePath}. Translation must be an object of the format {mesage: string}.`);
291
+ printValidationError(`Found string for a translation "${translationKey}" in ${filePath}. Translation must be an object of the format {message: string}.`);
225
292
  continue;
226
293
  }
227
294
 
228
295
  if (!translation) {
229
- printValidationError(`Found empty translation "${translationKey}" in ${filePath}. Translation must be an object of the format {mesage: string}.`);
296
+ printValidationError(`Found empty translation "${translationKey}" in ${filePath}. Translation must be an object of the format {message: string}.`);
230
297
  continue;
231
298
  }
232
299
 
233
300
  if (!translation.message || typeof translation.message !== 'string') {
234
- printValidationError(`No message found for translation "${translationKey}" in ${filePath}. Translation must be an object of the format {mesage: string}.`);
301
+ printValidationError(`No message found for translation "${translationKey}" in ${filePath}. Translation must be an object of the format {message: string}.`);
235
302
  continue;
236
303
  }
237
304
 
@@ -323,6 +390,19 @@ function loadTranslation({
323
390
  }, userConfig);
324
391
  }
325
392
 
393
+ for (const generatedLanguage of userConfig.generatedLanguages || []) {
394
+ const {
395
+ name: generatedLanguageName,
396
+ generator
397
+ } = generatedLanguage;
398
+ const baseLanguage = generatedLanguage.extends || userConfig.devLanguage;
399
+ const baseTranslations = languageSet[baseLanguage];
400
+ languageSet[generatedLanguageName] = generateLanguageFromTranslations({
401
+ baseTranslations,
402
+ generator
403
+ });
404
+ }
405
+
326
406
  return {
327
407
  filePath,
328
408
  keys: Object.keys(devTranslation),
@@ -694,6 +774,35 @@ const schema = {
694
774
  }
695
775
  }
696
776
  },
777
+ generatedLanguages: {
778
+ type: 'array',
779
+ items: {
780
+ type: 'object',
781
+ props: {
782
+ name: {
783
+ type: 'string'
784
+ },
785
+ extends: {
786
+ type: 'string',
787
+ optional: true
788
+ },
789
+ generator: {
790
+ type: 'object',
791
+ props: {
792
+ transformElement: {
793
+ type: 'function',
794
+ optional: true
795
+ },
796
+ transformMessage: {
797
+ type: 'function',
798
+ optional: true
799
+ }
800
+ }
801
+ }
802
+ }
803
+ },
804
+ optional: true
805
+ },
697
806
  translationsDirectorySuffix: {
698
807
  type: 'string',
699
808
  optional: true
@@ -731,13 +840,12 @@ function validateConfig(c) {
731
840
 
732
841
  return v.message;
733
842
  }).join(' \n'));
734
- } // Dev Language should exist in languages
735
-
843
+ }
736
844
 
737
- const languageStrings = c.languages.map(v => v.name);
845
+ const languageStrings = c.languages.map(v => v.name); // Dev Language should exist in languages
738
846
 
739
847
  if (!languageStrings.includes(c.devLanguage)) {
740
- throw new ValidationError('InvalidDevLanguage', `InvalidDevLanguage: The dev language "${chalk.bold.cyan(c.devLanguage)}" was not found in languages ${languageStrings.join(', ')}.`);
848
+ throw new ValidationError('InvalidDevLanguage', `The dev language "${chalk.bold.cyan(c.devLanguage)}" was not found in languages ${languageStrings.join(', ')}.`);
741
849
  }
742
850
 
743
851
  const foundLanguages = [];
@@ -755,6 +863,26 @@ function validateConfig(c) {
755
863
  }
756
864
  }
757
865
 
866
+ const foundGeneratedLanguages = [];
867
+
868
+ for (const generatedLang of c.generatedLanguages || []) {
869
+ // Generated languages must only exist once
870
+ if (foundGeneratedLanguages.includes(generatedLang.name)) {
871
+ throw new ValidationError('DuplicateGeneratedLanguage', `The generated language "${chalk.bold.cyan(generatedLang.name)}" was defined multiple times.`);
872
+ }
873
+
874
+ foundGeneratedLanguages.push(generatedLang.name); // Generated language names must not conflict with language names
875
+
876
+ if (languageStrings.includes(generatedLang.name)) {
877
+ throw new ValidationError('InvalidGeneratedLanguage', `The generated language "${chalk.bold.cyan(generatedLang.name)}" is already defined as a language.`);
878
+ } // Any extends must be in languages
879
+
880
+
881
+ if (generatedLang.extends && !languageStrings.includes(generatedLang.extends)) {
882
+ throw new ValidationError('InvalidExtends', `The generated language "${chalk.bold.cyan(generatedLang.name)}"'s extends of ${chalk.bold.cyan(generatedLang.extends)} was not found in languages ${languageStrings.join(', ')}.`);
883
+ }
884
+ }
885
+
758
886
  trace('Configuration file is valid');
759
887
  return true;
760
888
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vocab/core",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "main": "dist/vocab-core.cjs.js",
5
5
  "module": "dist/vocab-core.esm.js",
6
6
  "author": "SEEK",
@@ -13,9 +13,15 @@
13
13
  "translation-file.ts"
14
14
  ]
15
15
  },
16
+ "files": [
17
+ "dist",
18
+ "runtime",
19
+ "icu-handler",
20
+ "translation-file"
21
+ ],
16
22
  "dependencies": {
17
23
  "@formatjs/icu-messageformat-parser": "^2.0.10",
18
- "@vocab/types": "^1.0.1",
24
+ "@vocab/types": "^1.1.0",
19
25
  "chalk": "^4.1.0",
20
26
  "chokidar": "^3.4.3",
21
27
  "debug": "^4.3.1",
@@ -1,9 +0,0 @@
1
- export class ValidationError extends Error {
2
- code: string;
3
- rawMessage: string;
4
- constructor(code: string, message: string) {
5
- super(`Invalid vocab.config.js: ${code} - ${message}`);
6
- this.code = code;
7
- this.rawMessage = message;
8
- }
9
- }
package/src/compile.ts DELETED
@@ -1,329 +0,0 @@
1
- import { promises as fs, existsSync } from 'fs';
2
- import path from 'path';
3
-
4
- import { LoadedTranslation, UserConfig } from '@vocab/types';
5
- import {
6
- isArgumentElement,
7
- isDateElement,
8
- isNumberElement,
9
- isPluralElement,
10
- isSelectElement,
11
- isTagElement,
12
- isTimeElement,
13
- MessageFormatElement,
14
- parse,
15
- } from '@formatjs/icu-messageformat-parser';
16
- import prettier from 'prettier';
17
- import chokidar from 'chokidar';
18
-
19
- import {
20
- getTranslationMessages,
21
- getDevTranslationFileGlob,
22
- getTSFileFromDevLanguageFile,
23
- getDevLanguageFileFromAltLanguageFile,
24
- getAltTranslationFileGlob,
25
- isDevLanguageFile,
26
- isAltLanguageFile,
27
- getTranslationFolderGlob,
28
- devTranslationFileName,
29
- isTranslationDirectory,
30
- } from './utils';
31
- import { trace } from './logger';
32
- import { loadAllTranslations, loadTranslation } from './load-translations';
33
-
34
- type ICUParams = { [key: string]: string };
35
-
36
- interface TranslationTypeInfo {
37
- params: ICUParams;
38
- message: string;
39
- returnType: string;
40
- hasTags: boolean;
41
- }
42
-
43
- const encodeWithinSingleQuotes = (v: string) => v.replace(/'/g, "\\'");
44
-
45
- const encodeBackslash = (v: string) => v.replace(/\\/g, '\\\\');
46
-
47
- function extractHasTags(ast: MessageFormatElement[]): boolean {
48
- return ast.some((element) => {
49
- if (isSelectElement(element)) {
50
- const children = Object.values(element.options).map((o) => o.value);
51
- return children.some((child) => extractHasTags(child));
52
- }
53
- return isTagElement(element);
54
- });
55
- }
56
-
57
- function extractParamTypes(
58
- ast: MessageFormatElement[],
59
- ): [params: ICUParams, imports: Set<string>] {
60
- let params: ICUParams = {};
61
- let imports = new Set<string>();
62
-
63
- for (const element of ast) {
64
- if (isArgumentElement(element)) {
65
- params[element.value] = 'string';
66
- } else if (isNumberElement(element)) {
67
- params[element.value] = 'number';
68
- } else if (isPluralElement(element)) {
69
- params[element.value] = 'number';
70
- } else if (isDateElement(element) || isTimeElement(element)) {
71
- params[element.value] = 'Date | number';
72
- } else if (isTagElement(element)) {
73
- params[element.value] = 'FormatXMLElementFn<T>';
74
- imports.add(`import { FormatXMLElementFn } from '@vocab/types';`);
75
-
76
- const [subParams, subImports] = extractParamTypes(element.children);
77
-
78
- imports = new Set([...imports, ...subImports]);
79
- params = { ...params, ...subParams };
80
- } else if (isSelectElement(element)) {
81
- params[element.value] = Object.keys(element.options)
82
- .map((o) => `'${o}'`)
83
- .join(' | ');
84
-
85
- const children = Object.values(element.options).map((o) => o.value);
86
-
87
- for (const child of children) {
88
- const [subParams, subImports] = extractParamTypes(child);
89
-
90
- imports = new Set([...imports, ...subImports]);
91
- params = { ...params, ...subParams };
92
- }
93
- }
94
- }
95
-
96
- return [params, imports];
97
- }
98
-
99
- function serialiseObjectToType(v: any) {
100
- let result = '';
101
-
102
- for (const [key, value] of Object.entries(v)) {
103
- if (value && typeof value === 'object') {
104
- result += `'${encodeWithinSingleQuotes(key)}': ${serialiseObjectToType(
105
- value,
106
- )},`;
107
- } else {
108
- result += `'${encodeWithinSingleQuotes(key)}': ${value},`;
109
- }
110
- }
111
-
112
- return `{ ${result} }`;
113
- }
114
-
115
- const banner = `// This file is automatically generated by Vocab.\n// To make changes update translation.json files directly.`;
116
-
117
- function serialiseTranslationRuntime(
118
- value: Map<string, TranslationTypeInfo>,
119
- imports: Set<string>,
120
- loadedTranslation: LoadedTranslation,
121
- ) {
122
- trace('Serialising translations:', loadedTranslation);
123
- const translationsType: any = {};
124
-
125
- for (const [key, { params, message, hasTags }] of value.entries()) {
126
- let translationFunctionString = `() => ${message}`;
127
-
128
- if (Object.keys(params).length > 0) {
129
- const formatGeneric = hasTags ? '<T = string>' : '';
130
- const formatReturn = hasTags
131
- ? 'string | T | Array<string | T>'
132
- : 'string';
133
- translationFunctionString = `${formatGeneric}(values: ${serialiseObjectToType(
134
- params,
135
- )}) => ${formatReturn}`;
136
- }
137
-
138
- translationsType[encodeBackslash(key)] = translationFunctionString;
139
- }
140
-
141
- const content = Object.entries(loadedTranslation.languages)
142
- .map(
143
- ([languageName, translations]) =>
144
- `'${encodeWithinSingleQuotes(
145
- languageName,
146
- )}': createLanguage(${JSON.stringify(
147
- getTranslationMessages(translations),
148
- )})`,
149
- )
150
- .join(',');
151
-
152
- const languagesUnionAsString = Object.keys(loadedTranslation.languages)
153
- .map((l) => `'${l}'`)
154
- .join(' | ');
155
-
156
- return `${banner}
157
-
158
- ${Array.from(imports).join('\n')}
159
- import { createLanguage, createTranslationFile } from '@vocab/core/runtime';
160
-
161
- const translations = createTranslationFile<${languagesUnionAsString}, ${serialiseObjectToType(
162
- translationsType,
163
- )}>({${content}});
164
-
165
- export default translations;`;
166
- }
167
-
168
- export async function generateRuntime(loadedTranslation: LoadedTranslation) {
169
- const { languages: loadedLanguages, filePath } = loadedTranslation;
170
-
171
- trace('Generating types for', loadedTranslation.filePath);
172
- const translationTypes = new Map<string, TranslationTypeInfo>();
173
-
174
- let imports = new Set<string>();
175
-
176
- for (const key of loadedTranslation.keys) {
177
- let params: ICUParams = {};
178
- const messages = new Set();
179
- let hasTags = false;
180
-
181
- for (const translatedLanguage of Object.values(loadedLanguages)) {
182
- if (translatedLanguage[key]) {
183
- const ast = parse(translatedLanguage[key].message);
184
-
185
- hasTags = hasTags || extractHasTags(ast);
186
-
187
- const [parsedParams, parsedImports] = extractParamTypes(ast);
188
- imports = new Set([...imports, ...parsedImports]);
189
-
190
- params = {
191
- ...params,
192
- ...parsedParams,
193
- };
194
- messages.add(
195
- `'${encodeWithinSingleQuotes(translatedLanguage[key].message)}'`,
196
- );
197
- }
198
- }
199
-
200
- const returnType = hasTags ? 'NonNullable<ReactNode>' : 'string';
201
-
202
- translationTypes.set(key, {
203
- params,
204
- hasTags,
205
- message: Array.from(messages).join(' | '),
206
- returnType,
207
- });
208
- }
209
-
210
- const prettierConfig = await prettier.resolveConfig(filePath);
211
- const serializedTranslationType = serialiseTranslationRuntime(
212
- translationTypes,
213
- imports,
214
- loadedTranslation,
215
- );
216
- const declaration = prettier.format(serializedTranslationType, {
217
- ...prettierConfig,
218
- parser: 'typescript',
219
- });
220
- const outputFilePath = getTSFileFromDevLanguageFile(filePath);
221
- trace(`Writing translation types to ${outputFilePath}`);
222
- await writeIfChanged(outputFilePath, declaration);
223
- }
224
-
225
- export function watch(config: UserConfig) {
226
- const cwd = config.projectRoot || process.cwd();
227
-
228
- const watcher = chokidar.watch(
229
- [
230
- getDevTranslationFileGlob(config),
231
- getAltTranslationFileGlob(config),
232
- getTranslationFolderGlob(config),
233
- ],
234
- {
235
- cwd,
236
- ignored: config.ignore
237
- ? [...config.ignore, '**/node_modules/**']
238
- : ['**/node_modules/**'],
239
- ignoreInitial: true,
240
- },
241
- );
242
-
243
- const onTranslationChange = async (relativePath: string) => {
244
- trace(`Detected change for file ${relativePath}`);
245
-
246
- let targetFile;
247
-
248
- if (isDevLanguageFile(relativePath)) {
249
- targetFile = path.resolve(cwd, relativePath);
250
- } else if (isAltLanguageFile(relativePath)) {
251
- targetFile = getDevLanguageFileFromAltLanguageFile(
252
- path.resolve(cwd, relativePath),
253
- );
254
- }
255
-
256
- if (targetFile) {
257
- try {
258
- const loadedTranslation = await loadTranslation(
259
- { filePath: targetFile, fallbacks: 'all' },
260
- config,
261
- );
262
-
263
- await generateRuntime(loadedTranslation);
264
- } catch (e) {
265
- // eslint-disable-next-line no-console
266
- console.log('Failed to generate types for', relativePath);
267
- // eslint-disable-next-line no-console
268
- console.error(e);
269
- }
270
- }
271
- };
272
-
273
- const onNewDirectory = async (relativePath: string) => {
274
- trace('Detected new directory', relativePath);
275
- if (!isTranslationDirectory(relativePath, config)) {
276
- trace('Ignoring non-translation directory:', relativePath);
277
- return;
278
- }
279
- const newFilePath = path.join(relativePath, devTranslationFileName);
280
- if (!existsSync(newFilePath)) {
281
- await fs.writeFile(newFilePath, JSON.stringify({}, null, 2));
282
- trace('Created new empty translation file:', newFilePath);
283
- } else {
284
- trace(
285
- `New directory already contains translation file. Skipping creation. Existing file ${newFilePath}`,
286
- );
287
- }
288
- };
289
-
290
- watcher.on('addDir', onNewDirectory);
291
- watcher.on('add', onTranslationChange).on('change', onTranslationChange);
292
-
293
- return () => watcher.close();
294
- }
295
-
296
- export async function compile(
297
- { watch: shouldWatch = false } = {},
298
- config: UserConfig,
299
- ) {
300
- const translations = await loadAllTranslations(
301
- { fallbacks: 'all', includeNodeModules: false },
302
- config,
303
- );
304
-
305
- for (const loadedTranslation of translations) {
306
- await generateRuntime(loadedTranslation);
307
- }
308
-
309
- if (shouldWatch) {
310
- trace('Listening for changes to files...');
311
- return watch(config);
312
- }
313
- }
314
-
315
- async function writeIfChanged(filepath: string, contents: string) {
316
- let hasChanged = true;
317
-
318
- try {
319
- const existingContents = await fs.readFile(filepath, { encoding: 'utf-8' });
320
-
321
- hasChanged = existingContents !== contents;
322
- } catch (e) {
323
- // ignore error, likely a file doesn't exist error so we want to write anyway
324
- }
325
-
326
- if (hasChanged) {
327
- await fs.writeFile(filepath, contents, { encoding: 'utf-8' });
328
- }
329
- }
@@ -1,39 +0,0 @@
1
- import { validateConfig } from './config';
2
-
3
- describe('validateConfig', () => {
4
- it('should allow a valid config', () => {
5
- const config = { devLanguage: 'en', languages: [{ name: 'en' }] };
6
- expect(() => validateConfig(config)).not.toThrow();
7
- });
8
-
9
- it('should throw an error on no config', () => {
10
- // @ts-expect-error For Science!!!
11
- expect(() => validateConfig({})).toThrowError(
12
- expect.objectContaining({ code: 'InvalidStructure' }),
13
- );
14
- });
15
-
16
- it("should throw an error when the devLanguage isn't defined in languages", () => {
17
- expect(() =>
18
- validateConfig({ devLanguage: 'en', languages: [{ name: 'th' }] }),
19
- ).toThrowError(expect.objectContaining({ code: 'InvalidDevLanguage' }));
20
- });
21
-
22
- it('should throw an error when there are duplicate languages', () => {
23
- expect(() =>
24
- validateConfig({
25
- devLanguage: 'en',
26
- languages: [{ name: 'en' }, { name: 'en' }],
27
- }),
28
- ).toThrowError(expect.objectContaining({ code: 'DuplicateLanguage' }));
29
- });
30
-
31
- it('should throw an error when extending a missing language', () => {
32
- expect(() =>
33
- validateConfig({
34
- devLanguage: 'en',
35
- languages: [{ name: 'en' }, { name: 'en-US', extends: 'en-AU' }],
36
- }),
37
- ).toThrowError(expect.objectContaining({ code: 'InvalidExtends' }));
38
- });
39
- });