ReviewOS

also looking at this

stacks/lastsoulultra

LSU's own coaching surface, and what auditing the site turned up

#12
Merged glennmichael123 wants to merge claude/last-soul-ultra-web-6e0718 into main
86 files +3943 -1190

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

scripts/audit-html.tsmodified+54-33
Changes to scripts/audit-html.ts
@@ -1,41 +1,47 @@
11/* eslint-disable no-console */
22/**
3 * Audit the built site for the mistakes a template cannot catch itself.
3 * The mistakes a template cannot catch about itself.
44 *
55 * A page renders perfectly well with a link to a page that was renamed, two
6 * elements sharing an id, an h3 under an h1, or an image whose alt was never
7 * written. Nothing errors, nothing fails to build, and nobody notices until
8 * somebody using a screen reader or a keyboard does.
6 * elements sharing an id, an h3 under an h1, a link whose only content is an
7 * icon, or an image whose alt was never written. Nothing errors and nothing
8 * fails to build; it is noticed by the first person using a keyboard or a
9 * screen reader, which is to say not by us.
910 *
10 * bun run build && bun scripts/audit-html.ts
11 * The rules live here and run twice. tests/html-audit.test.ts applies them to
12 * the rendered views, so the suite catches a regression with no build; this
13 * script applies them to dist, so a release can be checked against what was
14 * actually produced rather than what the source implies:
1115 *
12 * Reads dist/, so it checks what is actually deployed rather than what the
13 * source says should be. Exits non-zero on any finding.
16 * bun run build && bun run audit:html
17 *
18 * Exits non-zero on any finding.
1419 */
1520
16import { readdirSync } from 'node:fs'
21import { readFileSync, readdirSync } from 'node:fs'
1722import { join, relative } from 'node:path'
1823import process from 'node:process'
1924import server from '../config/server'
2025
2126const ROOT = 'dist'
2227
23interface Finding {
28export interface Finding {
2429 page: string
2530 rule: string
2631 detail: string
2732}
2833
29function pages(dir = ROOT): string[] {
34/** Built pages, depth first. `_`-prefixed directories are build internals. */
35export function builtPages(dir: string = ROOT): string[] {
3036 return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
3137 const path = join(dir, entry.name)
32 if (entry.isDirectory()) return entry.name.startsWith('_') ? [] : pages(path)
38 if (entry.isDirectory()) return entry.name.startsWith('_') ? [] : builtPages(path)
3339 return entry.name.endsWith('.html') ? [path] : []
3440 })
3541}
3642
3743/** The URL a built file is served at. */
38function urlOf(file: string): string {
44export function urlOf(file: string): string {
3945 const path = `/${relative(ROOT, file).replace(/\.html$/, '')}`
4046 return path === '/index' ? '/' : path
4147}
@@ -55,19 +61,20 @@ function attr(tag: string, name: string): string | null {
5561 return new RegExp(`\\s${name}(?=[\\s/>])`, 'i').test(tag) ? '' : null
5662}
5763
58const built = new Set(pages().map(urlOf))
59const redirected = new Set(Object.keys(server.redirects ?? {}))
60const findings: Finding[] = []
64/** Every URL the deployed site answers, which is what a link may point at. */
65export function redirectSources(): string[] {
66 return Object.keys(server.redirects ?? {})
67}
6168
62for (const file of pages()) {
63 const page = urlOf(file)
64 const raw = await Bun.file(file).text()
69/** The rules, over one page. */
70export function auditPage(page: string, raw: string, served: Set<string>): Finding[] {
71 const findings: Finding[] = []
6572 const body = visible(raw)
6673
67 // 1. Internal links point at something this build serves.
74 // 1. Internal links point at something this site serves.
6875 for (const [, href] of raw.matchAll(/<a\b[^>]*\shref="(\/[^"#?]*)[^"]*"/gi)) {
6976 const target = href!.length > 1 ? href!.replace(/\/$/, '') : href!
70 if (built.has(target) || redirected.has(target)) continue
77 if (served.has(target)) continue
7178 // Assets are files on disk rather than pages.
7279 if (/\.[a-z0-9]{2,5}$/i.test(target) || target.startsWith('/assets/')) continue
7380 findings.push({ page, rule: 'dead-link', detail: target })
@@ -97,8 +104,7 @@ for (const file of pages()) {
97104 }
98105
99106 // 5. A link a screen reader can announce.
100 for (const [match, tag, inner] of body.matchAll(/(<a\b[^>]*>)([\s\S]*?)<\/a>/gi)) {
101 void match
107 for (const [, tag, inner] of body.matchAll(/(<a\b[^>]*>)([\s\S]*?)<\/a>/gi)) {
102108 const text = inner!.replace(/<[^>]+>/g, '').replace(/&[a-z]+;/gi, ' ').trim()
103109 if (text) continue
104110 if (attr(tag!, 'aria-label') || attr(tag!, 'title')) continue
@@ -110,19 +116,34 @@ for (const file of pages()) {
110116 // 6. A document that states its language.
111117 if (!/<html\b[^>]*\slang="[^"]+"/i.test(raw))
112118 findings.push({ page, rule: 'no-lang', detail: '' })
119
120 return findings
113121}
114122
115const byRule = new Map<string, Finding[]>()
116for (const finding of findings) {
117 if (!byRule.has(finding.rule)) byRule.set(finding.rule, [])
118 byRule.get(finding.rule)!.push(finding)
123/** Findings grouped by rule, for a terminal. */
124export function formatFindings(findings: Finding[]): string {
125 const byRule = new Map<string, Finding[]>()
126 for (const finding of findings) {
127 if (!byRule.has(finding.rule)) byRule.set(finding.rule, [])
128 byRule.get(finding.rule)!.push(finding)
129 }
130
131 return [...byRule]
132 .sort((a, b) => b[1].length - a[1].length)
133 .map(([rule, rows]) => [
134 `${rule} (${rows.length})`,
135 ...rows.slice(0, 12).map(row => ` ${row.page} ${row.detail}`),
136 ...(rows.length > 12 ? [` ... ${rows.length - 12} more`] : []),
137 ].join('\n'))
138 .join('\n\n')
119139}
120140
121console.log(`audited ${built.size} pages`)
122for (const [rule, rows] of [...byRule].sort((a, b) => b[1].length - a[1].length)) {
123 console.log(`\n${rule} (${rows.length})`)
124 for (const row of rows.slice(0, 12)) console.log(` ${row.page} ${row.detail}`)
125 if (rows.length > 12) console.log(` ... ${rows.length - 12} more`)
141if (import.meta.main) {
142 const files = builtPages()
143 const served = new Set([...files.map(urlOf), ...redirectSources()])
144 const findings = files.flatMap(file => auditPage(urlOf(file), readFileSync(file, 'utf8'), served))
145
146 console.log(`audited ${files.length} pages`)
147 console.log(findings.length === 0 ? '\nno findings' : `\n${formatFindings(findings)}`)
148 process.exit(findings.length === 0 ? 0 : 1)
126149}
127if (findings.length === 0) console.log('\nno findings')
128process.exit(findings.length === 0 ? 0 : 1)
tests/html-audit.test.tsadded+54-0
Changes to tests/html-audit.test.ts
@@ -0,0 +1,54 @@
1import { describe, expect, it } from 'bun:test'
2import { readdirSync } from 'node:fs'
3import { join, resolve } from 'node:path'
4import { processDirectives } from '@stacksjs/stx'
5import { auditPage, formatFindings, redirectSources } from '../scripts/audit-html'
6
7/**
8 * The same rules scripts/audit-html.ts runs over dist, run here over the
9 * rendered views so a regression fails the suite rather than waiting for
10 * somebody to build and remember to check.
11 *
12 * One gap worth knowing: component resolution here is not the build's.
13 * `<dialog>` on /soul-races resolves through the @stacksjs/components bun
14 * plugin at build time and renders as nothing in this pass, so what is inside
15 * that dialog is checked by the script against dist rather than here.
16 */
17
18function viewFiles(dir = 'resources/views', prefix = ''): { url: string, file: string }[] {
19 return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
20 if (entry.isDirectory())
21 return viewFiles(join(dir, entry.name), `${prefix}/${entry.name}`)
22 if (!entry.name.endsWith('.stx'))
23 return []
24 const name = entry.name.slice(0, -'.stx'.length)
25 return [{ url: name === 'index' && prefix === '' ? '/' : `${prefix}/${name}`, file: join(dir, entry.name) }]
26 })
27}
28
29const views = viewFiles()
30const served = new Set([...views.map(view => view.url), ...redirectSources()])
31
32async function render(file: string): Promise<string> {
33 const path = resolve(file)
34 return processDirectives(await Bun.file(path).text(), {}, path, {
35 enabled: true,
36 cache: false,
37 componentsDir: resolve('resources/components'),
38 layoutsDir: resolve('resources/layouts'),
39 partialsDir: resolve('resources/partials'),
40 }, new Set())
41}
42
43describe('every page the site serves', () => {
44 it('has no dead internal link, duplicate id, skipped heading or unnamed link', async () => {
45 const findings = (await Promise.all(views.map(async view => auditPage(view.url, await render(view.file), served)))).flat()
46
47 expect(formatFindings(findings)).toBe('')
48 }, 120_000)
49
50 it('checks every view, not a handful', () => {
51 expect(views.length).toBeGreaterThanOrEqual(23)
52 expect(views.map(view => view.url)).toContain('/coaching/feedback')
53 })
54})