UNPKG

lang-tag

Version:

A professional solution for managing translations in modern JavaScript/TypeScript projects, especially those using component-based architectures. `lang-tag` simplifies internationalization by allowing you to define translation keys directly within the com

732 lines (727 loc) 26.2 kB
#!/usr/bin/env node "use strict"; const commander = require("commander"); const path = require("pathe"); const fs = require("fs"); const url = require("url"); const globby = require("globby"); const process$1 = require("node:process"); const JSON5 = require("json5"); const promises = require("fs/promises"); const path$1 = require("path"); const chokidar = require("chokidar"); const micromatch = require("micromatch"); var _documentCurrentScript = typeof document !== "undefined" ? document.currentScript : null; function _interopNamespaceDefault(e) { const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } }); if (e) { for (const k in e) { if (k !== "default") { const d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: () => e[k] }); } } } n.default = e; return Object.freeze(n); } const process__namespace = /* @__PURE__ */ _interopNamespaceDefault(process$1); const CONFIG_FILE_NAME = ".lang-tag.config.js"; const EXPORTS_FILE_NAME = ".lang-tag.exports.json"; const ANSI_CODES = { reset: "\x1B[0m", red: "\x1B[31m", green: "\x1B[32m", blue: "\x1B[34m", yellow: "\x1B[33m", cyan: "\x1B[36m", bgRed: "\x1B[41m", bgGreen: "\x1B[42m", bgBlue: "\x1B[44m", bgYellow: "\x1B[43m", black: "\x1B[30m", white: "\x1B[37m" }; function colorize(text, colorCode) { return `${colorCode}${text}${ANSI_CODES.reset}`; } const miniChalk = { red: (text) => colorize(text, ANSI_CODES.red), green: (text) => colorize(text, ANSI_CODES.green), blue: (text) => colorize(text, ANSI_CODES.blue), yellow: (text) => colorize(text, ANSI_CODES.yellow), cyan: (text) => colorize(text, ANSI_CODES.cyan), bgRedWhite: (text) => colorize(text, `${ANSI_CODES.bgRed}${ANSI_CODES.white}`), bgGreenBlack: (text) => colorize(text, `${ANSI_CODES.bgGreen}${ANSI_CODES.black}`), bgBlueWhite: (text) => colorize(text, `${ANSI_CODES.bgBlue}${ANSI_CODES.white}`), bgYellowBlack: (text) => colorize(text, `${ANSI_CODES.bgYellow}${ANSI_CODES.black}`) }; function time() { const now = /* @__PURE__ */ new Date(); return now.getHours().toString().padStart(2, "0") + ":" + now.getMinutes().toString().padStart(2, "0") + ":" + now.getSeconds().toString().padStart(2, "0") + " "; } function success(message) { console.log(time() + miniChalk.green("Success: ") + message); } function info(message) { console.log(time() + miniChalk.cyan("Info: ") + message); } function warning(message) { console.warn(time() + miniChalk.yellow("Warning: ") + message); } function error(message) { console.error(time() + miniChalk.bgRedWhite("Error: ") + message); } function messageNamespacesUpdated(config, namespaces) { let n = namespaces.map((n2) => miniChalk.yellow('"') + miniChalk.cyan(n2 + ".json") + miniChalk.yellow('"')).join(miniChalk.yellow(", ")); success("Updated namespaces " + miniChalk.yellow(config.outputDir) + miniChalk.yellow("(") + n + miniChalk.yellow(")")); } function messageLangTagTranslationConfigRegenerated(filePath) { info(`Lang tag configurations written for file "${filePath}"`); } function messageCollectTranslations() { info("Collecting translations from source files..."); } function messageWatchMode() { info("Starting watch mode for translations..."); info("Watching for changes..."); info("Press Ctrl+C to stop watching"); } function messageFoundTranslationKeys(totalKeys) { info(`Found ${totalKeys} translation keys`); } function messageExistingConfiguration() { success(`Configuration file already exists. Please remove the existing configuration file before creating a new default one`); } function messageInitializedConfiguration() { success(`Configuration file created successfully`); } function messageNoChangesMade() { info("No changes were made based on the current configuration and files."); } function messageNodeModulesNotFound() { warning('"node_modules" directory not found.'); } function messageWrittenExportsFile() { success(`Written ${EXPORTS_FILE_NAME}`); } function messageImportedFile(fileName) { success(`Imported node_modules file: "${fileName}"`); } function messageOriginalNamespaceNotFound(filePath) { warning(`Original namespace file "${filePath}" not found. A new one will be created.`); } function messageErrorInFile(e, file, match) { error(e + ` Tag: ${match.fullMatch} File: ${file} Index: ${match.index}`); } function messageErrorInFileWatcher(e) { error(`Error in file watcher: ${String(e)}`); } function messageErrorReadingConfig(error2) { warning(`Error reading configuration file: ${String(error2)}`); } function messageSkippingInvalidJson(invalid, match) { warning(`Skipping invalid JSON "${invalid}" at match "${match.fullMatch}"`); } function messageErrorReadingDirectory(dir, e) { error(`Error reading directory "${dir}": ${String(e)}`); } function messageImportLibraries() { info("Importing translations from libraries..."); } function messageLibrariesImportedSuccessfully() { success("Successfully imported translations from libraries."); } const defaultConfig = { tagName: "lang", includes: ["src/**/*.{js,ts,jsx,tsx}"], excludes: ["node_modules", "dist", "build"], outputDir: "locales/en", import: { dir: "src/lang-libraries", tagImportPath: 'import { lang } from "@/my-lang-tag-path"', onImport: ({ importedRelativePath, fileGenerationData }, actions) => { const exportIndex = (fileGenerationData.index || 0) + 1; fileGenerationData.index = exportIndex; actions.setFile(path.basename(importedRelativePath)); actions.setExportName(`translations${exportIndex}`); } }, isLibrary: false, language: "en", translationArgPosition: 1, onConfigGeneration: (params) => void 0 }; async function readConfig(projectPath) { const configPath = path.resolve(projectPath, CONFIG_FILE_NAME); if (!fs.existsSync(configPath)) { throw new Error(`No "${CONFIG_FILE_NAME}" detected`); } try { const configModule = await import(url.pathToFileURL(configPath).href); if (!configModule.default || Object.keys(configModule.default).length === 0) { throw new Error(`Config found, but default export is undefined`); } const userConfig = configModule.default || {}; return { ...defaultConfig, ...userConfig, import: { ...defaultConfig.import, ...userConfig.import } }; } catch (error2) { messageErrorReadingConfig(error2); throw error2; } } function extractLangTagData(config, match, beforeError) { const pos = config.translationArgPosition; const tagTranslationsContent = pos === 1 ? match.content1 : match.content2; if (!tagTranslationsContent) { beforeError(); throw new Error("Translations not found"); } const tagTranslations = JSON5.parse(tagTranslationsContent); let tagConfigContent = pos === 1 ? match.content2 : match.content1; if (tagConfigContent) { tagConfigContent = JSON5.stringify(JSON5.parse(tagConfigContent), void 0, 4); } const tagConfig = tagConfigContent ? JSON5.parse(tagConfigContent) : { path: "", namespace: "" }; if (!tagConfig.path) tagConfig.path = ""; if (!tagConfig.namespace) tagConfig.namespace = ""; return { replaceTagConfigContent(newConfigString) { const arg1 = pos === 1 ? tagTranslationsContent : newConfigString; const arg2 = pos === 1 ? newConfigString : tagTranslationsContent; const tagFunction = `${config.tagName}(${arg1}, ${arg2})`; if (match.variableName) return ` ${match.variableName} = ${tagFunction}`; return tagFunction; }, tagTranslationsContent, tagConfigContent, tagTranslations, tagConfig }; } function findLangTags(config, content) { const tagName = config.tagName; const optionalVariableAssignment = `(?:\\s*(\\w+)\\s*=\\s*)?`; const matches = []; let currentIndex = 0; const startPattern = new RegExp(`${optionalVariableAssignment}${tagName}\\(\\s*\\{`, "g"); while (true) { startPattern.lastIndex = currentIndex; const startMatch = startPattern.exec(content); if (!startMatch) break; const matchStartIndex = startMatch.index; const variableName = startMatch[1] || void 0; let braceCount = 1; let i = matchStartIndex + startMatch[0].length; while (i < content.length && braceCount > 0) { if (content[i] === "{") braceCount++; if (content[i] === "}") braceCount--; i++; } if (braceCount !== 0) { currentIndex = matchStartIndex + 1; continue; } let content1 = content.substring(matchStartIndex + startMatch[0].length - 1, i); let content2; while (i < content.length && (content[i] === " " || content[i] === "\n" || content[i] === " " || content[i] === ",")) { i++; } if (i < content.length && content[i] === "{") { braceCount = 1; const secondParamStart = i; i++; while (i < content.length && braceCount > 0) { if (content[i] === "{") braceCount++; if (content[i] === "}") braceCount--; i++; } if (braceCount === 0) { content2 = content.substring(secondParamStart, i); } } while (i < content.length && content[i] !== ")") { i++; } if (i < content.length) { i++; } const fullMatch = content.substring(matchStartIndex, i); matches.push({ fullMatch, variableName, content1, content2, index: matchStartIndex }); currentIndex = i; } return matches.filter((m) => { try { JSON5.parse(m.content1); } catch (error2) { messageSkippingInvalidJson(m.content1, m); return false; } if (m.content2) { try { JSON5.parse(m.content2); } catch (error2) { messageSkippingInvalidJson(m.content2, m); return false; } } return true; }); } function replaceLangTags(content, replacements) { let updatedContent = content; let offset = 0; replacements.forEach((replacement, match) => { const startIdx = match.index + offset; const endIdx = startIdx + match.fullMatch.length; updatedContent = updatedContent.slice(0, startIdx) + replacement + updatedContent.slice(endIdx); offset += replacement.length - match.fullMatch.length; }); return updatedContent; } function deepMergeTranslations(target, source) { if (typeof target !== "object") { throw new Error("Target must be an object"); } if (typeof source !== "object") { throw new Error("Source must be an object"); } let changed = false; for (const key in source) { if (!source.hasOwnProperty(key)) { continue; } let targetValue = target[key]; const sourceValue = source[key]; if (typeof targetValue === "string" && typeof sourceValue === "object") { throw new Error(`Trying to write object into target key "${key}" which is translation already`); } if (Array.isArray(sourceValue)) { throw new Error(`Trying to write array into target key "${key}", we do not allow arrays inside translations`); } if (typeof sourceValue === "object") { if (!targetValue) { targetValue = {}; target[key] = targetValue; } if (deepMergeTranslations(targetValue, sourceValue)) { changed = true; } } else { if (target[key] !== sourceValue) { changed = true; } target[key] = sourceValue; } } return changed; } function gatherTranslationsToNamespaces(files, config) { const namespaces = {}; const pos = config.translationArgPosition; let totalKeys = 0; for (const file of files) { const content = fs.readFileSync(file, "utf-8"); const matches = findLangTags(config, content); totalKeys += matches.length; for (let match of matches) { const { tagTranslations, tagConfig } = extractLangTagData(config, match, () => { messageErrorInFile(`Translations at ${pos} argument position are not defined`, file, match); }); const namespaceTranslations = namespaces[tagConfig.namespace] || {}; if (!(tagConfig.namespace in namespaces)) { namespaces[tagConfig.namespace] = namespaceTranslations; } try { const translations = digToSection(tagConfig.path, namespaceTranslations); deepMergeTranslations(translations, tagTranslations); } catch (e) { messageErrorInFile(e.message + `, path: "${tagConfig.path}"`, file, match); throw e; } } } return { namespaces, totalKeys }; } function digToSection(key, translations) { if (!key) return translations; const sp = key.split("."); let currentValue = translations[sp[0]]; if (currentValue && typeof currentValue != "object") { throw new Error(`Key "${sp[0]}" is not an object (found value: "${currentValue}")`); } if (!currentValue) { currentValue = {}; translations[sp[0]] = currentValue; } sp.shift(); return digToSection(sp.join("."), currentValue); } async function ensureDirectoryExists(filePath) { await promises.mkdir(filePath, { recursive: true }); } async function writeJSON(filePath, data) { await promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8"); } async function readJSON(filePath) { const content = await promises.readFile(filePath, "utf-8"); return JSON.parse(content); } async function saveNamespaces(config, namespaces) { const changedNamespaces = []; await ensureDirectoryExists(config.outputDir); for (let namespace of Object.keys(namespaces)) { if (!namespace) { continue; } const filePath = path.resolve( process$1.cwd(), config.outputDir, namespace + ".json" ); let originalJSON = {}; try { originalJSON = await readJSON(filePath); } catch (e) { messageOriginalNamespaceNotFound(filePath); } if (deepMergeTranslations(originalJSON, namespaces[namespace])) { changedNamespaces.push(namespace); await writeJSON(filePath, originalJSON); } } return changedNamespaces; } async function saveAsLibrary(files, config) { const cwd = process$1.cwd(); const packageJson = await readJSON(path$1.resolve(cwd, "package.json")); if (!packageJson) { throw new Error("package.json not found"); } const pos = config.translationArgPosition; const langTagFiles = {}; for (const file of files) { const content = fs.readFileSync(file, "utf-8"); const matches = findLangTags(config, content); if (!matches?.length) { continue; } const relativePath = path$1.relative(cwd, file); const fileObject = { matches: [] }; langTagFiles[relativePath] = fileObject; for (let match of matches) { const { tagTranslationsContent, tagConfigContent } = extractLangTagData(config, match, () => { messageErrorInFile(`Translations at ${pos} argument position are not defined`, file, match); }); fileObject.matches.push({ translations: tagTranslationsContent, config: tagConfigContent, variableName: match.variableName }); } } const data = { language: config.language, packageName: packageJson.name || "", files: langTagFiles }; await writeJSON(EXPORTS_FILE_NAME, data); messageWrittenExportsFile(); } async function collectTranslations() { messageCollectTranslations(); const config = await readConfig(process__namespace.cwd()); const files = await globby.globby(config.includes, { cwd: process__namespace.cwd(), ignore: config.excludes, absolute: true }); if (config.isLibrary) { await saveAsLibrary(files, config); } else { const { namespaces, totalKeys } = gatherTranslationsToNamespaces(files, config); messageFoundTranslationKeys(totalKeys); const changedNamespaces = await saveNamespaces(config, namespaces); if (changedNamespaces.length > 0) { messageNamespacesUpdated(config, changedNamespaces); } else { messageNoChangesMade(); } } } async function checkAndRegenerateFileLangTags(config, file, path2) { const content = fs.readFileSync(file, "utf-8"); let libraryImportsDir = config.import.dir; if (!libraryImportsDir.endsWith(path$1.sep)) libraryImportsDir += path$1.sep; const pos = config.translationArgPosition; const matches = findLangTags(config, content); const replacements = /* @__PURE__ */ new Map(); for (let match of matches) { const { tagConfig, tagConfigContent, replaceTagConfigContent } = extractLangTagData(config, match, () => { messageErrorInFile(`Translations at ${pos} argument position are not defined`, file, match); }); const newConfig = config.onConfigGeneration({ config: tagConfig, fullPath: file, path: path2, isImportedLibrary: path2.startsWith(libraryImportsDir) }); if (!tagConfigContent || newConfig) { const newConfigString = JSON5.stringify(newConfig, void 0, 4); if (tagConfigContent !== newConfigString) { replacements.set(match, replaceTagConfigContent(newConfigString)); } } } if (replacements.size) { const newContent = replaceLangTags(content, replacements); await promises.writeFile(file, newContent, "utf-8"); return true; } return false; } async function regenerateTags() { const config = await readConfig(process.cwd()); const files = await globby.globby(config.includes, { cwd: process.cwd(), ignore: config.excludes, absolute: true }); const charactersToSkip = process.cwd().length + 1; let dirty = false; for (const file of files) { const path2 = file.substring(charactersToSkip); const localDirty = await checkAndRegenerateFileLangTags(config, file, path2); if (localDirty) { messageLangTagTranslationConfigRegenerated(path2); dirty = true; } } if (!dirty) { messageNoChangesMade(); } } function getBasePath(pattern) { const globStartIndex = pattern.indexOf("*"); const braceStartIndex = pattern.indexOf("{"); let endIndex = -1; if (globStartIndex !== -1 && braceStartIndex !== -1) { endIndex = Math.min(globStartIndex, braceStartIndex); } else if (globStartIndex !== -1) { endIndex = globStartIndex; } else if (braceStartIndex !== -1) { endIndex = braceStartIndex; } if (endIndex === -1) { const lastSlashIndex = pattern.lastIndexOf("/"); return lastSlashIndex !== -1 ? pattern.substring(0, lastSlashIndex) : "."; } const lastSeparatorIndex = pattern.lastIndexOf("/", endIndex); return lastSeparatorIndex === -1 ? "." : pattern.substring(0, lastSeparatorIndex); } async function watchTranslations() { const cwd = process.cwd(); const config = await readConfig(cwd); await collectTranslations(); const baseDirsToWatch = [ ...new Set(config.includes.map((pattern) => getBasePath(pattern))) ]; const finalDirsToWatch = baseDirsToWatch.map((dir) => dir === "." ? cwd : dir); const ignored = [...config.excludes, "**/.git/**"]; console.log("Original patterns:", config.includes); console.log("Watching base directories:", finalDirsToWatch); console.log("Ignoring patterns:", ignored); const watcher = chokidar.watch(finalDirsToWatch, { // Watch base directories cwd, ignored, persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 } }); messageWatchMode(); watcher.on("change", async (filePath) => await handleFile(config, filePath)).on("add", async (filePath) => await handleFile(config, filePath)).on("error", (error2) => { messageErrorInFileWatcher(error2); }); } async function handleFile(config, cwdRelativeFilePath, event) { if (!micromatch.isMatch(cwdRelativeFilePath, config.includes)) { return; } const cwd = process.cwd(); const absoluteFilePath = path$1.join(cwd, cwdRelativeFilePath); const dirty = await checkAndRegenerateFileLangTags(config, absoluteFilePath, cwdRelativeFilePath); if (dirty) { messageLangTagTranslationConfigRegenerated(cwdRelativeFilePath); } const { namespaces } = gatherTranslationsToNamespaces([cwdRelativeFilePath], config); const changedNamespaces = await saveNamespaces(config, namespaces); if (changedNamespaces.length > 0) { messageNamespacesUpdated(config, changedNamespaces); } } const DEFAULT_INIT_CONFIG = ` /** @type {import('lang-tag/cli/config').LangTagConfig} */ const config = { includes: ['src/**/*.{js,ts,jsx,tsx}'], excludes: ['node_modules', 'dist', 'build', '**/*.test.ts'], outputDir: 'public/locales/en', onConfigGeneration: (params) => { // We do not modify imported configurations if (params.isImportedLibrary) return undefined; //if (!params.config.path) { // params.config.path = 'test'; // params.config.namespace = 'testNamespace'; //} return undefined } }; module.exports = config; `; async function initConfig() { if (fs.existsSync(CONFIG_FILE_NAME)) { messageExistingConfiguration(); return; } try { await promises.writeFile(CONFIG_FILE_NAME, DEFAULT_INIT_CONFIG, "utf-8"); messageInitializedConfiguration(); } catch (error2) { console.error("Error creating configuration file:", error2); } } function findExportJson(dir, depth = 0, maxDepth = 3) { if (depth > maxDepth) return []; let results = []; try { const files = fs.readdirSync(dir); for (const file of files) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { results = results.concat(findExportJson(fullPath, depth + 1, maxDepth)); } else if (file === EXPORTS_FILE_NAME) { results.push(fullPath); } } } catch (error2) { messageErrorReadingDirectory(dir, error2); } return results; } function getExportFiles() { const nodeModulesPath = path.join(process__namespace.cwd(), "node_modules"); if (!fs.existsSync(nodeModulesPath)) { messageNodeModulesNotFound(); return []; } return findExportJson(nodeModulesPath); } async function importLibraries(config) { const files = getExportFiles(); await ensureDirectoryExists(config.import.dir); const generationFiles = {}; for (const filePath of files) { const exportData = await readJSON(filePath); for (let langTagFilePath in exportData.files) { const fileGenerationData = {}; const matches = exportData.files[langTagFilePath].matches; for (let match of matches) { let parsedTranslations = typeof match.translations === "string" ? JSON5.parse(match.translations) : match.translations; let parsedConfig = typeof match.config === "string" ? JSON5.parse(match.config) : match.config === void 0 ? {} : match.config; let file = langTagFilePath; let exportName = match.variableName || ""; config.import.onImport({ packageName: exportData.packageName, importedRelativePath: langTagFilePath, originalExportName: match.variableName, translations: parsedTranslations, config: parsedConfig, fileGenerationData }, { setFile: (f) => { file = f; }, setExportName: (name) => { exportName = name; }, setConfig: (newConfig) => { parsedConfig = newConfig; } }); if (!file || !exportName) { throw new Error(`[lang-tag] onImport did not set fileName or exportName for package: ${exportData.packageName}, file: '${file}' (original: '${langTagFilePath}'), exportName: '${exportName}' (original: ${match.variableName})`); } let exports2 = generationFiles[file]; if (!exports2) { exports2 = {}; generationFiles[file] = exports2; } const param1 = config.translationArgPosition === 1 ? parsedTranslations : parsedConfig; const param2 = config.translationArgPosition === 1 ? parsedConfig : parsedTranslations; exports2[exportName] = `${config.tagName}(${JSON5.stringify(param1, void 0, 4)}, ${JSON5.stringify(param2, void 0, 4)})`; } } } for (let file of Object.keys(generationFiles)) { const filePath = path.resolve( process__namespace.cwd(), config.import.dir, file ); const exports2 = Object.entries(generationFiles[file]).map(([name, tag]) => { return `export const ${name} = ${tag};`; }).join("\n\n"); const content = `${config.import.tagImportPath} ${exports2}`; await ensureDirectoryExists(path.dirname(filePath)); await promises.writeFile(filePath, content, "utf-8"); messageImportedFile(file); } if (config.import.onImportFinish) config.import.onImportFinish(); } async function importTranslations() { messageImportLibraries(); const config = await readConfig(process__namespace.cwd()); await importLibraries(config); messageLibrariesImportedSuccessfully(); } function createCli() { commander.program.name("lang-tag").description("CLI to manage language translations").version("0.1.0"); commander.program.command("collect").alias("c").description("Collect translations from source files").action(collectTranslations); commander.program.command("import").alias("i").description("Import translations from libraries in node_modules").action(importTranslations); commander.program.command("regenerate-tags").alias("rt").description("Regenerate configuration for language tags").action(regenerateTags); commander.program.command("watch").alias("w").description("Watch for changes in source files and automatically collect translations").action(watchTranslations); commander.program.command("init").description("Initialize project with default configuration").action(initConfig); return commander.program; } if ((typeof document === "undefined" ? require("url").pathToFileURL(__filename).href : _documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === "SCRIPT" && _documentCurrentScript.src || new URL("cli/index.cjs", document.baseURI).href) === `file://${process.argv[1]}`) { createCli().parse(); } createCli().parse();