vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Resolve a README's relative links against the file, not the page
796ccd9051ef49f10655088ec39b8dfb844f4124 · Keenan Nemetz · 13d ago · parent 53ff967b
Resolve a README's relative links against the file, not the page A relative link was resolved by the browser against the URL the README happened to be rendered at, so `docs/getting-started.md` reached `/r/<repo>/docs/getting-started.md` from the overview page and 404'd, and the same link broke differently from the code browser. A document that lives in the tree now carries its own path, and its links and images are rewritten after parsing and before rendering: markdown opens the preview, other files open the source, images point at the raw route, a directory opens the tree, and `.`/`..` resolve the way a reader expects with climbing clamped at the repository root. A destination that already names somewhere — a scheme, `//host`, a site-absolute path, a bare `#anchor` or `?query` — is left alone, and so is any document with no place in a tree, such as an issue body or a comment. The rewrite works on the parsed tree rather than on the markdown text, so reference-style links are covered too, and the source view still shows the file's bytes unchanged. Refs #01M5XF52R132A88C6WEQ6H6BK6
server/web/markdown_test.go +100
| @@ -0,0 +1,100 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | // A relative link in a file has to be resolved the way a reader expects: against | ||
| 6 | // the directory the file lives in, not against the URL the page happens to be | ||
| 7 | // served from. The overview page and the code browser serve the same README from | ||
| 8 | // different addresses, so getting this wrong breaks both. | ||
| 9 | func TestDocHrefResolvesAgainstTheDocument(t *testing.T) { | ||
| 10 | t.Parallel() | ||
| 11 | root := treeDoc{Repo: "demo", Ref: "main", Path: "README.md"} | ||
| 12 | guide := treeDoc{Repo: "demo", Ref: "main", Path: "docs/guide.md"} | ||
| 13 | feature := treeDoc{Repo: "demo", Ref: "feat/tree", Path: "README.md"} | ||
| 14 | loose := treeDoc{Repo: "demo"} | ||
| 15 | |||
| 16 | cases := []struct { | ||
| 17 | name string | ||
| 18 | doc treeDoc | ||
| 19 | dest string | ||
| 20 | image bool | ||
| 21 | want string | ||
| 22 | ok bool | ||
| 23 | }{ | ||
| 24 | { | ||
| 25 | name: "markdown opens rendered", doc: root, dest: "docs/guide.md", | ||
| 26 | want: "/r/demo/blob/main/docs/guide.md?preview=1", ok: true, | ||
| 27 | }, | ||
| 28 | { | ||
| 29 | name: "fragment follows the preview flag", doc: root, dest: "docs/guide.md#using-it", | ||
| 30 | want: "/r/demo/blob/main/docs/guide.md?preview=1#using-it", ok: true, | ||
| 31 | }, | ||
| 32 | { | ||
| 33 | name: "other files open as source", doc: root, dest: "LICENSE", | ||
| 34 | want: "/r/demo/blob/main/LICENSE", ok: true, | ||
| 35 | }, | ||
| 36 | { | ||
| 37 | name: "nested non-markdown", doc: root, dest: ".vole/workflows/verify.yaml", | ||
| 38 | want: "/r/demo/blob/main/.vole/workflows/verify.yaml", ok: true, | ||
| 39 | }, | ||
| 40 | { | ||
| 41 | name: "a directory opens the tree", doc: root, dest: "docs/", | ||
| 42 | want: "/r/demo/tree/main/docs", ok: true, | ||
| 43 | }, | ||
| 44 | { | ||
| 45 | name: "the document's own directory", doc: guide, dest: "./", | ||
| 46 | want: "/r/demo/tree/main/docs", ok: true, | ||
| 47 | }, | ||
| 48 | { | ||
| 49 | name: "the repository root", doc: root, dest: "./", | ||
| 50 | want: "/r/demo/code?ref=main", ok: true, | ||
| 51 | }, | ||
| 52 | { | ||
| 53 | name: "parent climbs one level", doc: guide, dest: "../LICENSE", | ||
| 54 | want: "/r/demo/blob/main/LICENSE", ok: true, | ||
| 55 | }, | ||
| 56 | { | ||
| 57 | name: "climbing stops at the root", doc: guide, dest: "../../../LICENSE", | ||
| 58 | want: "/r/demo/blob/main/LICENSE", ok: true, | ||
| 59 | }, | ||
| 60 | { | ||
| 61 | name: "a sibling from a subdirectory", doc: guide, dest: "sibling.md", | ||
| 62 | want: "/r/demo/blob/main/docs/sibling.md?preview=1", ok: true, | ||
| 63 | }, | ||
| 64 | { | ||
| 65 | name: "images point at raw", doc: root, dest: "img/shot.png", image: true, | ||
| 66 | want: "/r/demo/raw/main/img/shot.png", ok: true, | ||
| 67 | }, | ||
| 68 | { | ||
| 69 | name: "images resolve against their own file", doc: guide, dest: "shot.png", image: true, | ||
| 70 | want: "/r/demo/raw/main/docs/shot.png", ok: true, | ||
| 71 | }, | ||
| 72 | { | ||
| 73 | name: "a ref with a slash stays one segment", doc: feature, dest: "LICENSE", | ||
| 74 | want: "/r/demo/blob/feat%2Ftree/LICENSE", ok: true, | ||
| 75 | }, | ||
| 76 | |||
| 77 | // Everything below already names somewhere, so the document does not get | ||
| 78 | // to reinterpret it. | ||
| 79 | {name: "scheme", doc: root, dest: "https://example.com/x"}, | ||
| 80 | {name: "mailto", doc: root, dest: "mailto:someone@example.com"}, | ||
| 81 | {name: "protocol relative", doc: root, dest: "//example.com/x"}, | ||
| 82 | {name: "site absolute", doc: root, dest: "/r/demo/issues/01M5JR2JK29KKMAA1T9MDNTPE1"}, | ||
| 83 | {name: "same document anchor", doc: root, dest: "#why-vole"}, | ||
| 84 | {name: "same document query", doc: root, dest: "?preview=1"}, | ||
| 85 | {name: "empty", doc: root, dest: ""}, | ||
| 86 | {name: "not in a tree", doc: loose, dest: "docs/guide.md"}, | ||
| 87 | } | ||
| 88 | |||
| 89 | for _, tc := range cases { | ||
| 90 | t.Run(tc.name, func(t *testing.T) { | ||
| 91 | got, ok := docHref(tc.doc, tc.dest, tc.image) | ||
| 92 | if ok != tc.ok { | ||
| 93 | t.Fatalf("docHref(%q) ok = %v, want %v", tc.dest, ok, tc.ok) | ||
| 94 | } | ||
| 95 | if got != tc.want { | ||
| 96 | t.Fatalf("docHref(%q) = %q, want %q", tc.dest, got, tc.want) | ||
| 97 | } | ||
| 98 | }) | ||
| 99 | } | ||
| 100 | } | ||