@discord/intl-loader-core 0.0.3-canary.331f0a6

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Discord, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # @discord/intl-loader-core
2
+
3
+ This package acts as a core set of utilities and processes that loaders and transformers can rely on to implement
4
+ translation discovery, compilation, and management in a consistent way. It implements a core transformer to compile a
5
+ source file into a loader runtime for `@discord/intl`, as well as functions for scanning the file system for
6
+ translations, watching for changes, emitting typescript type definitions, and more.
7
+
8
+ For the most part, consuming packages should never have to interact with the message database directly when using this
9
+ package, which allows changes in the native extension to be masked and swapped out as needed. However, a `database`
10
+ instance is exposed for cases where additional functionality is needed.
package/index.js ADDED
@@ -0,0 +1,35 @@
1
+ const {
2
+ hashMessageKey,
3
+ isMessageDefinitionsFile,
4
+ isMessageTranslationsFile,
5
+ IntlCompiledMessageFormat,
6
+ } = require('@discord/intl-message-database');
7
+
8
+ const { database } = require('./src/database');
9
+ const {
10
+ generateTypeDefinitions,
11
+ processDefinitionsFile,
12
+ processTranslationsFile,
13
+ precompileFileForLocale,
14
+ } = require('./src/processing');
15
+ const { MessageDefinitionsTransformer } = require('./src/transformer');
16
+ const { findAllTranslationFiles, getLocaleFromTranslationsFileName } = require('./src/util');
17
+ const watcher = require('./src/watcher');
18
+
19
+ module.exports = {
20
+ // @ts-expect-error This is a const enum, which TypeScript doesn't like letting you export, even
21
+ // though it's a tangible object that can be accessed just fine from normal JS.
22
+ IntlCompiledMessageFormat,
23
+ MessageDefinitionsTransformer,
24
+ database,
25
+ findAllTranslationFiles,
26
+ getLocaleFromTranslationsFileName,
27
+ generateTypeDefinitions,
28
+ hashMessageKey,
29
+ isMessageDefinitionsFile,
30
+ isMessageTranslationsFile,
31
+ processDefinitionsFile,
32
+ processTranslationsFile,
33
+ precompileFileForLocale,
34
+ watcher,
35
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@discord/intl-loader-core",
3
+ "version": "0.0.3-canary.331f0a6",
4
+ "license": "MIT",
5
+ "description": "Core utilities for writing loaders and transformers using @discord/intl",
6
+ "author": "Jon Egeland",
7
+ "main": "index.js",
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "exports": {
12
+ ".": "./index.js"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/discord/discord-intl"
17
+ },
18
+ "dependencies": {
19
+ "chokidar": "^3.6.0",
20
+ "debug": "^4.3.6",
21
+ "fast-glob": "^3.3.2",
22
+ "@discord/intl-message-database": "0.0.3"
23
+ },
24
+ "devDependencies": {
25
+ "@types/debug": "^4.1.12"
26
+ }
27
+ }
@@ -0,0 +1,9 @@
1
+ const {IntlMessagesDatabase} = require('@discord/intl-message-database');
2
+
3
+ /**
4
+ * A shared message database instance that's used and shared across all parts
5
+ * of the loader and plugin together.
6
+ */
7
+ const database = new IntlMessagesDatabase();
8
+
9
+ module.exports = {database};
@@ -0,0 +1,140 @@
1
+ const path = require('node:path');
2
+
3
+ const { IntlCompiledMessageFormat } = require('@discord/intl-message-database');
4
+
5
+ const { database } = require('./database');
6
+ const { findAllTranslationFiles, getLocaleFromTranslationsFileName } = require('./util');
7
+
8
+ /**
9
+ * @param {string} sourcePath
10
+ * @param {string=} sourceContent
11
+ * @param {{
12
+ * processTranslations?: boolean,
13
+ * locale?: string
14
+ * }=} options
15
+ * @returns {import('./types').ProcessDefinitionsResult}
16
+ */
17
+ function processDefinitionsFile(sourcePath, sourceContent, options = {}) {
18
+ const {
19
+ processTranslations = false,
20
+ // TODO: Make this more configurable/automatically determined.
21
+ locale = 'en-US',
22
+ } = options;
23
+ if (sourceContent != null) {
24
+ database.processDefinitionsFileContent(sourcePath, sourceContent);
25
+ } else {
26
+ database.processDefinitionsFile(sourcePath);
27
+ }
28
+
29
+ const sourceFile = database.getSourceFile(sourcePath);
30
+ if (sourceFile.type !== 'definition') {
31
+ throw new Error(
32
+ `Expected ${sourcePath} to be a message definitions file, but it resulted in ${sourceFile.type} instead.`,
33
+ );
34
+ }
35
+
36
+ const hashedMessageKeys = database.getSourceFileHashedKeys(sourcePath);
37
+ const translationsPath = path.resolve(path.dirname(sourcePath), sourceFile.meta.translationsPath);
38
+ const translationsLocaleMap = findAllTranslationFiles(translationsPath);
39
+
40
+ if (processTranslations) {
41
+ database.processAllTranslationFiles(translationsLocaleMap);
42
+ }
43
+
44
+ return {
45
+ sourceFile,
46
+ locale,
47
+ hashedMessageKeys,
48
+ translationsPath,
49
+ translationsLocaleMap,
50
+ };
51
+ }
52
+
53
+ /**
54
+ *
55
+ * @param {string} sourcePath
56
+ * @param {string=} sourceContent
57
+ * @param {{
58
+ * locale?: string,
59
+ * outputFile?: string
60
+ * }=} options
61
+ * @returns {import('./types').ProcessTranslationsResult}
62
+ */
63
+ function processTranslationsFile(sourcePath, sourceContent, options = {}) {
64
+ const { locale = getLocaleFromTranslationsFileName(sourcePath), outputFile } = options;
65
+ if (sourceContent) {
66
+ database.processTranslationFileContent(sourcePath, locale, sourceContent);
67
+ } else {
68
+ database.processTranslationFile(sourcePath, locale);
69
+ }
70
+
71
+ const sourceFile = database.getSourceFile(sourcePath);
72
+ if (sourceFile.type !== 'translation') {
73
+ throw new Error(
74
+ `Expected ${sourcePath} to be a message translations file, but it resulted in ${sourceFile.type} instead.`,
75
+ );
76
+ }
77
+
78
+ return {
79
+ sourceFile,
80
+ locale,
81
+ hashedMessageKeys: database.getSourceFileHashedKeys(sourcePath),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Precompile the messages defined in the given `sourcePath` using the value of the translation for
87
+ * that message in the given `locale`. `format` specifies which serialization format the result
88
+ * will be written in.
89
+ *
90
+ * By default, the compiled content will be returned as a Buffer containing the serialized string,
91
+ * but if `outputFile` is given then the content will be written directly to the file and the
92
+ * function becomes `void`.
93
+ *
94
+ * @param {string} sourcePath
95
+ * @param {string} locale
96
+ * @param {{
97
+ * format?: IntlCompiledMessageFormat,
98
+ * outputFile?: string
99
+ * }=} options
100
+ *
101
+ * @returns {Buffer | void}
102
+ */
103
+ function precompileFileForLocale(sourcePath, locale, options = {}) {
104
+ const { format = IntlCompiledMessageFormat.Json, outputFile } = options;
105
+ return outputFile != null
106
+ ? database.precompile(sourcePath, locale, outputFile, format)
107
+ : database.precompileToBuffer(sourcePath, locale, format);
108
+ }
109
+
110
+ /**
111
+ * Generate a `.d.ts` file containing TypeScript type definitions for all of the messages defined in
112
+ * `sourcePath`. This method does not process `sourcePath` at all, meaning it expects the database
113
+ * to already know about the source, as well as all of the related translations to create an
114
+ * accurate typescript definition for each message.
115
+ *
116
+ * If not given, `outputFile` will default to the same path as `sourcePath`, with the last extension
117
+ * replaced by `.d.ts`. For example, a file like `SomeMessages.Other.messages.js` would become
118
+ * `SomeMessages.Other.messages.d.ts`.
119
+ *
120
+ * Returns `true` if the types were successfully generated, or `false` otherwise, such as if the
121
+ * source file is not already in the database.
122
+ *
123
+ * @param {string} sourcePath
124
+ * @param {string=} outputFile
125
+ * @returns {boolean}
126
+ */
127
+ function generateTypeDefinitions(sourcePath, outputFile) {
128
+ const paths = database.getAllSourceFilePaths();
129
+ if (!paths.includes(sourcePath)) return false;
130
+
131
+ database.generateTypes(sourcePath, outputFile ?? sourcePath.replace(/\.[^.]+/, '.d.ts'));
132
+ return true;
133
+ }
134
+
135
+ module.exports = {
136
+ generateTypeDefinitions,
137
+ precompileFileForLocale,
138
+ processDefinitionsFile,
139
+ processTranslationsFile,
140
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Common class for parsing and transforming the content of a messages
3
+ * definition file (e.g., "SomeFeature.messages.js") into a production-ready
4
+ * version, with message keys obfuscated, loading harnesses configured, and
5
+ * more.
6
+ *
7
+ * This transformation is intended to be used alongside the _consumer_
8
+ * transforms implemented as SWC and Babel plugins, which transform the
9
+ * callsites for messages into matching formats. Consider this input example:
10
+ *
11
+ * ```typescript
12
+ * // SomeModule.messages.js
13
+ * import {defineMessages} from '@discord/intl';
14
+ *
15
+ * export default defineMessages({
16
+ * THIS_IS_A_MESSAGE: 'it has some content with {values}',
17
+ * });
18
+ *
19
+ * // SomeConsumer.tsx
20
+ * import someModuleMessages from 'SomeModule.messages.js';
21
+ * i18n.format(someModuleMessages.THIS_IS_A_MESSAGE, {values: "I'm a value!"});
22
+ * ```
23
+ *
24
+ * This transformer will only handle `SomeModule.messages.js`, and will output
25
+ * something like:
26
+ *
27
+ * ```typescript
28
+ * const {i18n} = require('@discord/intl');
29
+ * const _keys = ["a9fn23"];
30
+ * const _locales = {"en-US": () => require('./messages/en-US.messages.json')};
31
+ * const loader = createLoader(_keys, _locales);
32
+ * export default loader.getBinds();
33
+ * ```
34
+ *
35
+ * Notice how the message keys have been hashed into short keys, and the
36
+ * dynamic imports for each locale's data have been inserted automatically. In
37
+ * this example, the only locale is the source locale.
38
+ *
39
+ * The SWC and Babel transformers then take care of the second file,
40
+ * transforming the usage to use the hashed keys:
41
+ *
42
+ * ```typescript
43
+ * import someModuleMessages from 'SomeModule.messages.js';
44
+ * i18n.format(someModuleMessages["a9fn23"], {values: "i'm a value!"});
45
+ * ```
46
+ */
47
+ class MessageDefinitionsTransformer {
48
+ /**
49
+ * @param {import('./types.d.ts').MessageDefinitionsTransformerOptions} options
50
+ */
51
+ constructor(options) {
52
+ this.options = options;
53
+ }
54
+
55
+ /**
56
+ * Returns a compiled string for an object that maps locale names to a
57
+ * dynamic require function for that locale, based on the supported locales
58
+ * that were determined for this file. The shape ends up as:
59
+ *
60
+ * ```typescript
61
+ * {
62
+ * "en-US": () => import("path/to/en-US.json"),
63
+ * }
64
+ * ```
65
+ *
66
+ * @returns {string}
67
+ */
68
+ getLocaleRequireMap() {
69
+ const localeProperties = [];
70
+ for (const [locale, importPath] of Object.entries(this.options.localeMap)) {
71
+ // This assumes that the author has specified `importPath`
72
+ // as a properly-resolvable path for the bundler, which we can't easily
73
+ // enforce, unfortunately.
74
+ localeProperties.push(`"${locale}": () => ${this.options.getTranslationImport(importPath)}`);
75
+ }
76
+
77
+ return `{${localeProperties.join(',')}}`;
78
+ }
79
+
80
+ /**
81
+ * Returns the reduced, transformed output for this file. Currently not
82
+ * configurable, but could be told to include default messages or preserve
83
+ * information as necessary.
84
+ *
85
+ * @returns {string}
86
+ */
87
+ getOutput() {
88
+ return [
89
+ this.options.getPrelude?.() ?? '// No additional prelude was configured.',
90
+ `const {createLoader} = require('@discord/intl');`,
91
+ `const _keys = ${JSON.stringify(this.options.messageKeys)};`,
92
+ `const _locales = ${this.getLocaleRequireMap()};`,
93
+ 'const loader = createLoader(_keys, _locales);',
94
+ 'export default loader.getBinds();',
95
+ ].join('\n');
96
+ }
97
+ }
98
+
99
+ module.exports = {
100
+ MessageDefinitionsTransformer,
101
+ };
package/src/types.d.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { IntlSourceFile } from '@discord/intl-message-database';
2
+
3
+ export interface MessageDefinitionsTransformerOptions {
4
+ /**
5
+ * The list of hashed message keys that this file manages.
6
+ */
7
+ messageKeys: string[];
8
+ /**
9
+ * Map of locale names to import paths used for loading translations.
10
+ */
11
+ localeMap: Record<string, string>;
12
+ /**
13
+ * Function to create a prelude that gets injected at the start of the transformed file to set up
14
+ * anything needed for other injections later on.
15
+ */
16
+ getPrelude?: () => string;
17
+ /**
18
+ * Function to generate an import/require statement for the compiled asset file. All imports
19
+ * should be asynchronous (e.g., typically use `import` rather than `require`), but some platforms
20
+ * implement loading differently and may need different syntax. For example, React Native Assets
21
+ * are bundled using `require` statements to return an Asset ID, which can then be loaded
22
+ * asynchronously by some other code to get the actual content of the asset.
23
+ *
24
+ * The code created by this function must create a `Promise<{default: Record<string, any>}>`. In
25
+ * other words, a Promise for an object with a `default` key pointing to an object map of message
26
+ * keys to their values. For `import` statements, this is already the default. For `requires`, you
27
+ * may need to wrap the result with the `default` key, like `.then((data) => ({default: data}))`.
28
+ */
29
+ getTranslationImport(importPath: string): string;
30
+ }
31
+
32
+ /**
33
+ * The result of calling `processDefinitionsFile`, including the created source file, locale map,
34
+ * and more.
35
+ */
36
+ export interface ProcessDefinitionsResult {
37
+ /**
38
+ * Direct source file from the database that was created or updated by this process.
39
+ */
40
+ sourceFile: IntlSourceFile;
41
+ /**
42
+ * The locale that was either determined from the sourceFile name or overridden by the options
43
+ * provided to this call.
44
+ */
45
+ locale: string;
46
+ /**
47
+ * The full list of message keys contained by the processed source file. While `sourceFile`
48
+ * contains a list of key _symbols_, this list contains all of the resolved strings for the
49
+ * hashed message keys.
50
+ */
51
+ hashedMessageKeys: string[];
52
+ /**
53
+ * Fully-resolved path to the translations directory that was scanned for entries for the
54
+ * source file.
55
+ */
56
+ translationsPath: string;
57
+ /**
58
+ * Map of locale names to file paths for all translations files that were discovered when scanning
59
+ * the configured `translationsPath`. Note that this _does not_ include the source locale, since
60
+ * it's target is often different between loaders (e.g., could be a virtual file, an asset that
61
+ * gets compiled separately, or use query parameters to control loader behavior when reusing the
62
+ * same file).
63
+ */
64
+ translationsLocaleMap: Record<string, string>;
65
+ }
66
+
67
+ /**
68
+ * The result of calling `processTranslationsFile`, including the created source file, locale map,
69
+ * and more.
70
+ */
71
+ export interface ProcessTranslationsResult {
72
+ /**
73
+ * Direct source file from the database that was created or updated by this process.
74
+ */
75
+ sourceFile: IntlSourceFile;
76
+ /**
77
+ * The locale that was either determined from the sourceFile name or overridden by the options
78
+ * provided to this call.
79
+ */
80
+ locale: string;
81
+ /**
82
+ * The full list of message keys contained by the processed source file. While `sourceFile`
83
+ * contains a list of key _symbols_, this list contains all of the resolved strings for the
84
+ * hashed message keys.
85
+ */
86
+ hashedMessageKeys: string[];
87
+ }
package/src/util.js ADDED
@@ -0,0 +1,55 @@
1
+ const path = require('node:path');
2
+ const fs = require('node:fs');
3
+ const { isMessageTranslationsFile } = require('@discord/intl-message-database');
4
+
5
+ const IGNORED_MESSAGE_FILE_PATTERNS = [/.*\.compiled.messages\..*/];
6
+
7
+ /**
8
+ * Return the presumed locale for a translations file from it's name. The convention follows the
9
+ * format: `some/path/to/<locale>.messages.jsona`, so the locale is determined by taking the content
10
+ * of the basename up until the first `.`.
11
+ *
12
+ * @param {string} fileName
13
+ * @returns {string}
14
+ */
15
+ function getLocaleFromTranslationsFileName(fileName) {
16
+ return path.basename(fileName).split('.')[0];
17
+ }
18
+
19
+ /**
20
+ * Scan the given `translationsPath` to discover all translation files that exist, returning them
21
+ * as a map from locale name to the path for importing.
22
+ *
23
+ * @param {string} translationsPath
24
+ * @returns {Record<string, string>}
25
+ */
26
+ function findAllTranslationFiles(translationsPath) {
27
+ /** @type {Record<string, string>} */
28
+ const localeMap = {};
29
+
30
+ try {
31
+ const translationFiles = fs.readdirSync(translationsPath, { encoding: 'utf-8' });
32
+ for (const foundFile of translationFiles) {
33
+ const filePath = path.join(translationsPath, foundFile);
34
+ // Only include translation files, not definitions files.
35
+ if (!isMessageTranslationsFile(filePath)) continue;
36
+ // Some files are excluded, like pre-compiled artifacts.
37
+ if (IGNORED_MESSAGE_FILE_PATTERNS.some((pattern) => pattern.test(filePath))) continue;
38
+
39
+ const locale = getLocaleFromTranslationsFileName(filePath);
40
+ localeMap[locale] = filePath;
41
+ }
42
+ } catch (e) {
43
+ throw new Error(
44
+ `The translations directory ${translationsPath} was not found. No translations will be loaded for these messages`,
45
+ );
46
+ }
47
+
48
+ return localeMap;
49
+ }
50
+
51
+ module.exports = {
52
+ IGNORED_MESSAGE_FILE_PATTERNS,
53
+ findAllTranslationFiles,
54
+ getLocaleFromTranslationsFileName,
55
+ };
package/src/watcher.js ADDED
@@ -0,0 +1,92 @@
1
+ const path = require('node:path');
2
+
3
+ const chokidar = require('chokidar');
4
+ const fg = require('fast-glob');
5
+ const debug = require('debug')('intl:metro-intl-transformer:watcher');
6
+ const {
7
+ isMessageDefinitionsFile,
8
+ IntlCompiledMessageFormat,
9
+ } = require('@discord/intl-message-database');
10
+
11
+ const { database } = require('./database');
12
+ const { processDefinitionsFile, precompileFileForLocale } = require('./processing');
13
+
14
+ const ALWAYS_IGNORE_PATTERNS = [
15
+ // Ignore our own compiled message files, even though they shouldn't have a matching extension.
16
+ '*.compiled.messages.*',
17
+ ];
18
+ // TODO: This should come from the database extension? Or Utilities? Unsure, but the extensions
19
+ // should have some centralized location in general
20
+ const MESSAGE_DEFINITION_FILE_PATTERNS = ['**/*.messages.js'];
21
+ const DEFAULT_LOCALE = 'en-US';
22
+
23
+ /**
24
+ * @param {string} filePath
25
+ * @param {string} assetExtension
26
+ */
27
+ function processFile(filePath, assetExtension) {
28
+ debug(`Processing file: ${filePath}`);
29
+ if (!isMessageDefinitionsFile(filePath)) {
30
+ debug(`${filePath} is not a definitions file. Skipping processing`);
31
+ return;
32
+ }
33
+
34
+ try {
35
+ // Convert the file name from `.messages.js` to `.compiled.messages.jsona` for output.
36
+ const outputPath = filePath.replace(/\.messages\.js$/, `.compiled.messages.${assetExtension}`);
37
+ const result = processDefinitionsFile(filePath);
38
+ precompileFileForLocale(filePath, result.locale, { format: IntlCompiledMessageFormat.Json });
39
+
40
+ database.processDefinitionsFile(filePath);
41
+ database.precompile(filePath, DEFAULT_LOCALE, outputPath, IntlCompiledMessageFormat.Json);
42
+ debug(`Wrote definitions to: ${outputPath}`);
43
+ } catch (e) {
44
+ debug('[INTL Error] Failed to compile messages');
45
+ console.error(e);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * @param {string[]} watchedFolders
51
+ * @param {{
52
+ * watch?: boolean,
53
+ * ignore?: string[],
54
+ * assetExtension?: string
55
+ * }} options
56
+ */
57
+ async function compileIntlMessageFiles(
58
+ watchedFolders,
59
+ { watch = true, ignore = [], assetExtension = 'json' } = {},
60
+ ) {
61
+ const ignoredPatterns = ignore.concat(ALWAYS_IGNORE_PATTERNS);
62
+ const globs = watchedFolders.flatMap((folder) =>
63
+ MESSAGE_DEFINITION_FILE_PATTERNS.map((pattern) => path.join(folder, pattern)),
64
+ );
65
+ debug(`Configured message file patterns:\n- ${globs.join('\n- ')}`);
66
+
67
+ // Perform one initial scan and compilation to ensure all files exist before Metro might try to
68
+ // resolve them.
69
+ debug('Scanning for initial messages files');
70
+ for await (const filePath of fg.stream(globs, {
71
+ ignore: ignoredPatterns,
72
+ absolute: true,
73
+ onlyFiles: true,
74
+ })) {
75
+ processFile(filePath.toString(), assetExtension);
76
+ }
77
+ debug('Initial message scan completed.');
78
+
79
+ if (watch) {
80
+ debug(`Setting up file watching for configured paths`);
81
+ chokidar
82
+ .watch(globs, { ignored: ignoredPatterns, ignoreInitial: true })
83
+ .on('all', (event, filePath) => {
84
+ debug(`Got event ${event} for ${filePath}`);
85
+ processFile(filePath, assetExtension);
86
+ });
87
+ } else {
88
+ debug('Not watching files because `watch` option was false');
89
+ }
90
+ }
91
+
92
+ module.exports = { compileIntlMessageFiles, ALWAYS_IGNORE_PATTERNS };