@@ -182,47 +182,106 @@ async function sriForUrl(url: string, cacheDir: string): Promise<string> {
182182}
183183
184184/**
185- * Scan HTML for `<script src=https://unpkg.com/...>` and
186- * `<link rel=stylesheet href=https://unpkg.com/... >` tags, fetch +
187- * hash each resource, and inject `integrity="sha384-..." crossorigin
188- * ="anonymous"` so the browser rejects any tampered CDN response .
185+ * Scan HTML for `<script src=...>`, `<link rel=stylesheet href=...>`,
186+ * and `<link rel=preload as=script href=... >` tags, hash each resource,
187+ * and inject `integrity="sha384-..."` so the browser rejects tampered
188+ * responses (CDN or our own origin) .
189189 *
190- * Idempotent — tags that already carry `integrity=` are left alone
191- * so hand-authored hashes survive.
190+ * Sources:
191+ * - `https://unpkg.com/...` → fetched + disk-cached (see sriForUrl).
192+ * - `/walkthrough.css` etc. → read from `walkthroughDir` directly.
193+ * - `basePath`-prefixed same-origin paths → stripped to the bare
194+ * file name, then read from `walkthroughDir`.
195+ *
196+ * CDN tags also get `crossorigin="anonymous"` (required for the SRI
197+ * check to run on cross-origin responses). Same-origin tags don't
198+ * need it and shouldn't have it (would trigger CORS unnecessarily).
199+ *
200+ * Idempotent — tags that already carry `integrity=` are left alone.
192201 */
193- async function injectSri ( html : string , cacheDir : string ) : Promise < string > {
194- // Collect unique URLs first so we fetch each once even if it's
195- // referenced in multiple tags.
196- const urlRe =
197- / < (?: s c r i p t \s [ ^ > ] * \b s r c | l i n k \s [ ^ > ] * \b h r e f ) = " ( h t t p s : \/ \/ u n p k g \. c o m \/ [ ^ " ] + ) " / gi
198- const urls = new Set < string > ( )
199- for ( const m of html . matchAll ( urlRe ) ) {
200- urls . add ( m [ 1 ] ! )
202+ async function injectSri (
203+ html : string ,
204+ walkthroughDir : string ,
205+ basePath : string ,
206+ cacheDir : string ,
207+ ) : Promise < string > {
208+ // Map of URL/path → SRI hash. Populated lazily as we walk the tag
209+ // stream so we resolve each URL at most once per file.
210+ const integrityByRef = new Map < string , string > ( )
211+
212+ const resolveIntegrity = async ( ref : string ) : Promise < string | null > => {
213+ if ( integrityByRef . has ( ref ) ) {
214+ return integrityByRef . get ( ref ) !
215+ }
216+ let integrity : string | null = null
217+ if ( ref . startsWith ( 'https://unpkg.com/' ) ) {
218+ integrity = await sriForUrl ( ref , cacheDir )
219+ } else if ( ref . startsWith ( '/' ) ) {
220+ // Strip leading basePath if present so we can read the bare
221+ // filename out of the output dir.
222+ const bareRef =
223+ basePath && ref . startsWith ( basePath + '/' )
224+ ? ref . slice ( basePath . length )
225+ : ref
226+ const localPath = path . join ( walkthroughDir , bareRef )
227+ if ( existsSync ( localPath ) ) {
228+ integrity = computeIntegrity ( readFileSync ( localPath ) )
229+ }
230+ }
231+ integrityByRef . set ( ref , integrity ?? '' )
232+ return integrity
201233 }
202- if ( urls . size === 0 ) {
203- return html
234+
235+ // Match tags carrying src/href pointing at either unpkg.com or a
236+ // same-origin absolute path. Exclude `/` from the attrs capture so
237+ // a self-closing `<link .../>` doesn't drag the slash mid-rewrite.
238+ const tagRe =
239+ / < ( s c r i p t | l i n k ) \s ( [ ^ > / ] * ?\b (?: s r c | h r e f ) = " ( (?: h t t p s : \/ \/ u n p k g \. c o m \/ | \/ ) [ ^ " ] + ) " [ ^ > / ] * ) ( \s * \/ ? \s * > ) / gi
240+
241+ // Browsers only honor `integrity` on:
242+ // <script>
243+ // <link rel=stylesheet>
244+ // <link rel=preload> / <link rel=modulepreload>
245+ // `<link rel=icon>` / `<link rel=apple-touch-icon>` ignore it, so
246+ // skip them — no point emitting hash bytes the browser throws away.
247+ const supportsSri = ( tag : string , attrs : string ) : boolean => {
248+ if ( tag . toLowerCase ( ) === 'script' ) {
249+ return true
250+ }
251+ const relMatch = attrs . match ( / \b r e l = " ( [ ^ " ] + ) " / i)
252+ if ( ! relMatch ) {
253+ return false
254+ }
255+ return / \b (?: s t y l e s h e e t | p r e l o a d | m o d u l e p r e l o a d ) \b / i. test ( relMatch [ 1 ] ! )
204256 }
205- const integrityByUrl = new Map < string , string > ( )
206- for ( const url of urls ) {
207- integrityByUrl . set ( url , await sriForUrl ( url , cacheDir ) )
257+
258+ // Two-pass: first collect + resolve all refs the browser honors,
259+ // then rewrite. `matchAll` is sync so we walk once to gather refs,
260+ // resolve them, then `replace` using the populated map.
261+ for ( const m of html . matchAll ( tagRe ) ) {
262+ if ( supportsSri ( m [ 1 ] ! , m [ 2 ] ! ) ) {
263+ await resolveIntegrity ( m [ 3 ] ! )
264+ }
208265 }
209- // Rewrite each tag. Skip tags that already carry `integrity=`.
210- // The `attrs` capture intentionally excludes `/` so a self-closing
211- // `<link ... />` doesn't pull the trailing slash into the attrs
212- // string (which we'd then echo back before our injected attrs,
213- // producing `href="..." / integrity="...">`).
214- const tagRe =
215- / < ( s c r i p t | l i n k ) \s ( [ ^ > / ] * ?\b (?: s r c | h r e f ) = " ( h t t p s : \/ \/ u n p k g \. c o m \/ [ ^ " ] + ) " [ ^ > / ] * ) ( \s * \/ ? \s * > ) / gi
216- return html . replace ( tagRe , ( full , tag , attrs , url , close ) => {
266+
267+ return html . replace ( tagRe , ( full , tag , attrs , ref , close ) => {
217268 if ( / \b i n t e g r i t y = / i. test ( attrs ) ) {
218269 return full
219270 }
220- const integrity = integrityByUrl . get ( url )
271+ if ( ! supportsSri ( tag , attrs ) ) {
272+ return full
273+ }
274+ const integrity = integrityByRef . get ( ref )
221275 if ( ! integrity ) {
222276 return full
223277 }
278+ // crossorigin=anonymous only makes sense (and is required for SRI)
279+ // on cross-origin requests. Same-origin tags shouldn't carry it.
280+ const crossorigin = ref . startsWith ( 'https://' )
281+ ? ' crossorigin="anonymous"'
282+ : ''
224283 const trimmedAttrs = attrs . trimEnd ( )
225- return `<${ tag } ${ trimmedAttrs } integrity="${ integrity } " crossorigin="anonymous" ${ close } `
284+ return `<${ tag } ${ trimmedAttrs } integrity="${ integrity } "${ crossorigin } ${ close } `
226285 } )
227286}
228287
@@ -437,14 +496,6 @@ async function generate(
437496 html = applyBasePath ( html , basePath , slug )
438497 }
439498
440- // Subresource Integrity on CDN scripts (marked, highlight.js,
441- // highlight.js CSS theme). Fetches each URL once per build,
442- // sha384-hashes the bytes, injects `integrity="sha384-..."
443- // crossorigin="anonymous"` so the browser rejects any tampered
444- // response. Disk-cached under .cache/sri/ so repeat builds are
445- // free; a version bump (new URL) invalidates automatically.
446- html = await injectSri ( html , path . join ( repoRoot , '.cache' , 'sri' ) )
447-
448499 writeFileSync ( htmlPath , html )
449500 }
450501
@@ -461,6 +512,24 @@ async function generate(
461512 if ( minify ) {
462513 await minifyEmittedAssets ( )
463514 }
515+
516+ // Subresource Integrity pass — runs LAST so the local-file hashes
517+ // we compute match the exact bytes that ship (post-minify). CDN
518+ // hashes are disk-cached under .cache/sri/; local ones hash the
519+ // files in `walkthroughDir` directly. Any `<script>` / `<link>`
520+ // (CDN or same-origin) ends up with `integrity="sha384-..."`.
521+ const sriCacheDir = path . join ( repoRoot , '.cache' , 'sri' )
522+ for ( const entry of readdirSync ( walkthroughDir ) ) {
523+ if ( ! entry . endsWith ( '.html' ) ) {
524+ continue
525+ }
526+ const htmlPath = path . join ( walkthroughDir , entry )
527+ const html = readFileSync ( htmlPath , 'utf8' )
528+ const withSri = await injectSri ( html , walkthroughDir , basePath , sriCacheDir )
529+ if ( withSri !== html ) {
530+ writeFileSync ( htmlPath , withSri )
531+ }
532+ }
464533}
465534
466535/**
0 commit comments