also looking at this
LSU's own coaching surface, and what auditing the site turned up
#12
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.
| @@ -27,7 +27,7 @@ | ||
| 27 | 27 | "upgrade": "./buddy upgrade", |
| 28 | 28 | "dev": "./buddy dev", |
| 29 | 29 | "development": "./buddy dev", |
| 30 | "build": "bun run build:assets && ./buddy build", | |
| 30 | "build": "bun run build:assets && ./buddy build && bun run build:bundles", | |
| 31 | 31 | "prod": "./buddy build", |
| 32 | 32 | "deploy": "./buddy deploy", |
| 33 | 33 | "lint": "./buddy lint", |
| @@ -73,6 +73,7 @@ | ||
| 73 | 73 | "bud": "./buddy", |
| 74 | 74 | "stx": "./buddy", |
| 75 | 75 | "build:assets": "bun scripts/build-site-assets.ts", |
| 76 | "build:bundles": "bun scripts/externalize-page-bundles.ts", | |
| 76 | 77 | "audit:html": "bun scripts/audit-html.ts" |
| 77 | 78 | }, |
| 78 | 79 | "dependencies": { |
| @@ -0,0 +1,87 @@ | ||
| 1 | /* eslint-disable no-console */ | |
| 2 | /** | |
| 3 | * Lift a page's own module bundle out of its HTML. | |
| 4 | * | |
| 5 | * The build externalises the blobs that repeat across pages - the signals | |
| 6 | * runtime, the router, the generated stylesheet - and leaves a bundle that | |
| 7 | * only one page uses inline. That is the wrong way round for the biggest one | |
| 8 | * here: /world-series carries @ts-maps inline, 844 KB of minified JavaScript | |
| 9 | * in the first 1% of the document, which the browser must download and parse | |
| 10 | * before it paints anything below it, on every single visit, because an | |
| 11 | * inline script cannot be cached. | |
| 12 | * | |
| 13 | * Moving it to a file named by its contents makes it cacheable, lets the | |
| 14 | * document stream, and costs one request. `/_stx/modules.<hash>.js` is the | |
| 15 | * shape stx's own SPA router already recognises as a module bundle, so a | |
| 16 | * client-side navigation that fails to load it recovers the page rather than | |
| 17 | * running a setup that can only throw. | |
| 18 | * | |
| 19 | * The tag keeps its attributes and gains no defer or async: an inline script | |
| 20 | * runs at its position in document order, and so does an external one without | |
| 21 | * those, so execution order is unchanged. | |
| 22 | * | |
| 23 | * bun run build # runs this after ./buddy build | |
| 24 | */ | |
| 25 | ||
| 26 | import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' | |
| 27 | import { join, relative } from 'node:path' | |
| 28 | import process from 'node:process' | |
| 29 | ||
| 30 | const ROOT = 'dist' | |
| 31 | const ASSET_DIR = join(ROOT, '_stx') | |
| 32 | /** Below this, a request costs more than the bytes saved. */ | |
| 33 | const MIN_BYTES = 16 * 1024 | |
| 34 | ||
| 35 | if (!existsSync(ROOT)) { | |
| 36 | console.error(`${ROOT}/ does not exist. Run the build first.`) | |
| 37 | process.exit(1) | |
| 38 | } | |
| 39 | ||
| 40 | function pages(dir = ROOT): string[] { | |
| 41 | return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { | |
| 42 | const path = join(dir, entry.name) | |
| 43 | if (entry.isDirectory()) return entry.name === '_stx' ? [] : pages(path) | |
| 44 | return entry.name.endsWith('.html') ? [path] : [] | |
| 45 | }) | |
| 46 | } | |
| 47 | ||
| 48 | /** The name stx's router matches as a module bundle: 16 hex characters. */ | |
| 49 | function bundleName(code: string): string { | |
| 50 | return `modules.${new Bun.CryptoHasher('sha256').update(code).digest('hex').slice(0, 16)}.js` | |
| 51 | } | |
| 52 | ||
| 53 | const written = new Map<string, number>() | |
| 54 | let movedBytes = 0 | |
| 55 | let touchedPages = 0 | |
| 56 | ||
| 57 | for (const file of pages()) { | |
| 58 | const html = readFileSync(file, 'utf8') | |
| 59 | let changed = false | |
| 60 | ||
| 61 | const next = html.replace( | |
| 62 | /<script([^>]*\bdata-stx-modules\b[^>]*)>([\s\S]*?)<\/script>/gi, | |
| 63 | (whole, attrs: string, code: string) => { | |
| 64 | // Already a reference, or small enough to leave where it is. | |
| 65 | if (/\bsrc\s*=/i.test(attrs) || code.length < MIN_BYTES) return whole | |
| 66 | ||
| 67 | const name = bundleName(code) | |
| 68 | if (!written.has(name)) { | |
| 69 | mkdirSync(ASSET_DIR, { recursive: true }) | |
| 70 | writeFileSync(join(ASSET_DIR, name), code) | |
| 71 | written.set(name, code.length) | |
| 72 | } | |
| 73 | changed = true | |
| 74 | movedBytes += code.length | |
| 75 | return `<script${attrs} src="/_stx/${name}"></script>` | |
| 76 | }, | |
| 77 | ) | |
| 78 | ||
| 79 | if (!changed) continue | |
| 80 | writeFileSync(file, next) | |
| 81 | touchedPages += 1 | |
| 82 | console.log(` ${relative(ROOT, file)} ${(html.length / 1024).toFixed(0)}KB -> ${(next.length / 1024).toFixed(0)}KB`) | |
| 83 | } | |
| 84 | ||
| 85 | console.log(written.size === 0 | |
| 86 | ? 'No page-local module bundle was large enough to externalize.' | |
| 87 | : `Externalized ${written.size} page module bundle(s) from ${touchedPages} page(s) (${(movedBytes / 1024).toFixed(1)}KB removed from HTML)`) | |