Public: anyone can read this; the crew can change it.

Website spec: HTML structure checks on every look (#2915 check 5) #2976

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2938-website-spec-html-structure-checks-on-ev → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2938-website-spec-html-structure-checks-on-ev was merged into main as 76b849c.

Review

Sign in to review this change.
5 files changed +135 −15
src/release/webspec.ts +76 −2
@@ -1,7 +1,7 @@
11 /**
2 * The website specification as a report (#2915, checks 1, 2 and 7). `webspec()`
2 * The website specification as a report (#2915, checks 1, 2, 5 and 7). `webspec()`
33 * fetches each path of a site once and returns what the page's head and
4 * response headers get wrong, each finding citing its specification.website
4 * response headers get wrong, and what the body's structure gets wrong, each finding citing its specification.website

This comment line is far over the width of its neighbours. Re-wrap it.

55 * section. It reports; it never throws on a bad page and never fails a run.
66 * The HTML is read with small regexes, one function per check.
77 */
@@ -68,6 +68,79 @@ export function headProblems(html: string): Problem[] {
6868 return out;
6969 }
7070
71 const HEADING = /<h([1-6])\b[^>]*>/gi;
72 const NO_LABEL_TYPES = new Set(["hidden", "submit", "button", "reset", "image"]);
73 /** A tag with its quoted values blanked, so attribute names can be read safely. */
74 const bare = (tag: string) => tag.replace(/"[^"]*"|'[^']*'/g, '""');
75 const text = (inner: string) => inner.replace(/<[^>]*>/g, "").replace(/&nbsp;|\s+/g, " ").trim();
76 const nonEmpty = (v: string | undefined) => (v ?? "").trim() !== "";
77
78 /** The body's structure: headings, names, labels, landmarks, handlers, scripts. */
79 export function bodyProblems(html: string, https: boolean): Problem[] {
80 const out: Problem[] = [];
81 const add = (check: string, message: string, section: string) => out.push({ check, message, section: s(section) });
82 const clean = html.replace(/<!--[\s\S]*?-->/g, "").replace(/(<(script|style)\b[^>]*>)[\s\S]*?<\/\2\s*>/gi, "$1</$2>");
83 const bodyAt = clean.search(/<body\b/i);
84 const body = bodyAt < 0 ? clean : clean.slice(bodyAt);
85
86 let prev = 0;
87 let h1 = 0;
88 for (const m of body.matchAll(HEADING)) {
89 const level = Number(m[1]);
90 if (level === 1) h1++;
91 if (level > prev + 1) add("headings", `<h${level}> follows ${prev ? `<h${prev}>` : "no heading"}; levels must not skip`, "headings");
92 prev = level;
93 }
94 if (h1 !== 1) add("h1", `expected exactly one <h1>, found ${h1}`, "headings");
95
96 for (const t of tags(body, "img")) if (attr(t, "alt") === undefined) add("img-alt", `<img> has no alt: ${attr(t, "src") ?? "no src"}`, "images");
97
98 const labels = [...body.matchAll(/<label\b[^>]*>[\s\S]*?<\/label\s*>/gi)];
99 const forIds = labels.map((l) => attr(l[0].match(/^<label\b[^>]*>/i)![0], "for")).filter(nonEmpty);
100 for (const f of body.matchAll(/<(input|select|textarea)\b[^>]*>/gi)) {
101 const t = f[0];
102 if (f[1]!.toLowerCase() === "input" && NO_LABEL_TYPES.has((attr(t, "type") ?? "text").toLowerCase())) continue;
103 const at = f.index!;
104 const wrapped = labels.some((l) => at > l.index! && at < l.index! + l[0].length);
105 const id = attr(t, "id");
106 if (!wrapped && !(nonEmpty(id) && forIds.includes(id!)) && !nonEmpty(attr(t, "aria-label")) && !nonEmpty(attr(t, "aria-labelledby")))
107 add("label", `<${f[1]!.toLowerCase()}${id ? ` id="${id}"` : attr(t, "name") ? ` name="${attr(t, "name")}"` : ""}> has no label`, "forms");
108 }
109
110 for (const m of body.matchAll(/<(a|button)\b([^>]*)>([\s\S]*?)<\/\1\s*>/gi)) {
111 const open = `<${m[1]}${m[2]}>`;
112 if (m[1]!.toLowerCase() === "a" && attr(open, "href") === undefined) continue;
113 const imgAlt = tags(m[3]!, "img").some((i) => nonEmpty(attr(i, "alt")));
114 if (!text(m[3]!) && !imgAlt && !nonEmpty(attr(open, "aria-label")) && !nonEmpty(attr(open, "aria-labelledby")) && !nonEmpty(attr(open, "title")))
115 add("accessible-name", `<${m[1]!.toLowerCase()}${attr(open, "href") ? ` href="${attr(open, "href")}"` : ""}> has no accessible name`, "names");
116 }
117
118 const focusable = body.match(/<(a\b[^>]*\shref|button\b|input\b|select\b|textarea\b|[a-z0-9]+\b[^>]*\stabindex\s*=\s*["']?(?:0|[1-9]))[^>]*>/i);
119 const first = focusable?.[0] ?? "";
120 const href = /^<a\b/i.test(first) ? (attr(first, "href") ?? "") : "";
121 if (focusable && !(href.startsWith("#") && href.length > 1)) add("skip-link", "the first focusable element is not a skip link (an in-page #target link)", "skip-link");
122
123 const mains = (body.match(/<main\b/gi) ?? []).length;
124 if (mains !== 1) add("main", `expected exactly one <main>, found ${mains}`, "landmarks");
125
126 for (const m of body.matchAll(/<[a-z][^>]*>/gi))
127 for (const h of bare(m[0]).matchAll(/[\s"'/](on[a-z]+)\s*=/gi)) add("inline-handler", `inline ${h[1]!.toLowerCase()} handler on ${m[0].slice(0, 60)}`, "csp");
128
129 for (const t of tags(body, "script")) {
130 if (attr(t, "src") === undefined) continue;
131 const flags = bare(t);
132 if (!/[\s"'/](defer|async)(?=[\s>/=]|$)/i.test(flags) && (attr(t, "type") ?? "").toLowerCase() !== "module")
133 add("script-defer", `external script ${attr(t, "src")} is not defer, async or module`, "scripts");
134 }
135
136 if (https) {
137 const refs = [...tags(clean, "link").filter((t) => !/\b(canonical|alternate|author)\b/i.test(attr(t, "rel") ?? "")).map((t) => attr(t, "href")),
138 ...["img", "script", "iframe", "source", "video", "audio", "embed", "track"].flatMap((n) => tags(clean, n).map((t) => attr(t, "src")))];
139 for (const r of refs) if (/^\s*http:\/\//i.test(r ?? "")) add("mixed-content", `http:// subresource on an https page: ${r}`, "https");
140 }
141 return out;
142 }
143
71144 /** Targets the head points at that must resolve: icons and the manifest. */
72145 export function headTargets(html: string, pageUrl: string): { urls: string[]; bad: string[] } {
73146 const hrefs = [...links(html, "icon"), ...links(html, "apple-touch-icon"), ...links(html, "manifest")].map((t) => attr(t, "href") ?? "");
@@ -162,6 +235,7 @@ export async function webspec(base: string, paths: string[], fetcher: Fetcher =
162235 continue;
163236 }
164237 report(headProblems(html));
238 report(bodyProblems(html, new URL(url).protocol === "https:"));
165239 report(encodingProblems(res.headers, htmlBytes));
166240 for (const sheet of stylesheetTargets(html, url)) {
167241 try {
src/web/asset.ts +3 −3
@@ -143,9 +143,9 @@ let shell: BuiltAsset | null = null;
143143
144144 /**
145145 * The shell script, content-addressed like the stylesheet. Hashed once per
146 * process. Linked as a classic (blocking) script ahead of the page's own
147 * inline one, so everything it defines is in scope when that runs, exactly as
148 * when the two were one inline block.
146 * process. Linked `defer`; the page's own inline script waits for
147 * DOMContentLoaded, which fires after deferred scripts, so everything the
148 * shell defines is in scope when that runs.
149149 */
150150 export function shellAsset(): BuiltAsset {
151151 if (shell === null) {
src/web/layout.ts +4 −2
@@ -668,10 +668,12 @@ export function page(options: PageOptions, status = 200): Response {
668668 </head>
669669 <body>
670670 ${shell}${iconSprite(shell.text)}
671 <script nonce="${nonce}" src="${shellAsset().path}"></script>
671 <script nonce="${nonce}" src="${shellAsset().path}" defer></script>
672672 <script nonce="${nonce}" src="${beaconAsset().path}" defer></script>
673673 ${options.scriptJs
674 ? raw(`<script nonce="${nonce}">${options.scriptJs}</script>`)
674 ? raw(
675 `<script nonce="${nonce}">document.addEventListener("DOMContentLoaded",()=>{${options.scriptJs}\n})</script>`,
676 )

Good: the newline before the closing brace protects a trailing // comment. Add a test that proves it, and one that a page script that throws does not stop other DOMContentLoaded listeners.

675677 : null}
676678 </body>
677679 </html>`;
test/web.test.ts +5 −4
@@ -378,21 +378,22 @@ describe("web pages end to end", () => {
378378 const shell = shellAsset();
379379 const home = await (await fetch(base, { headers: { cookie } })).text();
380380 expect(home).toMatch(
381 new RegExp(`<script nonce="[^"]+" src="${shell.path}"></script>`),
381 new RegExp(`<script nonce="[^"]+" src="${shell.path}" defer></script>`),
382382 );
383383 // Its source is no longer in the HTML, and the page is a good deal lighter.
384384 expect(home).not.toContain(shellJs.slice(0, 400));
385385 expect(home).not.toContain(shellJs.slice(-400));
386386
387 // A page with its own script: the shell is a classic script placed first,
388 // so it has run by the time the nonce'd inline one does.
387 // A page with its own script: the shell is deferred and the inline one waits
388 // for DOMContentLoaded, so the shell has run by then.
389389 const apps = await (
390390 await fetch(`${base}/apps/new`, { headers: { cookie } })
391391 ).text();
392 const at = apps.indexOf(`src="${shell.path}">`);
392 const at = apps.indexOf(`src="${shell.path}" defer>`);
393393 const inline = apps.indexOf("<script nonce=", at);
394394 expect(at).toBeGreaterThan(-1);
395395 expect(inline).toBeGreaterThan(at);
396 expect(apps).toContain('document.addEventListener("DOMContentLoaded"');
396397 expect(apps).not.toContain(shellJs.slice(0, 400));
397398
398399 const file = await fetch(base + shell.path);
test/webspec.test.ts +47 −4
@@ -9,7 +9,10 @@ const GOOD_HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8">
99 <meta name="color-scheme" content="light dark">
1010 <meta property="og:title" content="t"><meta property="og:type" content="website"><meta property="og:url" content="https://x.test/">
1111 <meta property="og:description" content="d"><meta property="og:image" content="https://x.test/og.png">
12 <link rel="icon" href="/icon.svg"><link rel="manifest" href="/m.json"></head><body>hi</body></html>`;
12 <link rel="icon" href="/icon.svg"><link rel="manifest" href="/m.json"></head><body><a href="#main">Skip to content</a><main id="main"><h1>Home</h1><h2>More</h2>
13 <img src="/a.png" alt=""><label for="q">Search</label><input id="q"><label>Name <input name="n"></label>
14 <input type="hidden" name="h"><button aria-label="Close"></button><a href="/x">Go</a>
15 <script src="/a.js" defer></script></main></body></html>`;
1316
1417 const GOOD_HEADERS: Record<string, string> = {
1518 "content-type": "text/html",
@@ -29,7 +32,7 @@ function site(html = GOOD_HTML, headers: Record<string, string> = GOOD_HEADERS,
2932 if (u.pathname === "/icon.svg" || u.pathname === "/m.json") return new Response("x");
3033 const h = new Headers(init?.headers);
3134 if (h.get("if-none-match") && headers["etag"] === h.get("if-none-match")) return new Response(null, { status: 304 });
32 return new Response(html, { headers });
35 return new Response(html, { headers: { "content-encoding": "br", vary: "Accept-Encoding", ...headers } });
3336 };
3437 }
3538
@@ -95,7 +98,7 @@ test("HSTS is an edge finding on https only", async () => {
9598
9699 test("an ETag that does not answer 304 is a finding", async () => {
97100 const f = async (input: string) =>
98 input.endsWith(".svg") || input.endsWith(".json") ? new Response("x") : new Response(GOOD_HTML, { headers: GOOD_HEADERS });
101 input.endsWith(".svg") || input.endsWith(".json") ? new Response("x") : new Response(GOOD_HTML, { headers: { ...GOOD_HEADERS, "content-encoding": "br", vary: "Accept-Encoding" } });
99102 const found = await webspec("https://x.test", ["/"], f);
100103 expect(found.map((x) => x.check)).toEqual(["etag"]);
101104 });
@@ -129,7 +132,7 @@ test("apple-touch-icon must resolve, and an invalid href is a finding", async ()
129132 const html = GOOD_HTML.replace("</head>", '<link rel="apple-touch-icon" href="/touch.png"></head>');
130133 expect(await checks(html, undefined, { "/touch.png": 404 })).toEqual(["head-links"]);
131134 expect(await checks(html, undefined, { "/touch.png": 200 })).toEqual([]);
132 const bad = GOOD_HTML.replace("</head>", '<link rel="icon" href="http://[bad"></head>');
135 const bad = GOOD_HTML.replace("</head>", '<link rel="icon" href="https://[bad"></head>');
133136 expect(await checks(bad)).toEqual(["head-links"]);
134137 });
135138
@@ -144,6 +147,46 @@ test("every fetch carries a timeout signal", async () => {
144147 expect(seen.every(Boolean)).toBe(true);
145148 });
146149
150 test("each body check yields exactly its finding", async () => {
151 const body = (from: string, to: string) => GOOD_HTML.replace(from, to);
152 const cases: [string, string, string][] = [
153 ["skipped level", body("<h2>More</h2>", "<h3>More</h3>"), "headings"],
154 ["no h1", body("<h1>Home</h1><h2>More</h2>", ""), "h1"],
155 ["two h1", body("<h2>More</h2>", "<h1>More</h1>"), "h1"],
156 ["img alt", body('<img src="/a.png" alt="">', '<img src="/a.png">'), "img-alt"],
157 ["unlabelled input", body('<label for="q">Search</label>', ""), "label"],
158 ["unlabelled select", body("</main>", "<select name=s></select></main>"), "label"],
159 ["empty link", body('<a href="/x">Go</a>', '<a href="/x"><span></span></a>'), "accessible-name"],
160 ["empty button", body('<button aria-label="Close">', "<button>"), "accessible-name"],
161 ["no skip link", body('<a href="#main">Skip to content</a>', '<a href="/y">Y</a>'), "skip-link"],
162 ["skip link not first", body('<a href="#main">Skip to content</a>', '<button aria-label="b"></button><a href="#main">Skip</a>'), "skip-link"],
163 ["no main", body("<main", "<div").replace("</main>", "</div>"), "main"],
164 ["two mains", body("</main>", "</main><main></main>"), "main"],
165 ["inline handler", body('<a href="/x">', '<a href="/x" onclick="f()">'), "inline-handler"],
166 ["blocking script", body(' defer></script>', "></script>"), "script-defer"],
167 ["http subresource", body('src="/a.png"', 'src="http://x.test/a.png"'), "mixed-content"],
168 ];
169 for (const [name, html, check] of cases) expect([name, await checks(html)]).toEqual([name, [check]]);
170 });
171
172 test("async and module scripts, wrapped labels, aria-label inputs and image-only links pass", async () => {
173 const html = GOOD_HTML.replace(
174 "</main>",
175 '<script src="/b.js" async></script><script type="module" src="/c.js"></script><input aria-label="x"><a href="/z"><img src="/i.png" alt="Home"></a><script>var onclick = 1</script><!-- <h4>x</h4> --></main>',
176 );
177 expect(await checks(html)).toEqual([]);
178 });
179
180 test("http:// subresources are fine on an http page", async () => {
181 const html = GOOD_HTML.replace('src="/a.png"', 'src="http://x.test/a.png"');
182 expect((await webspec("http://x.test", ["/"], site(html))).map((f) => f.check)).not.toContain("mixed-content");
183 });
184
185 test("every body finding cites its specification.website section", async () => {
186 const f = await webspec("https://x.test", ["/"], site(GOOD_HTML.replace("<main", "<div").replace("</main>", "</div>")));
187 expect(f.map((x) => x.section)).toEqual(["https://specification.website/#landmarks"]);
188 });
189
147190 test("compression: a big page and stylesheet must leave encoded with Vary", async () => {
148191 const big = GOOD_HTML.replace("</head>", `<link rel="stylesheet" href="/a.css"></head>`).replace(">hi<", `>${"x".repeat(2000)}<`);
149192 const fetcher = (encoded: boolean) => async (input: string, init?: RequestInit) => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs