v0.9.0 dd8597a

markdown.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"bytes"
7	"html/template"
8	"net/url"
9	"path"
10	"strings"
11
12	"github.com/yuin/goldmark"
13	"github.com/yuin/goldmark/ast"
14	"github.com/yuin/goldmark/extension"
15	"github.com/yuin/goldmark/parser"
16	"github.com/yuin/goldmark/renderer"
17	"github.com/yuin/goldmark/text"
18	"github.com/yuin/goldmark/util"
19)
20
21// headingIDPrefix keeps the ids of README headings apart from the ids of the
22// page (main, keys, cmd-list…): a heading must not be able to take one.
23const headingIDPrefix = "md-"
24
25// markdown renders the Markdown of a repository. Its output is safe to put in
26// a page: raw HTML is dropped, and links and images follow the policy below.
27//
28// Link policy:
29//   - http and https links are kept, with rel="nofollow noopener noreferrer";
30//   - relative links and links to the root of the repository are rewritten to
31//     the blob route of the ref being viewed, never above the repository root;
32//   - #fragments point at the (prefixed) heading ids;
33//   - everything else (javascript:, data:, vbscript:, file:, mailto:,
34//     protocol-relative //host, empty targets…) loses its link: the text stays;
35//   - images are never loaded (the CSP forbids other origins, and the design
36//     shows an image as its alt text): each becomes <span class="alt">[alt]</span>.
37type markdown struct {
38	md goldmark.Markdown
39}
40
41func newMarkdown() *markdown {
42	return &markdown{md: goldmark.New(
43		goldmark.WithExtensions(extension.GFM),
44		goldmark.WithParserOptions(parser.WithAutoHeadingID()),
45		goldmark.WithRendererOptions(
46			renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)),
47			// No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.
48			// Table alignment as an align attribute: the CSP forbids style="".
49			extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),
50		),
51	)}
52}
53
54// render turns source into HTML and the outline of its headings. repo and ref
55// are where relative links point; dir is the directory of the Markdown file
56// in the repository, "" for the root.
57func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, []Heading, error) {
58	return m.renderWithIDs(source, repo, ref, dir, headingIDPrefix)
59}
60
61// renderWithIDs is render with the prefix of the heading ids and of the
62// #fragment links chosen, so that several documents on one page (the summaries
63// of a run) do not share ids.
64func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
65	pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
66	doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
67	pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
68	pol.apply(doc)
69
70	var buf bytes.Buffer
71	if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
72		return "", nil, err
73	}
74	// #nosec G203 -- goldmark without WithUnsafe escapes text and drops raw
75	// HTML; linkPolicy has removed every URL it does not allow.
76	return template.HTML(buf.String()), outline(doc, source), nil
77}
78
79// prefixedIDs prefixes the heading ids that goldmark generates.
80type prefixedIDs struct {
81	prefix string
82	inner  parser.IDs
83}
84
85func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
86	return append([]byte(p.prefix), p.inner.Generate(value, kind)...)
87}
88
89func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) }
90
91// outline lists the headings of a document in order.
92func outline(doc ast.Node, source []byte) []Heading {
93	var out []Heading
94	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
95		h, ok := n.(*ast.Heading)
96		if !ok || !entering {
97			return ast.WalkContinue, nil
98		}
99		id, _ := h.AttributeString("id")
100		idb, _ := id.([]byte)
101		out = append(out, Heading{Level: h.Level, ID: string(idb), Text: plainText(h, source)})
102		return ast.WalkSkipChildren, nil
103	})
104	return out
105}
106
107// plainText is the text of a node without markup.
108func plainText(n ast.Node, source []byte) string {
109	var b strings.Builder
110	_ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
111		if !entering {
112			return ast.WalkContinue, nil
113		}
114		switch t := c.(type) {
115		case *ast.Text:
116			b.Write(t.Value(source))
117			if t.SoftLineBreak() || t.HardLineBreak() {
118				b.WriteByte(' ')
119			}
120		case *ast.String:
121			b.Write(t.Value)
122		}
123		return ast.WalkContinue, nil
124	})
125	return strings.TrimSpace(b.String())
126}
127
128// linkPolicy applies the link and image policy to a parsed document.
129type linkPolicy struct {
130	repo, ref, dir string
131	source         []byte
132	// idPrefix is the prefix of the heading ids that #fragment links point at.
133	idPrefix string
134}
135
136func (p linkPolicy) apply(doc ast.Node) {
137	// Collect first, change after: the walk must not see the tree move.
138	var links, images, autos []ast.Node
139	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
140		if !entering {
141			return ast.WalkContinue, nil
142		}
143		switch n.(type) {
144		case *ast.Link:
145			links = append(links, n)
146		case *ast.Image:
147			images = append(images, n)
148		case *ast.AutoLink:
149			autos = append(autos, n)
150		}
151		return ast.WalkContinue, nil
152	})
153	for _, n := range images {
154		p.image(n.(*ast.Image))
155	}
156	for _, n := range links {
157		l := n.(*ast.Link)
158		dest, kind := p.target(string(l.Destination))
159		if kind == dropped {
160			unwrap(l)
161			continue
162		}
163		l.Destination = []byte(dest)
164		l.Title = nil
165		if kind == external {
166			l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
167		}
168	}
169	for _, n := range autos {
170		a := n.(*ast.AutoLink)
171		if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
172			a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
173			continue
174		}
175		// An email address or another scheme: the label as plain text.
176		a.Parent().ReplaceChild(a.Parent(), a, ast.NewString(append([]byte(nil), a.Label(p.source)...)))
177	}
178}
179
180// unwrap replaces a node by its children.
181func unwrap(n ast.Node) {
182	parent := n.Parent()
183	for c := n.FirstChild(); c != nil; c = n.FirstChild() {
184		parent.InsertBefore(parent, n, c)
185	}
186	parent.RemoveChild(parent, n)
187}
188
189// image replaces an image by its alt text.
190func (p linkPolicy) image(img *ast.Image) {
191	alt := plainText(img, p.source)
192	if alt == "" {
193		alt = "image"
194	}
195	img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt})
196}
197
198type linkKind int
199
200const (
201	dropped linkKind = iota
202	external
203	internal
204)
205
206// target applies the link policy to a destination.
207func (p linkPolicy) target(dest string) (string, linkKind) {
208	dest = strings.TrimSpace(dest)
209	if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
210		return "", dropped
211	}
212	if strings.HasPrefix(dest, "#") {
213		frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
214		if frag == "" || strings.ContainsAny(frag, " \"'<>") {
215			return "", dropped
216		}
217		return "#" + p.idPrefix + frag, internal
218	}
219	if strings.HasPrefix(dest, "//") {
220		return "", dropped
221	}
222	u, err := url.Parse(dest)
223	if err != nil {
224		return "", dropped
225	}
226	switch strings.ToLower(u.Scheme) {
227	case "http", "https":
228		if u.Host == "" {
229			return "", dropped
230		}
231		return u.String(), external
232	case "":
233	default:
234		return "", dropped
235	}
236	if u.Host != "" || u.Opaque != "" || u.Path == "" {
237		return "", dropped
238	}
239	// A path in the repository: from the directory of the file, or from the
240	// root when it starts with a slash. path.Clean on a rooted path never
241	// leaves the root, so ../../ cannot climb out of the repository.
242	var rel string
243	if strings.HasPrefix(u.Path, "/") {
244		rel = path.Clean(u.Path)
245	} else {
246		rel = path.Clean("/" + path.Join(p.dir, u.Path))
247	}
248	rel = strings.TrimPrefix(rel, "/")
249	href := repoHref(p.repo)
250	if rel != "" {
251		href = repoHref(p.repo, "blob", p.ref, rel)
252	}
253	if u.Fragment != "" {
254		href += "#" + url.PathEscape(u.Fragment)
255	}
256	return href, internal
257}
258
259func isWebURL(s string) bool {
260	u, err := url.Parse(s)
261	if err != nil || u.Host == "" {
262		return false
263	}
264	sc := strings.ToLower(u.Scheme)
265	return sc == "http" || sc == "https"
266}
267
268// kindAlt is the kind of altSpan.
269var kindAlt = ast.NewNodeKind("AltSpan")
270
271// altSpan stands for an image: its alt text, in a span the stylesheet styles.
272type altSpan struct {
273	ast.BaseInline
274	alt string
275}
276
277func (n *altSpan) Kind() ast.NodeKind { return kindAlt }
278
279func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
280
281// altRenderer renders altSpan nodes.
282type altRenderer struct{}
283
284func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
285	r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
286		if entering {
287			_, _ = w.WriteString(`<span class="alt">[`)
288			_, _ = w.Write(util.EscapeHTML([]byte(n.(*altSpan).alt)))
289			_, _ = w.WriteString(`]</span>`)
290		}
291		return ast.WalkContinue, nil
292	})
293}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close