UNPKG

@layuplabs/layup-autoid

Version:

A tool to inject stable, unique IDs into React components

590 lines (535 loc) 17.3 kB
#!/usr/bin/env node 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: any): boolean { 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 = callee.property?.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 (current.callee?.type === "MemberExpression") { const propertyName = current.callee.property?.name; if (propertyName === "map" || propertyName === "forEach") { return true; } } current = current.callee?.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: any): boolean { 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?.type === "CallExpression" && expression.callee?.type === "MemberExpression" ) { const methodName = expression.callee.property?.name; if (methodName === "map" || methodName === "forEach") { return true; // Direct child of map/forEach } } // Check for ternary operators that contain map/forEach calls if (expression?.type === "ConditionalExpression") { // Check the consequent (true branch) for map/forEach const consequent = expression.consequent; if ( consequent?.type === "CallExpression" && consequent.callee?.type === "MemberExpression" ) { const methodName = consequent.callee.property?.name; if (methodName === "map" || methodName === "forEach") { return true; // Direct child of map/forEach in ternary } } // Check for nested ternary operators if (consequent?.type === "ConditionalExpression") { // Recursively check nested ternary const nestedConsequent = consequent.consequent; if ( nestedConsequent?.type === "CallExpression" && nestedConsequent.callee?.type === "MemberExpression" ) { const methodName = nestedConsequent.callee.property?.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?.type === "LogicalExpression") { // Check the right side of logical expressions (&&, ||, ??) const right = expression.right; if ( right?.type === "CallExpression" && right.callee?.type === "MemberExpression" ) { const methodName = right.callee.property?.name; if (methodName === "map" || methodName === "forEach") { return true; // Direct child of map/forEach in logical expression } } // Check for nested logical expressions if (right?.type === "LogicalExpression") { // Recursively check nested logical expression const nestedRight = right.right; if ( nestedRight?.type === "CallExpression" && nestedRight.callee?.type === "MemberExpression" ) { const methodName = nestedRight.callee.property?.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 ( currentPath.node?.type === "CallExpression" && currentPath.node.callee?.type === "MemberExpression" && currentPath.node.callee.property?.name === "push" ) { // Look for forEach in the ancestors let pushParent = currentPath.parentPath; while (pushParent) { if ( pushParent.node?.type === "ArrowFunctionExpression" || pushParent.node?.type === "FunctionExpression" ) { // Check if this function is inside a forEach let funcParent = pushParent.parentPath; while (funcParent) { if ( funcParent.node?.type === "CallExpression" && funcParent.node.callee?.type === "MemberExpression" && funcParent.node.callee.property?.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: string) { try { const code = fs.readFileSync(filePath, "utf-8"); const ast = recast.parse(code, { parser: { parse(source: string) { return parser.parse(source, { sourceType: "module", plugins: [ "jsx", "typescript", "classProperties", "objectRestSpread", ], tokens: true, }); }, }, }); let changed = false; traverse(ast, { JSXOpeningElement(path: any) { // 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: any) => 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: any) => 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 = existingTestIdAttr.value?.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: any) { 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: string) => /\.(js|jsx|ts|tsx)$/.test(file)); } catch (error: any) { 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: Set<string> = 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: string[] = []; 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: string) => { 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: any) { 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: string[]) => { CUSTOM_ELEMENTS.clear(); elements.forEach((el) => CUSTOM_ELEMENTS.add(el)); }, // Allow programmatic setting of ignoreDefaults setIgnoreDefaults: (ignore: boolean) => { IGNORE_DEFAULTS = ignore; }, // Allow programmatic setting of paths to ignore setIgnorePaths: (paths: string[]) => { IGNORE_PATHS = paths.map((p) => { // Format the ignore path for glob return p.endsWith("/") ? `${p}**` : `${p}/**`; }); }, // Allow programmatic setting of removeTestIds setRemoveTestIds: (remove: boolean) => { REMOVE_TESTIDS = remove; }, };