diff --git a/setup.go b/setup.go index 83e786bf..467edb5c 100644 --- a/setup.go +++ b/setup.go @@ -23,11 +23,8 @@ type SetupFunc = func(jw *Jaws, handleFn HandleFunc, prefix string) (urls []*url // makeAbsPath returns a copy of u with prefix prepended to relative paths. // -// When a non-empty prefix is applied the result is made absolute (a leading slash is -// ensured), so the head URL matches the always-absolute handler pattern that -// [staticserve.NormalizeGET] registers for a [staticserve.StaticServe] extra. A -// relative prefix would otherwise leave the URL relative, which a browser resolves -// against the current page and so fails to load on any non-root page. +// When a non-empty prefix is applied, the joined path is slash-rooted. An empty +// prefix preserves a relative URL. func makeAbsPath(prefix string, u *url.URL) *url.URL { if u != nil { copied := *u @@ -48,6 +45,13 @@ func makeAbsPath(prefix string, u *url.URL) *url.URL { // It calls [Jaws.GenerateHeadHTML] with the final list of URLs, with any // relative URL paths prefixed with prefix. // +// [staticserve.StaticServe] extras are local resources. Their generated URLs +// are slash-rooted so they match their registered handlers, including when +// prefix is empty. Other relative URL extras remain relative with an empty +// prefix. Each [staticserve.StaticServe.Name] is treated as a literal path, +// not as a pre-escaped URL; percent signs in a name are escaped as literal +// percent signs. +// // If handleFn is nil, Setup generates head HTML from the configured resources // without registering any handlers. func (jw *Jaws) Setup(handleFn HandleFunc, prefix string, extras ...any) (err error) { @@ -59,14 +63,14 @@ func (jw *Jaws) Setup(handleFn HandleFunc, prefix string, extras ...any) (err er handleStaticServe := func(ss *staticserve.StaticServe) { if ss != nil { - u, urlErr := url.Parse(ss.Name) - err = errors.Join(err, urlErr) - if u != nil { - u = makeAbsPath(prefix, u) - urls = append(urls, u) - if handleFn != nil { - setupHandleFn(staticserve.NormalizeGET(u.String()), ss) - } + assetPath := ss.Name + if !path.IsAbs(assetPath) { + assetPath = path.Join(prefix, assetPath) + } + u := &url.URL{Path: path.Join("/", assetPath)} + urls = append(urls, u) + if handleFn != nil { + setupHandleFn(staticserve.NormalizeGET(u.String()), ss) } } } diff --git a/setup_test.go b/setup_test.go index baa662c3..bdf41c7a 100644 --- a/setup_test.go +++ b/setup_test.go @@ -2,6 +2,7 @@ package jaws import ( "net/http" + "net/http/httptest" "net/url" "path" "strings" @@ -182,22 +183,133 @@ func TestJaws_SetupDoesNotPrefixProtocolRelativeURL(t *testing.T) { func TestJaws_SetupEmptyPrefix(t *testing.T) { ss := staticserve.Must("favicon.png", []byte("Hello")) - jw, _ := New() + jw, err := New() + if err != nil { + t.Fatal(err) + } defer jw.Close() - mux := &testMux{} - _ = jw.Setup(mux.Handle, "", ss) + mux := http.NewServeMux() + if err = jw.Setup(mux.Handle, "", ss); err != nil { + t.Fatal(err) + } - if got := jw.FaviconURL(); got != ss.Name { - t.Errorf("unexpected favicon URL: %q", got) + headURL := jw.FaviconURL() + if want := "/" + ss.Name; headURL != want { + t.Fatalf("favicon URL = %q, want %q", headURL, want) } - if len(mux.m) != 1 { - t.Fatalf("expected 1 handler, got %d", len(mux.m)) + if !strings.Contains(jw.headPrefix, `href="`+headURL+`"`) { + t.Fatalf("head HTML %q does not contain favicon URL %q", jw.headPrefix, headURL) } - for pattern := range mux.m { - if want := "GET /" + ss.Name; pattern != want { - t.Errorf("expected pattern %q, got %q", want, pattern) + + pageURL, err := url.Parse("http://example.test/account/view") + if err != nil { + t.Fatal(err) + } + ref, err := url.Parse(headURL) + if err != nil { + t.Fatal(err) + } + resolved := pageURL.ResolveReference(ref) + if resolved.Path != headURL { + t.Fatalf("favicon URL %q resolves from nested page to %q", headURL, resolved.Path) + } + + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, resolved.String(), nil)) + if rr.Code != http.StatusOK { + t.Fatalf("GET %q = %d, want %d", resolved.Path, rr.Code, http.StatusOK) + } + if got := rr.Body.String(); got != "Hello" { + t.Fatalf("GET %q body = %q, want %q", resolved.Path, got, "Hello") + } +} + +func TestJaws_SetupStaticServeEscapesName(t *testing.T) { + ss := staticserve.Must(`favicon:scheme {asset}#query?percent%\file.png`, []byte("Hello")) + + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + mux := http.NewServeMux() + if err = jw.Setup(mux.Handle, "", ss); err != nil { + t.Fatal(err) + } + + headURL := jw.FaviconURL() + if !strings.HasPrefix(headURL, "/favicon:scheme") { + t.Fatalf("favicon URL is not slash-rooted: %q", headURL) + } + for _, escaped := range []string{"%20", "%7Basset%7D", "%23", "%3F", "%25", "%5C"} { + if !strings.Contains(headURL, escaped) { + t.Errorf("favicon URL %q does not contain %q", headURL, escaped) } } + + u, err := url.Parse(headURL) + if err != nil { + t.Fatal(err) + } + if u.RawQuery != "" || u.Fragment != "" { + t.Fatalf("favicon URL parsed with query %q and fragment %q", u.RawQuery, u.Fragment) + } + + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, headURL, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("GET %q = %d, want %d", headURL, rr.Code, http.StatusOK) + } + + wildcardURL := strings.Replace(headURL, "%7Basset%7D", "other", 1) + rr = httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, wildcardURL, nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("GET wildcard candidate %q = %d, want %d", wildcardURL, rr.Code, http.StatusNotFound) + } +} + +func TestJaws_SetupEmptyPrefixKeepsGenericRelativeURLs(t *testing.T) { + urlExtra, err := url.Parse("url.css") + if err != nil { + t.Fatal(err) + } + setupURL, err := url.Parse("setup.css") + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + extra any + want string + }{ + {name: "string", extra: "string.css", want: "string.css"}, + {name: "URL", extra: urlExtra, want: "url.css"}, + {name: "SetupFunc", extra: SetupFunc(func(_ *Jaws, _ HandleFunc, _ string) (urls []*url.URL, err error) { + urls = append(urls, setupURL) + return + }), want: "setup.css"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + if err = jw.Setup(nil, "", tc.extra); err != nil { + t.Fatal(err) + } + + if !strings.Contains(jw.headPrefix, `href="`+tc.want+`"`) { + t.Fatalf("head HTML %q does not contain relative URL %q", jw.headPrefix, tc.want) + } + if strings.Contains(jw.headPrefix, `href="/`+tc.want+`"`) { + t.Fatalf("head HTML %q slash-rooted relative URL %q", jw.headPrefix, tc.want) + } + }) + } } func TestJaws_SetupKeepsMethodPattern(t *testing.T) {