Skip to content

g1t/apps/web/app/lib/no-native-title.test.ts

88 lines3,460 bytesCodeBlame
1import assert from "node:assert/strict";
2import { readFileSync, readdirSync } from "node:fs";
3import { join, relative } from "node:path";
4import { test } from "node:test";
5import { fileURLToPath } from "node:url";
6
7// Hover hints are the site's tooltip (components/ui/hint.tsx, or Tooltip
8// from components/ui/tooltip.tsx), never the browser's `title`: it looks
9// foreign, waits a second and a half, and never shows on a touch screen or
10// to the keyboard. This fails on a `title` on an HTML element, or on a
11// component that hands its props to one.
12
13/** Components that pass `title` on to an HTML element. */
14const FORWARDS = ["Link", "NavLink", "Button", "ButtonLink", "SubmitButton", "Badge", "Switch", "Input", "Textarea", "TabLink"];
15
16/**
17 * Where a native `title` is right. `file:line`, with the reason. An SVG's
18 * `<title>` child is an element, not an attribute, and needs no entry.
19 */
20const ALLOWED: Record<string, string> = {};
21
22const APP = fileURLToPath(new URL("..", import.meta.url));
23
24function* sources(dir: string): Generator<string> {
25 for (const entry of readdirSync(dir, { withFileTypes: true })) {
26 const path = join(dir, entry.name);
27 if (entry.isDirectory()) yield* sources(path);
28 else if (entry.name.endsWith(".tsx")) yield path;
29 }
30}
31
32/** Each opening tag of an HTML element or a forwarding component with a `title` attribute. */
33function nativeTitles(source: string): { tag: string; line: number }[] {
34 const found: { tag: string; line: number }[] = [];
35 const opening = new RegExp(`<([a-z][a-z0-9]*|${FORWARDS.join("|")})(?=[\\s/>])`, "g");
36 for (let match = opening.exec(source); match; match = opening.exec(source)) {
37 // The tag's attributes, up to its closing `>`, with every {...} left out.
38 let attributes = "";
39 let depth = 0;
40 let quote: string | null = null;
41 for (let i = match.index + match[0].length; i < source.length; i++) {
42 const c = source[i];
43 if (quote) {
44 if (c === quote) quote = null;
45 if (depth === 0) attributes += c;
46 continue;
47 }
48 if (c === '"' || c === "'" || c === "`") quote = c;
49 else if (c === "{") depth++;
50 else if (c === "}") depth--;
51 else if (c === ">" && depth === 0) break;
52 if (depth === 0) attributes += c;
53 }
54 if (/(^|\s)title=/.test(attributes)) {
55 found.push({ tag: match[1], line: source.slice(0, match.index).split("\n").length });
56 }
57 }
58 return found;
59}
60
61test("finds a title on an element, not on a component's own prop or inside an expression", () => {
62 const hits = nativeTitles(
63 [
64 `<span title="x">a</span>`,
65 `<Section title="Heading">`,
66 `<div\n className="a"\n title={label}\n>`,
67 `<Link to="/" title={x}>`,
68 `<button onClick={() => open({ title: "x" })}>`,
69 `<p className={cn("a", b && "title=")}>`,
70 ].join("\n"),
71 );
72 assert.deepEqual(hits, [
73 { tag: "span", line: 1 },
74 { tag: "div", line: 3 },
75 { tag: "Link", line: 7 },
76 ]);
77});
78
79test("no native title hints in apps/web", () => {
80 const offenders: string[] = [];
81 for (const file of sources(APP)) {
82 const name = relative(APP, file).replaceAll("\\", "/");
83 for (const { tag, line } of nativeTitles(readFileSync(file, "utf8"))) {
84 if (!ALLOWED[`${name}:${line}`]) offenders.push(`app/${name}:${line} <${tag} title=…>`);
85 }
86 }
87 assert.deepEqual(offenders, [], "Use <Hint label=…> from components/ui/hint.tsx in place of a native title.");
88});