261024.31 · 30121df19b286fd695eb04bdd2e20920b19b0921
Forge links and pasted URLs lead to online repos
The web address was reachable only from one line on the index, which a reader seldom sees. Now a doc's link to a repo on GitHub, GitLab, Codeberg or Bitbucket, or to a file or directory in one, is followed by a small raised www that opens it on the web address. A forge URL typed or pasted into the search box, with or without https://, adds a result under "online" above the others, so Enter opens it.
webPath maps both and drops the branch or commit a file link names, since the web address serves the commit it fetched. Issues, accounts and other hosts get no mark. A share offers neither.
reading declarations
@@ -60,7 +60,7 @@ The package also ships a system template, `www@.service`, which serves a user's ## Online repos -The server on your default address also serves 127.31.N.2, where a URL names a repo on a forge, eg `http://127.31.1.2/github.com/junegunn/fzf/`. The first visit fetches it into `~/.cache/www/` while a page shows each step, and www then serves it as it serves a checkout. A path that names no repo, eg `/github.com/junegunn`, lists the repos there. See [online repos](doc/online-repos.md).+The server on your default address also serves 127.31.N.2, where a URL names a repo on a forge, eg `http://127.31.1.2/github.com/junegunn/fzf/`. The first visit fetches it into `~/.cache/www/` while a page shows each step, and www then serves it as it serves a checkout. A path that names no repo, eg `/github.com/junegunn`, lists the repos there. Two ways lead there from any page: paste a repo's URL into the search box, eg `github.com/junegunn/fzf`, and Enter opens it; and a doc's link to a repo on GitHub, GitLab, Codeberg or Bitbucket, or to a file in one, carries a small www mark that opens it there. See [online repos](doc/online-repos.md). ## Sharing
@@ -362,7 +362,22 @@ p.textContent = "recent"; results.prepend(p, recent); }- const any = recent.children.length + (near ? near.children.length : 0) + far.children.length;+ // A forge's URL leads, read on the web address; Enter opens it.+ if (res.web) {+ const web = list(false);+ web.classList.add("web");+ const li = document.createElement("li");+ const a = document.createElement("a");+ a.href = res.web.href;+ a.textContent = res.web.path;+ li.append(a);+ web.append(li);+ const p = document.createElement("p");+ p.className = "note";+ p.textContent = "online";+ results.prepend(p, web);+ }+ const any = (res.web ? 1 : 0) + recent.children.length + (near ? near.children.length : 0) + far.children.length; if (near && near.children.length) results.append(near); else if (near && any) line("unfound", "nothing in this directory itself"); if (far.children.length) results.append(far);@@ -515,7 +530,7 @@ // other focused control comes back to the box. const openMenu = () => document.querySelector("details.latest[open]"); const walkable = () =>- openMenu() ? [...openMenu().querySelectorAll("ol.versions a")] : (searching() ? [...matches, ...document.querySelectorAll("div.found a")] : [...document.querySelectorAll("main ul.listing a")])+ openMenu() ? [...openMenu().querySelectorAll("ol.versions a")] : (searching() ? [...document.querySelectorAll("div.found ul.web a"), ...matches, ...document.querySelectorAll("div.found ul:not(.web) a")] : [...document.querySelectorAll("main ul.listing a")]) .filter((el) => el.offsetParent !== null); const up = () => { const crumbs = document.querySelectorAll("header nav a");
@@ -210,6 +210,9 @@ main a:hover { text-decoration-color: currentColor; } /* A link whose target is missing keeps its words and wears a dotted underline in the danger colour; its title names the missing path. */ main a.broken { text-decoration-style: dotted; text-decoration-color: var(--danger); }+/* The mark after a forge link reads that repo on the web address. */+main a.web { font-family: ui-monospace, "Iosevka", monospace; font-size: 0.6em; vertical-align: super; line-height: 0; margin-left: 0.2em; color: var(--muted); text-decoration: none; }+main a.web:hover { color: inherit; } header a, ul.listing a, table.locks a, .latest a, .own a { color: inherit; text-decoration-color: var(--rule); } header a:visited, ul.listing a:visited, table.locks a:visited, .latest a:visited, .own a:visited { color: var(--muted); } header a:hover, ul.listing a:hover, table.locks a:hover, .latest a:hover, .own a:hover { text-decoration-color: currentColor; }@@ -327,7 +330,7 @@ button.copy:active { border-color: var(--shadow) var(--light) var(--light) var(- which the text would count as breaks. Line numbers are generated content, which the text never holds. */ body.copying main .own, body.copying main div.view, body.copying main div.backlinks,-body.copying main li.log, body.copying main li:has(> a[href="../"]), body.copying main i.scope { display: none; }+body.copying main li.log, body.copying main li:has(> a[href="../"]), body.copying main i.scope, body.copying main a.web { display: none; } /* Search results: the path is the program's and mono, a matching line is the file's and stays mono too, in the muted colour. */ div.found .line { color: var(--muted); font-size: 0.8em; hyphens: none; }@@ -343,7 +346,7 @@ pre.numberSource.filtering code > span > a:first-child::before { content: attr(d ::highlight(search) { background-color: var(--target); } div.found ul.wider { opacity: 0.6; margin-top: 1.4em; } div.found p.note:first-child { margin-top: 0; }-div.found ul.recent { margin-bottom: 1.4em; }+div.found ul.recent, div.found ul.web { margin-bottom: 1.4em; } /* Below a doc, the docs that link to it, in the program's quiet voice. */ div.backlinks ul { list-style: none; padding: 0; text-align: left; font-size: 0.8em; } div.backlinks li { padding: 0.075em 0; overflow-wrap: anywhere; }
@@ -4,6 +4,16 @@ The server on your default address also listens on the address after it, 127.31. The index at `/` on the default address links the web address. The web address's own `/` lists the repos in the cache, each with its size and when it was last read, and how much of the disk limit they fill. +## Ways in++The index at `/` names the web address, but a reader seldom sees the index, so two ways in lie where a reader already is.++A link in a doc to a repo on GitHub, GitLab, Codeberg or Bitbucket, or to a file or directory in one, is followed by a small www, raised like a footnote mark. It opens the same repo or file on the web address, and its title names the repo. The forge's own link stays as it was. A link to a forge page that is not a file, eg an issue or an account, gets no mark. A file link names a branch or commit, but the web address serves the commit it fetched, so the mark opens the file as it is there. The copy button leaves the mark out.++A forge URL typed or pasted into the search box, with or without `https://`, adds a result above the others, under "online". Enter opens it, since it comes first. A URL to any other host, eg `https://git.suckless.org/dwm`, gets no result there; type it after the web address instead.++Both appear wherever a server serves the web address: on its default address and on the web address itself. A share offers neither.+ ## The first visit A repo not yet cached opens a page that says "reading". It names each step as www takes it: the git command, the URL and where the files go, with git's progress below and its time. When the repo is ready the page turns into it on its own. For most repos that takes one to three seconds; git's own repo takes about 20.
@@ -54,5 +54,5 @@ func TestNameHasOneSource(t *testing.T) { } // foreign removes the words that hold www without naming the program: web-// hosts and the form media type.-var foreign = strings.NewReplacer("://www.", "://", "x-www-form-urlencoded", "")+// hosts, the host prefix a forge link may carry and the form media type.+var foreign = strings.NewReplacer("://www.", "://", `"www."`, "", "x-www-form-urlencoded", "")
@@ -43,11 +43,18 @@ var markdown = goldmark.New( // renderMarkdown renders a doc. missing, when given, reports whether a // relative link's path, unescaped and without query or fragment, names // nothing; such a link gets the class broken and a title that says so.-func renderMarkdown(src []byte, missing func(dest string) bool) (template.HTML, error) {+// web, when given, is where the web address serves, eg+// http://127.31.1.2:8080 or "" on the web address itself; a link to a+// repo on a forge, or a file in one, is followed by a mark that reads it+// there.+func renderMarkdown(src []byte, missing func(dest string) bool, web *string) (template.HTML, error) { doc := markdown.Parser().Parse(text.NewReader(src)) if missing != nil { markBroken(doc, missing) }+ if web != nil {+ markWeb(doc, src, *web)+ } var out bytes.Buffer if err := markdown.Renderer().Render(&out, src, doc); err != nil { return "", err@@ -74,6 +81,57 @@ func markBroken(doc ast.Node, missing func(string) bool) { }) } +// webMark follows a link to a forge with a link that reads it on the web+// address.+type webMark struct {+ ast.BaseInline+ href, key string+}++var kindWebMark = ast.NewNodeKind("WebMark")++func (n *webMark) Kind() ast.NodeKind { return kindWebMark }+func (n *webMark) Dump(src []byte, level int) { ast.DumpHelper(n, src, level, nil, nil) }++// markWeb puts a webMark after each link, bare URLs included, that names a+// repo on a forge or a file in one.+func markWeb(doc ast.Node, src []byte, base string) {+ ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {+ var dest string+ switch l := n.(type) {+ case *ast.Link:+ dest = string(l.Destination)+ case *ast.AutoLink:+ dest = string(l.URL(src))+ default:+ return ast.WalkContinue, nil+ }+ if !entering {+ return ast.WalkContinue, nil+ }+ u, err := url.Parse(dest)+ if err != nil {+ return ast.WalkSkipChildren, nil+ }+ if key, p := webPath(u); p != "" {+ href := base + (&url.URL{Path: p, Fragment: u.Fragment}).String()+ // The walk visits the mark next and passes it by.+ n.Parent().InsertAfter(n.Parent(), n, &webMark{href: href, key: key})+ }+ return ast.WalkSkipChildren, nil+ })+}++// renderWebMark writes the mark, the program's name, after the link.+func renderWebMark(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {+ if entering {+ m := node.(*webMark)+ esc := template.HTMLEscapeString+ w.WriteString(`<a class="web" href="` + esc(m.href) + `" title="read ` + esc(m.key) + ` in ` + progName + `">` + progName + `</a>`)+ }+ return ast.WalkContinue, nil+}+ // relativePath is the path a relative link names, unescaped, or "" for a // link with a scheme or host, an absolute path or only a fragment or // query.@@ -92,6 +150,7 @@ type nodeRenderer struct{} func (nodeRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) { r.Register(ast.KindFencedCodeBlock, renderFence) r.Register(east.KindTaskCheckBox, renderCheckBox)+ r.Register(kindWebMark, renderWebMark) } // renderCheckBox leaves out goldmark's disabled attribute, which greys the
@@ -9,7 +9,7 @@ import ( // A fence in a known language gets pandoc's markup without line numbers; // any other fence is plain. func TestFences(t *testing.T) {- h, err := renderMarkdown([]byte("```go\nx := 1\n```\n\n```nosuch\n<b>\n```\n"), nil)+ h, err := renderMarkdown([]byte("```go\nx := 1\n```\n\n```nosuch\n<b>\n```\n"), nil, nil) if err != nil { t.Fatal(err) }@@ -46,7 +46,7 @@ func TestCodeLexerByName(t *testing.T) { // Task list boxes are not disabled, so they keep the browser's colour. func TestTaskBoxes(t *testing.T) {- h, err := renderMarkdown([]byte("- [x] done\n- [ ] open\n"), nil)+ h, err := renderMarkdown([]byte("- [x] done\n- [ ] open\n"), nil, nil) if err != nil { t.Fatal(err) }@@ -80,7 +80,7 @@ func TestGoTokens(t *testing.T) { // A relative link to a missing path is marked; others are left alone. func TestBrokenLinks(t *testing.T) { missing := func(dest string) bool { return dest == "gone.md" || dest == "a b.md" }- h, err := renderMarkdown([]byte("[x](gone.md#top) [y](here.md) [z](https://example.com/gone.md) [w](a%20b.md) [v](#gone.md) [u](/gone.md)\n"), missing)+ h, err := renderMarkdown([]byte("[x](gone.md#top) [y](here.md) [z](https://example.com/gone.md) [w](a%20b.md) [v](#gone.md) [u](/gone.md)\n"), missing, nil) if err != nil { t.Fatal(err) }@@ -96,7 +96,7 @@ func TestBrokenLinks(t *testing.T) { // A dot fence becomes inline SVG when dot is installed, and code with the // reason otherwise. func TestGraphFence(t *testing.T) {- h, err := renderMarkdown([]byte("```dot\ndigraph { a -> b }\n```\n"), nil)+ h, err := renderMarkdown([]byte("```dot\ndigraph { a -> b }\n```\n"), nil, nil) if err != nil { t.Fatal(err) }@@ -110,7 +110,7 @@ func TestGraphFence(t *testing.T) { if !strings.Contains(s, `<div class="graph"><svg`) || strings.Contains(s, "<?xml") { t.Errorf("not inline SVG: %.200s", s) }- h, _ = renderMarkdown([]byte("```dot\ndigraph { a -> }\n```\n"), nil)+ h, _ = renderMarkdown([]byte("```dot\ndigraph { a -> }\n```\n"), nil, nil) if s := string(h); !strings.Contains(s, `class="own warn"`) || !strings.Contains(s, "<pre") { t.Errorf("bad graph not shown as code with a reason: %s", s) }@@ -134,7 +134,7 @@ func TestWikiLinks(t *testing.T) { "`[[code]]`": `<code>[[code]]</code>`, "see [[a]] and [[b|B]] ok": `see <a href="a.md">a</a> and <a href="b.md">B</a> ok`, } {- h, err := renderMarkdown([]byte(src), missing)+ h, err := renderMarkdown([]byte(src), missing, nil) if err != nil { t.Fatal(err) }@@ -143,3 +143,29 @@ func TestWikiLinks(t *testing.T) { } } }++// A link to a forge's repo, bare or not, is followed by a mark that reads+// it on the web address; other links are not.+func TestWebMarks(t *testing.T) {+ base := "http://127.31.1.2:8080"+ src := "[fzf](https://github.com/junegunn/fzf/blob/master/README.md#usage), https://github.com/junegunn/fzf and [x](https://example.com/a/b)\n"+ h, err := renderMarkdown([]byte(src), nil, &base)+ if err != nil {+ t.Fatal(err)+ }+ s := string(h)+ if n := strings.Count(s, `class="web"`); n != 2 {+ t.Errorf("%d marks, want 2: %s", n, s)+ }+ for _, want := range []string{+ `fzf</a><a class="web" href="http://127.31.1.2:8080/github.com/junegunn/fzf/README.md#usage" title="read github.com/junegunn/fzf in ` + progName + `">` + progName + `</a>`,+ `https://github.com/junegunn/fzf</a><a class="web" href="http://127.31.1.2:8080/github.com/junegunn/fzf/"`,+ } {+ if !strings.Contains(s, want) {+ t.Errorf("no %s in %s", want, s)+ }+ }+ if h, _ := renderMarkdown([]byte(src), nil, nil); strings.Contains(string(h), `class="web"`) {+ t.Errorf("marks without a web address: %s", h)+ }+}
@@ -55,6 +55,8 @@ type found struct { Lines []hit `json:"lines"` Files int `json:"files"` // files searched for content Cut bool `json:"cut"` // stopped at the time budget or the hit limits+ // Web reads the query on the web address, when it is a forge's URL.+ Web *hit `json:"web,omitempty"` } // serveSearch answers ?search=q below name: names that hold q first, then@@ -65,11 +67,35 @@ func (t *tree) serveSearch(w http.ResponseWriter, r *http.Request, name, q strin ctx, cancel := context.WithTimeout(r.Context(), searchBudget) defer cancel() res := t.search(ctx, name, q)+ res.Web = t.webHit(q) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") json.NewEncoder(w).Encode(res) } +// webHit reads the query on the web address when it is a link to a repo+// on a forge, or a file in one, with or without its scheme, eg+// github.com/junegunn/fzf.+func (t *tree) webHit(q string) *hit {+ base := t.webBase()+ q = strings.TrimSpace(q)+ if base == nil || strings.ContainsAny(q, " \t") {+ return nil+ }+ if !strings.Contains(q, "://") {+ q = "https://" + q+ }+ u, err := url.Parse(q)+ if err != nil {+ return nil+ }+ _, p := webPath(u)+ if p == "" {+ return nil+ }+ return &hit{Href: *base + (&url.URL{Path: p, Fragment: u.Fragment}).String(), Path: strings.TrimPrefix(p, "/")}+}+ func (t *tree) search(ctx context.Context, dir, q string) found { res := found{Names: []hit{}, Lines: []hit{}} lq := strings.ToLower(strings.TrimSpace(q))
@@ -2,6 +2,7 @@ package main import ( "encoding/json"+ "net/url" "os" "path/filepath" "strings"@@ -94,3 +95,33 @@ func TestSearchHidden(t *testing.T) { t.Errorf("lines: %v", paths) } }++// A query that is a forge's URL, with or without its scheme, comes back as+// a link to the web address, and only where a web address is served.+func TestSearchWeb(t *testing.T) {+ s, _, _ := serveTree(t)+ var res found+ w := get(s.handler(), "/?search="+url.QueryEscape("github.com/junegunn/fzf"))+ json.Unmarshal(w.Body.Bytes(), &res)+ if res.Web != nil {+ t.Errorf("web hit without a web address: %+v", res.Web)+ }+ s.webURL = "http://127.31.1.2:8080"+ for q, want := range map[string]string{+ "github.com/junegunn/fzf": "http://127.31.1.2:8080/github.com/junegunn/fzf/",+ " https://github.com/junegunn/fzf/blob/master/main.go ": "http://127.31.1.2:8080/github.com/junegunn/fzf/main.go",+ } {+ res = found{}+ w = get(s.handler(), "/?search="+url.QueryEscape(q))+ json.Unmarshal(w.Body.Bytes(), &res)+ if res.Web == nil || res.Web.Href != want {+ t.Errorf("%q: %+v, want %s", q, res.Web, want)+ }+ }+ res = found{}+ w = get(s.handler(), "/?search=readme")+ json.Unmarshal(w.Body.Bytes(), &res)+ if res.Web != nil {+ t.Errorf("web hit for a word: %+v", res.Web)+ }+}
@@ -205,6 +205,7 @@ type tree struct { shared bool // served beyond loopback: only what other users could read secret bool // mounted under a token, and left off the index web *webRepo // the cached repo this tree is a worktree of, on the web address+ srv *server // the server that serves it, set when it joins linked linkIndex // the relative links of each doc, for ?backlinks at *commit // for a version, the commit it shows base *tree // for a version, the tree it is a version of@@ -224,6 +225,7 @@ func newTree(root *os.Root, dir, prefix, label string) *tree { func (s *server) add(t *tree) { s.mu.Lock() defer s.mu.Unlock()+ t.srv = s s.trees = append(s.trees, t) s.live.kick() }@@ -764,7 +766,7 @@ func (t *tree) serveMarkdown(w http.ResponseWriter, r *http.Request, name string t.fail(w, r, name, http.StatusInternalServerError, err.Error()) return }- body, err := renderMarkdown(b, t.missing(name))+ body, err := renderMarkdown(b, t.missing(name), t.webBase()) if err != nil { t.fail(w, r, name, http.StatusInternalServerError, err.Error()) return@@ -773,6 +775,24 @@ func (t *tree) serveMarkdown(w http.ResponseWriter, r *http.Request, name string t.render(w, r, http.StatusOK, page{Title: name, Crumbs: t.crumbs(name), Kind: "doc", Body: body, Raw: true}) } +// webBase is where t's docs send a link to a forge: the web address, ""+// on the web address itself, or nil where no server here serves it, eg on+// a share.+func (t *tree) webBase() *string {+ if t.base != nil {+ return t.base.webBase()+ }+ switch s := t.srv; {+ case s == nil:+ return nil+ case s.web != nil:+ return new(string)+ case s.webURL != "":+ return &s.webURL+ }+ return nil+}+ // missing reports, for the doc name, whether a relative link names // nothing the reader could open. A link that climbs out of the tree may // land in another tree, so it counts as found.
@@ -453,6 +453,75 @@ var depths = map[string]int{"github.com": 3, "bitbucket.org": 3, "codeberg.org": // groups, host included. var minDepths = map[string]int{"gitlab.com": 3} +// forgeViews gives, for each forge a doc's links are read from, the path+// segments after a repo's key that lead into a file or directory at a+// ref, eg blob and tree on GitHub. The ref follows them, then the path.+var forgeViews = map[string][][]string{+ "github.com": {{"blob"}, {"tree"}},+ "codeberg.org": {{"src", "branch"}, {"src", "tag"}, {"src", "commit"}},+ "bitbucket.org": {{"src"}},+ "gitlab.com": {{"-", "blob"}, {"-", "tree"}},+}++// forgePages are GitHub's first segments that name a page of the site,+// not an account, eg github.com/sponsors/junegunn.+var forgePages = map[string]bool{+ "about": true, "apps": true, "collections": true, "customer-stories": true, "enterprise": true,+ "features": true, "login": true, "marketplace": true, "orgs": true, "settings": true,+ "site": true, "sponsors": true, "topics": true, "trending": true, "users": true,+}++// webPath is the path on the web address that reads what the forge URL u+// shows, eg /github.com/junegunn/fzf/README.md for+// https://github.com/junegunn/fzf/blob/master/README.md, and the repo's+// key, or "" when u is not a repo or a file in one on a forge in+// forgeViews. The web address serves the commit it fetched, so the ref in+// u is dropped.+func webPath(u *url.URL) (key, p string) {+ if u.Scheme != "https" && u.Scheme != "http" || u.User != nil || u.Port() != "" {+ return "", ""+ }+ host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.")+ views, ok := forgeViews[host]+ if !ok {+ return "", ""+ }+ segs := strings.FieldsFunc(u.Path, func(r rune) bool { return r == '/' })+ if slices.ContainsFunc(segs, func(s string) bool { return s == "." || s == ".." }) {+ return "", ""+ }+ // A repo's key ends where a view begins, or after owner/name.+ n := len(segs)+ if host == "gitlab.com" {+ if i := slices.Index(segs, "-"); i >= 0 {+ n = i+ }+ } else {+ n = min(n, 2)+ }+ if n < 2 || host == "github.com" && forgePages[segs[0]] {+ return "", ""+ }+ k := append([]string{host}, segs[:n]...)+ k[n] = strings.TrimSuffix(k[n], ".git")+ key = strings.Join(k, "/")+ p = "/" + key + "/"+ rest := segs[n:]+ if len(rest) == 0 {+ return key, p+ }+ for _, v := range views {+ if len(rest) > len(v) && slices.Equal(rest[:len(v)], v) {+ file := strings.Join(rest[len(v)+1:], "/")+ if file != "" && v[len(v)-1] == "tree" {+ file += "/"+ }+ return key, p + file+ }+ }+ return "", ""+}+ // probe asks every prefix of the path at once whether a repo answers // there, each with and without .git, and takes the longest that does. It // returns the repo's key and URL, or "" and whether it probed at all.
@@ -6,6 +6,7 @@ import ( "net/http" "net/http/cgi" "net/http/httptest"+ "net/url" "os" "os/exec" "path/filepath"@@ -295,3 +296,34 @@ func TestWebEndpoint(t *testing.T) { t.Error("a server on another address serves the web") } }++// A link to a repo on a forge, or a file or directory in one, maps to its+// path on the web address; other pages of a forge, and other hosts, map+// to nothing.+func TestWebPath(t *testing.T) {+ for in, want := range map[string]string{+ "https://github.com/junegunn/fzf": "/github.com/junegunn/fzf/",+ "https://www.github.com/junegunn/fzf.git": "/github.com/junegunn/fzf/",+ "https://github.com/junegunn/fzf/blob/master/README.md": "/github.com/junegunn/fzf/README.md",+ "https://github.com/junegunn/fzf/tree/v0.50/shell": "/github.com/junegunn/fzf/shell/",+ "https://github.com/junegunn/fzf/tree/master": "/github.com/junegunn/fzf/",+ "https://codeberg.org/forgejo/forgejo/src/branch/main/go.mod": "/codeberg.org/forgejo/forgejo/go.mod",+ "https://gitlab.com/a/b/c/-/blob/main/x.md": "/gitlab.com/a/b/c/x.md",+ "https://gitlab.com/a/b": "/gitlab.com/a/b/",+ "https://bitbucket.org/a/b/src/main/x.md": "/bitbucket.org/a/b/x.md",+ "https://github.com/junegunn/fzf/issues/3": "",+ "https://github.com/junegunn": "",+ "https://github.com/sponsors/junegunn": "",+ "https://example.com/a/b": "",+ "ftp://github.com/a/b": "",+ "https://github.com/a/../b": "",+ } {+ u, err := url.Parse(in)+ if err != nil {+ t.Fatal(err)+ }+ if _, got := webPath(u); got != want {+ t.Errorf("%s: %q, want %q", in, got, want)+ }+ }+}