Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }