0ac4beaaa8
- 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)
223 lines
7.5 KiB
JavaScript
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, '&').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`); |