Public: anyone can read this; the crew can change it.
Website spec: HTML structure checks on every look (#2915 check 5) #2976
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2938-website-spec-html-structure-checks-on-ev → main 2 commits fork 2 threads · 2 unresolved
5 files
changed +135
−15
src/release/webspec.ts +76 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 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()` | |
| 3 | 3 | * 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 | |
| 5 | 5 | * section. It reports; it never throws on a bad page and never fails a run. |
| 6 | 6 | * The HTML is read with small regexes, one function per check. |
| 7 | 7 | */ |
| @@ -68,6 +68,79 @@ export function headProblems(html: string): Problem[] { | ||
| 68 | 68 | return out; |
| 69 | 69 | } |
| 70 | 70 | |
| 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(/ |\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 | ||
| 71 | 144 | /** Targets the head points at that must resolve: icons and the manifest. */ |
| 72 | 145 | export function headTargets(html: string, pageUrl: string): { urls: string[]; bad: string[] } { |
| 73 | 146 | 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 = | ||
| 162 | 235 | continue; |
| 163 | 236 | } |
| 164 | 237 | report(headProblems(html)); |
| 238 | report(bodyProblems(html, new URL(url).protocol === "https:")); | |
| 165 | 239 | report(encodingProblems(res.headers, htmlBytes)); |
| 166 | 240 | for (const sheet of stylesheetTargets(html, url)) { |
| 167 | 241 | try { |
src/web/asset.ts +3 −3
| @@ -143,9 +143,9 @@ let shell: BuiltAsset | null = null; | ||
| 143 | 143 | |
| 144 | 144 | /** |
| 145 | 145 | * 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. | |
| 149 | 149 | */ |
| 150 | 150 | export function shellAsset(): BuiltAsset { |
| 151 | 151 | if (shell === null) { |
src/web/layout.ts +4 −2
| @@ -668,10 +668,12 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 668 | 668 | </head> |
| 669 | 669 | <body> |
| 670 | 670 | ${shell}${iconSprite(shell.text)} |
| 671 | <script nonce="${nonce}" src="${shellAsset().path}"></script> | |
| 671 | <script nonce="${nonce}" src="${shellAsset().path}" defer></script> | |
| 672 | 672 | <script nonce="${nonce}" src="${beaconAsset().path}" defer></script> |
| 673 | 673 | ${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. |
||
| 675 | 677 | : null} |
| 676 | 678 | </body> |
| 677 | 679 | </html>`; |
test/web.test.ts +5 −4
| @@ -378,21 +378,22 @@ describe("web pages end to end", () => { | ||
| 378 | 378 | const shell = shellAsset(); |
| 379 | 379 | const home = await (await fetch(base, { headers: { cookie } })).text(); |
| 380 | 380 | expect(home).toMatch( |
| 381 | new RegExp(`<script nonce="[^"]+" src="${shell.path}"></script>`), | |
| 381 | new RegExp(`<script nonce="[^"]+" src="${shell.path}" defer></script>`), | |
| 382 | 382 | ); |
| 383 | 383 | // Its source is no longer in the HTML, and the page is a good deal lighter. |
| 384 | 384 | expect(home).not.toContain(shellJs.slice(0, 400)); |
| 385 | 385 | expect(home).not.toContain(shellJs.slice(-400)); |
| 386 | 386 | |
| 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. | |
| 389 | 389 | const apps = await ( |
| 390 | 390 | await fetch(`${base}/apps/new`, { headers: { cookie } }) |
| 391 | 391 | ).text(); |
| 392 | const at = apps.indexOf(`src="${shell.path}">`); | |
| 392 | const at = apps.indexOf(`src="${shell.path}" defer>`); | |
| 393 | 393 | const inline = apps.indexOf("<script nonce=", at); |
| 394 | 394 | expect(at).toBeGreaterThan(-1); |
| 395 | 395 | expect(inline).toBeGreaterThan(at); |
| 396 | expect(apps).toContain('document.addEventListener("DOMContentLoaded"'); | |
| 396 | 397 | expect(apps).not.toContain(shellJs.slice(0, 400)); |
| 397 | 398 | |
| 398 | 399 | 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"> | ||
| 9 | 9 | <meta name="color-scheme" content="light dark"> |
| 10 | 10 | <meta property="og:title" content="t"><meta property="og:type" content="website"><meta property="og:url" content="https://x.test/"> |
| 11 | 11 | <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>`; | |
| 13 | 16 | |
| 14 | 17 | const GOOD_HEADERS: Record<string, string> = { |
| 15 | 18 | "content-type": "text/html", |
| @@ -29,7 +32,7 @@ function site(html = GOOD_HTML, headers: Record<string, string> = GOOD_HEADERS, | ||
| 29 | 32 | if (u.pathname === "/icon.svg" || u.pathname === "/m.json") return new Response("x"); |
| 30 | 33 | const h = new Headers(init?.headers); |
| 31 | 34 | 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 } }); | |
| 33 | 36 | }; |
| 34 | 37 | } |
| 35 | 38 | |
| @@ -95,7 +98,7 @@ test("HSTS is an edge finding on https only", async () => { | ||
| 95 | 98 | |
| 96 | 99 | test("an ETag that does not answer 304 is a finding", async () => { |
| 97 | 100 | 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" } }); | |
| 99 | 102 | const found = await webspec("https://x.test", ["/"], f); |
| 100 | 103 | expect(found.map((x) => x.check)).toEqual(["etag"]); |
| 101 | 104 | }); |
| @@ -129,7 +132,7 @@ test("apple-touch-icon must resolve, and an invalid href is a finding", async () | ||
| 129 | 132 | const html = GOOD_HTML.replace("</head>", '<link rel="apple-touch-icon" href="/touch.png"></head>'); |
| 130 | 133 | expect(await checks(html, undefined, { "/touch.png": 404 })).toEqual(["head-links"]); |
| 131 | 134 | 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>'); | |
| 133 | 136 | expect(await checks(bad)).toEqual(["head-links"]); |
| 134 | 137 | }); |
| 135 | 138 | |
| @@ -144,6 +147,46 @@ test("every fetch carries a timeout signal", async () => { | ||
| 144 | 147 | expect(seen.every(Boolean)).toBe(true); |
| 145 | 148 | }); |
| 146 | 149 | |
| 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 | ||
| 147 | 190 | test("compression: a big page and stylesheet must leave encoded with Vary", async () => { |
| 148 | 191 | const big = GOOD_HTML.replace("</head>", `<link rel="stylesheet" href="/a.css"></head>`).replace(">hi<", `>${"x".repeat(2000)}<`); |
| 149 | 192 | const fetcher = (encoded: boolean) => async (input: string, init?: RequestInit) => { |
new above
offline
This comment line is far over the width of its neighbours. Re-wrap it.