@vocab/phrase 2.1.3 → 2.2.0-add-auto-translate-20251105043557
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/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/README.md +0 -827
package/dist/index.d.mts
CHANGED
|
@@ -13,6 +13,7 @@ declare function pull({
|
|
|
13
13
|
//#endregion
|
|
14
14
|
//#region src/push-translations.d.ts
|
|
15
15
|
interface PushOptions {
|
|
16
|
+
autoTranslate?: boolean;
|
|
16
17
|
branch: string;
|
|
17
18
|
deleteUnusedKeys?: boolean;
|
|
18
19
|
ignore?: string[];
|
|
@@ -22,6 +23,7 @@ interface PushOptions {
|
|
|
22
23
|
* A unique namespace is appended to each key using the file path the key came from.
|
|
23
24
|
*/
|
|
24
25
|
declare function push({
|
|
26
|
+
autoTranslate,
|
|
25
27
|
branch,
|
|
26
28
|
deleteUnusedKeys,
|
|
27
29
|
ignore
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ declare function pull({
|
|
|
13
13
|
//#endregion
|
|
14
14
|
//#region src/push-translations.d.ts
|
|
15
15
|
interface PushOptions {
|
|
16
|
+
autoTranslate?: boolean;
|
|
16
17
|
branch: string;
|
|
17
18
|
deleteUnusedKeys?: boolean;
|
|
18
19
|
ignore?: string[];
|
|
@@ -22,6 +23,7 @@ interface PushOptions {
|
|
|
22
23
|
* A unique namespace is appended to each key using the file path the key came from.
|
|
23
24
|
*/
|
|
24
25
|
declare function push({
|
|
26
|
+
autoTranslate,
|
|
25
27
|
branch,
|
|
26
28
|
deleteUnusedKeys,
|
|
27
29
|
ignore
|
package/dist/index.js
CHANGED
|
@@ -22,17 +22,14 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
22
22
|
|
|
23
23
|
//#endregion
|
|
24
24
|
let fs = require("fs");
|
|
25
|
-
fs = __toESM(fs);
|
|
26
25
|
let path = require("path");
|
|
27
26
|
path = __toESM(path);
|
|
28
27
|
let __vocab_core = require("@vocab/core");
|
|
29
|
-
__vocab_core = __toESM(__vocab_core);
|
|
30
28
|
let picocolors = require("picocolors");
|
|
31
29
|
picocolors = __toESM(picocolors);
|
|
32
30
|
let debug = require("debug");
|
|
33
31
|
debug = __toESM(debug);
|
|
34
32
|
let csv_stringify_sync = require("csv-stringify/sync");
|
|
35
|
-
csv_stringify_sync = __toESM(csv_stringify_sync);
|
|
36
33
|
|
|
37
34
|
//#region src/file.ts
|
|
38
35
|
const mkdir = fs.promises.mkdir;
|
|
@@ -66,20 +63,18 @@ function translationsToCsv(translations, devLanguage) {
|
|
|
66
63
|
});
|
|
67
64
|
});
|
|
68
65
|
const csvFileStrings = Object.fromEntries(Object.entries(csvFilesByLanguage).filter(([_, csvFile]) => csvFile.length > 0).map(([language, csvFile]) => {
|
|
69
|
-
|
|
66
|
+
return [language, (0, csv_stringify_sync.stringify)(csvFile, {
|
|
70
67
|
delimiter: ",",
|
|
71
68
|
header: false
|
|
72
|
-
});
|
|
73
|
-
return [language, csvFileString];
|
|
69
|
+
})];
|
|
74
70
|
}));
|
|
75
71
|
const keyIndex = 1;
|
|
76
72
|
const commentIndex = keyIndex + 1;
|
|
77
73
|
const tagColumn = commentIndex + 1;
|
|
78
|
-
const messageIndex = tagColumn + 1;
|
|
79
74
|
return {
|
|
80
75
|
csvFileStrings,
|
|
81
76
|
keyIndex,
|
|
82
|
-
messageIndex,
|
|
77
|
+
messageIndex: tagColumn + 1,
|
|
83
78
|
commentIndex,
|
|
84
79
|
tagColumn
|
|
85
80
|
};
|
|
@@ -139,7 +134,7 @@ async function pullAllTranslations(branch) {
|
|
|
139
134
|
}
|
|
140
135
|
return translations;
|
|
141
136
|
}
|
|
142
|
-
async function pushTranslations(translationsByLanguage, {
|
|
137
|
+
async function pushTranslations(translationsByLanguage, { autoTranslate, branch, devLanguage }) {
|
|
143
138
|
const { csvFileStrings, keyIndex, commentIndex, tagColumn, messageIndex } = translationsToCsv(translationsByLanguage, devLanguage);
|
|
144
139
|
let devLanguageUploadId = "";
|
|
145
140
|
for (const [language, csvFileString] of Object.entries(csvFileStrings)) {
|
|
@@ -149,6 +144,7 @@ async function pushTranslations(translationsByLanguage, { devLanguage, branch })
|
|
|
149
144
|
formData.append("branch", branch);
|
|
150
145
|
formData.append("update_translations", "true");
|
|
151
146
|
formData.append("update_descriptions", "true");
|
|
147
|
+
if (autoTranslate) formData.append("autoTranslate", "true");
|
|
152
148
|
formData.append(`locale_mapping[${language}]`, messageIndex.toString());
|
|
153
149
|
formData.append("format_options[key_index]", keyIndex.toString());
|
|
154
150
|
formData.append("format_options[comment_index]", commentIndex.toString());
|
|
@@ -249,7 +245,7 @@ async function pull({ branch = "local-development", errorOnNoGlobalKeyTranslatio
|
|
|
249
245
|
* Uploads translations to the Phrase API for each language.
|
|
250
246
|
* A unique namespace is appended to each key using the file path the key came from.
|
|
251
247
|
*/
|
|
252
|
-
async function push({ branch, deleteUnusedKeys: deleteUnusedKeys$1, ignore }, config) {
|
|
248
|
+
async function push({ autoTranslate, branch, deleteUnusedKeys: deleteUnusedKeys$1, ignore }, config) {
|
|
253
249
|
if (ignore) trace(`ignoring files on paths: ${ignore.join(", ")}`);
|
|
254
250
|
const allLanguageTranslations = await (0, __vocab_core.loadAllTranslations)({
|
|
255
251
|
fallbacks: "none",
|
|
@@ -277,8 +273,9 @@ async function push({ branch, deleteUnusedKeys: deleteUnusedKeys$1, ignore }, co
|
|
|
277
273
|
}
|
|
278
274
|
}
|
|
279
275
|
const { devLanguageUploadId } = await pushTranslations(phraseTranslations, {
|
|
280
|
-
|
|
281
|
-
branch
|
|
276
|
+
autoTranslate,
|
|
277
|
+
branch,
|
|
278
|
+
devLanguage: config.devLanguage
|
|
282
279
|
});
|
|
283
280
|
if (deleteUnusedKeys$1) await deleteUnusedKeys(devLanguageUploadId, branch);
|
|
284
281
|
}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["pc","csvFilesByLanguage: Record<LanguageName, CsvFile>","path","translations: TranslationsByLanguage","defaultValues: TranslationFileContents","phraseTranslations: TranslationsByLanguage","deleteUnusedKeys","phraseDeleteUnusedKeys"],"sources":["../src/file.ts","../src/logger.ts","../src/csv.ts","../src/phrase-api.ts","../src/pull-translations.ts","../src/push-translations.ts"],"sourcesContent":["import { promises as fs } from 'fs';\n\nexport const mkdir = fs.mkdir;\nexport const writeFile = fs.writeFile;\n","import pc from 'picocolors';\nimport debug from 'debug';\n\nexport const trace = debug(`vocab:phrase`);\n\nexport const log = (...params: unknown[]) => {\n // eslint-disable-next-line no-console\n console.log(pc.yellow('Vocab'), ...params);\n};\n","import { stringify } from 'csv-stringify/sync';\nimport type { LanguageName, TranslationsByLanguage } from '@vocab/core';\n\ntype Value = string | undefined;\ntype CsvRow = Value[];\ntype CsvFile = CsvRow[];\n\nexport function translationsToCsv(\n translations: TranslationsByLanguage,\n devLanguage: string,\n) {\n const languages = Object.keys(translations);\n const altLanguages = languages.filter((language) => language !== devLanguage);\n\n const devLanguageTranslations = translations[devLanguage];\n\n const csvFilesByLanguage: Record<LanguageName, CsvFile> = Object.fromEntries(\n languages.map((language) => [language, []]),\n );\n\n Object.entries(devLanguageTranslations).map(\n ([key, { message, description, tags }]) => {\n const sharedData = [key, description, tags?.join(',')];\n const devLanguageRow = [...sharedData, message];\n csvFilesByLanguage[devLanguage].push(devLanguageRow);\n\n altLanguages.map((language) => {\n const altTranslationMessage = translations[language]?.[key]?.message;\n\n if (altTranslationMessage) {\n csvFilesByLanguage[language].push([\n ...sharedData,\n altTranslationMessage,\n ]);\n }\n });\n },\n );\n\n const csvFileStrings = Object.fromEntries(\n Object.entries(csvFilesByLanguage)\n // Ensure CSV files are only created if the language has at least 1 translation\n .filter(([_, csvFile]) => csvFile.length > 0)\n .map(([language, csvFile]) => {\n const csvFileString = stringify(csvFile, {\n delimiter: ',',\n header: false,\n });\n\n return [language, csvFileString];\n }),\n );\n\n // Column indices start at 1\n const keyIndex = 1;\n const commentIndex = keyIndex + 1;\n const tagColumn = commentIndex + 1;\n const messageIndex = tagColumn + 1;\n\n return { csvFileStrings, keyIndex, messageIndex, commentIndex, tagColumn };\n}\n","/* eslint-disable no-console */\nimport type { TranslationsByLanguage } from '@vocab/core';\nimport { log, trace } from './logger';\nimport { translationsToCsv } from './csv';\n\nfunction _callPhrase(path: string, options: Parameters<typeof fetch>[1] = {}) {\n const phraseApiToken = process.env.PHRASE_API_TOKEN;\n\n if (!phraseApiToken) {\n throw new Error('Missing PHRASE_API_TOKEN');\n }\n\n return fetch(path, {\n ...options,\n headers: {\n Authorization: `token ${phraseApiToken}`,\n // Provide identification via User Agent as requested in https://developers.phrase.com/api/#overview--identification-via-user-agent\n 'User-Agent': 'Vocab Client (https://github.com/seek-oss/vocab)',\n ...options.headers,\n },\n }).then(async (response) => {\n console.log(`${path}: ${response.status} - ${response.statusText}`);\n\n const secondsUntilLimitReset = Math.ceil(\n Number.parseFloat(response.headers.get('X-Rate-Limit-Reset') || '0') -\n Date.now() / 1000,\n );\n console.log(\n `Rate Limit: ${response.headers.get(\n 'X-Rate-Limit-Remaining',\n )} of ${response.headers.get(\n 'X-Rate-Limit-Limit',\n )} remaining. (${secondsUntilLimitReset} seconds remaining)`,\n );\n\n trace('\\nLink:', response.headers.get('Link'), '\\n');\n // Print All Headers:\n // console.log(Array.from(r.headers.entries()));\n\n try {\n const result = await response.json();\n\n trace(`Internal Result (Length: ${result.length})\\n`);\n\n if (\n (!options.method || options.method === 'GET') &&\n response.headers.get('Link')?.includes('rel=next')\n ) {\n const [, nextPageUrl] =\n response.headers.get('Link')?.match(/<([^>]*)>; rel=next/) ?? [];\n\n if (!nextPageUrl) {\n throw new Error(\"Can't parse next page URL\");\n }\n\n console.log('Results received with next page: ', nextPageUrl);\n\n const nextPageResult = (await _callPhrase(nextPageUrl, options)) as any;\n\n return [...result, ...nextPageResult];\n }\n\n return result;\n } catch (e) {\n console.error('Unable to parse response as JSON', e);\n return response.text();\n }\n });\n}\n\nexport async function callPhrase<T = any>(\n relativePath: string,\n options: Parameters<typeof fetch>[1] = {},\n): Promise<T> {\n const projectId = process.env.PHRASE_PROJECT_ID;\n\n if (!projectId) {\n throw new Error('Missing PHRASE_PROJECT_ID');\n }\n return _callPhrase(\n `https://api.phrase.com/v2/projects/${projectId}/${relativePath}`,\n options,\n )\n .then((result) => {\n if (Array.isArray(result)) {\n console.log('Result length:', result.length);\n }\n return result;\n })\n .catch((error) => {\n console.error(`Error calling phrase for ${relativePath}:`, error);\n throw Error;\n });\n}\n\nexport async function pullAllTranslations(\n branch: string,\n): Promise<TranslationsByLanguage> {\n const phraseResult = await callPhrase<\n Array<{\n key: { name: string };\n locale: { name: string };\n content: string;\n }>\n >(`translations?branch=${branch}&per_page=100`);\n\n const translations: TranslationsByLanguage = {};\n\n for (const r of phraseResult) {\n if (!translations[r.locale.name]) {\n translations[r.locale.name] = {};\n }\n translations[r.locale.name][r.key.name] = { message: r.content };\n }\n\n return translations;\n}\n\nexport async function pushTranslations(\n translationsByLanguage: TranslationsByLanguage,\n { devLanguage, branch }: { devLanguage: string; branch: string },\n) {\n const { csvFileStrings, keyIndex, commentIndex, tagColumn, messageIndex } =\n translationsToCsv(translationsByLanguage, devLanguage);\n\n let devLanguageUploadId = '';\n\n for (const [language, csvFileString] of Object.entries(csvFileStrings)) {\n const formData = new FormData();\n\n formData.append(\n 'file',\n new Blob([csvFileString], {\n type: 'text/csv',\n }),\n `${language}.translations.csv`,\n );\n\n formData.append('file_format', 'csv');\n formData.append('branch', branch);\n formData.append('update_translations', 'true');\n formData.append('update_descriptions', 'true');\n\n formData.append(`locale_mapping[${language}]`, messageIndex.toString());\n\n formData.append('format_options[key_index]', keyIndex.toString());\n formData.append('format_options[comment_index]', commentIndex.toString());\n formData.append('format_options[tag_column]', tagColumn.toString());\n formData.append('format_options[enable_pluralization]', 'false');\n\n log(`Uploading translations for language ${language}`);\n\n const result = await callPhrase<\n | {\n id: string;\n }\n | {\n message: string;\n errors: unknown[];\n }\n | undefined\n >(`uploads`, {\n method: 'POST',\n body: formData,\n });\n\n trace('Upload result:\\n', result);\n\n if (result && 'id' in result) {\n log('Upload ID:', result.id, '\\n');\n log('Successfully Uploaded\\n');\n } else {\n log(`Error uploading: ${result?.message}\\n`);\n log('Response:', result);\n throw new Error('Error uploading');\n }\n\n if (language === devLanguage) {\n devLanguageUploadId = result.id;\n }\n }\n\n return {\n devLanguageUploadId,\n };\n}\n\nexport async function deleteUnusedKeys(uploadId: string, branch: string) {\n const query = `unmentioned_in_upload:${uploadId}`;\n const { records_affected } = await callPhrase<{ records_affected: number }>(\n 'keys',\n {\n method: 'DELETE',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ branch, q: query }),\n },\n );\n\n log(\n 'Successfully deleted',\n records_affected,\n 'unused keys from branch',\n branch,\n );\n}\n\nexport async function ensureBranch(branch: string) {\n await callPhrase(`branches`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ name: branch }),\n });\n\n log('Created branch:', branch);\n}\n","import { writeFile, mkdir } from './file';\nimport path from 'path';\n\nimport {\n type TranslationFileContents,\n type UserConfig,\n loadAllTranslations,\n getAltLanguageFilePath,\n getAltLanguages,\n getUniqueKey,\n} from '@vocab/core';\n\nimport { pullAllTranslations, ensureBranch } from './phrase-api';\nimport { trace } from './logger';\n\ninterface PullOptions {\n branch?: string;\n deleteUnusedKeys?: boolean;\n errorOnNoGlobalKeyTranslation?: boolean;\n}\n\nexport async function pull(\n { branch = 'local-development', errorOnNoGlobalKeyTranslation }: PullOptions,\n config: UserConfig,\n) {\n trace(`Pulling translations from branch ${branch}`);\n await ensureBranch(branch);\n const alternativeLanguages = getAltLanguages(config);\n const allPhraseTranslations = await pullAllTranslations(branch);\n trace(\n `Pulling translations from Phrase for languages ${\n config.devLanguage\n } and ${alternativeLanguages.join(', ')}`,\n );\n\n const phraseLanguages = Object.keys(allPhraseTranslations);\n trace(\n `Found Phrase translations for languages ${phraseLanguages.join(', ')}`,\n );\n\n if (!phraseLanguages.includes(config.devLanguage)) {\n throw new Error(\n `Phrase did not return any translations for the configured development language \"${config.devLanguage}\".\\nPlease ensure this language is present in your Phrase project's configuration.`,\n );\n }\n\n const allVocabTranslations = await loadAllTranslations(\n { fallbacks: 'none', includeNodeModules: false, withTags: true },\n config,\n );\n\n for (const loadedTranslation of allVocabTranslations) {\n const devTranslations = loadedTranslation.languages[config.devLanguage];\n\n if (!devTranslations) {\n throw new Error('No dev language translations loaded');\n }\n\n const defaultValues: TranslationFileContents = { ...devTranslations };\n const localKeys = Object.keys(defaultValues);\n\n for (const key of localKeys) {\n defaultValues[key] = {\n ...defaultValues[key],\n ...allPhraseTranslations[config.devLanguage][\n defaultValues[key].globalKey ??\n getUniqueKey(key, loadedTranslation.namespace)\n ],\n };\n }\n\n // Only write a `_meta` field if necessary\n if (Object.keys(loadedTranslation.metadata).length > 0) {\n defaultValues._meta = loadedTranslation.metadata;\n }\n\n await writeFile(\n loadedTranslation.filePath,\n `${JSON.stringify(defaultValues, null, 2)}\\n`,\n );\n\n for (const alternativeLanguage of alternativeLanguages) {\n if (alternativeLanguage in allPhraseTranslations) {\n const altTranslations = {\n ...loadedTranslation.languages[alternativeLanguage],\n };\n const phraseAltTranslations =\n allPhraseTranslations[alternativeLanguage];\n\n for (const key of localKeys) {\n const phraseKey =\n defaultValues[key].globalKey ??\n getUniqueKey(key, loadedTranslation.namespace);\n const phraseTranslationMessage =\n phraseAltTranslations[phraseKey]?.message;\n\n if (!phraseTranslationMessage) {\n trace(\n `Missing translation. No translation for key ${key} in phrase as ${phraseKey} in language ${alternativeLanguage}.`,\n );\n if (errorOnNoGlobalKeyTranslation && defaultValues[key].globalKey) {\n throw new Error(\n `Missing translation for global key ${key} in language ${alternativeLanguage}`,\n );\n }\n continue;\n }\n\n altTranslations[key] = {\n ...altTranslations[key],\n message: phraseTranslationMessage,\n };\n }\n\n const altTranslationFilePath = getAltLanguageFilePath(\n loadedTranslation.filePath,\n alternativeLanguage,\n );\n\n await mkdir(path.dirname(altTranslationFilePath), {\n recursive: true,\n });\n await writeFile(\n altTranslationFilePath,\n `${JSON.stringify(altTranslations, null, 2)}\\n`,\n );\n }\n }\n }\n}\n","import {\n loadAllTranslations,\n getUniqueKey,\n type TranslationData,\n type TranslationsByLanguage,\n type UserConfig,\n} from '@vocab/core';\nimport {\n ensureBranch,\n deleteUnusedKeys as phraseDeleteUnusedKeys,\n pushTranslations,\n} from './phrase-api';\nimport { trace } from './logger';\n\ninterface PushOptions {\n branch: string;\n deleteUnusedKeys?: boolean;\n ignore?: string[];\n}\n\n/**\n * Uploads translations to the Phrase API for each language.\n * A unique namespace is appended to each key using the file path the key came from.\n */\nexport async function push(\n { branch, deleteUnusedKeys, ignore }: PushOptions,\n config: UserConfig,\n) {\n if (ignore) {\n trace(`ignoring files on paths: ${ignore.join(', ')}`);\n }\n const allLanguageTranslations = await loadAllTranslations(\n { fallbacks: 'none', includeNodeModules: false, withTags: true },\n {\n ...config,\n ignore: [...(config.ignore || []), ...(ignore || [])],\n },\n );\n trace(`Pushing translations to branch ${branch}`);\n const allLanguages = config.languages.map((v) => v.name);\n await ensureBranch(branch);\n\n trace(\n `Pushing translations to phrase for languages ${allLanguages.join(', ')}`,\n );\n\n const phraseTranslations: TranslationsByLanguage = {};\n\n for (const loadedTranslation of allLanguageTranslations) {\n for (const language of allLanguages) {\n const localTranslations = loadedTranslation.languages[language];\n if (!localTranslations) {\n continue;\n }\n if (!phraseTranslations[language]) {\n phraseTranslations[language] = {};\n }\n\n const {\n metadata: { tags: sharedTags = [] },\n } = loadedTranslation;\n\n for (const localKey of Object.keys(localTranslations)) {\n const { tags = [], ...localTranslation } = localTranslations[localKey];\n if (language === config.devLanguage) {\n (localTranslation as TranslationData).tags = [...tags, ...sharedTags];\n }\n const globalKey =\n loadedTranslation.languages[config.devLanguage][localKey].globalKey;\n\n const phraseKey =\n globalKey ?? getUniqueKey(localKey, loadedTranslation.namespace);\n\n phraseTranslations[language][phraseKey] = localTranslation;\n }\n }\n }\n\n const { devLanguageUploadId } = await pushTranslations(phraseTranslations, {\n devLanguage: config.devLanguage,\n branch,\n });\n\n if (deleteUnusedKeys) {\n await phraseDeleteUnusedKeys(devLanguageUploadId, branch);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["pc","csvFilesByLanguage: Record<LanguageName, CsvFile>","path","translations: TranslationsByLanguage","defaultValues: TranslationFileContents","phraseTranslations: TranslationsByLanguage","deleteUnusedKeys","phraseDeleteUnusedKeys"],"sources":["../src/file.ts","../src/logger.ts","../src/csv.ts","../src/phrase-api.ts","../src/pull-translations.ts","../src/push-translations.ts"],"sourcesContent":["import { promises as fs } from 'fs';\n\nexport const mkdir = fs.mkdir;\nexport const writeFile = fs.writeFile;\n","import pc from 'picocolors';\nimport debug from 'debug';\n\nexport const trace = debug(`vocab:phrase`);\n\nexport const log = (...params: unknown[]) => {\n // eslint-disable-next-line no-console\n console.log(pc.yellow('Vocab'), ...params);\n};\n","import { stringify } from 'csv-stringify/sync';\nimport type { LanguageName, TranslationsByLanguage } from '@vocab/core';\n\ntype Value = string | undefined;\ntype CsvRow = Value[];\ntype CsvFile = CsvRow[];\n\nexport function translationsToCsv(\n translations: TranslationsByLanguage,\n devLanguage: string,\n) {\n const languages = Object.keys(translations);\n const altLanguages = languages.filter((language) => language !== devLanguage);\n\n const devLanguageTranslations = translations[devLanguage];\n\n const csvFilesByLanguage: Record<LanguageName, CsvFile> = Object.fromEntries(\n languages.map((language) => [language, []]),\n );\n\n Object.entries(devLanguageTranslations).map(\n ([key, { message, description, tags }]) => {\n const sharedData = [key, description, tags?.join(',')];\n const devLanguageRow = [...sharedData, message];\n csvFilesByLanguage[devLanguage].push(devLanguageRow);\n\n altLanguages.map((language) => {\n const altTranslationMessage = translations[language]?.[key]?.message;\n\n if (altTranslationMessage) {\n csvFilesByLanguage[language].push([\n ...sharedData,\n altTranslationMessage,\n ]);\n }\n });\n },\n );\n\n const csvFileStrings = Object.fromEntries(\n Object.entries(csvFilesByLanguage)\n // Ensure CSV files are only created if the language has at least 1 translation\n .filter(([_, csvFile]) => csvFile.length > 0)\n .map(([language, csvFile]) => {\n const csvFileString = stringify(csvFile, {\n delimiter: ',',\n header: false,\n });\n\n return [language, csvFileString];\n }),\n );\n\n // Column indices start at 1\n const keyIndex = 1;\n const commentIndex = keyIndex + 1;\n const tagColumn = commentIndex + 1;\n const messageIndex = tagColumn + 1;\n\n return { csvFileStrings, keyIndex, messageIndex, commentIndex, tagColumn };\n}\n","/* eslint-disable no-console */\nimport type { TranslationsByLanguage } from '@vocab/core';\nimport { log, trace } from './logger';\nimport { translationsToCsv } from './csv';\n\nfunction _callPhrase(path: string, options: Parameters<typeof fetch>[1] = {}) {\n const phraseApiToken = process.env.PHRASE_API_TOKEN;\n\n if (!phraseApiToken) {\n throw new Error('Missing PHRASE_API_TOKEN');\n }\n\n return fetch(path, {\n ...options,\n headers: {\n Authorization: `token ${phraseApiToken}`,\n // Provide identification via User Agent as requested in https://developers.phrase.com/api/#overview--identification-via-user-agent\n 'User-Agent': 'Vocab Client (https://github.com/seek-oss/vocab)',\n ...options.headers,\n },\n }).then(async (response) => {\n console.log(`${path}: ${response.status} - ${response.statusText}`);\n\n const secondsUntilLimitReset = Math.ceil(\n Number.parseFloat(response.headers.get('X-Rate-Limit-Reset') || '0') -\n Date.now() / 1000,\n );\n console.log(\n `Rate Limit: ${response.headers.get(\n 'X-Rate-Limit-Remaining',\n )} of ${response.headers.get(\n 'X-Rate-Limit-Limit',\n )} remaining. (${secondsUntilLimitReset} seconds remaining)`,\n );\n\n trace('\\nLink:', response.headers.get('Link'), '\\n');\n // Print All Headers:\n // console.log(Array.from(r.headers.entries()));\n\n try {\n const result = await response.json();\n\n trace(`Internal Result (Length: ${result.length})\\n`);\n\n if (\n (!options.method || options.method === 'GET') &&\n response.headers.get('Link')?.includes('rel=next')\n ) {\n const [, nextPageUrl] =\n response.headers.get('Link')?.match(/<([^>]*)>; rel=next/) ?? [];\n\n if (!nextPageUrl) {\n throw new Error(\"Can't parse next page URL\");\n }\n\n console.log('Results received with next page: ', nextPageUrl);\n\n const nextPageResult = (await _callPhrase(nextPageUrl, options)) as any;\n\n return [...result, ...nextPageResult];\n }\n\n return result;\n } catch (e) {\n console.error('Unable to parse response as JSON', e);\n return response.text();\n }\n });\n}\n\nexport async function callPhrase<T = any>(\n relativePath: string,\n options: Parameters<typeof fetch>[1] = {},\n): Promise<T> {\n const projectId = process.env.PHRASE_PROJECT_ID;\n\n if (!projectId) {\n throw new Error('Missing PHRASE_PROJECT_ID');\n }\n return _callPhrase(\n `https://api.phrase.com/v2/projects/${projectId}/${relativePath}`,\n options,\n )\n .then((result) => {\n if (Array.isArray(result)) {\n console.log('Result length:', result.length);\n }\n return result;\n })\n .catch((error) => {\n console.error(`Error calling phrase for ${relativePath}:`, error);\n throw Error;\n });\n}\n\nexport async function pullAllTranslations(\n branch: string,\n): Promise<TranslationsByLanguage> {\n const phraseResult = await callPhrase<\n Array<{\n key: { name: string };\n locale: { name: string };\n content: string;\n }>\n >(`translations?branch=${branch}&per_page=100`);\n\n const translations: TranslationsByLanguage = {};\n\n for (const r of phraseResult) {\n if (!translations[r.locale.name]) {\n translations[r.locale.name] = {};\n }\n translations[r.locale.name][r.key.name] = { message: r.content };\n }\n\n return translations;\n}\n\nexport async function pushTranslations(\n translationsByLanguage: TranslationsByLanguage,\n {\n autoTranslate,\n branch,\n devLanguage,\n }: { autoTranslate?: boolean; branch: string; devLanguage: string },\n) {\n const { csvFileStrings, keyIndex, commentIndex, tagColumn, messageIndex } =\n translationsToCsv(translationsByLanguage, devLanguage);\n\n let devLanguageUploadId = '';\n\n for (const [language, csvFileString] of Object.entries(csvFileStrings)) {\n const formData = new FormData();\n\n formData.append(\n 'file',\n new Blob([csvFileString], {\n type: 'text/csv',\n }),\n `${language}.translations.csv`,\n );\n\n formData.append('file_format', 'csv');\n formData.append('branch', branch);\n formData.append('update_translations', 'true');\n formData.append('update_descriptions', 'true');\n\n if (autoTranslate) {\n formData.append('autoTranslate', 'true');\n }\n\n formData.append(`locale_mapping[${language}]`, messageIndex.toString());\n\n formData.append('format_options[key_index]', keyIndex.toString());\n formData.append('format_options[comment_index]', commentIndex.toString());\n formData.append('format_options[tag_column]', tagColumn.toString());\n formData.append('format_options[enable_pluralization]', 'false');\n\n log(`Uploading translations for language ${language}`);\n\n const result = await callPhrase<\n | {\n id: string;\n }\n | {\n message: string;\n errors: unknown[];\n }\n | undefined\n >(`uploads`, {\n method: 'POST',\n body: formData,\n });\n\n trace('Upload result:\\n', result);\n\n if (result && 'id' in result) {\n log('Upload ID:', result.id, '\\n');\n log('Successfully Uploaded\\n');\n } else {\n log(`Error uploading: ${result?.message}\\n`);\n log('Response:', result);\n throw new Error('Error uploading');\n }\n\n if (language === devLanguage) {\n devLanguageUploadId = result.id;\n }\n }\n\n return {\n devLanguageUploadId,\n };\n}\n\nexport async function deleteUnusedKeys(uploadId: string, branch: string) {\n const query = `unmentioned_in_upload:${uploadId}`;\n const { records_affected } = await callPhrase<{ records_affected: number }>(\n 'keys',\n {\n method: 'DELETE',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ branch, q: query }),\n },\n );\n\n log(\n 'Successfully deleted',\n records_affected,\n 'unused keys from branch',\n branch,\n );\n}\n\nexport async function ensureBranch(branch: string) {\n await callPhrase(`branches`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ name: branch }),\n });\n\n log('Created branch:', branch);\n}\n","import { writeFile, mkdir } from './file';\nimport path from 'path';\n\nimport {\n type TranslationFileContents,\n type UserConfig,\n loadAllTranslations,\n getAltLanguageFilePath,\n getAltLanguages,\n getUniqueKey,\n} from '@vocab/core';\n\nimport { pullAllTranslations, ensureBranch } from './phrase-api';\nimport { trace } from './logger';\n\ninterface PullOptions {\n branch?: string;\n deleteUnusedKeys?: boolean;\n errorOnNoGlobalKeyTranslation?: boolean;\n}\n\nexport async function pull(\n { branch = 'local-development', errorOnNoGlobalKeyTranslation }: PullOptions,\n config: UserConfig,\n) {\n trace(`Pulling translations from branch ${branch}`);\n await ensureBranch(branch);\n const alternativeLanguages = getAltLanguages(config);\n const allPhraseTranslations = await pullAllTranslations(branch);\n trace(\n `Pulling translations from Phrase for languages ${\n config.devLanguage\n } and ${alternativeLanguages.join(', ')}`,\n );\n\n const phraseLanguages = Object.keys(allPhraseTranslations);\n trace(\n `Found Phrase translations for languages ${phraseLanguages.join(', ')}`,\n );\n\n if (!phraseLanguages.includes(config.devLanguage)) {\n throw new Error(\n `Phrase did not return any translations for the configured development language \"${config.devLanguage}\".\\nPlease ensure this language is present in your Phrase project's configuration.`,\n );\n }\n\n const allVocabTranslations = await loadAllTranslations(\n { fallbacks: 'none', includeNodeModules: false, withTags: true },\n config,\n );\n\n for (const loadedTranslation of allVocabTranslations) {\n const devTranslations = loadedTranslation.languages[config.devLanguage];\n\n if (!devTranslations) {\n throw new Error('No dev language translations loaded');\n }\n\n const defaultValues: TranslationFileContents = { ...devTranslations };\n const localKeys = Object.keys(defaultValues);\n\n for (const key of localKeys) {\n defaultValues[key] = {\n ...defaultValues[key],\n ...allPhraseTranslations[config.devLanguage][\n defaultValues[key].globalKey ??\n getUniqueKey(key, loadedTranslation.namespace)\n ],\n };\n }\n\n // Only write a `_meta` field if necessary\n if (Object.keys(loadedTranslation.metadata).length > 0) {\n defaultValues._meta = loadedTranslation.metadata;\n }\n\n await writeFile(\n loadedTranslation.filePath,\n `${JSON.stringify(defaultValues, null, 2)}\\n`,\n );\n\n for (const alternativeLanguage of alternativeLanguages) {\n if (alternativeLanguage in allPhraseTranslations) {\n const altTranslations = {\n ...loadedTranslation.languages[alternativeLanguage],\n };\n const phraseAltTranslations =\n allPhraseTranslations[alternativeLanguage];\n\n for (const key of localKeys) {\n const phraseKey =\n defaultValues[key].globalKey ??\n getUniqueKey(key, loadedTranslation.namespace);\n const phraseTranslationMessage =\n phraseAltTranslations[phraseKey]?.message;\n\n if (!phraseTranslationMessage) {\n trace(\n `Missing translation. No translation for key ${key} in phrase as ${phraseKey} in language ${alternativeLanguage}.`,\n );\n if (errorOnNoGlobalKeyTranslation && defaultValues[key].globalKey) {\n throw new Error(\n `Missing translation for global key ${key} in language ${alternativeLanguage}`,\n );\n }\n continue;\n }\n\n altTranslations[key] = {\n ...altTranslations[key],\n message: phraseTranslationMessage,\n };\n }\n\n const altTranslationFilePath = getAltLanguageFilePath(\n loadedTranslation.filePath,\n alternativeLanguage,\n );\n\n await mkdir(path.dirname(altTranslationFilePath), {\n recursive: true,\n });\n await writeFile(\n altTranslationFilePath,\n `${JSON.stringify(altTranslations, null, 2)}\\n`,\n );\n }\n }\n }\n}\n","import {\n loadAllTranslations,\n getUniqueKey,\n type TranslationData,\n type TranslationsByLanguage,\n type UserConfig,\n} from '@vocab/core';\nimport {\n ensureBranch,\n deleteUnusedKeys as phraseDeleteUnusedKeys,\n pushTranslations,\n} from './phrase-api';\nimport { trace } from './logger';\n\ninterface PushOptions {\n autoTranslate?: boolean;\n branch: string;\n deleteUnusedKeys?: boolean;\n ignore?: string[];\n}\n\n/**\n * Uploads translations to the Phrase API for each language.\n * A unique namespace is appended to each key using the file path the key came from.\n */\nexport async function push(\n { autoTranslate, branch, deleteUnusedKeys, ignore }: PushOptions,\n config: UserConfig,\n) {\n if (ignore) {\n trace(`ignoring files on paths: ${ignore.join(', ')}`);\n }\n const allLanguageTranslations = await loadAllTranslations(\n { fallbacks: 'none', includeNodeModules: false, withTags: true },\n {\n ...config,\n ignore: [...(config.ignore || []), ...(ignore || [])],\n },\n );\n trace(`Pushing translations to branch ${branch}`);\n const allLanguages = config.languages.map((v) => v.name);\n await ensureBranch(branch);\n\n trace(\n `Pushing translations to phrase for languages ${allLanguages.join(', ')}`,\n );\n\n const phraseTranslations: TranslationsByLanguage = {};\n\n for (const loadedTranslation of allLanguageTranslations) {\n for (const language of allLanguages) {\n const localTranslations = loadedTranslation.languages[language];\n if (!localTranslations) {\n continue;\n }\n if (!phraseTranslations[language]) {\n phraseTranslations[language] = {};\n }\n\n const {\n metadata: { tags: sharedTags = [] },\n } = loadedTranslation;\n\n for (const localKey of Object.keys(localTranslations)) {\n const { tags = [], ...localTranslation } = localTranslations[localKey];\n if (language === config.devLanguage) {\n (localTranslation as TranslationData).tags = [...tags, ...sharedTags];\n }\n const globalKey =\n loadedTranslation.languages[config.devLanguage][localKey].globalKey;\n\n const phraseKey =\n globalKey ?? getUniqueKey(localKey, loadedTranslation.namespace);\n\n phraseTranslations[language][phraseKey] = localTranslation;\n }\n }\n }\n\n const { devLanguageUploadId } = await pushTranslations(phraseTranslations, {\n autoTranslate,\n branch,\n devLanguage: config.devLanguage,\n });\n\n if (deleteUnusedKeys) {\n await phraseDeleteUnusedKeys(devLanguageUploadId, branch);\n }\n}\n"],"mappings":"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"}
|
package/dist/index.mjs
CHANGED
|
@@ -37,20 +37,18 @@ function translationsToCsv(translations, devLanguage) {
|
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
39
|
const csvFileStrings = Object.fromEntries(Object.entries(csvFilesByLanguage).filter(([_, csvFile]) => csvFile.length > 0).map(([language, csvFile]) => {
|
|
40
|
-
|
|
40
|
+
return [language, stringify(csvFile, {
|
|
41
41
|
delimiter: ",",
|
|
42
42
|
header: false
|
|
43
|
-
});
|
|
44
|
-
return [language, csvFileString];
|
|
43
|
+
})];
|
|
45
44
|
}));
|
|
46
45
|
const keyIndex = 1;
|
|
47
46
|
const commentIndex = keyIndex + 1;
|
|
48
47
|
const tagColumn = commentIndex + 1;
|
|
49
|
-
const messageIndex = tagColumn + 1;
|
|
50
48
|
return {
|
|
51
49
|
csvFileStrings,
|
|
52
50
|
keyIndex,
|
|
53
|
-
messageIndex,
|
|
51
|
+
messageIndex: tagColumn + 1,
|
|
54
52
|
commentIndex,
|
|
55
53
|
tagColumn
|
|
56
54
|
};
|
|
@@ -110,7 +108,7 @@ async function pullAllTranslations(branch) {
|
|
|
110
108
|
}
|
|
111
109
|
return translations;
|
|
112
110
|
}
|
|
113
|
-
async function pushTranslations(translationsByLanguage, {
|
|
111
|
+
async function pushTranslations(translationsByLanguage, { autoTranslate, branch, devLanguage }) {
|
|
114
112
|
const { csvFileStrings, keyIndex, commentIndex, tagColumn, messageIndex } = translationsToCsv(translationsByLanguage, devLanguage);
|
|
115
113
|
let devLanguageUploadId = "";
|
|
116
114
|
for (const [language, csvFileString] of Object.entries(csvFileStrings)) {
|
|
@@ -120,6 +118,7 @@ async function pushTranslations(translationsByLanguage, { devLanguage, branch })
|
|
|
120
118
|
formData.append("branch", branch);
|
|
121
119
|
formData.append("update_translations", "true");
|
|
122
120
|
formData.append("update_descriptions", "true");
|
|
121
|
+
if (autoTranslate) formData.append("autoTranslate", "true");
|
|
123
122
|
formData.append(`locale_mapping[${language}]`, messageIndex.toString());
|
|
124
123
|
formData.append("format_options[key_index]", keyIndex.toString());
|
|
125
124
|
formData.append("format_options[comment_index]", commentIndex.toString());
|
|
@@ -220,7 +219,7 @@ async function pull({ branch = "local-development", errorOnNoGlobalKeyTranslatio
|
|
|
220
219
|
* Uploads translations to the Phrase API for each language.
|
|
221
220
|
* A unique namespace is appended to each key using the file path the key came from.
|
|
222
221
|
*/
|
|
223
|
-
async function push({ branch, deleteUnusedKeys: deleteUnusedKeys$1, ignore }, config) {
|
|
222
|
+
async function push({ autoTranslate, branch, deleteUnusedKeys: deleteUnusedKeys$1, ignore }, config) {
|
|
224
223
|
if (ignore) trace(`ignoring files on paths: ${ignore.join(", ")}`);
|
|
225
224
|
const allLanguageTranslations = await loadAllTranslations({
|
|
226
225
|
fallbacks: "none",
|
|
@@ -248,8 +247,9 @@ async function push({ branch, deleteUnusedKeys: deleteUnusedKeys$1, ignore }, co
|
|
|
248
247
|
}
|
|
249
248
|
}
|
|
250
249
|
const { devLanguageUploadId } = await pushTranslations(phraseTranslations, {
|
|
251
|
-
|
|
252
|
-
branch
|
|
250
|
+
autoTranslate,
|
|
251
|
+
branch,
|
|
252
|
+
devLanguage: config.devLanguage
|
|
253
253
|
});
|
|
254
254
|
if (deleteUnusedKeys$1) await deleteUnusedKeys(devLanguageUploadId, branch);
|
|
255
255
|
}
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["fs","csvFilesByLanguage: Record<LanguageName, CsvFile>","path","translations: TranslationsByLanguage","defaultValues: TranslationFileContents","phraseTranslations: TranslationsByLanguage","deleteUnusedKeys","phraseDeleteUnusedKeys"],"sources":["../src/file.ts","../src/logger.ts","../src/csv.ts","../src/phrase-api.ts","../src/pull-translations.ts","../src/push-translations.ts"],"sourcesContent":["import { promises as fs } from 'fs';\n\nexport const mkdir = fs.mkdir;\nexport const writeFile = fs.writeFile;\n","import pc from 'picocolors';\nimport debug from 'debug';\n\nexport const trace = debug(`vocab:phrase`);\n\nexport const log = (...params: unknown[]) => {\n // eslint-disable-next-line no-console\n console.log(pc.yellow('Vocab'), ...params);\n};\n","import { stringify } from 'csv-stringify/sync';\nimport type { LanguageName, TranslationsByLanguage } from '@vocab/core';\n\ntype Value = string | undefined;\ntype CsvRow = Value[];\ntype CsvFile = CsvRow[];\n\nexport function translationsToCsv(\n translations: TranslationsByLanguage,\n devLanguage: string,\n) {\n const languages = Object.keys(translations);\n const altLanguages = languages.filter((language) => language !== devLanguage);\n\n const devLanguageTranslations = translations[devLanguage];\n\n const csvFilesByLanguage: Record<LanguageName, CsvFile> = Object.fromEntries(\n languages.map((language) => [language, []]),\n );\n\n Object.entries(devLanguageTranslations).map(\n ([key, { message, description, tags }]) => {\n const sharedData = [key, description, tags?.join(',')];\n const devLanguageRow = [...sharedData, message];\n csvFilesByLanguage[devLanguage].push(devLanguageRow);\n\n altLanguages.map((language) => {\n const altTranslationMessage = translations[language]?.[key]?.message;\n\n if (altTranslationMessage) {\n csvFilesByLanguage[language].push([\n ...sharedData,\n altTranslationMessage,\n ]);\n }\n });\n },\n );\n\n const csvFileStrings = Object.fromEntries(\n Object.entries(csvFilesByLanguage)\n // Ensure CSV files are only created if the language has at least 1 translation\n .filter(([_, csvFile]) => csvFile.length > 0)\n .map(([language, csvFile]) => {\n const csvFileString = stringify(csvFile, {\n delimiter: ',',\n header: false,\n });\n\n return [language, csvFileString];\n }),\n );\n\n // Column indices start at 1\n const keyIndex = 1;\n const commentIndex = keyIndex + 1;\n const tagColumn = commentIndex + 1;\n const messageIndex = tagColumn + 1;\n\n return { csvFileStrings, keyIndex, messageIndex, commentIndex, tagColumn };\n}\n","/* eslint-disable no-console */\nimport type { TranslationsByLanguage } from '@vocab/core';\nimport { log, trace } from './logger';\nimport { translationsToCsv } from './csv';\n\nfunction _callPhrase(path: string, options: Parameters<typeof fetch>[1] = {}) {\n const phraseApiToken = process.env.PHRASE_API_TOKEN;\n\n if (!phraseApiToken) {\n throw new Error('Missing PHRASE_API_TOKEN');\n }\n\n return fetch(path, {\n ...options,\n headers: {\n Authorization: `token ${phraseApiToken}`,\n // Provide identification via User Agent as requested in https://developers.phrase.com/api/#overview--identification-via-user-agent\n 'User-Agent': 'Vocab Client (https://github.com/seek-oss/vocab)',\n ...options.headers,\n },\n }).then(async (response) => {\n console.log(`${path}: ${response.status} - ${response.statusText}`);\n\n const secondsUntilLimitReset = Math.ceil(\n Number.parseFloat(response.headers.get('X-Rate-Limit-Reset') || '0') -\n Date.now() / 1000,\n );\n console.log(\n `Rate Limit: ${response.headers.get(\n 'X-Rate-Limit-Remaining',\n )} of ${response.headers.get(\n 'X-Rate-Limit-Limit',\n )} remaining. (${secondsUntilLimitReset} seconds remaining)`,\n );\n\n trace('\\nLink:', response.headers.get('Link'), '\\n');\n // Print All Headers:\n // console.log(Array.from(r.headers.entries()));\n\n try {\n const result = await response.json();\n\n trace(`Internal Result (Length: ${result.length})\\n`);\n\n if (\n (!options.method || options.method === 'GET') &&\n response.headers.get('Link')?.includes('rel=next')\n ) {\n const [, nextPageUrl] =\n response.headers.get('Link')?.match(/<([^>]*)>; rel=next/) ?? [];\n\n if (!nextPageUrl) {\n throw new Error(\"Can't parse next page URL\");\n }\n\n console.log('Results received with next page: ', nextPageUrl);\n\n const nextPageResult = (await _callPhrase(nextPageUrl, options)) as any;\n\n return [...result, ...nextPageResult];\n }\n\n return result;\n } catch (e) {\n console.error('Unable to parse response as JSON', e);\n return response.text();\n }\n });\n}\n\nexport async function callPhrase<T = any>(\n relativePath: string,\n options: Parameters<typeof fetch>[1] = {},\n): Promise<T> {\n const projectId = process.env.PHRASE_PROJECT_ID;\n\n if (!projectId) {\n throw new Error('Missing PHRASE_PROJECT_ID');\n }\n return _callPhrase(\n `https://api.phrase.com/v2/projects/${projectId}/${relativePath}`,\n options,\n )\n .then((result) => {\n if (Array.isArray(result)) {\n console.log('Result length:', result.length);\n }\n return result;\n })\n .catch((error) => {\n console.error(`Error calling phrase for ${relativePath}:`, error);\n throw Error;\n });\n}\n\nexport async function pullAllTranslations(\n branch: string,\n): Promise<TranslationsByLanguage> {\n const phraseResult = await callPhrase<\n Array<{\n key: { name: string };\n locale: { name: string };\n content: string;\n }>\n >(`translations?branch=${branch}&per_page=100`);\n\n const translations: TranslationsByLanguage = {};\n\n for (const r of phraseResult) {\n if (!translations[r.locale.name]) {\n translations[r.locale.name] = {};\n }\n translations[r.locale.name][r.key.name] = { message: r.content };\n }\n\n return translations;\n}\n\nexport async function pushTranslations(\n translationsByLanguage: TranslationsByLanguage,\n { devLanguage, branch }: { devLanguage: string; branch: string },\n) {\n const { csvFileStrings, keyIndex, commentIndex, tagColumn, messageIndex } =\n translationsToCsv(translationsByLanguage, devLanguage);\n\n let devLanguageUploadId = '';\n\n for (const [language, csvFileString] of Object.entries(csvFileStrings)) {\n const formData = new FormData();\n\n formData.append(\n 'file',\n new Blob([csvFileString], {\n type: 'text/csv',\n }),\n `${language}.translations.csv`,\n );\n\n formData.append('file_format', 'csv');\n formData.append('branch', branch);\n formData.append('update_translations', 'true');\n formData.append('update_descriptions', 'true');\n\n formData.append(`locale_mapping[${language}]`, messageIndex.toString());\n\n formData.append('format_options[key_index]', keyIndex.toString());\n formData.append('format_options[comment_index]', commentIndex.toString());\n formData.append('format_options[tag_column]', tagColumn.toString());\n formData.append('format_options[enable_pluralization]', 'false');\n\n log(`Uploading translations for language ${language}`);\n\n const result = await callPhrase<\n | {\n id: string;\n }\n | {\n message: string;\n errors: unknown[];\n }\n | undefined\n >(`uploads`, {\n method: 'POST',\n body: formData,\n });\n\n trace('Upload result:\\n', result);\n\n if (result && 'id' in result) {\n log('Upload ID:', result.id, '\\n');\n log('Successfully Uploaded\\n');\n } else {\n log(`Error uploading: ${result?.message}\\n`);\n log('Response:', result);\n throw new Error('Error uploading');\n }\n\n if (language === devLanguage) {\n devLanguageUploadId = result.id;\n }\n }\n\n return {\n devLanguageUploadId,\n };\n}\n\nexport async function deleteUnusedKeys(uploadId: string, branch: string) {\n const query = `unmentioned_in_upload:${uploadId}`;\n const { records_affected } = await callPhrase<{ records_affected: number }>(\n 'keys',\n {\n method: 'DELETE',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ branch, q: query }),\n },\n );\n\n log(\n 'Successfully deleted',\n records_affected,\n 'unused keys from branch',\n branch,\n );\n}\n\nexport async function ensureBranch(branch: string) {\n await callPhrase(`branches`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ name: branch }),\n });\n\n log('Created branch:', branch);\n}\n","import { writeFile, mkdir } from './file';\nimport path from 'path';\n\nimport {\n type TranslationFileContents,\n type UserConfig,\n loadAllTranslations,\n getAltLanguageFilePath,\n getAltLanguages,\n getUniqueKey,\n} from '@vocab/core';\n\nimport { pullAllTranslations, ensureBranch } from './phrase-api';\nimport { trace } from './logger';\n\ninterface PullOptions {\n branch?: string;\n deleteUnusedKeys?: boolean;\n errorOnNoGlobalKeyTranslation?: boolean;\n}\n\nexport async function pull(\n { branch = 'local-development', errorOnNoGlobalKeyTranslation }: PullOptions,\n config: UserConfig,\n) {\n trace(`Pulling translations from branch ${branch}`);\n await ensureBranch(branch);\n const alternativeLanguages = getAltLanguages(config);\n const allPhraseTranslations = await pullAllTranslations(branch);\n trace(\n `Pulling translations from Phrase for languages ${\n config.devLanguage\n } and ${alternativeLanguages.join(', ')}`,\n );\n\n const phraseLanguages = Object.keys(allPhraseTranslations);\n trace(\n `Found Phrase translations for languages ${phraseLanguages.join(', ')}`,\n );\n\n if (!phraseLanguages.includes(config.devLanguage)) {\n throw new Error(\n `Phrase did not return any translations for the configured development language \"${config.devLanguage}\".\\nPlease ensure this language is present in your Phrase project's configuration.`,\n );\n }\n\n const allVocabTranslations = await loadAllTranslations(\n { fallbacks: 'none', includeNodeModules: false, withTags: true },\n config,\n );\n\n for (const loadedTranslation of allVocabTranslations) {\n const devTranslations = loadedTranslation.languages[config.devLanguage];\n\n if (!devTranslations) {\n throw new Error('No dev language translations loaded');\n }\n\n const defaultValues: TranslationFileContents = { ...devTranslations };\n const localKeys = Object.keys(defaultValues);\n\n for (const key of localKeys) {\n defaultValues[key] = {\n ...defaultValues[key],\n ...allPhraseTranslations[config.devLanguage][\n defaultValues[key].globalKey ??\n getUniqueKey(key, loadedTranslation.namespace)\n ],\n };\n }\n\n // Only write a `_meta` field if necessary\n if (Object.keys(loadedTranslation.metadata).length > 0) {\n defaultValues._meta = loadedTranslation.metadata;\n }\n\n await writeFile(\n loadedTranslation.filePath,\n `${JSON.stringify(defaultValues, null, 2)}\\n`,\n );\n\n for (const alternativeLanguage of alternativeLanguages) {\n if (alternativeLanguage in allPhraseTranslations) {\n const altTranslations = {\n ...loadedTranslation.languages[alternativeLanguage],\n };\n const phraseAltTranslations =\n allPhraseTranslations[alternativeLanguage];\n\n for (const key of localKeys) {\n const phraseKey =\n defaultValues[key].globalKey ??\n getUniqueKey(key, loadedTranslation.namespace);\n const phraseTranslationMessage =\n phraseAltTranslations[phraseKey]?.message;\n\n if (!phraseTranslationMessage) {\n trace(\n `Missing translation. No translation for key ${key} in phrase as ${phraseKey} in language ${alternativeLanguage}.`,\n );\n if (errorOnNoGlobalKeyTranslation && defaultValues[key].globalKey) {\n throw new Error(\n `Missing translation for global key ${key} in language ${alternativeLanguage}`,\n );\n }\n continue;\n }\n\n altTranslations[key] = {\n ...altTranslations[key],\n message: phraseTranslationMessage,\n };\n }\n\n const altTranslationFilePath = getAltLanguageFilePath(\n loadedTranslation.filePath,\n alternativeLanguage,\n );\n\n await mkdir(path.dirname(altTranslationFilePath), {\n recursive: true,\n });\n await writeFile(\n altTranslationFilePath,\n `${JSON.stringify(altTranslations, null, 2)}\\n`,\n );\n }\n }\n }\n}\n","import {\n loadAllTranslations,\n getUniqueKey,\n type TranslationData,\n type TranslationsByLanguage,\n type UserConfig,\n} from '@vocab/core';\nimport {\n ensureBranch,\n deleteUnusedKeys as phraseDeleteUnusedKeys,\n pushTranslations,\n} from './phrase-api';\nimport { trace } from './logger';\n\ninterface PushOptions {\n branch: string;\n deleteUnusedKeys?: boolean;\n ignore?: string[];\n}\n\n/**\n * Uploads translations to the Phrase API for each language.\n * A unique namespace is appended to each key using the file path the key came from.\n */\nexport async function push(\n { branch, deleteUnusedKeys, ignore }: PushOptions,\n config: UserConfig,\n) {\n if (ignore) {\n trace(`ignoring files on paths: ${ignore.join(', ')}`);\n }\n const allLanguageTranslations = await loadAllTranslations(\n { fallbacks: 'none', includeNodeModules: false, withTags: true },\n {\n ...config,\n ignore: [...(config.ignore || []), ...(ignore || [])],\n },\n );\n trace(`Pushing translations to branch ${branch}`);\n const allLanguages = config.languages.map((v) => v.name);\n await ensureBranch(branch);\n\n trace(\n `Pushing translations to phrase for languages ${allLanguages.join(', ')}`,\n );\n\n const phraseTranslations: TranslationsByLanguage = {};\n\n for (const loadedTranslation of allLanguageTranslations) {\n for (const language of allLanguages) {\n const localTranslations = loadedTranslation.languages[language];\n if (!localTranslations) {\n continue;\n }\n if (!phraseTranslations[language]) {\n phraseTranslations[language] = {};\n }\n\n const {\n metadata: { tags: sharedTags = [] },\n } = loadedTranslation;\n\n for (const localKey of Object.keys(localTranslations)) {\n const { tags = [], ...localTranslation } = localTranslations[localKey];\n if (language === config.devLanguage) {\n (localTranslation as TranslationData).tags = [...tags, ...sharedTags];\n }\n const globalKey =\n loadedTranslation.languages[config.devLanguage][localKey].globalKey;\n\n const phraseKey =\n globalKey ?? getUniqueKey(localKey, loadedTranslation.namespace);\n\n phraseTranslations[language][phraseKey] = localTranslation;\n }\n }\n }\n\n const { devLanguageUploadId } = await pushTranslations(phraseTranslations, {\n devLanguage: config.devLanguage,\n branch,\n });\n\n if (deleteUnusedKeys) {\n await phraseDeleteUnusedKeys(devLanguageUploadId, branch);\n }\n}\n"],"mappings":";;;;;;;;AAEA,MAAa,QAAQA,SAAG;AACxB,MAAa,YAAYA,SAAG;;;;ACA5B,MAAa,QAAQ,MAAM,eAAe;AAE1C,MAAa,OAAO,GAAG,WAAsB;AAE3C,SAAQ,IAAI,GAAG,OAAO,QAAQ,EAAE,GAAG,OAAO;;;;;ACA5C,SAAgB,kBACd,cACA,aACA;CACA,MAAM,YAAY,OAAO,KAAK,aAAa;CAC3C,MAAM,eAAe,UAAU,QAAQ,aAAa,aAAa,YAAY;CAE7E,MAAM,0BAA0B,aAAa;CAE7C,MAAMC,qBAAoD,OAAO,YAC/D,UAAU,KAAK,aAAa,CAAC,UAAU,EAAE,CAAC,CAAC,CAC5C;AAED,QAAO,QAAQ,wBAAwB,CAAC,KACrC,CAAC,KAAK,EAAE,SAAS,aAAa,YAAY;EACzC,MAAM,aAAa;GAAC;GAAK;GAAa,MAAM,KAAK,IAAI;GAAC;EACtD,MAAM,iBAAiB,CAAC,GAAG,YAAY,QAAQ;AAC/C,qBAAmB,aAAa,KAAK,eAAe;AAEpD,eAAa,KAAK,aAAa;GAC7B,MAAM,wBAAwB,aAAa,YAAY,MAAM;AAE7D,OAAI,sBACF,oBAAmB,UAAU,KAAK,CAChC,GAAG,YACH,sBACD,CAAC;IAEJ;GAEL;CAED,MAAM,iBAAiB,OAAO,YAC5B,OAAO,QAAQ,mBAAmB,CAE/B,QAAQ,CAAC,GAAG,aAAa,QAAQ,SAAS,EAAE,CAC5C,KAAK,CAAC,UAAU,aAAa;EAC5B,MAAM,gBAAgB,UAAU,SAAS;GACvC,WAAW;GACX,QAAQ;GACT,CAAC;AAEF,SAAO,CAAC,UAAU,cAAc;GAChC,CACL;CAGD,MAAM,WAAW;CACjB,MAAM,eAAe,WAAW;CAChC,MAAM,YAAY,eAAe;CACjC,MAAM,eAAe,YAAY;AAEjC,QAAO;EAAE;EAAgB;EAAU;EAAc;EAAc;EAAW;;;;;ACtD5E,SAAS,YAAY,QAAc,UAAuC,EAAE,EAAE;CAC5E,MAAM,iBAAiB,QAAQ,IAAI;AAEnC,KAAI,CAAC,eACH,OAAM,IAAI,MAAM,2BAA2B;AAG7C,QAAO,MAAMC,QAAM;EACjB,GAAG;EACH,SAAS;GACP,eAAe,SAAS;GAExB,cAAc;GACd,GAAG,QAAQ;GACZ;EACF,CAAC,CAAC,KAAK,OAAO,aAAa;AAC1B,UAAQ,IAAI,GAAGA,OAAK,IAAI,SAAS,OAAO,KAAK,SAAS,aAAa;EAEnE,MAAM,yBAAyB,KAAK,KAClC,OAAO,WAAW,SAAS,QAAQ,IAAI,qBAAqB,IAAI,IAAI,GAClE,KAAK,KAAK,GAAG,IAChB;AACD,UAAQ,IACN,eAAe,SAAS,QAAQ,IAC9B,yBACD,CAAC,MAAM,SAAS,QAAQ,IACvB,qBACD,CAAC,eAAe,uBAAuB,qBACzC;AAED,QAAM,WAAW,SAAS,QAAQ,IAAI,OAAO,EAAE,KAAK;AAIpD,MAAI;GACF,MAAM,SAAS,MAAM,SAAS,MAAM;AAEpC,SAAM,4BAA4B,OAAO,OAAO,KAAK;AAErD,QACG,CAAC,QAAQ,UAAU,QAAQ,WAAW,UACvC,SAAS,QAAQ,IAAI,OAAO,EAAE,SAAS,WAAW,EAClD;IACA,MAAM,GAAG,eACP,SAAS,QAAQ,IAAI,OAAO,EAAE,MAAM,sBAAsB,IAAI,EAAE;AAElE,QAAI,CAAC,YACH,OAAM,IAAI,MAAM,4BAA4B;AAG9C,YAAQ,IAAI,qCAAqC,YAAY;IAE7D,MAAM,iBAAkB,MAAM,YAAY,aAAa,QAAQ;AAE/D,WAAO,CAAC,GAAG,QAAQ,GAAG,eAAe;;AAGvC,UAAO;WACA,GAAG;AACV,WAAQ,MAAM,oCAAoC,EAAE;AACpD,UAAO,SAAS,MAAM;;GAExB;;AAGJ,eAAsB,WACpB,cACA,UAAuC,EAAE,EAC7B;CACZ,MAAM,YAAY,QAAQ,IAAI;AAE9B,KAAI,CAAC,UACH,OAAM,IAAI,MAAM,4BAA4B;AAE9C,QAAO,YACL,sCAAsC,UAAU,GAAG,gBACnD,QACD,CACE,MAAM,WAAW;AAChB,MAAI,MAAM,QAAQ,OAAO,CACvB,SAAQ,IAAI,kBAAkB,OAAO,OAAO;AAE9C,SAAO;GACP,CACD,OAAO,UAAU;AAChB,UAAQ,MAAM,4BAA4B,aAAa,IAAI,MAAM;AACjE,QAAM;GACN;;AAGN,eAAsB,oBACpB,QACiC;CACjC,MAAM,eAAe,MAAM,WAMzB,uBAAuB,OAAO,eAAe;CAE/C,MAAMC,eAAuC,EAAE;AAE/C,MAAK,MAAM,KAAK,cAAc;AAC5B,MAAI,CAAC,aAAa,EAAE,OAAO,MACzB,cAAa,EAAE,OAAO,QAAQ,EAAE;AAElC,eAAa,EAAE,OAAO,MAAM,EAAE,IAAI,QAAQ,EAAE,SAAS,EAAE,SAAS;;AAGlE,QAAO;;AAGT,eAAsB,iBACpB,wBACA,EAAE,aAAa,UACf;CACA,MAAM,EAAE,gBAAgB,UAAU,cAAc,WAAW,iBACzD,kBAAkB,wBAAwB,YAAY;CAExD,IAAI,sBAAsB;AAE1B,MAAK,MAAM,CAAC,UAAU,kBAAkB,OAAO,QAAQ,eAAe,EAAE;EACtE,MAAM,WAAW,IAAI,UAAU;AAE/B,WAAS,OACP,QACA,IAAI,KAAK,CAAC,cAAc,EAAE,EACxB,MAAM,YACP,CAAC,EACF,GAAG,SAAS,mBACb;AAED,WAAS,OAAO,eAAe,MAAM;AACrC,WAAS,OAAO,UAAU,OAAO;AACjC,WAAS,OAAO,uBAAuB,OAAO;AAC9C,WAAS,OAAO,uBAAuB,OAAO;AAE9C,WAAS,OAAO,kBAAkB,SAAS,IAAI,aAAa,UAAU,CAAC;AAEvE,WAAS,OAAO,6BAA6B,SAAS,UAAU,CAAC;AACjE,WAAS,OAAO,iCAAiC,aAAa,UAAU,CAAC;AACzE,WAAS,OAAO,8BAA8B,UAAU,UAAU,CAAC;AACnE,WAAS,OAAO,wCAAwC,QAAQ;AAEhE,MAAI,uCAAuC,WAAW;EAEtD,MAAM,SAAS,MAAM,WASnB,WAAW;GACX,QAAQ;GACR,MAAM;GACP,CAAC;AAEF,QAAM,oBAAoB,OAAO;AAEjC,MAAI,UAAU,QAAQ,QAAQ;AAC5B,OAAI,cAAc,OAAO,IAAI,KAAK;AAClC,OAAI,0BAA0B;SACzB;AACL,OAAI,oBAAoB,QAAQ,QAAQ,IAAI;AAC5C,OAAI,aAAa,OAAO;AACxB,SAAM,IAAI,MAAM,kBAAkB;;AAGpC,MAAI,aAAa,YACf,uBAAsB,OAAO;;AAIjC,QAAO,EACL,qBACD;;AAGH,eAAsB,iBAAiB,UAAkB,QAAgB;CACvE,MAAM,QAAQ,yBAAyB;CACvC,MAAM,EAAE,qBAAqB,MAAM,WACjC,QACA;EACE,QAAQ;EACR,SAAS,EACP,gBAAgB,oBACjB;EACD,MAAM,KAAK,UAAU;GAAE;GAAQ,GAAG;GAAO,CAAC;EAC3C,CACF;AAED,KACE,wBACA,kBACA,2BACA,OACD;;AAGH,eAAsB,aAAa,QAAgB;AACjD,OAAM,WAAW,YAAY;EAC3B,QAAQ;EACR,SAAS,EACP,gBAAgB,oBACjB;EACD,MAAM,KAAK,UAAU,EAAE,MAAM,QAAQ,CAAC;EACvC,CAAC;AAEF,KAAI,mBAAmB,OAAO;;;;;ACpMhC,eAAsB,KACpB,EAAE,SAAS,qBAAqB,iCAChC,QACA;AACA,OAAM,oCAAoC,SAAS;AACnD,OAAM,aAAa,OAAO;CAC1B,MAAM,uBAAuB,gBAAgB,OAAO;CACpD,MAAM,wBAAwB,MAAM,oBAAoB,OAAO;AAC/D,OACE,kDACE,OAAO,YACR,OAAO,qBAAqB,KAAK,KAAK,GACxC;CAED,MAAM,kBAAkB,OAAO,KAAK,sBAAsB;AAC1D,OACE,2CAA2C,gBAAgB,KAAK,KAAK,GACtE;AAED,KAAI,CAAC,gBAAgB,SAAS,OAAO,YAAY,CAC/C,OAAM,IAAI,MACR,mFAAmF,OAAO,YAAY,oFACvG;CAGH,MAAM,uBAAuB,MAAM,oBACjC;EAAE,WAAW;EAAQ,oBAAoB;EAAO,UAAU;EAAM,EAChE,OACD;AAED,MAAK,MAAM,qBAAqB,sBAAsB;EACpD,MAAM,kBAAkB,kBAAkB,UAAU,OAAO;AAE3D,MAAI,CAAC,gBACH,OAAM,IAAI,MAAM,sCAAsC;EAGxD,MAAMC,gBAAyC,EAAE,GAAG,iBAAiB;EACrE,MAAM,YAAY,OAAO,KAAK,cAAc;AAE5C,OAAK,MAAM,OAAO,UAChB,eAAc,OAAO;GACnB,GAAG,cAAc;GACjB,GAAG,sBAAsB,OAAO,aAC9B,cAAc,KAAK,aACjB,aAAa,KAAK,kBAAkB,UAAU;GAEnD;AAIH,MAAI,OAAO,KAAK,kBAAkB,SAAS,CAAC,SAAS,EACnD,eAAc,QAAQ,kBAAkB;AAG1C,QAAM,UACJ,kBAAkB,UAClB,GAAG,KAAK,UAAU,eAAe,MAAM,EAAE,CAAC,IAC3C;AAED,OAAK,MAAM,uBAAuB,qBAChC,KAAI,uBAAuB,uBAAuB;GAChD,MAAM,kBAAkB,EACtB,GAAG,kBAAkB,UAAU,sBAChC;GACD,MAAM,wBACJ,sBAAsB;AAExB,QAAK,MAAM,OAAO,WAAW;IAC3B,MAAM,YACJ,cAAc,KAAK,aACnB,aAAa,KAAK,kBAAkB,UAAU;IAChD,MAAM,2BACJ,sBAAsB,YAAY;AAEpC,QAAI,CAAC,0BAA0B;AAC7B,WACE,+CAA+C,IAAI,gBAAgB,UAAU,eAAe,oBAAoB,GACjH;AACD,SAAI,iCAAiC,cAAc,KAAK,UACtD,OAAM,IAAI,MACR,sCAAsC,IAAI,eAAe,sBAC1D;AAEH;;AAGF,oBAAgB,OAAO;KACrB,GAAG,gBAAgB;KACnB,SAAS;KACV;;GAGH,MAAM,yBAAyB,uBAC7B,kBAAkB,UAClB,oBACD;AAED,SAAM,MAAM,KAAK,QAAQ,uBAAuB,EAAE,EAChD,WAAW,MACZ,CAAC;AACF,SAAM,UACJ,wBACA,GAAG,KAAK,UAAU,iBAAiB,MAAM,EAAE,CAAC,IAC7C;;;;;;;;;;;ACrGT,eAAsB,KACpB,EAAE,QAAQ,sCAAkB,UAC5B,QACA;AACA,KAAI,OACF,OAAM,4BAA4B,OAAO,KAAK,KAAK,GAAG;CAExD,MAAM,0BAA0B,MAAM,oBACpC;EAAE,WAAW;EAAQ,oBAAoB;EAAO,UAAU;EAAM,EAChE;EACE,GAAG;EACH,QAAQ,CAAC,GAAI,OAAO,UAAU,EAAE,EAAG,GAAI,UAAU,EAAE,CAAE;EACtD,CACF;AACD,OAAM,kCAAkC,SAAS;CACjD,MAAM,eAAe,OAAO,UAAU,KAAK,MAAM,EAAE,KAAK;AACxD,OAAM,aAAa,OAAO;AAE1B,OACE,gDAAgD,aAAa,KAAK,KAAK,GACxE;CAED,MAAMC,qBAA6C,EAAE;AAErD,MAAK,MAAM,qBAAqB,wBAC9B,MAAK,MAAM,YAAY,cAAc;EACnC,MAAM,oBAAoB,kBAAkB,UAAU;AACtD,MAAI,CAAC,kBACH;AAEF,MAAI,CAAC,mBAAmB,UACtB,oBAAmB,YAAY,EAAE;EAGnC,MAAM,EACJ,UAAU,EAAE,MAAM,aAAa,EAAE,OAC/B;AAEJ,OAAK,MAAM,YAAY,OAAO,KAAK,kBAAkB,EAAE;GACrD,MAAM,EAAE,OAAO,EAAE,CAAE,GAAG,qBAAqB,kBAAkB;AAC7D,OAAI,aAAa,OAAO,YACtB,CAAC,iBAAqC,OAAO,CAAC,GAAG,MAAM,GAAG,WAAW;GAKvE,MAAM,YAFJ,kBAAkB,UAAU,OAAO,aAAa,UAAU,aAG7C,aAAa,UAAU,kBAAkB,UAAU;AAElE,sBAAmB,UAAU,aAAa;;;CAKhD,MAAM,EAAE,wBAAwB,MAAM,iBAAiB,oBAAoB;EACzE,aAAa,OAAO;EACpB;EACD,CAAC;AAEF,KAAIC,mBACF,OAAMC,iBAAuB,qBAAqB,OAAO"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["fs","csvFilesByLanguage: Record<LanguageName, CsvFile>","path","translations: TranslationsByLanguage","defaultValues: TranslationFileContents","phraseTranslations: TranslationsByLanguage","deleteUnusedKeys","phraseDeleteUnusedKeys"],"sources":["../src/file.ts","../src/logger.ts","../src/csv.ts","../src/phrase-api.ts","../src/pull-translations.ts","../src/push-translations.ts"],"sourcesContent":["import { promises as fs } from 'fs';\n\nexport const mkdir = fs.mkdir;\nexport const writeFile = fs.writeFile;\n","import pc from 'picocolors';\nimport debug from 'debug';\n\nexport const trace = debug(`vocab:phrase`);\n\nexport const log = (...params: unknown[]) => {\n // eslint-disable-next-line no-console\n console.log(pc.yellow('Vocab'), ...params);\n};\n","import { stringify } from 'csv-stringify/sync';\nimport type { LanguageName, TranslationsByLanguage } from '@vocab/core';\n\ntype Value = string | undefined;\ntype CsvRow = Value[];\ntype CsvFile = CsvRow[];\n\nexport function translationsToCsv(\n translations: TranslationsByLanguage,\n devLanguage: string,\n) {\n const languages = Object.keys(translations);\n const altLanguages = languages.filter((language) => language !== devLanguage);\n\n const devLanguageTranslations = translations[devLanguage];\n\n const csvFilesByLanguage: Record<LanguageName, CsvFile> = Object.fromEntries(\n languages.map((language) => [language, []]),\n );\n\n Object.entries(devLanguageTranslations).map(\n ([key, { message, description, tags }]) => {\n const sharedData = [key, description, tags?.join(',')];\n const devLanguageRow = [...sharedData, message];\n csvFilesByLanguage[devLanguage].push(devLanguageRow);\n\n altLanguages.map((language) => {\n const altTranslationMessage = translations[language]?.[key]?.message;\n\n if (altTranslationMessage) {\n csvFilesByLanguage[language].push([\n ...sharedData,\n altTranslationMessage,\n ]);\n }\n });\n },\n );\n\n const csvFileStrings = Object.fromEntries(\n Object.entries(csvFilesByLanguage)\n // Ensure CSV files are only created if the language has at least 1 translation\n .filter(([_, csvFile]) => csvFile.length > 0)\n .map(([language, csvFile]) => {\n const csvFileString = stringify(csvFile, {\n delimiter: ',',\n header: false,\n });\n\n return [language, csvFileString];\n }),\n );\n\n // Column indices start at 1\n const keyIndex = 1;\n const commentIndex = keyIndex + 1;\n const tagColumn = commentIndex + 1;\n const messageIndex = tagColumn + 1;\n\n return { csvFileStrings, keyIndex, messageIndex, commentIndex, tagColumn };\n}\n","/* eslint-disable no-console */\nimport type { TranslationsByLanguage } from '@vocab/core';\nimport { log, trace } from './logger';\nimport { translationsToCsv } from './csv';\n\nfunction _callPhrase(path: string, options: Parameters<typeof fetch>[1] = {}) {\n const phraseApiToken = process.env.PHRASE_API_TOKEN;\n\n if (!phraseApiToken) {\n throw new Error('Missing PHRASE_API_TOKEN');\n }\n\n return fetch(path, {\n ...options,\n headers: {\n Authorization: `token ${phraseApiToken}`,\n // Provide identification via User Agent as requested in https://developers.phrase.com/api/#overview--identification-via-user-agent\n 'User-Agent': 'Vocab Client (https://github.com/seek-oss/vocab)',\n ...options.headers,\n },\n }).then(async (response) => {\n console.log(`${path}: ${response.status} - ${response.statusText}`);\n\n const secondsUntilLimitReset = Math.ceil(\n Number.parseFloat(response.headers.get('X-Rate-Limit-Reset') || '0') -\n Date.now() / 1000,\n );\n console.log(\n `Rate Limit: ${response.headers.get(\n 'X-Rate-Limit-Remaining',\n )} of ${response.headers.get(\n 'X-Rate-Limit-Limit',\n )} remaining. (${secondsUntilLimitReset} seconds remaining)`,\n );\n\n trace('\\nLink:', response.headers.get('Link'), '\\n');\n // Print All Headers:\n // console.log(Array.from(r.headers.entries()));\n\n try {\n const result = await response.json();\n\n trace(`Internal Result (Length: ${result.length})\\n`);\n\n if (\n (!options.method || options.method === 'GET') &&\n response.headers.get('Link')?.includes('rel=next')\n ) {\n const [, nextPageUrl] =\n response.headers.get('Link')?.match(/<([^>]*)>; rel=next/) ?? [];\n\n if (!nextPageUrl) {\n throw new Error(\"Can't parse next page URL\");\n }\n\n console.log('Results received with next page: ', nextPageUrl);\n\n const nextPageResult = (await _callPhrase(nextPageUrl, options)) as any;\n\n return [...result, ...nextPageResult];\n }\n\n return result;\n } catch (e) {\n console.error('Unable to parse response as JSON', e);\n return response.text();\n }\n });\n}\n\nexport async function callPhrase<T = any>(\n relativePath: string,\n options: Parameters<typeof fetch>[1] = {},\n): Promise<T> {\n const projectId = process.env.PHRASE_PROJECT_ID;\n\n if (!projectId) {\n throw new Error('Missing PHRASE_PROJECT_ID');\n }\n return _callPhrase(\n `https://api.phrase.com/v2/projects/${projectId}/${relativePath}`,\n options,\n )\n .then((result) => {\n if (Array.isArray(result)) {\n console.log('Result length:', result.length);\n }\n return result;\n })\n .catch((error) => {\n console.error(`Error calling phrase for ${relativePath}:`, error);\n throw Error;\n });\n}\n\nexport async function pullAllTranslations(\n branch: string,\n): Promise<TranslationsByLanguage> {\n const phraseResult = await callPhrase<\n Array<{\n key: { name: string };\n locale: { name: string };\n content: string;\n }>\n >(`translations?branch=${branch}&per_page=100`);\n\n const translations: TranslationsByLanguage = {};\n\n for (const r of phraseResult) {\n if (!translations[r.locale.name]) {\n translations[r.locale.name] = {};\n }\n translations[r.locale.name][r.key.name] = { message: r.content };\n }\n\n return translations;\n}\n\nexport async function pushTranslations(\n translationsByLanguage: TranslationsByLanguage,\n {\n autoTranslate,\n branch,\n devLanguage,\n }: { autoTranslate?: boolean; branch: string; devLanguage: string },\n) {\n const { csvFileStrings, keyIndex, commentIndex, tagColumn, messageIndex } =\n translationsToCsv(translationsByLanguage, devLanguage);\n\n let devLanguageUploadId = '';\n\n for (const [language, csvFileString] of Object.entries(csvFileStrings)) {\n const formData = new FormData();\n\n formData.append(\n 'file',\n new Blob([csvFileString], {\n type: 'text/csv',\n }),\n `${language}.translations.csv`,\n );\n\n formData.append('file_format', 'csv');\n formData.append('branch', branch);\n formData.append('update_translations', 'true');\n formData.append('update_descriptions', 'true');\n\n if (autoTranslate) {\n formData.append('autoTranslate', 'true');\n }\n\n formData.append(`locale_mapping[${language}]`, messageIndex.toString());\n\n formData.append('format_options[key_index]', keyIndex.toString());\n formData.append('format_options[comment_index]', commentIndex.toString());\n formData.append('format_options[tag_column]', tagColumn.toString());\n formData.append('format_options[enable_pluralization]', 'false');\n\n log(`Uploading translations for language ${language}`);\n\n const result = await callPhrase<\n | {\n id: string;\n }\n | {\n message: string;\n errors: unknown[];\n }\n | undefined\n >(`uploads`, {\n method: 'POST',\n body: formData,\n });\n\n trace('Upload result:\\n', result);\n\n if (result && 'id' in result) {\n log('Upload ID:', result.id, '\\n');\n log('Successfully Uploaded\\n');\n } else {\n log(`Error uploading: ${result?.message}\\n`);\n log('Response:', result);\n throw new Error('Error uploading');\n }\n\n if (language === devLanguage) {\n devLanguageUploadId = result.id;\n }\n }\n\n return {\n devLanguageUploadId,\n };\n}\n\nexport async function deleteUnusedKeys(uploadId: string, branch: string) {\n const query = `unmentioned_in_upload:${uploadId}`;\n const { records_affected } = await callPhrase<{ records_affected: number }>(\n 'keys',\n {\n method: 'DELETE',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ branch, q: query }),\n },\n );\n\n log(\n 'Successfully deleted',\n records_affected,\n 'unused keys from branch',\n branch,\n );\n}\n\nexport async function ensureBranch(branch: string) {\n await callPhrase(`branches`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ name: branch }),\n });\n\n log('Created branch:', branch);\n}\n","import { writeFile, mkdir } from './file';\nimport path from 'path';\n\nimport {\n type TranslationFileContents,\n type UserConfig,\n loadAllTranslations,\n getAltLanguageFilePath,\n getAltLanguages,\n getUniqueKey,\n} from '@vocab/core';\n\nimport { pullAllTranslations, ensureBranch } from './phrase-api';\nimport { trace } from './logger';\n\ninterface PullOptions {\n branch?: string;\n deleteUnusedKeys?: boolean;\n errorOnNoGlobalKeyTranslation?: boolean;\n}\n\nexport async function pull(\n { branch = 'local-development', errorOnNoGlobalKeyTranslation }: PullOptions,\n config: UserConfig,\n) {\n trace(`Pulling translations from branch ${branch}`);\n await ensureBranch(branch);\n const alternativeLanguages = getAltLanguages(config);\n const allPhraseTranslations = await pullAllTranslations(branch);\n trace(\n `Pulling translations from Phrase for languages ${\n config.devLanguage\n } and ${alternativeLanguages.join(', ')}`,\n );\n\n const phraseLanguages = Object.keys(allPhraseTranslations);\n trace(\n `Found Phrase translations for languages ${phraseLanguages.join(', ')}`,\n );\n\n if (!phraseLanguages.includes(config.devLanguage)) {\n throw new Error(\n `Phrase did not return any translations for the configured development language \"${config.devLanguage}\".\\nPlease ensure this language is present in your Phrase project's configuration.`,\n );\n }\n\n const allVocabTranslations = await loadAllTranslations(\n { fallbacks: 'none', includeNodeModules: false, withTags: true },\n config,\n );\n\n for (const loadedTranslation of allVocabTranslations) {\n const devTranslations = loadedTranslation.languages[config.devLanguage];\n\n if (!devTranslations) {\n throw new Error('No dev language translations loaded');\n }\n\n const defaultValues: TranslationFileContents = { ...devTranslations };\n const localKeys = Object.keys(defaultValues);\n\n for (const key of localKeys) {\n defaultValues[key] = {\n ...defaultValues[key],\n ...allPhraseTranslations[config.devLanguage][\n defaultValues[key].globalKey ??\n getUniqueKey(key, loadedTranslation.namespace)\n ],\n };\n }\n\n // Only write a `_meta` field if necessary\n if (Object.keys(loadedTranslation.metadata).length > 0) {\n defaultValues._meta = loadedTranslation.metadata;\n }\n\n await writeFile(\n loadedTranslation.filePath,\n `${JSON.stringify(defaultValues, null, 2)}\\n`,\n );\n\n for (const alternativeLanguage of alternativeLanguages) {\n if (alternativeLanguage in allPhraseTranslations) {\n const altTranslations = {\n ...loadedTranslation.languages[alternativeLanguage],\n };\n const phraseAltTranslations =\n allPhraseTranslations[alternativeLanguage];\n\n for (const key of localKeys) {\n const phraseKey =\n defaultValues[key].globalKey ??\n getUniqueKey(key, loadedTranslation.namespace);\n const phraseTranslationMessage =\n phraseAltTranslations[phraseKey]?.message;\n\n if (!phraseTranslationMessage) {\n trace(\n `Missing translation. No translation for key ${key} in phrase as ${phraseKey} in language ${alternativeLanguage}.`,\n );\n if (errorOnNoGlobalKeyTranslation && defaultValues[key].globalKey) {\n throw new Error(\n `Missing translation for global key ${key} in language ${alternativeLanguage}`,\n );\n }\n continue;\n }\n\n altTranslations[key] = {\n ...altTranslations[key],\n message: phraseTranslationMessage,\n };\n }\n\n const altTranslationFilePath = getAltLanguageFilePath(\n loadedTranslation.filePath,\n alternativeLanguage,\n );\n\n await mkdir(path.dirname(altTranslationFilePath), {\n recursive: true,\n });\n await writeFile(\n altTranslationFilePath,\n `${JSON.stringify(altTranslations, null, 2)}\\n`,\n );\n }\n }\n }\n}\n","import {\n loadAllTranslations,\n getUniqueKey,\n type TranslationData,\n type TranslationsByLanguage,\n type UserConfig,\n} from '@vocab/core';\nimport {\n ensureBranch,\n deleteUnusedKeys as phraseDeleteUnusedKeys,\n pushTranslations,\n} from './phrase-api';\nimport { trace } from './logger';\n\ninterface PushOptions {\n autoTranslate?: boolean;\n branch: string;\n deleteUnusedKeys?: boolean;\n ignore?: string[];\n}\n\n/**\n * Uploads translations to the Phrase API for each language.\n * A unique namespace is appended to each key using the file path the key came from.\n */\nexport async function push(\n { autoTranslate, branch, deleteUnusedKeys, ignore }: PushOptions,\n config: UserConfig,\n) {\n if (ignore) {\n trace(`ignoring files on paths: ${ignore.join(', ')}`);\n }\n const allLanguageTranslations = await loadAllTranslations(\n { fallbacks: 'none', includeNodeModules: false, withTags: true },\n {\n ...config,\n ignore: [...(config.ignore || []), ...(ignore || [])],\n },\n );\n trace(`Pushing translations to branch ${branch}`);\n const allLanguages = config.languages.map((v) => v.name);\n await ensureBranch(branch);\n\n trace(\n `Pushing translations to phrase for languages ${allLanguages.join(', ')}`,\n );\n\n const phraseTranslations: TranslationsByLanguage = {};\n\n for (const loadedTranslation of allLanguageTranslations) {\n for (const language of allLanguages) {\n const localTranslations = loadedTranslation.languages[language];\n if (!localTranslations) {\n continue;\n }\n if (!phraseTranslations[language]) {\n phraseTranslations[language] = {};\n }\n\n const {\n metadata: { tags: sharedTags = [] },\n } = loadedTranslation;\n\n for (const localKey of Object.keys(localTranslations)) {\n const { tags = [], ...localTranslation } = localTranslations[localKey];\n if (language === config.devLanguage) {\n (localTranslation as TranslationData).tags = [...tags, ...sharedTags];\n }\n const globalKey =\n loadedTranslation.languages[config.devLanguage][localKey].globalKey;\n\n const phraseKey =\n globalKey ?? getUniqueKey(localKey, loadedTranslation.namespace);\n\n phraseTranslations[language][phraseKey] = localTranslation;\n }\n }\n }\n\n const { devLanguageUploadId } = await pushTranslations(phraseTranslations, {\n autoTranslate,\n branch,\n devLanguage: config.devLanguage,\n });\n\n if (deleteUnusedKeys) {\n await phraseDeleteUnusedKeys(devLanguageUploadId, branch);\n }\n}\n"],"mappings":";;;;;;;;AAEA,MAAa,QAAQA,SAAG;AACxB,MAAa,YAAYA,SAAG;;;;ACA5B,MAAa,QAAQ,MAAM,eAAe;AAE1C,MAAa,OAAO,GAAG,WAAsB;AAE3C,SAAQ,IAAI,GAAG,OAAO,QAAQ,EAAE,GAAG,OAAO;;;;;ACA5C,SAAgB,kBACd,cACA,aACA;CACA,MAAM,YAAY,OAAO,KAAK,aAAa;CAC3C,MAAM,eAAe,UAAU,QAAQ,aAAa,aAAa,YAAY;CAE7E,MAAM,0BAA0B,aAAa;CAE7C,MAAMC,qBAAoD,OAAO,YAC/D,UAAU,KAAK,aAAa,CAAC,UAAU,EAAE,CAAC,CAAC,CAC5C;AAED,QAAO,QAAQ,wBAAwB,CAAC,KACrC,CAAC,KAAK,EAAE,SAAS,aAAa,YAAY;EACzC,MAAM,aAAa;GAAC;GAAK;GAAa,MAAM,KAAK,IAAI;GAAC;EACtD,MAAM,iBAAiB,CAAC,GAAG,YAAY,QAAQ;AAC/C,qBAAmB,aAAa,KAAK,eAAe;AAEpD,eAAa,KAAK,aAAa;GAC7B,MAAM,wBAAwB,aAAa,YAAY,MAAM;AAE7D,OAAI,sBACF,oBAAmB,UAAU,KAAK,CAChC,GAAG,YACH,sBACD,CAAC;IAEJ;GAEL;CAED,MAAM,iBAAiB,OAAO,YAC5B,OAAO,QAAQ,mBAAmB,CAE/B,QAAQ,CAAC,GAAG,aAAa,QAAQ,SAAS,EAAE,CAC5C,KAAK,CAAC,UAAU,aAAa;AAM5B,SAAO,CAAC,UALc,UAAU,SAAS;GACvC,WAAW;GACX,QAAQ;GACT,CAAC,CAE8B;GAChC,CACL;CAGD,MAAM,WAAW;CACjB,MAAM,eAAe,WAAW;CAChC,MAAM,YAAY,eAAe;AAGjC,QAAO;EAAE;EAAgB;EAAU,cAFd,YAAY;EAEgB;EAAc;EAAW;;;;;ACtD5E,SAAS,YAAY,QAAc,UAAuC,EAAE,EAAE;CAC5E,MAAM,iBAAiB,QAAQ,IAAI;AAEnC,KAAI,CAAC,eACH,OAAM,IAAI,MAAM,2BAA2B;AAG7C,QAAO,MAAMC,QAAM;EACjB,GAAG;EACH,SAAS;GACP,eAAe,SAAS;GAExB,cAAc;GACd,GAAG,QAAQ;GACZ;EACF,CAAC,CAAC,KAAK,OAAO,aAAa;AAC1B,UAAQ,IAAI,GAAGA,OAAK,IAAI,SAAS,OAAO,KAAK,SAAS,aAAa;EAEnE,MAAM,yBAAyB,KAAK,KAClC,OAAO,WAAW,SAAS,QAAQ,IAAI,qBAAqB,IAAI,IAAI,GAClE,KAAK,KAAK,GAAG,IAChB;AACD,UAAQ,IACN,eAAe,SAAS,QAAQ,IAC9B,yBACD,CAAC,MAAM,SAAS,QAAQ,IACvB,qBACD,CAAC,eAAe,uBAAuB,qBACzC;AAED,QAAM,WAAW,SAAS,QAAQ,IAAI,OAAO,EAAE,KAAK;AAIpD,MAAI;GACF,MAAM,SAAS,MAAM,SAAS,MAAM;AAEpC,SAAM,4BAA4B,OAAO,OAAO,KAAK;AAErD,QACG,CAAC,QAAQ,UAAU,QAAQ,WAAW,UACvC,SAAS,QAAQ,IAAI,OAAO,EAAE,SAAS,WAAW,EAClD;IACA,MAAM,GAAG,eACP,SAAS,QAAQ,IAAI,OAAO,EAAE,MAAM,sBAAsB,IAAI,EAAE;AAElE,QAAI,CAAC,YACH,OAAM,IAAI,MAAM,4BAA4B;AAG9C,YAAQ,IAAI,qCAAqC,YAAY;IAE7D,MAAM,iBAAkB,MAAM,YAAY,aAAa,QAAQ;AAE/D,WAAO,CAAC,GAAG,QAAQ,GAAG,eAAe;;AAGvC,UAAO;WACA,GAAG;AACV,WAAQ,MAAM,oCAAoC,EAAE;AACpD,UAAO,SAAS,MAAM;;GAExB;;AAGJ,eAAsB,WACpB,cACA,UAAuC,EAAE,EAC7B;CACZ,MAAM,YAAY,QAAQ,IAAI;AAE9B,KAAI,CAAC,UACH,OAAM,IAAI,MAAM,4BAA4B;AAE9C,QAAO,YACL,sCAAsC,UAAU,GAAG,gBACnD,QACD,CACE,MAAM,WAAW;AAChB,MAAI,MAAM,QAAQ,OAAO,CACvB,SAAQ,IAAI,kBAAkB,OAAO,OAAO;AAE9C,SAAO;GACP,CACD,OAAO,UAAU;AAChB,UAAQ,MAAM,4BAA4B,aAAa,IAAI,MAAM;AACjE,QAAM;GACN;;AAGN,eAAsB,oBACpB,QACiC;CACjC,MAAM,eAAe,MAAM,WAMzB,uBAAuB,OAAO,eAAe;CAE/C,MAAMC,eAAuC,EAAE;AAE/C,MAAK,MAAM,KAAK,cAAc;AAC5B,MAAI,CAAC,aAAa,EAAE,OAAO,MACzB,cAAa,EAAE,OAAO,QAAQ,EAAE;AAElC,eAAa,EAAE,OAAO,MAAM,EAAE,IAAI,QAAQ,EAAE,SAAS,EAAE,SAAS;;AAGlE,QAAO;;AAGT,eAAsB,iBACpB,wBACA,EACE,eACA,QACA,eAEF;CACA,MAAM,EAAE,gBAAgB,UAAU,cAAc,WAAW,iBACzD,kBAAkB,wBAAwB,YAAY;CAExD,IAAI,sBAAsB;AAE1B,MAAK,MAAM,CAAC,UAAU,kBAAkB,OAAO,QAAQ,eAAe,EAAE;EACtE,MAAM,WAAW,IAAI,UAAU;AAE/B,WAAS,OACP,QACA,IAAI,KAAK,CAAC,cAAc,EAAE,EACxB,MAAM,YACP,CAAC,EACF,GAAG,SAAS,mBACb;AAED,WAAS,OAAO,eAAe,MAAM;AACrC,WAAS,OAAO,UAAU,OAAO;AACjC,WAAS,OAAO,uBAAuB,OAAO;AAC9C,WAAS,OAAO,uBAAuB,OAAO;AAE9C,MAAI,cACF,UAAS,OAAO,iBAAiB,OAAO;AAG1C,WAAS,OAAO,kBAAkB,SAAS,IAAI,aAAa,UAAU,CAAC;AAEvE,WAAS,OAAO,6BAA6B,SAAS,UAAU,CAAC;AACjE,WAAS,OAAO,iCAAiC,aAAa,UAAU,CAAC;AACzE,WAAS,OAAO,8BAA8B,UAAU,UAAU,CAAC;AACnE,WAAS,OAAO,wCAAwC,QAAQ;AAEhE,MAAI,uCAAuC,WAAW;EAEtD,MAAM,SAAS,MAAM,WASnB,WAAW;GACX,QAAQ;GACR,MAAM;GACP,CAAC;AAEF,QAAM,oBAAoB,OAAO;AAEjC,MAAI,UAAU,QAAQ,QAAQ;AAC5B,OAAI,cAAc,OAAO,IAAI,KAAK;AAClC,OAAI,0BAA0B;SACzB;AACL,OAAI,oBAAoB,QAAQ,QAAQ,IAAI;AAC5C,OAAI,aAAa,OAAO;AACxB,SAAM,IAAI,MAAM,kBAAkB;;AAGpC,MAAI,aAAa,YACf,uBAAsB,OAAO;;AAIjC,QAAO,EACL,qBACD;;AAGH,eAAsB,iBAAiB,UAAkB,QAAgB;CACvE,MAAM,QAAQ,yBAAyB;CACvC,MAAM,EAAE,qBAAqB,MAAM,WACjC,QACA;EACE,QAAQ;EACR,SAAS,EACP,gBAAgB,oBACjB;EACD,MAAM,KAAK,UAAU;GAAE;GAAQ,GAAG;GAAO,CAAC;EAC3C,CACF;AAED,KACE,wBACA,kBACA,2BACA,OACD;;AAGH,eAAsB,aAAa,QAAgB;AACjD,OAAM,WAAW,YAAY;EAC3B,QAAQ;EACR,SAAS,EACP,gBAAgB,oBACjB;EACD,MAAM,KAAK,UAAU,EAAE,MAAM,QAAQ,CAAC;EACvC,CAAC;AAEF,KAAI,mBAAmB,OAAO;;;;;AC5MhC,eAAsB,KACpB,EAAE,SAAS,qBAAqB,iCAChC,QACA;AACA,OAAM,oCAAoC,SAAS;AACnD,OAAM,aAAa,OAAO;CAC1B,MAAM,uBAAuB,gBAAgB,OAAO;CACpD,MAAM,wBAAwB,MAAM,oBAAoB,OAAO;AAC/D,OACE,kDACE,OAAO,YACR,OAAO,qBAAqB,KAAK,KAAK,GACxC;CAED,MAAM,kBAAkB,OAAO,KAAK,sBAAsB;AAC1D,OACE,2CAA2C,gBAAgB,KAAK,KAAK,GACtE;AAED,KAAI,CAAC,gBAAgB,SAAS,OAAO,YAAY,CAC/C,OAAM,IAAI,MACR,mFAAmF,OAAO,YAAY,oFACvG;CAGH,MAAM,uBAAuB,MAAM,oBACjC;EAAE,WAAW;EAAQ,oBAAoB;EAAO,UAAU;EAAM,EAChE,OACD;AAED,MAAK,MAAM,qBAAqB,sBAAsB;EACpD,MAAM,kBAAkB,kBAAkB,UAAU,OAAO;AAE3D,MAAI,CAAC,gBACH,OAAM,IAAI,MAAM,sCAAsC;EAGxD,MAAMC,gBAAyC,EAAE,GAAG,iBAAiB;EACrE,MAAM,YAAY,OAAO,KAAK,cAAc;AAE5C,OAAK,MAAM,OAAO,UAChB,eAAc,OAAO;GACnB,GAAG,cAAc;GACjB,GAAG,sBAAsB,OAAO,aAC9B,cAAc,KAAK,aACjB,aAAa,KAAK,kBAAkB,UAAU;GAEnD;AAIH,MAAI,OAAO,KAAK,kBAAkB,SAAS,CAAC,SAAS,EACnD,eAAc,QAAQ,kBAAkB;AAG1C,QAAM,UACJ,kBAAkB,UAClB,GAAG,KAAK,UAAU,eAAe,MAAM,EAAE,CAAC,IAC3C;AAED,OAAK,MAAM,uBAAuB,qBAChC,KAAI,uBAAuB,uBAAuB;GAChD,MAAM,kBAAkB,EACtB,GAAG,kBAAkB,UAAU,sBAChC;GACD,MAAM,wBACJ,sBAAsB;AAExB,QAAK,MAAM,OAAO,WAAW;IAC3B,MAAM,YACJ,cAAc,KAAK,aACnB,aAAa,KAAK,kBAAkB,UAAU;IAChD,MAAM,2BACJ,sBAAsB,YAAY;AAEpC,QAAI,CAAC,0BAA0B;AAC7B,WACE,+CAA+C,IAAI,gBAAgB,UAAU,eAAe,oBAAoB,GACjH;AACD,SAAI,iCAAiC,cAAc,KAAK,UACtD,OAAM,IAAI,MACR,sCAAsC,IAAI,eAAe,sBAC1D;AAEH;;AAGF,oBAAgB,OAAO;KACrB,GAAG,gBAAgB;KACnB,SAAS;KACV;;GAGH,MAAM,yBAAyB,uBAC7B,kBAAkB,UAClB,oBACD;AAED,SAAM,MAAM,KAAK,QAAQ,uBAAuB,EAAE,EAChD,WAAW,MACZ,CAAC;AACF,SAAM,UACJ,wBACA,GAAG,KAAK,UAAU,iBAAiB,MAAM,EAAE,CAAC,IAC7C;;;;;;;;;;;ACpGT,eAAsB,KACpB,EAAE,eAAe,QAAQ,sCAAkB,UAC3C,QACA;AACA,KAAI,OACF,OAAM,4BAA4B,OAAO,KAAK,KAAK,GAAG;CAExD,MAAM,0BAA0B,MAAM,oBACpC;EAAE,WAAW;EAAQ,oBAAoB;EAAO,UAAU;EAAM,EAChE;EACE,GAAG;EACH,QAAQ,CAAC,GAAI,OAAO,UAAU,EAAE,EAAG,GAAI,UAAU,EAAE,CAAE;EACtD,CACF;AACD,OAAM,kCAAkC,SAAS;CACjD,MAAM,eAAe,OAAO,UAAU,KAAK,MAAM,EAAE,KAAK;AACxD,OAAM,aAAa,OAAO;AAE1B,OACE,gDAAgD,aAAa,KAAK,KAAK,GACxE;CAED,MAAMC,qBAA6C,EAAE;AAErD,MAAK,MAAM,qBAAqB,wBAC9B,MAAK,MAAM,YAAY,cAAc;EACnC,MAAM,oBAAoB,kBAAkB,UAAU;AACtD,MAAI,CAAC,kBACH;AAEF,MAAI,CAAC,mBAAmB,UACtB,oBAAmB,YAAY,EAAE;EAGnC,MAAM,EACJ,UAAU,EAAE,MAAM,aAAa,EAAE,OAC/B;AAEJ,OAAK,MAAM,YAAY,OAAO,KAAK,kBAAkB,EAAE;GACrD,MAAM,EAAE,OAAO,EAAE,CAAE,GAAG,qBAAqB,kBAAkB;AAC7D,OAAI,aAAa,OAAO,YACtB,CAAC,iBAAqC,OAAO,CAAC,GAAG,MAAM,GAAG,WAAW;GAKvE,MAAM,YAFJ,kBAAkB,UAAU,OAAO,aAAa,UAAU,aAG7C,aAAa,UAAU,kBAAkB,UAAU;AAElE,sBAAmB,UAAU,aAAa;;;CAKhD,MAAM,EAAE,wBAAwB,MAAM,iBAAiB,oBAAoB;EACzE;EACA;EACA,aAAa,OAAO;EACrB,CAAC;AAEF,KAAIC,mBACF,OAAMC,iBAAuB,qBAAqB,OAAO"}
|
package/package.json
CHANGED
package/README.md
DELETED
|
@@ -1,827 +0,0 @@
|
|
|
1
|
-
# Vocab
|
|
2
|
-
|
|
3
|
-
Vocab is a strongly typed internationalization framework for React.
|
|
4
|
-
|
|
5
|
-
Vocab helps you ship multiple languages without compromising the reliability of your site or slowing down delivery.
|
|
6
|
-
|
|
7
|
-
- **Shareable translations**\
|
|
8
|
-
Translations are co-located with the components that use them. Vocab uses the module graph allowing shared components to be installed with package managers like npm, just like any other module.
|
|
9
|
-
|
|
10
|
-
- **Loading translations dynamically**\
|
|
11
|
-
Vocab only loads the current user's language. If the language changes Vocab can load the new language behind the scenes without reloading the page.
|
|
12
|
-
|
|
13
|
-
- **Strongly typed with TypeScript**\
|
|
14
|
-
When using translations TypeScript will ensure code only accesses valid translations and translations are passed all required dynamic values.
|
|
15
|
-
|
|
16
|
-
## Table of contents
|
|
17
|
-
|
|
18
|
-
- [Getting started](#getting-started)
|
|
19
|
-
- [Step 1: Install Dependencies](#step-1-install-dependencies)
|
|
20
|
-
- [Step 2: Configure Vocab](#step-2-configure-vocab)
|
|
21
|
-
- [Step 3: Set the language using the React Provider](#step-3-set-the-language-using-the-react-provider)
|
|
22
|
-
- [Step 4: Create translations](#step-4-create-translations)
|
|
23
|
-
- [Step 5: Compile and consume translations](#step-5-compile-and-consume-translations)
|
|
24
|
-
- [Step 6: [Optional] Set up plugin](#step-6-optional-set-up-plugin)
|
|
25
|
-
- [Step 7: [Optional] Optimize for fast page loading](#step-7-optional-optimize-for-fast-page-loading)
|
|
26
|
-
|
|
27
|
-
## Getting started
|
|
28
|
-
|
|
29
|
-
### Step 1: Install Dependencies
|
|
30
|
-
|
|
31
|
-
Vocab is a monorepo containing different packages you can install depending on your usage.
|
|
32
|
-
The below list will get you started using the CLI and React integration.
|
|
33
|
-
|
|
34
|
-
```sh
|
|
35
|
-
npm install --save-dev @vocab/cli
|
|
36
|
-
npm install --save @vocab/core @vocab/react
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
### Step 2: Configure Vocab
|
|
40
|
-
|
|
41
|
-
You can configure Vocab directly when calling the API, or via a `vocab.config.js` or `vocab.config.cjs` file.
|
|
42
|
-
|
|
43
|
-
> [!TIP]
|
|
44
|
-
> It's a good idea to name your languages using [IETF language tags], however this is not a requirement.
|
|
45
|
-
|
|
46
|
-
In this example we've configured two languages named `en` (English) and `fr` (French).
|
|
47
|
-
We've also configured a `devLanguage` of `en`.
|
|
48
|
-
This is the language Vocab will assume when it sees a `translation.json` file without a language prefix.
|
|
49
|
-
|
|
50
|
-
```js
|
|
51
|
-
// vocab.config.js
|
|
52
|
-
module.exports = {
|
|
53
|
-
languages: [{ name: 'en' }, { name: 'fr' }],
|
|
54
|
-
devLanguage: 'en'
|
|
55
|
-
};
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
See the [configuration] section for more configuration options.
|
|
59
|
-
|
|
60
|
-
[IETF language tags]: https://en.wikipedia.org/wiki/IETF_language_tag
|
|
61
|
-
[configuration]: #configuration
|
|
62
|
-
|
|
63
|
-
### Step 3: Set the language using the React Provider
|
|
64
|
-
|
|
65
|
-
Vocab uses React's context API to provide information for your translation lookups.
|
|
66
|
-
To tell Vocab which language to use, wrap your app in a `VocabProvider` component and pass in a `language` prop corresponding to one of the language names configured in your `vocab.config.js` file.
|
|
67
|
-
|
|
68
|
-
> [!NOTE]
|
|
69
|
-
> Using methods discussed later we'll make sure the first language is loaded on page load.
|
|
70
|
-
> However, after this, changing languages may lead to a period of no translations as Vocab downloads the new language's translations.
|
|
71
|
-
|
|
72
|
-
```tsx
|
|
73
|
-
// src/App.tsx
|
|
74
|
-
|
|
75
|
-
import { VocabProvider } from '@vocab/react';
|
|
76
|
-
|
|
77
|
-
const App = ({ children }) => {
|
|
78
|
-
return (
|
|
79
|
-
<VocabProvider language="en">{children}</VocabProvider>
|
|
80
|
-
);
|
|
81
|
-
};
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
If you need to customize the locale for your language, you can pass a `locale` prop to the `VocabProvider` component.
|
|
85
|
-
This tells Vocab which locale to use when formatting your translations.
|
|
86
|
-
|
|
87
|
-
```tsx
|
|
88
|
-
// src/App.tsx
|
|
89
|
-
|
|
90
|
-
import { VocabProvider } from '@vocab/react';
|
|
91
|
-
|
|
92
|
-
function App({ children }) {
|
|
93
|
-
return (
|
|
94
|
-
<VocabProvider language="myCustomLanguage" locale="en">
|
|
95
|
-
{children}
|
|
96
|
-
</VocabProvider>
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
See [here][overriding the locale] for more information on how and when to use the `locale` prop.
|
|
102
|
-
|
|
103
|
-
[overriding the locale]: #overriding-the-locale
|
|
104
|
-
|
|
105
|
-
### Step 4: Create translations
|
|
106
|
-
|
|
107
|
-
A translation file is a JSON file consisting of a flat structure of keys.
|
|
108
|
-
Each key must contain a `message` property, and optionally a `description` property.
|
|
109
|
-
|
|
110
|
-
Rather than creating one giant file for each language's translations, Vocab enables you to co-locate the translations alongside their consuming components.
|
|
111
|
-
To facilitate this, Vocab lets you group translations inside folders ending in `.vocab`.
|
|
112
|
-
You may have as many of these folders as you like in your project.
|
|
113
|
-
|
|
114
|
-
> [!TIP]
|
|
115
|
-
> Your folders can be named anything, as long as it ends in `.vocab`.
|
|
116
|
-
> It's recommened to just name your folders `.vocab` so you have one less name to think of/rename in the future.
|
|
117
|
-
|
|
118
|
-
Translation files must follow the naming pattern of `{languageName}.translations.json`.
|
|
119
|
-
The exception to this is translations for your `devLanguage` which must be placed in a file named `translations.json`.
|
|
120
|
-
|
|
121
|
-
In the following examples, we're defining translations for our `devLanguage`, and a language named `fr`.
|
|
122
|
-
|
|
123
|
-
```jsonc
|
|
124
|
-
// src/MyComponent/.vocab/translations.json
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
"my key": {
|
|
128
|
-
"message": "Hello from Vocab",
|
|
129
|
-
"description": "An optional description to help when translating"
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
```jsonc
|
|
135
|
-
// src/MyComponent/.vocab/fr.translations.json
|
|
136
|
-
|
|
137
|
-
{
|
|
138
|
-
"my key": {
|
|
139
|
-
"message": "Bonjour de Vocab",
|
|
140
|
-
"description": "An optional description to help when translating"
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
> [!NOTE]
|
|
146
|
-
> You can create your translation files manually.
|
|
147
|
-
> However, Vocab also offers integrations with remote translation platforms to push and pull translations automatically.
|
|
148
|
-
> See [External translation tooling] for more information.
|
|
149
|
-
|
|
150
|
-
[External translation tooling]: #external-translation-tooling
|
|
151
|
-
|
|
152
|
-
### Step 5: Compile and consume translations
|
|
153
|
-
|
|
154
|
-
Once you have created some translations, run `vocab compile`.
|
|
155
|
-
This command creates an `index.ts` file inside each folder ending in `.vocab`.
|
|
156
|
-
Importing this file provides type-safe translations for your React components.
|
|
157
|
-
Accessing translation messages is done by passing these imported translations to the `useTranslations` hook and using the returned `t` function.
|
|
158
|
-
|
|
159
|
-
```tsx
|
|
160
|
-
// src/MyComponent.tsx
|
|
161
|
-
|
|
162
|
-
import { useTranslations } from '@vocab/react';
|
|
163
|
-
import translations from './.vocab';
|
|
164
|
-
|
|
165
|
-
function MyComponent({ children }) {
|
|
166
|
-
const { t } = useTranslations(translations);
|
|
167
|
-
|
|
168
|
-
// t('my key') will return the appropriate translation based on the language set in your app's VocabProvider
|
|
169
|
-
return <div>{t('my key')}</div>;
|
|
170
|
-
}
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
### Step 6: [Optional] Set up plugin
|
|
174
|
-
|
|
175
|
-
#### Webpack Plugin
|
|
176
|
-
|
|
177
|
-
With the default setup, every language is loaded into your web application all the time, potentially leading to a large bundle size.
|
|
178
|
-
Ideally you will want to switch out the Node.js/default runtime for the web runtime, which only loads the active language.
|
|
179
|
-
|
|
180
|
-
This is done using the `VocabWebpackPlugin`.
|
|
181
|
-
Applying this plugin to your client webpack configuration will replace all vocab files with dynamic asynchronous chunks designed for the web.
|
|
182
|
-
|
|
183
|
-
```sh
|
|
184
|
-
npm i --save-dev @vocab/webpack
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
```js
|
|
188
|
-
// webpack.config.js
|
|
189
|
-
|
|
190
|
-
const { VocabWebpackPlugin } = require('@vocab/webpack');
|
|
191
|
-
|
|
192
|
-
module.exports = {
|
|
193
|
-
plugins: [new VocabWebpackPlugin()]
|
|
194
|
-
};
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
#### Vite Plugin _(this plugin is experimental)_
|
|
198
|
-
|
|
199
|
-
> [!NOTE]
|
|
200
|
-
> This plugin is still experimental and may not work in all cases. If you encounter any issues, please open an issue on the Vocab GitHub repository.
|
|
201
|
-
|
|
202
|
-
Vocab also provides a Vite plugin to handle the same functionality as the Webpack plugin.
|
|
203
|
-
|
|
204
|
-
```shell
|
|
205
|
-
npm i --save-dev @vocab/vite
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
default usage
|
|
209
|
-
|
|
210
|
-
```js
|
|
211
|
-
// vite.config.js
|
|
212
|
-
import { defineConfig } from 'vite';
|
|
213
|
-
import { vocabPluginVite } from '@vocab/vite';
|
|
214
|
-
import vocabConfig from './vocab.config.cjs';
|
|
215
|
-
|
|
216
|
-
export default defineConfig({
|
|
217
|
-
plugins: [
|
|
218
|
-
vocabPluginVite({
|
|
219
|
-
vocabConfig
|
|
220
|
-
})
|
|
221
|
-
]
|
|
222
|
-
});
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
#### createVocabChunks
|
|
226
|
-
|
|
227
|
-
If you want to combine all language files into a single chunk, you can use the `createVocabChunks` function.
|
|
228
|
-
Simply use the function in your `manualChunks` configuration.
|
|
229
|
-
|
|
230
|
-
```js
|
|
231
|
-
// vite.config.js
|
|
232
|
-
import { defineConfig } from 'vite';
|
|
233
|
-
import { vocabPluginVite } from '@vocab/vite';
|
|
234
|
-
import { createVocabChunks } from '@vocab/vite/create-vocab-chunks';
|
|
235
|
-
import vocabConfig from './vocab.config.cjs';
|
|
236
|
-
|
|
237
|
-
export default defineConfig({
|
|
238
|
-
plugins: [
|
|
239
|
-
vocabPluginVite({
|
|
240
|
-
vocabConfig
|
|
241
|
-
})
|
|
242
|
-
],
|
|
243
|
-
build: {
|
|
244
|
-
rollupOptions: {
|
|
245
|
-
output: {
|
|
246
|
-
manualChunks: (id, ctx) => {
|
|
247
|
-
// handle your own manual chunks before or after the vocab chunks.
|
|
248
|
-
const languageChunkName = createVocabChunks(
|
|
249
|
-
id,
|
|
250
|
-
ctx
|
|
251
|
-
);
|
|
252
|
-
if (languageChunkName) {
|
|
253
|
-
// vocab has found a language chunk. Either return it or handle it in your own way.
|
|
254
|
-
return languageChunkName;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
#### VocabPluginOptions
|
|
264
|
-
|
|
265
|
-
```ts
|
|
266
|
-
type VocabPluginOptions = {
|
|
267
|
-
/**
|
|
268
|
-
* The Vocab configuration file.
|
|
269
|
-
* The type can be found in the `@vocab/core/types`.
|
|
270
|
-
* This value is required
|
|
271
|
-
*/
|
|
272
|
-
vocabConfig: UserConfig;
|
|
273
|
-
};
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
### Step 7: [Optional] Optimize for fast page loading
|
|
277
|
-
|
|
278
|
-
Using the above method without optimizing what chunks webpack uses you may find the page needing to do an extra round trip to load languages on a page.
|
|
279
|
-
|
|
280
|
-
This is where `getChunkName` can be used to retrieve the Webpack chunk used for a specific language.
|
|
281
|
-
|
|
282
|
-
For example, here is a server render function that would add the current language chunk to [Loadable component's ChunkExtractor](https://loadable-components.com/docs/api-loadable-server/#chunkextractor).
|
|
283
|
-
|
|
284
|
-
```tsx
|
|
285
|
-
// src/render.tsx
|
|
286
|
-
|
|
287
|
-
import { getChunkName } from '@vocab/webpack/chunk-name';
|
|
288
|
-
|
|
289
|
-
// ...
|
|
290
|
-
|
|
291
|
-
const chunkName = getChunkName(language);
|
|
292
|
-
|
|
293
|
-
const extractor = new ChunkExtractor();
|
|
294
|
-
|
|
295
|
-
extractor.addChunk(chunkName);
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
## Dynamic Values in Translations
|
|
299
|
-
|
|
300
|
-
Translation messages can sometimes contain dynamic values, such as dates/times, links, usernames, etc.
|
|
301
|
-
These values often exist somewhere in the middle of a message, and could change location depending on the translation.
|
|
302
|
-
To support this, Vocab uses [Format.js's `intl-messageformat` library], which enables you to use [ICU Message syntax](https://formatjs.github.io/docs/core-concepts/icu-syntax/) in your messages.
|
|
303
|
-
|
|
304
|
-
In the below example we are defining two messages: one that accepts a single parameter, and one that accepts a component.
|
|
305
|
-
|
|
306
|
-
```json
|
|
307
|
-
{
|
|
308
|
-
"my key with param": {
|
|
309
|
-
"message": "Bonjour de {name}"
|
|
310
|
-
},
|
|
311
|
-
"my key with component": {
|
|
312
|
-
"message": "Bonjour de <Link>Vocab</Link>"
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
Vocab will automatically parse these strings as ICU messages and generate strict types for any parameters it finds.
|
|
318
|
-
|
|
319
|
-
```tsx
|
|
320
|
-
t('my key with param', { name: 'Vocab' });
|
|
321
|
-
t('my key with component', {
|
|
322
|
-
Link: (children) => <a href="/foo">{children}</a>
|
|
323
|
-
});
|
|
324
|
-
```
|
|
325
|
-
|
|
326
|
-
[Format.js's `intl-messageformat` library]: https://formatjs.github.io/docs/intl-messageformat/
|
|
327
|
-
|
|
328
|
-
## Overriding the Locale
|
|
329
|
-
|
|
330
|
-
By default, your language name is passed as the `locale` to the formatting API provided by [`intl-messageformat`].
|
|
331
|
-
The `locale` is used to determine how to format dates, numbers, and other locale-sensitive values.
|
|
332
|
-
If you wish to customize this behaviour, you can pass a `locale` prop to the `VocabProvider` component.
|
|
333
|
-
|
|
334
|
-
```tsx
|
|
335
|
-
<VocabProvider language="myCustomLanguage" locale="th-TH">
|
|
336
|
-
{children}
|
|
337
|
-
</VocabProvider>
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
This can be useful in certain situations:
|
|
341
|
-
|
|
342
|
-
- You have chosen to name your language something other than an [IETF language tag], but still want to use a specific locale for formatting
|
|
343
|
-
- You want to use a different locale for formatting a specific language.
|
|
344
|
-
E.g. when formatting values for `th` (Thai) locales, the default calendar is Buddhist, but you may want to use the Gregorian calendar.
|
|
345
|
-
This can be achieved by specifying a `locale` value with a BCP 47 extension sequence suffix such as `-u-ca-gregory`.
|
|
346
|
-
For example: `th-u-ca-gregory`.
|
|
347
|
-
See the [MDN Intl docs] for more information on BCP 47 extension sequences.
|
|
348
|
-
|
|
349
|
-
[`intl-messageformat`]: https://formatjs.github.io/docs/intl-messageformat/
|
|
350
|
-
[IETF language tag]: https://en.wikipedia.org/wiki/IETF_language_tag
|
|
351
|
-
[mdn intl docs]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument
|
|
352
|
-
|
|
353
|
-
## Accessing the Current `language` or `locale`
|
|
354
|
-
|
|
355
|
-
If you need to access either the `language` or `locale` that you passed to your `VocabProvider`, you can use the `useLanguage` hook:
|
|
356
|
-
|
|
357
|
-
```tsx
|
|
358
|
-
import { useLanguage } from '@vocab/react';
|
|
359
|
-
|
|
360
|
-
const MyComponent = () => {
|
|
361
|
-
const { language, locale } = useLanguage();
|
|
362
|
-
return (
|
|
363
|
-
<div>
|
|
364
|
-
{language} - {locale}
|
|
365
|
-
</div>
|
|
366
|
-
);
|
|
367
|
-
};
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
> [!CAUTION]\
|
|
371
|
-
> `locale` is only available when you pass a `locale` prop to your `VocabProvider`.
|
|
372
|
-
> If you don't pass a `locale` prop, `locale` will be `undefined`.
|
|
373
|
-
> It's generally advised to name your languages using [IETF language tags] and let Vocab handle the locale for you.
|
|
374
|
-
> This gives you the added benefit that you can use the `language` from `useLanguage` if necessary, and it will always be defined.
|
|
375
|
-
|
|
376
|
-
Typically you won't need to access these values since the ICU message syntax supports locale-aware formatting of [numbers], [dates, and times].
|
|
377
|
-
However, one use case where you might need to access these values is when formatting a currency value.
|
|
378
|
-
This is because there is currently no way to specify the currency for an ICU message programmatically, so it must be hardcoded within the messsage.
|
|
379
|
-
This poses a problem when you don't want to couple your translations to a specific currency.
|
|
380
|
-
|
|
381
|
-
```json
|
|
382
|
-
{
|
|
383
|
-
"my key with currency": {
|
|
384
|
-
"message": "You have {value, number, ::compact-short currency/GBP}"
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
When given a `value` of `123`, the above message would render as `You have GBP 123`.
|
|
390
|
-
|
|
391
|
-
To format a value with a dynamic currency, you could use the `useLanguage` hook to access the current `language` and format the currency value using the `Intl.NumberFormat` API:
|
|
392
|
-
|
|
393
|
-
```tsx
|
|
394
|
-
const Currency = ({ value, currency }) => {
|
|
395
|
-
const { language } = useLanguage();
|
|
396
|
-
|
|
397
|
-
const formattedValue = new Intl.NumberFormat(locale, {
|
|
398
|
-
style: 'currency',
|
|
399
|
-
currency
|
|
400
|
-
}).format(value);
|
|
401
|
-
|
|
402
|
-
return <div>{formattedValue}</div>;
|
|
403
|
-
};
|
|
404
|
-
```
|
|
405
|
-
|
|
406
|
-
[numbers]: https://formatjs.github.io/docs/core-concepts/icu-syntax/#number-type
|
|
407
|
-
[dates, and times]: https://formatjs.github.io/docs/core-concepts/icu-syntax/#supported-datetime-skeleton
|
|
408
|
-
|
|
409
|
-
## Configuration
|
|
410
|
-
|
|
411
|
-
Configuration can either be passed into the Node API directly or be gathered from the nearest `vocab.config.js` or `vocab.config.cjs` file.
|
|
412
|
-
|
|
413
|
-
```js
|
|
414
|
-
// vocab.config.js
|
|
415
|
-
|
|
416
|
-
function capitalize(element) {
|
|
417
|
-
return element.toUpperCase();
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function pad(message) {
|
|
421
|
-
return '[' + message + ']';
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
module.exports = {
|
|
425
|
-
devLanguage: 'en',
|
|
426
|
-
languages: [
|
|
427
|
-
{ name: 'en' },
|
|
428
|
-
{ name: 'en-AU', extends: 'en' },
|
|
429
|
-
{ name: 'en-US', extends: 'en' },
|
|
430
|
-
{ name: 'fr-FR' }
|
|
431
|
-
],
|
|
432
|
-
/**
|
|
433
|
-
* An array of languages to generate based off translations for existing languages
|
|
434
|
-
* Default: []
|
|
435
|
-
*/
|
|
436
|
-
generatedLanguages: [
|
|
437
|
-
{
|
|
438
|
-
name: 'generatedLanguage',
|
|
439
|
-
extends: 'en',
|
|
440
|
-
generator: {
|
|
441
|
-
transformElement: capitalize,
|
|
442
|
-
transformMessage: pad
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
],
|
|
446
|
-
/**
|
|
447
|
-
* The root directory to compile and validate translations
|
|
448
|
-
* Default: Current working directory
|
|
449
|
-
*/
|
|
450
|
-
projectRoot: './example/',
|
|
451
|
-
/**
|
|
452
|
-
* A custom suffix to name vocab translation directories
|
|
453
|
-
* Default: '.vocab'
|
|
454
|
-
*/
|
|
455
|
-
translationsDirectorySuffix: '.vocab',
|
|
456
|
-
/**
|
|
457
|
-
* An array of glob paths to ignore from compilation and validation
|
|
458
|
-
*/
|
|
459
|
-
ignore: ['**/ignored_directory/**']
|
|
460
|
-
};
|
|
461
|
-
```
|
|
462
|
-
|
|
463
|
-
## Translation Key Types
|
|
464
|
-
|
|
465
|
-
If you need to access the keys of your translations as a TypeScript type, you can use the `TranslationKeys` type from `@vocab/core`:
|
|
466
|
-
|
|
467
|
-
```jsonc
|
|
468
|
-
// translations.json
|
|
469
|
-
{
|
|
470
|
-
"Hello": {
|
|
471
|
-
"message": "Hello"
|
|
472
|
-
},
|
|
473
|
-
"Goodbye": {
|
|
474
|
-
"message": "Goodbye"
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
```
|
|
478
|
-
|
|
479
|
-
```ts
|
|
480
|
-
import type { TranslationKeys } from '@vocab/core';
|
|
481
|
-
import translations from './.vocab';
|
|
482
|
-
|
|
483
|
-
// "Hello" | "Goodbye"
|
|
484
|
-
type MyTranslationKeys = TranslationKeys<
|
|
485
|
-
typeof translations
|
|
486
|
-
>;
|
|
487
|
-
```
|
|
488
|
-
|
|
489
|
-
## Generated languages
|
|
490
|
-
|
|
491
|
-
Vocab supports the creation of generated languages via the `generatedLanguages` config.
|
|
492
|
-
|
|
493
|
-
Generated languages are created by running a message `generator` over every translation message in an existing translation.
|
|
494
|
-
A `generator` may contain a `transformElement` function, a `transformMessage` function, or both.
|
|
495
|
-
Both of these functions accept a single string parameter and return a string.
|
|
496
|
-
|
|
497
|
-
`transformElement` is applied to string literal values contained within `MessageFormatElement`s.
|
|
498
|
-
A `MessageFormatElement` is an object representing a node in the AST of a compiled translation message.
|
|
499
|
-
Simply put, any text that would end up being translated by a translator, i.e. anything that is not part of the [ICU Message syntax], will be passed to `transformElement`.
|
|
500
|
-
An example of a use case for this function would be adding [diacritics] to every letter in order to stress your UI from a vertical line-height perspective.
|
|
501
|
-
|
|
502
|
-
`transformMessage` receives the entire translation message _after_ `transformElement` has been applied to its individual elements.
|
|
503
|
-
An example of a use case for this function would be adding padding text to the start/end of your messages in order to easily identify which text in your app has not been extracted into a `translations.json` file.
|
|
504
|
-
|
|
505
|
-
By default, a generated language's messages will be based off the `devLanguage`'s messages, but this can be overridden by providing an `extends` value that references another language.
|
|
506
|
-
|
|
507
|
-
```js
|
|
508
|
-
// vocab.config.js
|
|
509
|
-
|
|
510
|
-
function capitalize(message) {
|
|
511
|
-
return message.toUpperCase();
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function pad(message) {
|
|
515
|
-
return '[' + message + ']';
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
module.exports = {
|
|
519
|
-
devLanguage: 'en',
|
|
520
|
-
languages: [{ name: 'en' }, { name: 'fr' }],
|
|
521
|
-
generatedLanguages: [
|
|
522
|
-
{
|
|
523
|
-
name: 'generatedLanguage',
|
|
524
|
-
extends: 'en',
|
|
525
|
-
generator: {
|
|
526
|
-
transformElement: capitalize,
|
|
527
|
-
transformMessage: pad
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
]
|
|
531
|
-
};
|
|
532
|
-
```
|
|
533
|
-
|
|
534
|
-
Generated languages are consumed the same way as regular languages.
|
|
535
|
-
Any Vocab API that accepts a `language` parameter will work with a generated language as well as a regular language.
|
|
536
|
-
|
|
537
|
-
```tsx
|
|
538
|
-
// App.tsx
|
|
539
|
-
|
|
540
|
-
const App = () => (
|
|
541
|
-
<VocabProvider language="generatedLanguage">
|
|
542
|
-
<div>Hello, world!</div>
|
|
543
|
-
</VocabProvider>
|
|
544
|
-
);
|
|
545
|
-
```
|
|
546
|
-
|
|
547
|
-
[icu message syntax]: https://formatjs.github.io/docs/intl-messageformat/#message-syntax
|
|
548
|
-
[diacritics]: https://en.wikipedia.org/wiki/Diacritic
|
|
549
|
-
|
|
550
|
-
## Pseudo-localization
|
|
551
|
-
|
|
552
|
-
The `@vocab/pseudo-localize` package exports low-level functions that can be used for pseudo-localization of translation messages.
|
|
553
|
-
|
|
554
|
-
```sh
|
|
555
|
-
$ npm install --save-dev @vocab/pseudo-localize
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
```ts
|
|
559
|
-
import {
|
|
560
|
-
extendVowels,
|
|
561
|
-
padString,
|
|
562
|
-
pseudoLocalize,
|
|
563
|
-
substituteCharacters
|
|
564
|
-
} from '@vocab/pseudo-localize';
|
|
565
|
-
|
|
566
|
-
const message = 'Hello';
|
|
567
|
-
|
|
568
|
-
// [Hello]
|
|
569
|
-
const paddedMessage = padString(message);
|
|
570
|
-
|
|
571
|
-
// Ḩẽƚƚö
|
|
572
|
-
const substitutedMessage = substituteCharacters(message);
|
|
573
|
-
|
|
574
|
-
// Heelloo
|
|
575
|
-
const extendedMessage = extendVowels(message);
|
|
576
|
-
|
|
577
|
-
// Extend the message and then substitute characters
|
|
578
|
-
// Ḩẽẽƚƚöö
|
|
579
|
-
const pseudoLocalizedMessage = pseudoLocalize(message);
|
|
580
|
-
```
|
|
581
|
-
|
|
582
|
-
Pseudo-localization is a transformation that can be applied to a translation message.
|
|
583
|
-
Vocab's implementation of this transformation contains the following elements:
|
|
584
|
-
|
|
585
|
-
- _Start and end markers (`padString`):_ All strings are encapsulated in `[` and `]`.
|
|
586
|
-
|
|
587
|
-
If a developer doesn’t see these characters they know the string has been clipped by an inflexible UI element.
|
|
588
|
-
|
|
589
|
-
- _Transformation of ASCII characters to extended character equivalents (`substituteCharacters`):_ Stresses the UI from a vertical line-height perspective, tests font and encoding support, and weeds out strings that haven’t been externalized correctly (they will not have the pseudo-localization applied to them).
|
|
590
|
-
|
|
591
|
-
- _Padding text (`extendVowels`):_ Simulates translation-induced expansion.
|
|
592
|
-
|
|
593
|
-
Vocab's implementation of this involves repeating vowels (and `y`) to simulate a 40% expansion in the message's length.
|
|
594
|
-
|
|
595
|
-
This [Netflix technology blog post] inspired Vocab's implementation of this functionality.
|
|
596
|
-
|
|
597
|
-
[netflix technology blog post]: https://netflixtechblog.com/pseudo-localization-netflix-12fff76fbcbe
|
|
598
|
-
|
|
599
|
-
### Generating a pseudo-localized language using Vocab
|
|
600
|
-
|
|
601
|
-
Vocab can generate a pseudo-localized language via the [`generatedLanguages` config][generated languages config], either via the webpack plugin or your `vocab.config.js` or `vocab.config.cjs` file.
|
|
602
|
-
`@vocab/pseudo-localize` exports a `generator` that can be used directly in your config.
|
|
603
|
-
|
|
604
|
-
```js
|
|
605
|
-
// vocab.config.js
|
|
606
|
-
|
|
607
|
-
const { generator } = require('@vocab/pseudo-localize');
|
|
608
|
-
|
|
609
|
-
module.exports = {
|
|
610
|
-
devLanguage: 'en',
|
|
611
|
-
languages: [{ name: 'en' }, { name: 'fr' }],
|
|
612
|
-
generatedLanguages: [
|
|
613
|
-
{
|
|
614
|
-
name: 'pseudo',
|
|
615
|
-
extends: 'en',
|
|
616
|
-
generator
|
|
617
|
-
}
|
|
618
|
-
]
|
|
619
|
-
};
|
|
620
|
-
```
|
|
621
|
-
|
|
622
|
-
[generated languages config]: #generated-languages
|
|
623
|
-
|
|
624
|
-
## Use Without React
|
|
625
|
-
|
|
626
|
-
If you need to use Vocab outside of React, you can access the translations directly.
|
|
627
|
-
You'll then be responsible for when to load translations and how to update on translation load.
|
|
628
|
-
|
|
629
|
-
#### Async access
|
|
630
|
-
|
|
631
|
-
- `getMessages(language: string) => Promise<Messages>` returns messages for the given language formatted according to the correct locale.
|
|
632
|
-
If the language has not been loaded it will load the language before resolving.
|
|
633
|
-
|
|
634
|
-
> [!NOTE]
|
|
635
|
-
> To optimize loading time you may want to call [`load`] ahead of use.
|
|
636
|
-
|
|
637
|
-
[`load`]: #sync-access
|
|
638
|
-
|
|
639
|
-
#### Sync access
|
|
640
|
-
|
|
641
|
-
- `load(language: string) => Promise<void>` attempts to pre-load messages for the given language, resolving once loaded.
|
|
642
|
-
This function only ensures the language is available and does not return any translations.
|
|
643
|
-
- `getLoadedMessages(language: string) => Messages | null` returns messages for the given language formatted according to the correct locale.
|
|
644
|
-
If the language has not been loaded it will return `null`.
|
|
645
|
-
This will not load a language that is not available.
|
|
646
|
-
Useful when a synchronous (non-promise) return is required.
|
|
647
|
-
|
|
648
|
-
**Example: Promise based formatting of messages**
|
|
649
|
-
|
|
650
|
-
```ts
|
|
651
|
-
import translations from './.vocab';
|
|
652
|
-
|
|
653
|
-
async function getFooMessage(language) {
|
|
654
|
-
let messages = await translations.getMessages(language);
|
|
655
|
-
return messages['my key'].format();
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
getFooMessage().then((m) => console.log(m));
|
|
659
|
-
```
|
|
660
|
-
|
|
661
|
-
**Example: Synchronously returning a message**
|
|
662
|
-
|
|
663
|
-
```ts
|
|
664
|
-
import translations from './.vocab';
|
|
665
|
-
|
|
666
|
-
function getFooMessageSync(language) {
|
|
667
|
-
let messages = translations.getLoadedMessages(language);
|
|
668
|
-
if (!messages) {
|
|
669
|
-
// Translations not loaded, start loading and return null for now
|
|
670
|
-
translations.load();
|
|
671
|
-
return null;
|
|
672
|
-
}
|
|
673
|
-
return messages.foo.format();
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
translations.load();
|
|
677
|
-
|
|
678
|
-
const onClick = () => {
|
|
679
|
-
console.log(getFooMessageSync());
|
|
680
|
-
};
|
|
681
|
-
```
|
|
682
|
-
|
|
683
|
-
## Generate Types
|
|
684
|
-
|
|
685
|
-
Vocab generates custom `index.ts` files that give your React components strongly typed translations to work with.
|
|
686
|
-
|
|
687
|
-
To generate these files run:
|
|
688
|
-
|
|
689
|
-
```sh
|
|
690
|
-
vocab compile
|
|
691
|
-
```
|
|
692
|
-
|
|
693
|
-
Or to re-run the compiler when files change:
|
|
694
|
-
|
|
695
|
-
```sh
|
|
696
|
-
vocab compile --watch
|
|
697
|
-
```
|
|
698
|
-
|
|
699
|
-
## External Translation Tooling
|
|
700
|
-
|
|
701
|
-
Vocab can be used to synchronize your translations with translations from a remote translation platform.
|
|
702
|
-
|
|
703
|
-
| Platform | Environment Variables |
|
|
704
|
-
| -------- | ----------------------------------- |
|
|
705
|
-
| [Phrase] | PHRASE_PROJECT_ID, PHRASE_API_TOKEN |
|
|
706
|
-
|
|
707
|
-
```sh
|
|
708
|
-
vocab push --branch my-branch
|
|
709
|
-
vocab pull --branch my-branch
|
|
710
|
-
```
|
|
711
|
-
|
|
712
|
-
### [Phrase] Platform Features
|
|
713
|
-
|
|
714
|
-
#### Delete Unused keys
|
|
715
|
-
|
|
716
|
-
When uploading translations, Phrase identifies keys that exist in the Phrase project, but were not
|
|
717
|
-
referenced in the upload. These keys can be deleted from Phrase by providing the
|
|
718
|
-
`--delete-unused-keys` flag to `vocab push`. E.g.
|
|
719
|
-
|
|
720
|
-
```sh
|
|
721
|
-
vocab push --branch my-branch --delete-unused-keys
|
|
722
|
-
```
|
|
723
|
-
|
|
724
|
-
#### Ignoring Files
|
|
725
|
-
|
|
726
|
-
The `ignore` key in your [Vocab config](#configuration) allows you to ignore certain files from being validated, compiled and uploaded.
|
|
727
|
-
However, in some cases you may only want certain files to be compiled and validated, but not uploaded, such as those present in a build output directory.
|
|
728
|
-
This can be accomplished by providing the `--ignore` flag to `vocab push`.
|
|
729
|
-
This flag accepts an array of glob patterns to ignore.
|
|
730
|
-
|
|
731
|
-
```sh
|
|
732
|
-
vocab push --branch my-branch --ignore "**/dist/**" "**/another_ignored_directory/**"
|
|
733
|
-
```
|
|
734
|
-
|
|
735
|
-
[phrase]: https://developers.phrase.com/api/
|
|
736
|
-
|
|
737
|
-
#### [Tags]
|
|
738
|
-
|
|
739
|
-
`vocab push` supports uploading [tags] to Phrase.
|
|
740
|
-
|
|
741
|
-
Tags can be added to an individual key via the `tags` property:
|
|
742
|
-
|
|
743
|
-
```jsonc
|
|
744
|
-
// translations.json
|
|
745
|
-
|
|
746
|
-
{
|
|
747
|
-
"Hello": {
|
|
748
|
-
"message": "Hello",
|
|
749
|
-
"tags": ["greeting", "home_page"]
|
|
750
|
-
},
|
|
751
|
-
"Goodbye": {
|
|
752
|
-
"message": "Goodbye",
|
|
753
|
-
"tags": ["home_page"]
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
```
|
|
757
|
-
|
|
758
|
-
Tags can also be added under a top-level `_meta` field. This will result in the tags applying to all
|
|
759
|
-
keys specified in the file:
|
|
760
|
-
|
|
761
|
-
```jsonc
|
|
762
|
-
// translations.json
|
|
763
|
-
|
|
764
|
-
{
|
|
765
|
-
"_meta": {
|
|
766
|
-
"tags": ["home_page"]
|
|
767
|
-
},
|
|
768
|
-
"Hello": {
|
|
769
|
-
"message": "Hello",
|
|
770
|
-
"tags": ["greeting"]
|
|
771
|
-
},
|
|
772
|
-
"Goodbye": {
|
|
773
|
-
"message": "Goodbye"
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
```
|
|
777
|
-
|
|
778
|
-
In the above example, both the `Hello` and `Goodbye` keys would have the `home_page` tag attached to
|
|
779
|
-
them, but only the `Hello` key would have the `usage_greeting` tag attached to it.
|
|
780
|
-
|
|
781
|
-
> [!NOTE]
|
|
782
|
-
> Only tags specified on keys in your [`devLanguage`][configuration] will be uploaded.
|
|
783
|
-
> Tags on keys in other languages will be ignored.
|
|
784
|
-
|
|
785
|
-
[tags]: https://support.phrase.com/hc/en-us/articles/5822598372252-Tags-Strings-
|
|
786
|
-
[configuration]: #configuration
|
|
787
|
-
|
|
788
|
-
#### Global key
|
|
789
|
-
|
|
790
|
-
`vocab push` and `vocab pull` can support global keys mapping. When you want certain translations to use a specific/custom key in Phrase, add the `globalKey` to the structure.
|
|
791
|
-
|
|
792
|
-
```jsonc
|
|
793
|
-
// translations.json
|
|
794
|
-
|
|
795
|
-
{
|
|
796
|
-
"Hello": {
|
|
797
|
-
"message": "Hello",
|
|
798
|
-
"globalKey": "hello"
|
|
799
|
-
},
|
|
800
|
-
"Goodbye": {
|
|
801
|
-
"message": "Goodbye",
|
|
802
|
-
"globalKey": "app.goodbye.label"
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
```
|
|
806
|
-
|
|
807
|
-
In the above example,
|
|
808
|
-
|
|
809
|
-
- `vocab push` will push the `hello` and `app.goodbye.label` keys to Phrase.
|
|
810
|
-
- `vocab pull` will pull translations from Phrase and map them to the `hello` and `app.goodbye.label` keys.
|
|
811
|
-
|
|
812
|
-
##### Error on no translation for global key
|
|
813
|
-
|
|
814
|
-
By default, `vocab pull` will not error if a translation is missing in Phrase for a translation with a global key.
|
|
815
|
-
If you want to throw an error in this situation, pass the `--error-on-no-global-key-translation` flag:
|
|
816
|
-
|
|
817
|
-
```sh
|
|
818
|
-
vocab pull --error-on-no-global-key-translation
|
|
819
|
-
```
|
|
820
|
-
|
|
821
|
-
## Troubleshooting
|
|
822
|
-
|
|
823
|
-
### Problem: Passed locale is being ignored or using en-US instead
|
|
824
|
-
|
|
825
|
-
When running in Node.js, the locale formatting is supported by [Node.js's Internationalization support](https://nodejs.org/api/intl.html#intl_internationalization_support).
|
|
826
|
-
Node.js will silently switch to the closest locale it can find if the passed locale is not available.
|
|
827
|
-
See Node's documentation on [Options for building Node.js](https://nodejs.org/api/intl.html#intl_options_for_building_node_js) for information on ensuring Node has the locales you need.
|