Phone hit kit: read IconButton className off the TypeScript AST
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m53s
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m53s
className must appear once, with no spread after it, as the imported PhoneHit* identifier bare, a classNames/clsx/cx direct argument, or a template span, and the identifier must occur nowhere else in the expression. Tag enumeration uses the same AST. expectPhoneHitClasses takes (string|null)[] (null = no class), as the phone-hit-pages branch does, and is exercised by a test on a temp source. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d13cbc9b2e
commit
4eaa6f9c55
2 changed files with 191 additions and 84 deletions
|
|
@ -1,5 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { drawnPx, expectSizeTie, iconButtons, phoneHitOf } from './phoneHitTestKit';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { relative } from 'path';
|
||||
import {
|
||||
drawnPx,
|
||||
expectPhoneHitClasses,
|
||||
expectSizeTie,
|
||||
iconButtons,
|
||||
phoneHitOf,
|
||||
} from './phoneHitTestKit';
|
||||
|
||||
describe('phone hit test kit', () => {
|
||||
it('reads <IconButton only, not a longer component name', () => {
|
||||
|
|
@ -39,38 +48,60 @@ describe('phone hit test kit', () => {
|
|||
expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/);
|
||||
});
|
||||
|
||||
describe('phoneHitOf reads the imported class, applied unconditionally', () => {
|
||||
describe('phoneHitOf reads the imported class off the AST, applied unconditionally', () => {
|
||||
const tag = (expr: string) => `<IconButton size="300" className={${expr}}>`;
|
||||
const ok = (expr: string, name: string) => expect(phoneHitOf(tag(expr))).toBe(name);
|
||||
const no = (expr: string) => expect(phoneHitOf(tag(expr))).toBeNull();
|
||||
|
||||
it('accepts the bare identifier and merges that always include it', () => {
|
||||
expect(phoneHitOf(tag('PhoneHitSpread26'))).toBe('PhoneHitSpread26');
|
||||
expect(phoneHitOf(tag('classNames(PhoneHitRoom36, css.other)'))).toBe('PhoneHitRoom36');
|
||||
expect(phoneHitOf(tag('`${PhoneHit} ${css.other}`'))).toBe('PhoneHit');
|
||||
expect(phoneHitOf(tag("[PhoneHit, 'a b'].join(' ')"))).toBe('PhoneHit');
|
||||
expect(phoneHitOf(tag('classNames(css.a, { [css.b]: on }, PhoneHit)'))).toBe('PhoneHit');
|
||||
ok('PhoneHitSpread26', 'PhoneHitSpread26');
|
||||
ok('classNames(PhoneHitRoom36, css.other)', 'PhoneHitRoom36');
|
||||
ok('clsx(css.a, { [css.b]: on }, PhoneHit)', 'PhoneHit');
|
||||
ok('cx(PhoneHit)', 'PhoneHit');
|
||||
ok('`${PhoneHit} ${css.other}`', 'PhoneHit');
|
||||
ok('classNames(/* PhoneHit */ PhoneHit)', 'PhoneHit');
|
||||
});
|
||||
|
||||
it('rejects the name as a string literal', () => {
|
||||
expect(phoneHitOf(tag("'PhoneHitSpread26'"))).toBeNull();
|
||||
expect(phoneHitOf(tag('`PhoneHitSpread26`'))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a name built by concatenation', () => {
|
||||
expect(phoneHitOf(tag('"PhoneHitSpread26" + "x"'))).toBeNull();
|
||||
expect(phoneHitOf(tag("'PhoneHit' + 'Spread26'"))).toBeNull();
|
||||
it('rejects the name as a string, a concatenation or a comment', () => {
|
||||
no("'PhoneHitSpread26'");
|
||||
no('`PhoneHitSpread26`');
|
||||
no('"PhoneHitSpread26" + "x"');
|
||||
no("'PhoneHit' + 'Spread26'");
|
||||
no('/* PhoneHit */ undefined');
|
||||
no('css.a // PhoneHit');
|
||||
});
|
||||
|
||||
it('rejects a conditionally applied name', () => {
|
||||
expect(phoneHitOf(tag('cond ? PhoneHitSpread26 : undefined'))).toBeNull();
|
||||
expect(phoneHitOf(tag('cond && PhoneHitSpread26'))).toBeNull();
|
||||
expect(phoneHitOf(tag('cond || PhoneHitSpread26'))).toBeNull();
|
||||
expect(phoneHitOf(tag('maybe ?? PhoneHitSpread26'))).toBeNull();
|
||||
expect(phoneHitOf(tag("classNames(cond ? PhoneHit : '', css.a)"))).toBeNull();
|
||||
no('cond ? PhoneHitSpread26 : undefined');
|
||||
no('cond && PhoneHitSpread26');
|
||||
no('cond || PhoneHitSpread26');
|
||||
no('maybe ?? PhoneHitSpread26');
|
||||
no("classNames(cond ? PhoneHit : '', css.a)");
|
||||
no('classNames(PhoneHit, cond && PhoneHit)');
|
||||
});
|
||||
|
||||
it('rejects a member of another object and a tag without className', () => {
|
||||
expect(phoneHitOf(tag('other.PhoneHit'))).toBeNull();
|
||||
it('rejects every other expression shape the name can hide in', () => {
|
||||
no('classNames({ PhoneHit: on })');
|
||||
no('clsx({ [PhoneHit]: on })');
|
||||
no('`${`${PhoneHit}`}`');
|
||||
no('typeof PhoneHit');
|
||||
no("(PhoneHit = 'x')");
|
||||
no('(PhoneHit, css.a)');
|
||||
no('(PhoneHit)');
|
||||
no('() => PhoneHit');
|
||||
no('PhoneHit.foo');
|
||||
no('other.PhoneHit');
|
||||
no('classNames(css.a, [PhoneHit])');
|
||||
no('wrap(PhoneHit)');
|
||||
no('PhoneHitSpread26é');
|
||||
no('`${css.a}`');
|
||||
});
|
||||
|
||||
it('rejects a tag without className, with two, or with a non-expression one', () => {
|
||||
expect(phoneHitOf('<IconButton size="300">')).toBeNull();
|
||||
expect(phoneHitOf('<IconButton className={PhoneHit} className={css.a}>')).toBeNull();
|
||||
expect(phoneHitOf('<IconButton className="PhoneHit">')).toBeNull();
|
||||
expect(phoneHitOf('<IconButton className={PhoneHit} />')).toBe('PhoneHit');
|
||||
});
|
||||
|
||||
it('rejects a spread after className, which can override it, and accepts one before', () => {
|
||||
|
|
@ -80,7 +111,44 @@ describe('phone hit test kit', () => {
|
|||
});
|
||||
|
||||
it('reads a className past braces inside strings', () => {
|
||||
expect(phoneHitOf(tag("classNames('{', PhoneHit)"))).toBe('PhoneHit');
|
||||
ok("classNames('{', PhoneHit)", 'PhoneHit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expectPhoneHitClasses', () => {
|
||||
const check = (src: string, want: (string | null)[]) => {
|
||||
const dir = mkdtempSync(`${tmpdir()}/phone-hit-kit-`);
|
||||
try {
|
||||
writeFileSync(`${dir}/Probe.tsx`, src);
|
||||
expectPhoneHitClasses(relative(`${process.cwd()}/src/app`, `${dir}/Probe.tsx`), want);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true });
|
||||
}
|
||||
};
|
||||
const imp = "import { PhoneHit, PhoneHitSpread26 } from '../../styles/PhoneHit.css';\n";
|
||||
const src =
|
||||
imp +
|
||||
'const A = () => (<><IconButton className={PhoneHit} /><IconButton className={classNames(PhoneHitSpread26, a)} /><IconButton /></>);';
|
||||
|
||||
it('pins each button in order, null meaning no class', () => {
|
||||
check(src, ['PhoneHit', 'PhoneHitSpread26', null]);
|
||||
expect(() => check(src, ['PhoneHit', 'PhoneHit', null])).toThrow();
|
||||
expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26', 'PhoneHit'])).toThrow();
|
||||
expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26'])).toThrow();
|
||||
});
|
||||
|
||||
it('fails a name that is not read from PhoneHit.css or is applied conditionally', () => {
|
||||
const local = "const PhoneHit = 'x';\n<IconButton className={PhoneHit} />";
|
||||
expect(() => check(local, ['PhoneHit'])).toThrow();
|
||||
expect(() => check(local, [null])).not.toThrow();
|
||||
const cond = imp + '<IconButton className={on ? PhoneHit : undefined} />';
|
||||
expect(() => check(cond, ['PhoneHit'])).toThrow();
|
||||
const aliased =
|
||||
"import { Other as PhoneHit } from '../../styles/PhoneHit.css';\n<IconButton className={PhoneHit} />";
|
||||
expect(() => check(aliased, ['PhoneHit'])).toThrow();
|
||||
const wrongModule =
|
||||
"import { PhoneHit } from './other';\n<IconButton className={PhoneHit} />";
|
||||
expect(() => check(wrongModule, ['PhoneHit'])).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// group of IconButtons). Not a test file: it registers nothing, the test files call it.
|
||||
import { readFileSync } from 'fs';
|
||||
import { expect } from 'vitest';
|
||||
import ts from 'typescript';
|
||||
import zlib from 'zlib';
|
||||
import { compile, processVanillaFile } from '@vanilla-extract/integration';
|
||||
|
||||
|
|
@ -72,77 +73,113 @@ const tagEnd = (src: string, from: number) => {
|
|||
return j;
|
||||
};
|
||||
|
||||
/** Every <IconButton (not <IconButtonX): its opening tag, read to its closing > outside any {} expression, and
|
||||
* what it wraps (empty when self-closing). */
|
||||
const tsx = (src: string) =>
|
||||
ts.createSourceFile('source.tsx', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
|
||||
type Opening = ts.JsxOpeningElement | ts.JsxSelfClosingElement;
|
||||
|
||||
/** Every IconButton element (not IconButtonX) of a parsed source, in source order. */
|
||||
const elements = (sf: ts.SourceFile) => {
|
||||
const found: Opening[] = [];
|
||||
const walk = (n: ts.Node) => {
|
||||
if (
|
||||
(ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) &&
|
||||
ts.isIdentifier(n.tagName) &&
|
||||
n.tagName.text === 'IconButton'
|
||||
)
|
||||
found.push(n);
|
||||
ts.forEachChild(n, walk);
|
||||
};
|
||||
walk(sf);
|
||||
return found;
|
||||
};
|
||||
|
||||
const bodyOf = (el: Opening) =>
|
||||
ts.isJsxOpeningElement(el) && ts.isJsxElement(el.parent)
|
||||
? el.getSourceFile().text.slice(el.end, el.parent.closingElement.getStart())
|
||||
: '';
|
||||
|
||||
/** Every <IconButton (not <IconButtonX): its opening tag and what it wraps (empty when self-closing). */
|
||||
export const iconButtons = (src: string) =>
|
||||
[...src.matchAll(/<IconButton(?=[\s>/])/g)].map((m) => {
|
||||
const start = m.index ?? 0;
|
||||
const end = tagEnd(src, start);
|
||||
const tag = src.slice(start, end + 1);
|
||||
const close = src.indexOf('</IconButton>', end);
|
||||
return { tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) };
|
||||
});
|
||||
elements(tsx(src)).map((el) => ({ tag: el.getText(), body: bodyOf(el) }));
|
||||
|
||||
export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag);
|
||||
|
||||
/** The expression of a tag's className={...} and the index of its closing brace, read to the balanced brace
|
||||
* (strings and templates may hold braces); undefined when the tag has none. */
|
||||
const classNameExpr = (tag: string) => {
|
||||
const m = /\sclassName=\{/.exec(tag);
|
||||
if (!m) return undefined;
|
||||
const from = m.index + m[0].length;
|
||||
let depth = 1;
|
||||
let quote = '';
|
||||
for (let j = from; j < tag.length; j += 1) {
|
||||
const c = tag[j];
|
||||
if (quote) {
|
||||
if (c === '\\') j += 1;
|
||||
else if (c === quote) quote = '';
|
||||
} else if (c === '"' || c === "'" || c === '`') quote = c;
|
||||
else if (c === '{') depth += 1;
|
||||
else if (c === '}' && (depth -= 1) === 0) return { expr: tag.slice(from, j), end: j };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** The expression with its string literals and the literal text of its templates removed (a `${...}` keeps its
|
||||
* expression), so only identifiers and operators remain. */
|
||||
const withoutLiterals = (expr: string) =>
|
||||
expr
|
||||
.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, ' ')
|
||||
.replace(/`((?:[^`\\]|\\.)*)`/g, (_, body: string) =>
|
||||
[...body.matchAll(/\$\{([^}]*)\}/g)].map((t) => ` ${t[1]} `).join(' ')
|
||||
);
|
||||
|
||||
/** The PhoneHit* class a tag carries: a bare identifier in its className expression, applied unconditionally. A
|
||||
* name inside a string, built by concatenation, or behind ?:, &&, || or ??, or followed by a {...spread}, is not the
|
||||
* imported class (or not always applied), so it reads as null. */
|
||||
export const phoneHitOf = (tag: string) => {
|
||||
const found = classNameExpr(tag);
|
||||
if (found === undefined) return null;
|
||||
// props spread in after className can override it at runtime; before it they cannot
|
||||
if (tag.slice(found.end).includes('{...')) return null;
|
||||
const bare = withoutLiterals(found.expr);
|
||||
if (/\?|&&|\|\|/.test(bare)) return null;
|
||||
const names = [...bare.matchAll(/(?<![\w$.])(PhoneHit\w*)(?![\w$])/g)].map((m) => m[1]);
|
||||
return names.length === 1 ? names[0] : null;
|
||||
};
|
||||
|
||||
/** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and
|
||||
* whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks
|
||||
* the name is the one PhoneHit.css.ts exports, not a local of the same name. */
|
||||
export const expectPhoneHitClasses = (file: string, want: string[]) => {
|
||||
const src = readSource(file);
|
||||
expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want);
|
||||
[...new Set(want)].forEach((name) =>
|
||||
expect(src).toMatch(
|
||||
new RegExp(
|
||||
`import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'`
|
||||
)
|
||||
)
|
||||
/** Local names the source imports from the PhoneHit.css module (as written, not aliased). */
|
||||
const importedHits = (sf: ts.SourceFile) =>
|
||||
new Set(
|
||||
sf.statements.flatMap((st) => {
|
||||
const nb = ts.isImportDeclaration(st) ? st.importClause?.namedBindings : undefined;
|
||||
return nb &&
|
||||
ts.isNamedImports(nb) &&
|
||||
ts.isStringLiteral((st as ts.ImportDeclaration).moduleSpecifier) &&
|
||||
/^(\.\.\/)+styles\/PhoneHit\.css$/.test(
|
||||
((st as ts.ImportDeclaration).moduleSpecifier as ts.StringLiteral).text
|
||||
)
|
||||
? nb.elements.filter((e) => !e.propertyName).map((e) => e.name.text)
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
const MERGERS = new Set(['classNames', 'clsx', 'cx']);
|
||||
|
||||
/** The PhoneHit* class an IconButton carries, or null. className must appear exactly once, with no props spread
|
||||
* after it (a spread can override it at runtime), and its expression must be: the bare identifier; a classNames /
|
||||
* clsx / cx call with it as a direct argument; or a template with it as a direct span. The identifier must occur
|
||||
* nowhere else in the expression, so a key, a member, a closure, a conditional or a comma can never carry it. When
|
||||
* `imported` is given, the identifier must also be a binding imported from the PhoneHit.css module. */
|
||||
const hitOf = (el: Opening, imported?: Set<string>): string | null => {
|
||||
const props = el.attributes.properties;
|
||||
const at = props.flatMap((p, i) =>
|
||||
ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'className' ? [i] : []
|
||||
);
|
||||
if (at.length !== 1 || props.slice(at[0] + 1).some(ts.isJsxSpreadAttribute)) return null;
|
||||
const attr = props[at[0]] as ts.JsxAttribute;
|
||||
const expr =
|
||||
attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression;
|
||||
if (!expr) return null;
|
||||
const isHit = (n: ts.Node): n is ts.Identifier =>
|
||||
ts.isIdentifier(n) && /^PhoneHit\w*$/.test(n.text) && (!imported || imported.has(n.text));
|
||||
let direct: ts.Node[] = [];
|
||||
if (ts.isIdentifier(expr)) direct = [expr];
|
||||
else if (
|
||||
ts.isCallExpression(expr) &&
|
||||
ts.isIdentifier(expr.expression) &&
|
||||
MERGERS.has(expr.expression.text)
|
||||
)
|
||||
direct = [...expr.arguments];
|
||||
else if (ts.isTemplateExpression(expr)) direct = expr.templateSpans.map((sp) => sp.expression);
|
||||
const hits = direct.filter(isHit);
|
||||
if (hits.length !== 1) return null;
|
||||
let occurrences = 0;
|
||||
const count = (n: ts.Node) => {
|
||||
if (ts.isIdentifier(n) && n.text === hits[0].text) occurrences += 1;
|
||||
ts.forEachChild(n, count);
|
||||
};
|
||||
count(expr);
|
||||
return occurrences === 1 ? hits[0].text : null;
|
||||
};
|
||||
|
||||
/** The PhoneHit* class of one tag's text (see hitOf), without the import-binding check. */
|
||||
export const phoneHitOf = (tag: string) => {
|
||||
const els = elements(tsx(tag.endsWith('/>') ? tag : `${tag}</IconButton>`));
|
||||
return els.length === 1 ? hitOf(els[0]) : null;
|
||||
};
|
||||
|
||||
/** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not
|
||||
* the one imported from PhoneHit.css. */
|
||||
const phoneHitClasses = (src: string) => {
|
||||
const sf = tsx(src);
|
||||
const imported = importedHits(sf);
|
||||
return elements(sf).map((el) => hitOf(el, imported));
|
||||
};
|
||||
|
||||
/** The class each IconButton must carry, in source order (null = must carry none): the variant is chosen by the
|
||||
* button's drawn size and whether a clipping container or neighbours surround it, so a swapped variant is a wrong
|
||||
* hit area. The name must also be the one PhoneHit.css exports, not a local of the same name. */
|
||||
export const expectPhoneHitClasses = (file: string, want: (string | null)[]) =>
|
||||
expect(phoneHitClasses(readSource(file))).toEqual(want);
|
||||
|
||||
// IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400.
|
||||
const BUTTON_PAD: Record<string, number> = { '300': 4, '400': 8, '500': 12, '600': 16 };
|
||||
const ICON_PX: Record<string, number> = {
|
||||
|
|
@ -188,10 +225,12 @@ export type SizeAllow = Record<string, Record<number, string>>;
|
|||
export const expectSizeTie = (file: string, allow: SizeAllow = {}) => {
|
||||
const allowed = allow[file] ?? {};
|
||||
const seen = new Set<number>();
|
||||
const buttons = iconButtons(readSource(file));
|
||||
const src = readSource(file);
|
||||
const buttons = iconButtons(src);
|
||||
const classes = phoneHitClasses(src);
|
||||
expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0);
|
||||
buttons.forEach(({ tag, body }, i) => {
|
||||
const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/);
|
||||
const variant = classes[i]?.match(/^PhoneHit(?:Spread|Room)(\d+)$/);
|
||||
if (!variant) {
|
||||
expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined();
|
||||
return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue