#!/usr/bin/env node /** * Uses TypeScript AST to find // JSX elements * missing aria-label/aria-labelledby and adds aria-label. * Idempotent and structurally safe. */ const fs = require('fs'); const path = require('path'); const ts = require('typescript'); function titleCase(s) { return s .replace(/[-_]/g, ' ') .replace(/\s+/g, ' ') .trim() .split(' ') .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : '')) .join(' '); } function getJsxAttr(name, attrs) { for (const a of attrs.properties) { if (a.kind === ts.SyntaxKind.JsxAttribute && a.name && a.name.text === name) return a; } return null; } function getStringAttrValue(name, attrs) { const a = getJsxAttr(name, attrs); if (!a || !a.initializer) return undefined; if (a.initializer.kind === ts.SyntaxKind.StringLiteral) return a.initializer.text; if (a.initializer.kind === ts.SyntaxKind.JsxExpressionContainer && a.initializer.expression) { const e = a.initializer.expression; if (e.kind === ts.SyntaxKind.StringLiteral) return e.text; } return undefined; } function hasBooleanAttr(name, attrs) { return !!getJsxAttr(name, attrs); } function getNameText(name) { if (!name) return null; if (name.kind === ts.SyntaxKind.Identifier) return name.text; if (name.kind === ts.SyntaxKind.PropertyAccessExpression) return name.name.text; return null; } function inferLabelFromSiblingsOrPreceding(sourceFile, node) { const scanner = ts.createScanner(ts.ScriptTarget.Latest, /*skipTrivia*/ true, ts.LanguageVariant.JSX, sourceFile.text); // Walk text before the node to find preceding label or {label(...)} const start = node.getStart(sourceFile); const before = sourceFile.text.slice(Math.max(0, start - 800), start); // Look for label("...") in the most recent expression const labelCallMatch = before.match(/label\(\s*['"`]([^'"`]+)['"`]\s*\)/); if (labelCallMatch) return labelCallMatch[1]; // Look for a label JSX element without closing const lastLabelOpen = [...before.matchAll(/]*>(?:(?!<\/label>)[\s\S])*$/gi)]; if (lastLabelOpen.length) { const last = lastLabelOpen[lastLabelOpen.length - 1]; const afterOpen = before.slice(last.index + last[0].match(/^]*>/i)[0].length); // If there's a after, this label isn't enclosing if (!/<\/label>/i.test(afterOpen)) { const inner = afterOpen.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); if (inner) return inner.replace(/[*?]\s*$/, '').trim(); } } return null; } function isInsideLabel(sourceFile, node) { let parent = node.parent; while (parent) { if (parent.kind === ts.SyntaxKind.JsxElement) { const opening = parent.openingElement; const tagName = getNameText(opening.tagName); if (tagName === 'label') return true; // Also check if it's inside a JsxFragment whose parent is a label } parent = parent.parent; } // Also check if we're directly inside a label's children let cur = node; while (cur.parent) { if (cur.parent.kind === ts.SyntaxKind.JsxElement && getNameText(cur.parent.openingElement.tagName) === 'label') { return true; } cur = cur.parent; } return false; } function inferLabel(node, sourceFile) { const attrs = node.attributes; // Type attribute const typeAttr = getStringAttrValue('type', attrs); const placeholder = getStringAttrValue('placeholder', attrs); const nameAttr = getStringAttrValue('name', attrs); const idAttr = getStringAttrValue('id', attrs); const className = getStringAttrValue('className', attrs); const tagName = getNameText(node.tagName); if (className && /\bhidden\b/.test(className) && typeAttr === 'file') return 'File upload'; if (placeholder) { const cleaned = placeholder.replace(/\b(e\.g\.?|i\.e\.?|min\.?)\b/gi, '').trim(); return titleCase(cleaned || placeholder); } if (nameAttr) return titleCase(nameAttr.replace(/\[.*?\]/g, '')); if (idAttr) return titleCase(idAttr); const ctx = inferLabelFromSiblingsOrPreceding(sourceFile, node); if (ctx) return ctx; if (tagName === 'input' && typeAttr) return titleCase(typeAttr); return titleCase(tagName); } function findTargets(sourceFile) { const out = []; function visit(node) { if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { const tagName = getNameText(node.tagName); if (['input', 'select', 'textarea'].includes(tagName)) { const attrs = node.attributes; const hasAriaLabel = hasBooleanAttr('aria-label', attrs) || getStringAttrValue('aria-label', attrs) !== undefined; const hasAriaLabelledBy = hasBooleanAttr('aria-labelledby', attrs) || getStringAttrValue('aria-labelledby', attrs) !== undefined; const typeAttr = getStringAttrValue('type', attrs); if (!hasAriaLabel && !hasAriaLabelledBy && typeAttr !== 'hidden') { if (!isInsideLabel(sourceFile, node)) { out.push(node); } } } } ts.forEachChild(node, visit); } visit(sourceFile); return out; } function escapeForJsx(s) { return s.replace(/&/g, '&').replace(/"/g, '"'); } function processFile(filePath) { const text = fs.readFileSync(filePath, 'utf8'); const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.LanguageVariant.JSX); const targets = findTargets(sourceFile); if (targets.length === 0) return 0; // Sort by position descending so insertions don't shift earlier offsets targets.sort((a, b) => b.getStart(sourceFile) - a.getStart(sourceFile)); let updated = text; let changes = 0; for (const node of targets) { const inferred = inferLabel(node, sourceFile); if (!inferred) continue; // Find position: right after the tag name const tagNameEnd = node.tagName.getEnd(); const ariaStr = ` aria-label="${escapeForJsx(inferred)}"`; // Reconstruct from the position const head = updated.slice(0, tagNameEnd); const rest = updated.slice(tagNameEnd); updated = head + ariaStr + rest; changes++; } if (changes > 0) { fs.writeFileSync(filePath, updated); } return changes; } const ROOT = path.resolve(__dirname, '..'); const TARGET_DIRS = ['src/app', 'src/components', 'src/wholesale', 'src/landing']; const SKIP_PATTERNS = [ /node_modules/, /\.next\//, /dist\//, /coverage\//, /\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, ]; function walk(dir, files = []) { if (!fs.existsSync(dir)) return files; for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, ent.name); if (SKIP_PATTERNS.some((rx) => rx.test(p))) continue; if (ent.isDirectory()) walk(p, files); else if (/\.(tsx|jsx)$/.test(ent.name)) files.push(p); } return files; } let totalChanges = 0; let totalFiles = 0; const changedFiles = []; const errors = []; for (const t of TARGET_DIRS) { const dir = path.join(ROOT, t); if (!fs.existsSync(dir)) continue; const files = walk(dir); for (const f of files) { try { const c = processFile(f); if (c > 0) { changedFiles.push([c, path.relative(ROOT, f)]); totalFiles++; totalChanges += c; } } catch (e) { errors.push(`${f}: ${e.message}`); } } } changedFiles.sort((a, b) => b[0] - a[0]); for (const [c, f] of changedFiles) { console.log(` ${c.toString().padStart(4)} ${f}`); } if (errors.length) { console.error('\nErrors:'); errors.forEach((e) => console.error(' ', e)); } console.log(`\nTotal: ${totalChanges} aria-labels added across ${totalFiles} files`);