Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions jaws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 18 additions & 8 deletions lib/assets/js.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, ";")
Expand All @@ -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
Expand All @@ -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 {
Expand Down
75 changes: 55 additions & 20 deletions lib/assets/js_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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&copy=2"
fontMime, _, _ := strings.Cut(mime.TypeByExtension(".woff2"), ";")

serveJS, err := staticserve.New("/jaws/.jaws.js", []byte(JavascriptText))
if err != nil {
Expand Down Expand Up @@ -66,7 +67,7 @@ func Test_PreloadHTML(t *testing.T) {
mustParseURL(extraStyle),
mustParseURL(extraImage),
mustParseURL(extraLogo),
mustParseURL(extraBinary),
mustParseURL(extraUnknown),
mustParseURL(extraFont),
mustParseURL(extraFontWithQuery),
)
Expand Down Expand Up @@ -97,20 +98,26 @@ func Test_PreloadHTML(t *testing.T) {
if strings.Count(txt, "<script") != strings.Count(txt, "</script>") {
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 = `<link rel="preload" href="someExtraFont.woff2" as="font" type="` + fontMime + `">`
} else {
// No font/* MIME on this platform: still a preload link, but no as/type.
wantFontLink = `<link rel="preload" href="someExtraFont.woff2">`
fontAs = "font"
}
wantFontLink := `<link rel="preload" href="someExtraFont.woff2" as="` + fontAs + `"`
if fontMime != "" {
wantFontLink += ` type="` + fontMime + `"`
}
wantFontLink += ` crossorigin="anonymous">`
if !strings.Contains(txt, wantFontLink) {
t.Fatalf("missing structured font preload %q in %q", wantFontLink, txt)
}
Expand All @@ -128,18 +135,46 @@ 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 := `<link rel="preload" href="data">`
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 := `<link rel="preload" href="data" as="fetch" crossorigin="anonymous">`
if !strings.Contains(txt, wantUnknownLink) {
t.Fatalf("missing fetch preload %q in %q", wantUnknownLink, txt)
}

if fav != extraImage {
t.Fatalf("favicon = %q, want %q", fav, extraImage)
}
}

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&copy=2"},
&url.URL{Path: "face" + fontExt, RawQuery: "x=1&copy=2"},
)
want := `<link rel="preload" href="module.jawspreloadwasm?x=1&amp;copy=2" as="fetch" type="application/wasm" crossorigin="anonymous">
<link rel="preload" href="face.jawspreloadfont?x=1&amp;copy=2" as="font" type="font/jaws-test" crossorigin="anonymous">
`
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
Expand Down Expand Up @@ -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 := `<link rel="preload" href="favicon.jawsimagery" type="imagery/not-an-image">`
// imagery/* is not an image: it never becomes a favicon and uses the generic
// fetch destination rather than as="image".
wantImagery := `<link rel="preload" href="favicon.jawsimagery" as="fetch" type="imagery/not-an-image" crossorigin="anonymous">`
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 := `<link rel="preload" href="brand.jawsfontastic" type="fontastic/not-a-font">`
// fontastic/* is not a font: it uses fetch rather than as="font".
wantFontastic := `<link rel="preload" href="brand.jawsfontastic" as="fetch" type="fontastic/not-a-font" crossorigin="anonymous">`
if !strings.Contains(txt, wantFontastic) {
t.Errorf("fontastic/* preload = missing %q in %q", wantFontastic, txt)
}
Expand All @@ -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 := `<link rel="preload" href="brand.jawsupperfont" as="font" type="FONT/woff2">`
// FONT/* qualifies as a font, so it receives as="font" and anonymous CORS.
wantUpperFont := `<link rel="preload" href="brand.jawsupperfont" as="font" type="FONT/woff2" crossorigin="anonymous">`
if !strings.Contains(txt, wantUpperFont) {
t.Errorf("case-insensitive font preload = missing %q in %q", wantUpperFont, txt)
}
Expand Down
Loading