@layuplabs/layup-autoid
Version:
A tool to inject stable, unique IDs into React components
510 lines (509 loc) • 21.4 kB
JavaScript
;
const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const recast = require("recast");
const glob = require("glob");
const { v4: uuidv4 } = require("uuid");
// List of standard HTML elements
const HTML_ELEMENTS = new Set([
"div",
"span",
"p",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"section",
"article",
"nav",
"header",
"footer",
"main",
"aside",
"form",
"input",
"button",
"label",
"select",
"option",
"textarea",
"a",
"img",
"ul",
"ol",
"li",
"table",
"tr",
"td",
"th",
"thead",
"tbody",
"tfoot",
"dl",
"dt",
"dd",
"figure",
"figcaption",
"blockquote",
"pre",
"code",
"strong",
"em",
"mark",
"small",
"sub",
"sup",
"time",
"video",
"audio",
"canvas",
"svg",
"path",
"circle",
"rect",
"line",
"polygon",
"text",
"iframe",
"object",
"embed",
"param",
"source",
"track",
"map",
"area",
"col",
"colgroup",
"caption",
"thead",
"tbody",
"tfoot",
"th",
"td",
"tr",
"br",
"hr",
"meta",
"link",
"style",
"script",
"noscript",
"title",
"head",
"body",
"html",
]);
// Helper function to detect if an element is inside a loop
function isInsideLoop(path) {
var _a, _b, _c, _d;
let currentPath = path;
while (currentPath) {
// Check if we're inside a JSX expression that contains array iteration methods
if (currentPath.isJSXExpressionContainer()) {
const expression = currentPath.node.expression;
// Check if the expression is a call expression (like array.map())
if (expression && expression.type === "CallExpression") {
const { callee } = expression;
// Check for method calls like .map(), .forEach(), .filter().map(), etc.
if (callee.type === "MemberExpression") {
const propertyName = (_a = callee.property) === null || _a === void 0 ? void 0 : _a.name;
if (propertyName === "map" || propertyName === "forEach") {
return true;
}
}
}
// Check for chained methods ending with map (e.g., array.filter().map())
if (expression && expression.type === "CallExpression") {
let current = expression;
while (current && current.type === "CallExpression") {
if (((_b = current.callee) === null || _b === void 0 ? void 0 : _b.type) === "MemberExpression") {
const propertyName = (_c = current.callee.property) === null || _c === void 0 ? void 0 : _c.name;
if (propertyName === "map" || propertyName === "forEach") {
return true;
}
}
current = (_d = current.callee) === null || _d === void 0 ? void 0 : _d.object;
}
}
}
// Check if we're inside a for loop, while loop, or do-while loop
if (currentPath.isForStatement() ||
currentPath.isForInStatement() ||
currentPath.isForOfStatement() ||
currentPath.isWhileStatement() ||
currentPath.isDoWhileStatement()) {
return true;
}
currentPath = currentPath.parentPath;
}
return false;
}
// Simplified function to check if element should get auto-id-list prefix
function shouldUseListPrefix(path) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
let currentPath = path;
let jsxDepth = 0;
while (currentPath) {
// Count JSX elements we traverse
if (currentPath.isJSXElement() || currentPath.isJSXFragment()) {
jsxDepth++;
}
// Stop counting after the first JSX element (the element itself)
if (jsxDepth > 1) {
return false; // We're nested inside another JSX element
}
// Check for direct map/forEach in JSX expression
if (currentPath.isJSXExpressionContainer()) {
const expression = currentPath.node.expression;
if ((expression === null || expression === void 0 ? void 0 : expression.type) === "CallExpression" &&
((_a = expression.callee) === null || _a === void 0 ? void 0 : _a.type) === "MemberExpression") {
const methodName = (_b = expression.callee.property) === null || _b === void 0 ? void 0 : _b.name;
if (methodName === "map" || methodName === "forEach") {
return true; // Direct child of map/forEach
}
}
// Check for ternary operators that contain map/forEach calls
if ((expression === null || expression === void 0 ? void 0 : expression.type) === "ConditionalExpression") {
// Check the consequent (true branch) for map/forEach
const consequent = expression.consequent;
if ((consequent === null || consequent === void 0 ? void 0 : consequent.type) === "CallExpression" &&
((_c = consequent.callee) === null || _c === void 0 ? void 0 : _c.type) === "MemberExpression") {
const methodName = (_d = consequent.callee.property) === null || _d === void 0 ? void 0 : _d.name;
if (methodName === "map" || methodName === "forEach") {
return true; // Direct child of map/forEach in ternary
}
}
// Check for nested ternary operators
if ((consequent === null || consequent === void 0 ? void 0 : consequent.type) === "ConditionalExpression") {
// Recursively check nested ternary
const nestedConsequent = consequent.consequent;
if ((nestedConsequent === null || nestedConsequent === void 0 ? void 0 : nestedConsequent.type) === "CallExpression" &&
((_e = nestedConsequent.callee) === null || _e === void 0 ? void 0 : _e.type) === "MemberExpression") {
const methodName = (_f = nestedConsequent.callee.property) === null || _f === void 0 ? void 0 : _f.name;
if (methodName === "map" || methodName === "forEach") {
return true; // Direct child of map/forEach in nested ternary
}
}
}
}
// Check for logical expressions (&&, ||, ??) that contain map/forEach calls
if ((expression === null || expression === void 0 ? void 0 : expression.type) === "LogicalExpression") {
// Check the right side of logical expressions (&&, ||, ??)
const right = expression.right;
if ((right === null || right === void 0 ? void 0 : right.type) === "CallExpression" &&
((_g = right.callee) === null || _g === void 0 ? void 0 : _g.type) === "MemberExpression") {
const methodName = (_h = right.callee.property) === null || _h === void 0 ? void 0 : _h.name;
if (methodName === "map" || methodName === "forEach") {
return true; // Direct child of map/forEach in logical expression
}
}
// Check for nested logical expressions
if ((right === null || right === void 0 ? void 0 : right.type) === "LogicalExpression") {
// Recursively check nested logical expression
const nestedRight = right.right;
if ((nestedRight === null || nestedRight === void 0 ? void 0 : nestedRight.type) === "CallExpression" &&
((_j = nestedRight.callee) === null || _j === void 0 ? void 0 : _j.type) === "MemberExpression") {
const methodName = (_k = nestedRight.callee.property) === null || _k === void 0 ? void 0 : _k.name;
if (methodName === "map" || methodName === "forEach") {
return true; // Direct child of map/forEach in nested logical expression
}
}
}
}
}
// Check for push to array inside forEach
if (((_l = currentPath.node) === null || _l === void 0 ? void 0 : _l.type) === "CallExpression" &&
((_m = currentPath.node.callee) === null || _m === void 0 ? void 0 : _m.type) === "MemberExpression" &&
((_o = currentPath.node.callee.property) === null || _o === void 0 ? void 0 : _o.name) === "push") {
// Look for forEach in the ancestors
let pushParent = currentPath.parentPath;
while (pushParent) {
if (((_p = pushParent.node) === null || _p === void 0 ? void 0 : _p.type) === "ArrowFunctionExpression" ||
((_q = pushParent.node) === null || _q === void 0 ? void 0 : _q.type) === "FunctionExpression") {
// Check if this function is inside a forEach
let funcParent = pushParent.parentPath;
while (funcParent) {
if (((_r = funcParent.node) === null || _r === void 0 ? void 0 : _r.type) === "CallExpression" &&
((_s = funcParent.node.callee) === null || _s === void 0 ? void 0 : _s.type) === "MemberExpression" &&
((_t = funcParent.node.callee.property) === null || _t === void 0 ? void 0 : _t.name) === "forEach") {
return true;
}
funcParent = funcParent.parentPath;
}
}
pushParent = pushParent.parentPath;
}
}
// Check for traditional loops
if (currentPath.isForStatement() ||
currentPath.isForInStatement() ||
currentPath.isForOfStatement() ||
currentPath.isWhileStatement() ||
currentPath.isDoWhileStatement()) {
return true;
}
currentPath = currentPath.parentPath;
}
return false;
}
function injectIdsInFile(filePath) {
try {
const code = fs.readFileSync(filePath, "utf-8");
const ast = recast.parse(code, {
parser: {
parse(source) {
return parser.parse(source, {
sourceType: "module",
plugins: [
"jsx",
"typescript",
"classProperties",
"objectRestSpread",
],
tokens: true,
});
},
},
});
let changed = false;
traverse(ast, {
JSXOpeningElement(path) {
var _a;
// Get the element name
const elementName = path.node.name.name;
if (!elementName) {
return; // Skip if no element name
}
// Check if this is a standard HTML element or a custom element
const isHtmlElement = !IGNORE_DEFAULTS && HTML_ELEMENTS.has(elementName.toLowerCase());
const isCustomElement = CUSTOM_ELEMENTS.has(elementName);
if (!isHtmlElement && !isCustomElement) {
return; // Skip non-HTML and non-custom elements
}
if (REMOVE_TESTIDS) {
// Remove data-testid attributes
const testIdIndex = path.node.attributes.findIndex((attr) => attr.name && attr.name.name === "data-testid");
if (testIdIndex !== -1) {
// Remove the attribute
path.node.attributes.splice(testIdIndex, 1);
changed = true;
}
}
else {
// Add or update data-testid attributes
const existingTestIdAttr = path.node.attributes.find((attr) => attr.name && attr.name.name === "data-testid");
// Check if element should get auto-id-list prefix
const useListPrefix = shouldUseListPrefix(path);
const expectedPrefix = useListPrefix ? "auto-id-list" : "auto-id";
if (existingTestIdAttr) {
// Check if existing data-testid has the wrong prefix
const currentValue = ((_a = existingTestIdAttr.value) === null || _a === void 0 ? void 0 : _a.value) || "";
const hasAutoIdPrefix = currentValue.startsWith("auto-id-list-") ||
currentValue.startsWith("auto-id-");
if (hasAutoIdPrefix) {
const currentPrefix = currentValue.startsWith("auto-id-list-")
? "auto-id-list"
: "auto-id";
// Only update if the prefix is incorrect
if (currentPrefix !== expectedPrefix) {
// Extract the UUID part and replace the prefix
const uuidPart = currentValue.replace(/^auto-id(-list)?-/, "");
existingTestIdAttr.value.value = `${expectedPrefix}-${uuidPart}`;
changed = true;
}
}
}
else {
// No data-testid exists, add one
const randomId = uuidv4();
path.node.attributes.push({
type: "JSXAttribute",
name: { type: "JSXIdentifier", name: "data-testid" },
value: {
type: "StringLiteral",
value: `${expectedPrefix}-${randomId}`,
},
});
changed = true;
}
}
},
});
if (changed) {
const output = recast.print(ast).code;
fs.writeFileSync(filePath, output, "utf-8");
const action = REMOVE_TESTIDS
? "Removed testids from"
: "Added testids to";
console.log(`${action}: ${filePath}`);
return true;
}
return false;
}
catch (error) {
console.error(`Error processing file ${filePath}:`, error.message);
return false;
}
}
async function getStagedFiles() {
const { execSync } = require("child_process");
try {
const output = execSync("git diff --cached --name-only --diff-filter=ACMR", { encoding: "utf-8" });
return output
.split("\n")
.filter((file) => /\.(js|jsx|ts|tsx)$/.test(file));
}
catch (error) {
console.error("Error getting staged files:", error.message);
return [];
}
}
// Custom elements to process alongside HTML elements
// Define CUSTOM_ELEMENTS in module scope
const CUSTOM_ELEMENTS = new Set();
// Flag to determine whether to ignore default HTML elements
let IGNORE_DEFAULTS = false;
// Flag to determine whether to remove data-testid attributes instead of adding them
let REMOVE_TESTIDS = false;
// Array of paths to ignore
let IGNORE_PATHS = [];
async function run() {
try {
let files = [];
const args = process.argv.slice(2);
const isStagedOnly = args.includes("--staged");
// Extract path option
let searchPath = "**";
const pathFlagIndex = args.findIndex((arg) => arg === "--path" || arg === "-p");
if (pathFlagIndex !== -1 && args.length > pathFlagIndex + 1) {
searchPath = args[pathFlagIndex + 1];
}
// Set up default ignore patterns
const ignorePatterns = ["**/node_modules/**", "**/dist/**", "**/build/**"];
// Add any programmatically set ignore paths
ignorePatterns.push(...IGNORE_PATHS);
// Extract ignore path option
const ignorePathIndex = args.findIndex((arg) => arg === "--ignore-path" || arg === "-ip");
if (ignorePathIndex !== -1 && args.length > ignorePathIndex + 1) {
const ignorePath = args[ignorePathIndex + 1];
if (ignorePath) {
// Format the ignore path for glob
const formattedIgnorePath = ignorePath.endsWith("/")
? `${ignorePath}**`
: `${ignorePath}/**`;
ignorePatterns.push(formattedIgnorePath);
}
}
// Extract custom elements option
const customElementsIndex = args.findIndex((arg) => arg === "--custom-elements" || arg === "-c");
if (customElementsIndex !== -1 && args.length > customElementsIndex + 1) {
try {
const customElementsJson = args[customElementsIndex + 1];
const customElements = JSON.parse(customElementsJson);
if (Array.isArray(customElements)) {
// Clear the existing set and add new elements
CUSTOM_ELEMENTS.clear();
customElements.forEach((el) => CUSTOM_ELEMENTS.add(el));
}
}
catch (error) {
console.error("Error parsing custom elements:", error);
}
}
// Check for --ignore-defaults flag
const ignoreDefaults = args.includes("--ignore-defaults") || args.includes("-i");
if (ignoreDefaults) {
// Ensure that custom elements are provided when ignore-defaults is used
if (customElementsIndex === -1 || CUSTOM_ELEMENTS.size === 0) {
console.error("Error: --ignore-defaults requires --custom-elements to be provided");
process.exit(1);
}
IGNORE_DEFAULTS = true;
}
else {
IGNORE_DEFAULTS = false;
}
// Check for --remove flag
const removeTestIds = args.includes("--remove") || args.includes("-r");
REMOVE_TESTIDS = removeTestIds;
if (isStagedOnly) {
files = await getStagedFiles();
// For staged files, we need to filter manually if ignore paths were provided
if (ignorePathIndex !== -1) {
files = files.filter((file) => {
return !ignorePatterns.some((pattern) => {
// Convert glob pattern to regex pattern
const regexPattern = pattern
.replace(/\*\*/g, ".*")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, ".");
return new RegExp(regexPattern).test(file);
});
});
}
}
else {
// Use the configured search path
const pattern = searchPath.endsWith("/*.{js,jsx,ts,tsx}")
? searchPath
: `${searchPath}/**/*.{js,jsx,ts,tsx}`;
files = glob.sync(pattern, {
absolute: true,
ignore: ignorePatterns,
});
}
if (files.length === 0) {
console.log("No files found to process");
return;
}
console.log("Processing files:", files);
const results = files.map(injectIdsInFile);
const changedFiles = results.filter(Boolean).length;
if (changedFiles > 0) {
const action = REMOVE_TESTIDS ? "removed testids from" : "updated";
console.log(`\nSuccessfully ${action} ${changedFiles} files`);
if (isStagedOnly) {
console.log("Please stage the changes and commit again");
process.exit(1);
}
}
}
catch (error) {
console.error("Error running the script:", error.message);
process.exit(1);
}
}
// Only run if called directly (not required as a module)
if (require.main === module) {
run();
}
// Export functions and provide access to custom elements
module.exports = {
injectIdsInFile,
run,
// Allow programmatic setting of custom elements
setCustomElements: (elements) => {
CUSTOM_ELEMENTS.clear();
elements.forEach((el) => CUSTOM_ELEMENTS.add(el));
},
// Allow programmatic setting of ignoreDefaults
setIgnoreDefaults: (ignore) => {
IGNORE_DEFAULTS = ignore;
},
// Allow programmatic setting of paths to ignore
setIgnorePaths: (paths) => {
IGNORE_PATHS = paths.map((p) => {
// Format the ignore path for glob
return p.endsWith("/") ? `${p}**` : `${p}/**`;
});
},
// Allow programmatic setting of removeTestIds
setRemoveTestIds: (remove) => {
REMOVE_TESTIDS = remove;
},
};