refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSources } from "./lib/rawSources.testkit";
|
||||
|
||||
// Every rail and every card of a card list is walkable by the keyboard (lib/listNav). Two
|
||||
// opt-in halves, both checked here, because either one missing silently leaves the keyboard out —
|
||||
// and a page where `j` walks two rails and skips the third reads as broken, not as unfinished:
|
||||
// - a `Carousel`, a `VirtualGrid` and a `DataTable` say `keyboardNav`, and a card says `navIndex`;
|
||||
// - a card that says `navIndex` sits in a list that registers with listNav — a `NavGrid`, a
|
||||
// `Carousel`, or a `VirtualGrid` (its `renderItem`). In a plain `<div className="grid">` the card
|
||||
// is marked as an item of a list nobody walks.
|
||||
// Each exception is listed with its reason.
|
||||
//
|
||||
// The assertion pins the SET of offending tags, never a count, and checks the glob matched at all: a
|
||||
// guard whose glob stops matching is green over nothing.
|
||||
const WALKABLE = ["Carousel", "VirtualGrid", "DataTable"];
|
||||
const CARDS = [
|
||||
"VideoCard",
|
||||
"PlexPosterCard",
|
||||
"SeasonCard",
|
||||
"EpisodeCard",
|
||||
"PosterTile",
|
||||
"ShelfTile",
|
||||
];
|
||||
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
|
||||
|
||||
/** `path#tag#n` → why it is not a keyboard list. An entry that no longer matches a tag fails the test. */
|
||||
const EXEMPT: Record<string, string> = {};
|
||||
|
||||
interface Tag {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const stripComments = (source: string) =>
|
||||
source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
|
||||
|
||||
/** Each opening `<Name …>` tag, found by brace depth — a JSX attribute holds its own `>`
|
||||
* (`title={<span>…</span>}`, an arrow `=>`), so the first `>` is not the end of the tag. Takes
|
||||
* comment-free code, so the positions line up with `inList`. */
|
||||
export function openingTags(code: string, name: string): Tag[] {
|
||||
const tags: Tag[] = [];
|
||||
const start = new RegExp(`<${name}(?=[\\s/>])`, "g");
|
||||
for (let m = start.exec(code); m; m = start.exec(code)) {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let i = m.index + name.length + 1;
|
||||
for (; i < code.length; i++) {
|
||||
const c = code[i];
|
||||
if (quote) {
|
||||
if (c === quote) quote = null;
|
||||
} else if (depth === 0 && (c === '"' || c === "'")) quote = c;
|
||||
else if (c === "{") depth++;
|
||||
else if (c === "}") depth--;
|
||||
else if (c === ">" && depth === 0) break;
|
||||
}
|
||||
tags.push({ start: m.index, end: i + 1, text: code.slice(m.index, i + 1) });
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Whether `pos` lies inside an open list element: between `<List …>` and `</List>`, or inside a
|
||||
* list's own tag (a `renderItem={…}` attribute). */
|
||||
export function inList(code: string, pos: number): boolean {
|
||||
return LISTS.some((name) => {
|
||||
let open = 0;
|
||||
for (const tag of openingTags(code, name)) {
|
||||
if (tag.start > pos) break;
|
||||
if (tag.end > pos) return true;
|
||||
if (!tag.text.endsWith("/>")) open++;
|
||||
}
|
||||
return open > code.slice(0, pos).split(`</${name}>`).length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
const has = (tag: Tag, prop: string) => new RegExp(`\\s${prop}\\b`).test(tag.text);
|
||||
|
||||
function offenders(): { hits: string[]; tags: number; exempted: Set<string> } {
|
||||
const files = readSources(__dirname, ["./**/*.tsx", "!./design-lab/**"]);
|
||||
const hits: string[] = [];
|
||||
const exempted = new Set<string>();
|
||||
let tags = 0;
|
||||
for (const [rel, source] of Object.entries(files)) {
|
||||
const path = rel.replace(/^\.\//, "");
|
||||
if (/\.test\.tsx$/.test(path)) continue;
|
||||
const code = stripComments(String(source));
|
||||
const report = (id: string, why: string) => {
|
||||
if (id in EXEMPT) exempted.add(id);
|
||||
else hits.push(`${id} ${why}`);
|
||||
};
|
||||
for (const name of WALKABLE) {
|
||||
openingTags(code, name).forEach((tag, n) => {
|
||||
tags++;
|
||||
if (!has(tag, "keyboardNav")) report(`${path}#${name}#${n + 1}`, "no keyboardNav");
|
||||
});
|
||||
}
|
||||
for (const name of CARDS) {
|
||||
openingTags(code, name).forEach((tag, n) => {
|
||||
tags++;
|
||||
const id = `${path}#${name}#${n + 1}`;
|
||||
if (!has(tag, "navIndex")) report(id, "no navIndex");
|
||||
else if (!inList(code, tag.start)) report(id, "not inside a NavGrid/Carousel/VirtualGrid");
|
||||
});
|
||||
}
|
||||
}
|
||||
return { hits: hits.sort(), tags, exempted };
|
||||
}
|
||||
|
||||
describe("the keyboard-list wiring contract", () => {
|
||||
it("has no rail or card list the keyboard cannot walk", () => {
|
||||
const { hits, tags, exempted } = offenders();
|
||||
expect(tags).toBeGreaterThan(15);
|
||||
expect(hits).toEqual([]);
|
||||
expect(Object.keys(EXEMPT).filter((id) => !exempted.has(id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads a tag to its real end, past the > inside its attributes", () => {
|
||||
const code = stripComments(`
|
||||
<Carousel
|
||||
title={<span className="x">{title}</span>}
|
||||
onSeeAll={() => go(1 > 0)}
|
||||
keyboardNav
|
||||
>
|
||||
// <Carousel keyboardNav> in a comment is no tag
|
||||
<Carousel ariaLabel="a > b">
|
||||
`);
|
||||
const tags = openingTags(code, "Carousel");
|
||||
expect(tags).toHaveLength(2);
|
||||
expect(has(tags[0]!, "keyboardNav")).toBe(true);
|
||||
expect(has(tags[1]!, "keyboardNav")).toBe(false);
|
||||
});
|
||||
|
||||
it("tells a card inside a list from one in a plain grid", () => {
|
||||
const code = `
|
||||
<NavGrid className="grid">{a.map((c, i) => <PosterTile navIndex={i} />)}</NavGrid>
|
||||
<div className="grid">{a.map((c, i) => <PosterTile navIndex={i} />)}</div>
|
||||
<VirtualGrid renderItem={(v, w, i) => <VideoCard navIndex={i} />} />
|
||||
`;
|
||||
const at = (n: number) => openingTags(code, "PosterTile")[n]!.start;
|
||||
expect(inList(code, at(0))).toBe(true);
|
||||
expect(inList(code, at(1))).toBe(false);
|
||||
expect(inList(code, openingTags(code, "VideoCard")[0]!.start)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user