@vocab/core 1.0.3 → 1.1.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.
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
- });
package/src/config.ts DELETED
@@ -1,144 +0,0 @@
1
- import { UserConfig } from '@vocab/types';
2
- import path from 'path';
3
-
4
- import chalk from 'chalk';
5
- import findUp from 'find-up';
6
- import Validator from 'fastest-validator';
7
- import { ValidationError } from './ValidationError';
8
- import { trace } from './logger';
9
-
10
- const validator = new Validator();
11
- const schema = {
12
- $$strict: true,
13
- devLanguage: {
14
- type: 'string',
15
- },
16
- languages: {
17
- type: 'array',
18
- items: {
19
- type: 'object',
20
- props: {
21
- name: { type: 'string' },
22
- extends: { type: 'string', optional: true },
23
- },
24
- },
25
- },
26
- translationsDirectorySuffix: { type: 'string', optional: true },
27
- projectRoot: { type: 'string', optional: true },
28
- ignore: {
29
- type: 'array',
30
- items: 'string',
31
- optional: true,
32
- },
33
- };
34
- const checkConfigFile = validator.compile(schema);
35
-
36
- const splitMap = (message: string, callback: (value: string) => string) =>
37
- message
38
- .split(' ,')
39
- .map((v) => callback(v))
40
- .join(' ,');
41
-
42
- export function validateConfig(c: UserConfig) {
43
- trace('Validating configuration file');
44
- // Note: checkConfigFile mutates the config file by applying defaults
45
- const isValid = checkConfigFile(c);
46
- if (isValid !== true) {
47
- throw new ValidationError(
48
- 'InvalidStructure',
49
- (Array.isArray(isValid) ? isValid : [])
50
- .map((v) => {
51
- if (v.type === 'objectStrict') {
52
- return `Invalid key(s) ${splitMap(
53
- v.actual,
54
- (m) => `"${chalk.cyan(m)}"`,
55
- )}. Expected one of ${splitMap(v.expected, chalk.green)}`;
56
- }
57
- if (v.field) {
58
- return v.message?.replace(v.field, chalk.cyan(v.field));
59
- }
60
- return v.message;
61
- })
62
- .join(' \n'),
63
- );
64
- }
65
-
66
- // Dev Language should exist in languages
67
- const languageStrings = c.languages.map((v) => v.name);
68
- if (!languageStrings.includes(c.devLanguage)) {
69
- throw new ValidationError(
70
- 'InvalidDevLanguage',
71
- `InvalidDevLanguage: The dev language "${chalk.bold.cyan(
72
- c.devLanguage,
73
- )}" was not found in languages ${languageStrings.join(', ')}.`,
74
- );
75
- }
76
-
77
- const foundLanguages: string[] = [];
78
- for (const lang of c.languages) {
79
- // Languages must only exist once
80
- if (foundLanguages.includes(lang.name)) {
81
- throw new ValidationError(
82
- 'DuplicateLanguage',
83
- `The language "${chalk.bold.cyan(
84
- lang.name,
85
- )}" was defined multiple times.`,
86
- );
87
- }
88
- foundLanguages.push(lang.name);
89
-
90
- // Any extends must be in languages
91
- if (lang.extends && !languageStrings.includes(lang.extends)) {
92
- throw new ValidationError(
93
- 'InvalidExtends',
94
- `The language "${chalk.bold.cyan(
95
- lang.name,
96
- )}"'s extends of ${chalk.bold.cyan(
97
- lang.extends,
98
- )} was not found in languages ${languageStrings.join(', ')}.`,
99
- );
100
- }
101
- }
102
- trace('Configuration file is valid');
103
- return true;
104
- }
105
-
106
- function createConfig(configFilePath: string) {
107
- const cwd = path.dirname(configFilePath);
108
-
109
- return {
110
- projectRoot: cwd,
111
- ...(require(configFilePath as string) as UserConfig),
112
- };
113
- }
114
-
115
- export async function resolveConfig(
116
- customConfigFilePath?: string,
117
- ): Promise<UserConfig | null> {
118
- const configFilePath = customConfigFilePath
119
- ? path.resolve(customConfigFilePath)
120
- : await findUp('vocab.config.js');
121
-
122
- if (configFilePath) {
123
- trace(`Resolved configuration file to ${configFilePath}`);
124
- return createConfig(configFilePath);
125
- }
126
- trace('No configuration file found');
127
- return null;
128
- }
129
-
130
- export function resolveConfigSync(
131
- customConfigFilePath?: string,
132
- ): UserConfig | null {
133
- const configFilePath = customConfigFilePath
134
- ? path.resolve(customConfigFilePath)
135
- : findUp.sync('vocab.config.js');
136
-
137
- if (configFilePath) {
138
- trace(`Resolved configuration file to ${configFilePath}`);
139
- return createConfig(configFilePath);
140
- }
141
- trace('No configuration file found');
142
-
143
- return null;
144
- }
@@ -1,35 +0,0 @@
1
- import { ParsedICUMessages, TranslationMessagesByKey } from '@vocab/types';
2
- import IntlMessageFormat from 'intl-messageformat';
3
-
4
- type ICUMessagesByLocale = {
5
- [locale: string]: ParsedICUMessages<any>;
6
- };
7
-
8
- const moduleCache = new WeakMap<
9
- TranslationMessagesByKey,
10
- ICUMessagesByLocale
11
- >();
12
-
13
- export const getParsedICUMessages = (
14
- m: TranslationMessagesByKey,
15
- locale: string,
16
- ): ParsedICUMessages<any> => {
17
- const moduleCachedResult = moduleCache.get(m);
18
-
19
- if (moduleCachedResult && moduleCachedResult[locale]) {
20
- return moduleCachedResult[locale];
21
- }
22
-
23
- const parsedICUMessages: ParsedICUMessages<any> = {};
24
-
25
- for (const translation of Object.keys(m)) {
26
- const intlMessageFormat = new IntlMessageFormat(m[translation], locale);
27
- parsedICUMessages[translation] = {
28
- format: (params: any) => intlMessageFormat.format(params),
29
- };
30
- }
31
-
32
- moduleCache.set(m, { ...moduleCachedResult, [locale]: parsedICUMessages });
33
-
34
- return parsedICUMessages;
35
- };
package/src/index.ts DELETED
@@ -1,14 +0,0 @@
1
- export { compile, watch } from './compile';
2
- export { validate } from './validate';
3
- export { resolveConfig, resolveConfigSync, validateConfig } from './config';
4
- export {
5
- getAltLanguages,
6
- getAltLanguageFilePath,
7
- getDevLanguageFileFromTsFile,
8
- } from './utils';
9
- export {
10
- getUniqueKey,
11
- loadAllTranslations,
12
- loadTranslation,
13
- } from './load-translations';
14
- export type { TranslationFile } from '@vocab/types';