Files
route-commerce/scripts/fix-control-has-associated-label.js
T
Nora 0ac4beaaa8 fix: react-doctor errors → 0 errors, 1649 warnings (46/100)
- Add requireAuth() to admin-permissions.ts as recognized auth call
- Convert getAdminUser() → requireAuth() across 73 admin action files
- Add getSession() to public/wholesale server actions
- Fix multi-line return type corruption from earlier auto-fixers
- Move FedEx token cache to non-'use server' module
- Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD,
  EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS
- Update Stripe API version 2026-05-27 → 2026-06-24
- Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient
- Fix 51 TypeScript errors (return type corruption, missing imports)
2026-06-25 23:49:37 -06:00

223 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Uses TypeScript AST to find <input>/<select>/<textarea> 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\b[^>]*>(?:(?!<\/label>)[\s\S])*$/gi)];
if (lastLabelOpen.length) {
const last = lastLabelOpen[lastLabelOpen.length - 1];
const afterOpen = before.slice(last.index + last[0].match(/^<label\b[^>]*>/i)[0].length);
// If there's a </label> 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, '&amp;').replace(/"/g, '&quot;');
}
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`);