@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
JavaScript
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