From 35786c60cd666da70893213bb69de3c02d40d893 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 7 Aug 2026 19:10:45 +0200 Subject: [PATCH] fix(assets): emit reusable preload metadata Give generic resources a fetch destination and anonymous CORS, and match font preloads to CSS font fetching with anonymous CORS. Cover both paths with process-local MIME registrations and preserve the existing image, favicon, script, stylesheet, and escaping behavior. --- jaws.go | 12 +++---- lib/assets/js.go | 26 ++++++++++----- lib/assets/js_test.go | 75 +++++++++++++++++++++++++++++++------------ 3 files changed, 79 insertions(+), 34 deletions(-) diff --git a/jaws.go b/jaws.go index 61d7a9aa..db7ec0f6 100644 --- a/jaws.go +++ b/jaws.go @@ -390,18 +390,18 @@ func (m secureHeadersMiddleware) ServeHTTP(hw http.ResponseWriter, hr *http.Requ m.Handler.ServeHTTP(hw, hr) } -// GenerateHeadHTML regenerates the HTML code that goes in the HEAD section, -// ensuring that the provided URL resources in extra are loaded, along with the -// JaWS JavaScript. +// GenerateHeadHTML regenerates the HTML code that goes in the HEAD section. // -// If one of the resources is named "favicon", its URL will be stored and can -// be retrieved using [Jaws.FaviconURL]. +// It emits the provided URL resources in extra according to +// [assets.PreloadHTML], along with the JaWS JavaScript and stylesheet. If an +// extra resource's base name begins with "favicon" and its MIME type is image/*, +// its URL is stored and can be retrieved using [Jaws.FaviconURL]. // // If one or more URLs in extra fail to parse, GenerateHeadHTML still installs // the regenerated head HTML and Content-Security-Policy with the failing // resources omitted, and returns the joined parse errors. // -// You only need to call this if you add your own images, scripts and stylesheets. +// Call GenerateHeadHTML after changing [Jaws.Debug] or the extra resources. func (jw *Jaws) GenerateHeadHTML(extra ...string) (err error) { var jawsurl *url.URL if jawsurl, err = url.Parse(jw.serveJS.Name); err == nil { diff --git a/lib/assets/js.go b/lib/assets/js.go index e7da1233..6a737049 100644 --- a/lib/assets/js.go +++ b/lib/assets/js.go @@ -26,17 +26,18 @@ var JawsCSS string // PreloadHTML returns HTML code to load the given resources efficiently. // -// JavaScript and CSS files are emitted as script and stylesheet tags. Other -// recognized resources are emitted as preload tags. Favicon image URLs are -// returned separately. +// JavaScript and CSS files are emitted as script and stylesheet tags. Image and +// font resources are emitted as preloads for their respective destinations; +// other resources use the fetch destination. Font and fetch preloads use +// anonymous CORS. Favicon image URLs are returned separately. A recognized MIME +// type is included in the preload's type attribute. // // Nil URL arguments are skipped. A resource is returned as faviconURL only when // its base name begins with "favicon" and its MIME type (resolved from the file -// extension) is image/*; a favicon whose extension has no image MIME mapping is -// emitted as an ordinary preload link instead. A .js or .css resource is always -// emitted as a script or stylesheet and is never treated as a favicon, regardless -// of its base name. If more than one resource qualifies as a favicon, the last one -// wins and earlier favicon URLs are discarded rather than emitted as preload links. +// extension) is image/*. A .js or .css resource is always emitted as a script or +// stylesheet and is never treated as a favicon, regardless of its base name. If +// more than one resource qualifies as a favicon, the last one wins and earlier +// favicon URLs are discarded rather than emitted as preload links. func PreloadHTML(urls ...*url.URL) (htmlCode, faviconURL string) { var jsurls, cssurls []string var favicontype string @@ -46,6 +47,7 @@ func PreloadHTML(urls ...*url.URL) (htmlCode, faviconURL string) { continue } var asattr string + var crossorigin bool ext := strings.ToLower(path.Ext(u.Path)) mimetype := mime.TypeByExtension(ext) mimetype, _, _ = strings.Cut(mimetype, ";") @@ -62,8 +64,13 @@ func PreloadHTML(urls ...*url.URL) (htmlCode, faviconURL string) { // unrelated types such as "imagery/*" or "fontastic/*" are not mistaken // for "image/*" or "font/*". lowmime := strings.ToLower(mimetype) + // The URL and MIME type provide no more specific consumer destination + // for other resources, so fetch is the actionable generic fallback. + asattr = "fetch" + crossorigin = true if strings.HasPrefix(lowmime, "image/") { asattr = "image" + crossorigin = false if strings.HasPrefix(strings.ToLower(path.Base(u.Path)), "favicon") { favicontype = mimetype faviconURL = urlstr @@ -81,6 +88,9 @@ func PreloadHTML(urls ...*url.URL) (htmlCode, faviconURL string) { if mimetype != "" { buf = htmlio.AppendAttr(buf, "type", mimetype) } + if crossorigin { + buf = htmlio.AppendAttr(buf, "crossorigin", "anonymous") + } buf = append(buf, ">\n"...) } for _, urlstr := range cssurls { diff --git a/lib/assets/js_test.go b/lib/assets/js_test.go index e790c8c0..9f337010 100644 --- a/lib/assets/js_test.go +++ b/lib/assets/js_test.go @@ -28,9 +28,10 @@ func Test_PreloadHTML(t *testing.T) { const extraStyle = "someExtraStyle.css" const extraImage = "favicon.png" const extraLogo = "logo.png" - const extraBinary = "data" + const extraUnknown = "data" const extraFont = "someExtraFont.woff2" const extraFontWithQuery = "someExtraFontQuery.woff2?x=1©=2" + fontMime, _, _ := strings.Cut(mime.TypeByExtension(".woff2"), ";") serveJS, err := staticserve.New("/jaws/.jaws.js", []byte(JavascriptText)) if err != nil { @@ -66,7 +67,7 @@ func Test_PreloadHTML(t *testing.T) { mustParseURL(extraStyle), mustParseURL(extraImage), mustParseURL(extraLogo), - mustParseURL(extraBinary), + mustParseURL(extraUnknown), mustParseURL(extraFont), mustParseURL(extraFontWithQuery), ) @@ -97,20 +98,26 @@ func Test_PreloadHTML(t *testing.T) { if strings.Count(txt, "") { t.Fatalf("script tags are unbalanced: %q", txt) } + for line := range strings.SplitSeq(txt, "\n") { + if strings.Contains(line, `rel="preload"`) && !strings.Contains(line, ` as="`) { + t.Errorf("preload link has no request destination: %q", line) + } + } // Assert the full as/type structure, not just substring presence. Compute the // expected MIME types the same way PreloadHTML does so the test stays correct // regardless of the platform's MIME table. - fontMime, _, _ := strings.Cut(mime.TypeByExtension(".woff2"), ";") - var wantFontLink string + fontAs := "fetch" // Classify the family exactly as PreloadHTML does (case-insensitive "font/" // prefix) so the expectation matches the code on any platform MIME table. if strings.HasPrefix(strings.ToLower(fontMime), "font/") { - wantFontLink = `` - } else { - // No font/* MIME on this platform: still a preload link, but no as/type. - wantFontLink = `` + fontAs = "font" } + wantFontLink := `` if !strings.Contains(txt, wantFontLink) { t.Fatalf("missing structured font preload %q in %q", wantFontLink, txt) } @@ -128,11 +135,10 @@ func Test_PreloadHTML(t *testing.T) { t.Fatalf("missing structured image preload %q in %q", wantLogoLink, txt) } - // An extensionless / unknown-MIME resource yields the bare preload form with - // neither as= nor type=. - wantBinaryLink := `` - if !strings.Contains(txt, wantBinaryLink) { - t.Fatalf("missing bare preload link %q in %q", wantBinaryLink, txt) + // An extensionless / unknown-MIME resource uses fetch without a type. + wantUnknownLink := `` + if !strings.Contains(txt, wantUnknownLink) { + t.Fatalf("missing fetch preload %q in %q", wantUnknownLink, txt) } if fav != extraImage { @@ -140,6 +146,35 @@ func Test_PreloadHTML(t *testing.T) { } } +func Test_PreloadHTML_FetchAndFontPreloadMetadata(t *testing.T) { + const ( + wasmExt = ".jawspreloadwasm" + fontExt = ".jawspreloadfont" + ) + for ext, typ := range map[string]string{ + wasmExt: "application/wasm", + fontExt: "font/jaws-test", + } { + if err := mime.AddExtensionType(ext, typ); err != nil { + t.Fatalf("AddExtensionType(%q, %q): %v", ext, typ, err) + } + } + + htmlCode, faviconURL := PreloadHTML( + &url.URL{Path: "module" + wasmExt, RawQuery: "x=1©=2"}, + &url.URL{Path: "face" + fontExt, RawQuery: "x=1©=2"}, + ) + want := ` + +` + if htmlCode != want { + t.Fatalf("PreloadHTML() = %q, want %q", htmlCode, want) + } + if faviconURL != "" { + t.Fatalf("faviconURL = %q, want empty", faviconURL) + } +} + // Test_PreloadHTML_MultipleFaviconsLastWins pins the documented contract that when // several resources qualify as favicons, only the last is honored (returned as // faviconURL and emitted as the rel="icon" link) and the earlier ones are discarded @@ -201,15 +236,15 @@ func Test_PreloadHTML_MIMEFamilyMatching(t *testing.T) { mustParseURL("brand.jawsupperfont"), // FONT/* is font/* (case-insensitive) ) - // imagery/* is not an image: it never becomes a favicon and falls through to a - // bare preload link carrying only its (non-image) type, with no as="image". - wantImagery := `` + // imagery/* is not an image: it never becomes a favicon and uses the generic + // fetch destination rather than as="image". + wantImagery := `` if !strings.Contains(txt, wantImagery) { t.Errorf("imagery/* preload = missing %q in %q", wantImagery, txt) } - // fontastic/* is not a font: no as="font" is attached. - wantFontastic := `` + // fontastic/* is not a font: it uses fetch rather than as="font". + wantFontastic := `` if !strings.Contains(txt, wantFontastic) { t.Errorf("fontastic/* preload = missing %q in %q", wantFontastic, txt) } @@ -224,8 +259,8 @@ func Test_PreloadHTML_MIMEFamilyMatching(t *testing.T) { t.Errorf("case-insensitive favicon = missing %q in %q", wantFaviconLink, txt) } - // FONT/* qualifies as a font, so it receives as="font". - wantUpperFont := `` + // FONT/* qualifies as a font, so it receives as="font" and anonymous CORS. + wantUpperFont := `` if !strings.Contains(txt, wantUpperFont) { t.Errorf("case-insensitive font preload = missing %q in %q", wantUpperFont, txt) }