UNPKG

@intlayer/chokidar

Version:

Uses chokidar to scan and build Intlayer declaration files into dictionaries based on Intlayer configuration.

412 lines (410 loc) 17.6 kB
import { getNodeType } from "@intlayer/core/dictionaryManipulator"; import * as NodeTypes from "@intlayer/types/nodeType"; import * as recast from "recast"; import * as babelTsParser from "recast/parsers/babel-ts.js"; //#region src/writeContentDeclaration/transformJSFile.ts const b = recast.types.builders; const n = recast.types.namedTypes; /** * Unwraps TypeScript/Babel expression wrappers (satisfies, as, !, <Type>). * Uses string fallbacks to bypass outdated ast-types definitions. */ const unwrap = (node) => { while (node && (n.TSSatisfiesExpression?.check(node) || n.TSAsExpression?.check(node) || n.TSTypeAssertion?.check(node) || n.TSNonNullExpression?.check(node) || [ "TSSatisfiesExpression", "TSAsExpression", "TSTypeAssertion", "TSNonNullExpression" ].includes(node.type))) node = node.expression; return node; }; /** * Robustly finds a property in a recast ObjectExpression. * Handles quoted ("key") or unquoted (key) properties. */ const getMatchingProperty = (node, key) => { return node.properties.find((prop) => { if (n.Property.check(prop) || n.ObjectProperty.check(prop)) { if (n.Identifier.check(prop.key) && prop.key.name === key) return true; if (n.StringLiteral.check(prop.key) && prop.key.value === key) return true; if (n.Literal.check(prop.key) && prop.key.value === key) return true; } return false; }); }; /** * Synchronizes numeric suffixes across locales. * E.g. "Hello 1" -> "Hello 3" updates "Bonjour 1" to "Bonjour 3". */ const syncNumericSuffixAcrossLocales = (existingNode, fallbackLocaleCode, newFallbackValue) => { const trailingNumberMatch = newFallbackValue.match(/\d+(?!.*\d)/); if (!trailingNumberMatch) return; const newTrailingNumber = trailingNumberMatch[0]; if (n.ObjectExpression.check(existingNode)) { for (const prop of existingNode.properties) if (n.Property.check(prop) || n.ObjectProperty.check(prop)) { let propName = ""; if (n.Identifier.check(prop.key)) propName = prop.key.name; else if (n.Literal.check(prop.key) && typeof prop.key.value === "string") propName = prop.key.value; else if (n.StringLiteral.check(prop.key)) propName = prop.key.value; if (propName && propName !== fallbackLocaleCode) { if (n.Literal.check(prop.value) && typeof prop.value.value === "string") { const currentValue = prop.value.value; if (currentValue.match(/\d+(?!.*\d)/)) prop.value = b.literal(currentValue.replace(/(\d+)(?!.*\d)/, newTrailingNumber)); } else if (n.StringLiteral.check(prop.value)) { const currentValue = prop.value.value; if (currentValue.match(/\d+(?!.*\d)/)) prop.value = b.stringLiteral(currentValue.replace(/(\d+)(?!.*\d)/, newTrailingNumber)); } } } } }; /** * Checks if a value represents a multilingual Intlayer node. * A node is multilingual if it is a Translation node, or if it is a specialized node * (Markdown, HTML, etc.) that contains a Translation node. */ const isMultilingualNode = (val) => { if (typeof val !== "object" || val === null || Array.isArray(val)) return false; const nodeType = getNodeType(val); if (nodeType === NodeTypes.TRANSLATION) return true; if (nodeType === NodeTypes.MARKDOWN || nodeType === NodeTypes.HTML || nodeType === NodeTypes.INSERTION) return isMultilingualNode(val[nodeType]); if (nodeType === NodeTypes.ENUMERATION || nodeType === NodeTypes.PLURAL || nodeType === NodeTypes.CONDITION || nodeType === NodeTypes.GENDER) { const data = val[nodeType]; if (data && typeof data === "object") return Object.values(data).some((v) => isMultilingualNode(v)); } return false; }; /** * Recursively builds or updates an AST node for a given dictionary value. */ const buildNodeForValue = (val, existingNode, fallbackLocale, requiredImports) => { const unwrappedExisting = unwrap(existingNode); if (unwrappedExisting) { if (!(n.Literal.check(unwrappedExisting) || n.StringLiteral.check(unwrappedExisting) || n.NumericLiteral.check(unwrappedExisting) || n.BooleanLiteral.check(unwrappedExisting) || n.TemplateLiteral.check(unwrappedExisting) || n.ObjectExpression.check(unwrappedExisting) || n.ArrayExpression.check(unwrappedExisting) || n.CallExpression.check(unwrappedExisting) && n.Identifier.check(unwrappedExisting.callee) && [ "t", "enu", "plural", "cond", "gender", "insert", "md", "html", "file", "nest" ].includes(unwrappedExisting.callee.name))) return existingNode; } if (fallbackLocale && !existingNode && !isMultilingualNode(val)) { if (val === null) return b.literal(null); if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") { if (typeof val === "string" && val.includes("\n")) return b.templateLiteral([b.templateElement({ raw: val, cooked: val }, true)], []); return b.literal(val); } } if (fallbackLocale && existingNode && !isMultilingualNode(val)) { if (n.CallExpression.check(existingNode) && n.Identifier.check(existingNode.callee) && existingNode.callee.name === "t") { const arg = unwrap(existingNode.arguments[0]); if (n.ObjectExpression.check(arg)) { if (typeof val === "string") syncNumericSuffixAcrossLocales(arg, fallbackLocale, val); updateObjectLiteral(arg, { [fallbackLocale]: val }, fallbackLocale, requiredImports); if (!fallbackLocale) requiredImports.add("t"); return existingNode; } } if ((!val || typeof val !== "object") && n.CallExpression.check(existingNode) && n.Identifier.check(existingNode.callee) && existingNode.callee.name === "md") { const innerArg = existingNode.arguments[0]; if (n.CallExpression.check(innerArg) && n.Identifier.check(innerArg.callee) && innerArg.callee.name === "t") { const tArg = unwrap(innerArg.arguments[0]); if (n.ObjectExpression.check(tArg)) { if (typeof val === "string") syncNumericSuffixAcrossLocales(tArg, fallbackLocale, val); updateObjectLiteral(tArg, { [fallbackLocale]: val }, fallbackLocale, requiredImports); requiredImports.add("md"); requiredImports.add("t"); return existingNode; } } } } if (val === null) return b.literal(null); if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") { if (unwrappedExisting) { if (n.TemplateLiteral.check(unwrappedExisting) && unwrappedExisting.expressions.length === 0) { unwrappedExisting.quasis[0].value.raw = String(val); unwrappedExisting.quasis[0].value.cooked = String(val); return existingNode; } if (n.Literal.check(unwrappedExisting) || n.StringLiteral.check(unwrappedExisting)) { unwrappedExisting.value = val; return existingNode; } } if (typeof val === "string" && val.includes("\n")) return b.templateLiteral([b.templateElement({ raw: val, cooked: val }, true)], []); return b.literal(val); } if (Array.isArray(val)) if (unwrappedExisting && n.ArrayExpression.check(unwrappedExisting)) { const elements = [...unwrappedExisting.elements]; val.forEach((item, i) => { elements[i] = buildNodeForValue(item, elements[i], fallbackLocale, requiredImports); }); if (elements.length > val.length) elements.length = val.length; unwrappedExisting.elements = elements; return existingNode; } else return b.arrayExpression(val.map((item) => buildNodeForValue(item, null, fallbackLocale, requiredImports))); const nodeType = val && typeof val === "object" && !Array.isArray(val) ? getNodeType(val) : null; if (nodeType && [ NodeTypes.TRANSLATION, NodeTypes.ENUMERATION, NodeTypes.PLURAL, NodeTypes.CONDITION, NodeTypes.GENDER, NodeTypes.INSERTION, NodeTypes.MARKDOWN, NodeTypes.HTML, NodeTypes.FILE, NodeTypes.NESTED, NodeTypes.ARRAY, NodeTypes.OBJECT, NodeTypes.REACT_NODE, NodeTypes.NUMBER, NodeTypes.BOOLEAN, NodeTypes.NULL, NodeTypes.UNKNOWN ].includes(nodeType) && nodeType !== NodeTypes.TEXT) { const nodeData = val[nodeType]; let calleeName = ""; if (nodeType === NodeTypes.TRANSLATION) calleeName = "t"; else if (nodeType === NodeTypes.ENUMERATION) calleeName = "enu"; else if (nodeType === NodeTypes.PLURAL) calleeName = "plural"; else if (nodeType === NodeTypes.CONDITION) calleeName = "cond"; else if (nodeType === NodeTypes.GENDER) calleeName = "gender"; else if (nodeType === NodeTypes.INSERTION) calleeName = "insert"; else if (nodeType === NodeTypes.MARKDOWN) calleeName = "md"; else if (nodeType === NodeTypes.HTML) calleeName = "html"; else if (nodeType === NodeTypes.FILE) calleeName = "file"; else if (nodeType === NodeTypes.NESTED) calleeName = "nest"; if (calleeName) requiredImports.add(calleeName); const isMatchingCall = existingNode && n.CallExpression.check(existingNode) && n.Identifier.check(existingNode.callee) && existingNode.callee.name === calleeName; if ([ "t", "enu", "plural", "cond", "gender" ].includes(calleeName)) { let objArg = null; if (isMatchingCall && existingNode.arguments.length > 0 && n.ObjectExpression.check(existingNode.arguments[0])) objArg = existingNode.arguments[0]; else objArg = b.objectExpression([]); updateObjectLiteral(objArg, nodeData, fallbackLocale, requiredImports); return isMatchingCall ? existingNode : b.callExpression(b.identifier(calleeName), [objArg]); } if ([ "md", "html", "insert", "file" ].includes(calleeName)) { const argNode = buildNodeForValue(nodeData, isMatchingCall && existingNode.arguments.length > 0 ? existingNode.arguments[0] : null, fallbackLocale, requiredImports); if (isMatchingCall) { existingNode.arguments[0] = argNode; return existingNode; } return b.callExpression(b.identifier(calleeName), [argNode]); } if (calleeName === "nest") { const args = [b.literal(nodeData.dictionaryKey)]; if (nodeData.path) args.push(b.literal(nodeData.path)); if (isMatchingCall) { existingNode.arguments = args; return existingNode; } return b.callExpression(b.identifier("nest"), args); } } const objNode = unwrappedExisting && n.ObjectExpression.check(unwrappedExisting) ? unwrappedExisting : b.objectExpression([]); updateObjectLiteral(objNode, val, fallbackLocale, requiredImports); return existingNode && unwrappedExisting === existingNode ? objNode : existingNode || objNode; }; /** * Recursively updates the AST object literal properties. */ const updateObjectLiteral = (node, data, fallbackLocale, requiredImports) => { for (const [key, val] of Object.entries(data)) { if (val === void 0) continue; const existingProp = getMatchingProperty(node, key); if (existingProp) existingProp.value = buildNodeForValue(val, existingProp.value, fallbackLocale, requiredImports); else { const keyNode = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? b.identifier(key) : b.literal(key); const valueNode = buildNodeForValue(val, null, fallbackLocale, requiredImports); node.properties.push(b.property("init", keyNode, valueNode)); } } }; /** * Modifies the AST's top-level imports to inject dynamically needed helper utilities seamlessly. */ const addImports = (ast, requiredImports, isESM) => { if (requiredImports.size === 0) return; const existingCoreImports = /* @__PURE__ */ new Set(); let coreImportPath = null; recast.visit(ast, { visitImportDeclaration(path) { if (path.node.source.value === "intlayer") { coreImportPath = path; path.node.specifiers?.forEach((spec) => { if (n.ImportSpecifier.check(spec) && typeof spec.imported.name === "string") existingCoreImports.add(spec.imported.name); }); } return false; }, visitVariableDeclaration(path) { path.node.declarations.forEach((decl) => { if (n.VariableDeclarator.check(decl) && n.CallExpression.check(decl.init) && n.Identifier.check(decl.init.callee) && decl.init.callee.name === "require") { const arg = decl.init.arguments[0]; if (n.Literal.check(arg)) { if (arg.value === "intlayer") { if (n.ObjectPattern.check(decl.id)) decl.id.properties.forEach((prop) => { if (n.Property.check(prop) && (n.Identifier.check(prop.key) || n.Identifier.check(prop.value))) { const name = n.Identifier.check(prop.key) ? prop.key.name : prop.value.name; existingCoreImports.add(name); } }); else if (n.Identifier.check(decl.id)) existingCoreImports.add(decl.id.name); } } } }); return false; } }); const missingCore = Array.from(requiredImports).filter((imp) => !existingCoreImports.has(imp)); if (missingCore.length === 0) return; if (isESM) if (coreImportPath) { missingCore.forEach((imp) => { coreImportPath.node.specifiers.push(b.importSpecifier(b.identifier(imp))); }); coreImportPath.node.specifiers.sort((a, b) => a.imported.name.localeCompare(b.imported.name)); } else { const specifiers = missingCore.sort().map((imp) => b.importSpecifier(b.identifier(imp))); const newImport = b.importDeclaration(specifiers, b.literal("intlayer")); ast.program.body.unshift(newImport); } else { let insertIndex = 0; if (ast.program.body.length > 0 && n.ExpressionStatement.check(ast.program.body[0]) && n.Literal.check(ast.program.body[0].expression)) insertIndex = 1; const cjsLines = []; const properties = missingCore.sort().map((imp) => { const prop = b.property("init", b.identifier(imp), b.identifier(imp)); prop.shorthand = true; return prop; }); cjsLines.push(b.variableDeclaration("const", [b.variableDeclarator(b.objectPattern(properties), b.callExpression(b.identifier("require"), [b.literal("intlayer")]))])); ast.program.body.splice(insertIndex, 0, ...cjsLines); } }; /** * Updates a JS/TS file seamlessly to map new localization keys, arrays, complex nodes and nested dictionaries gracefully using AST updates via Recast parser. */ const transformJSFile = async (fileContent, dictionary, fallbackLocale, noMetadata) => { if (!dictionary || typeof dictionary !== "object") return fileContent; let ast; try { ast = recast.parse(fileContent, { parser: babelTsParser }); } catch (error) { console.error({ error }); return fileContent; } let rootObject = null; let isESM = false; recast.visit(ast, { visitExportDefaultDeclaration() { isESM = true; return false; }, visitImportDeclaration() { isESM = true; return false; } }); recast.visit(ast, { visitExportDefaultDeclaration(path) { const decl = path.node.declaration; const unwrappedDecl = unwrap(decl); if (n.ObjectExpression.check(unwrappedDecl)) rootObject = unwrappedDecl; else if (n.Identifier.check(unwrappedDecl)) { const varName = unwrappedDecl.name; recast.visit(ast, { visitVariableDeclarator(vp) { const unwrappedInit = unwrap(vp.node.init); if (n.Identifier.check(vp.node.id) && vp.node.id.name === varName && n.ObjectExpression.check(unwrappedInit)) rootObject = unwrappedInit; return false; } }); } return false; }, visitAssignmentExpression(path) { const left = path.node.left; if (n.MemberExpression.check(left)) { if (n.Identifier.check(left.object) && left.object.name === "module" && n.Identifier.check(left.property) && left.property.name === "exports") { const unwrappedRight = unwrap(path.node.right); if (n.ObjectExpression.check(unwrappedRight)) rootObject = unwrappedRight; } if (n.Identifier.check(left.object) && left.object.name === "exports" && n.Identifier.check(left.property) && left.property.name === "default") { const unwrappedRight = unwrap(path.node.right); if (n.ObjectExpression.check(unwrappedRight)) rootObject = unwrappedRight; } } this.traverse(path); } }); if (!rootObject) recast.visit(ast, { visitVariableDeclarator(path) { const unwrappedInit = unwrap(path.node.init); if (!rootObject && n.ObjectExpression.check(unwrappedInit)) rootObject = unwrappedInit; return false; } }); if (!rootObject) return fileContent; const requiredImports = /* @__PURE__ */ new Set(); const effectiveFallbackLocale = fallbackLocale ?? "en"; const metadataProperties = [ "id", "locale", "filled", "fill", "title", "description", "tags", "version", "priority", "contentAutoTransformation" ]; if (noMetadata) { rootObject.properties = rootObject.properties.filter((prop) => { if (n.Property.check(prop) || n.ObjectProperty.check(prop)) { let propName = ""; if (n.Identifier.check(prop.key)) propName = prop.key.name; else if (n.StringLiteral.check(prop.key)) propName = prop.key.value; else if (n.Literal.check(prop.key)) propName = String(prop.key.value); return ![ "key", "content", ...metadataProperties ].includes(propName); } return true; }); recast.visit(ast, { visitNode(path) { const node = path.node; if ((n.TSSatisfiesExpression?.check(node) || node.type === "TSSatisfiesExpression") && node.typeAnnotation && n.TSTypeReference.check(node.typeAnnotation) && n.Identifier.check(node.typeAnnotation.typeName) && node.typeAnnotation.typeName.name === "Dictionary") node.typeAnnotation = b.tsIndexedAccessType(b.tsTypeReference(b.identifier("Dictionary")), b.tsLiteralType(b.stringLiteral("content"))); this.traverse(path); } }); } else for (const prop of metadataProperties) if (dictionary[prop] !== void 0) updateObjectLiteral(rootObject, { [prop]: dictionary[prop] }, void 0, requiredImports); if (dictionary.content !== void 0) updateObjectLiteral(rootObject, noMetadata ? dictionary.content : { content: dictionary.content }, effectiveFallbackLocale, requiredImports); addImports(ast, requiredImports, isESM); return recast.print(ast).code; }; //#endregion export { transformJSFile }; //# sourceMappingURL=transformJSFile.mjs.map