@ 261101.7

261023.3 · 2748f9bb997af01b8c7e62bce2571bc4da28614e

Wiki links

A doc renders [[name]] as a link to name.md in its own directory, and [[sub/name]] to sub/name.md, as a relative link resolves. A name with an extension keeps it. [[name|label]] shows the label and [[name#heading]] opens at the heading, by the id goldmark gives it.

The parser runs before goldmark's link parser and leaves anything that is not a wiki link to it, so [[x]](y) stays a link with the text [x]. Its links are plain links in the tree, so a missing target is marked broken and backlinks count them.

reading declarations

README.md

@@ -108,7 +108,7 @@ In a large repo git can take a second for a history, because it reads every comm  Open pages reload when what they show changes. A doc takes its new version in place, without a reload, and each block that changed fills with the highlight colour and fades over three seconds, so the eye finds the change after a save. A code page reloads when its file is written, a listing when a name in it changes, any page in a repo when its HEAD moves, and the index when a tree joins or leaves. A page whose file or tree is gone waits two seconds for it to return, then reloads to say so. A page whose server stops reloads after three seconds without it, so the browser says the server is gone; a restart takes about 60 ms, and its new server reloads the page first. Each page holds a server-sent events stream while its tab is visible; a hidden tab lets go, since a browser opens at most six connections to one host, and checks again when shown. The server watches the directory of each open page and its repo's git directory through inotify, since editors save by renaming a new file over the old one, and polls every second where inotify fails. -Docs follow CommonMark with GitHub's extensions, rendered by goldmark: tables, strikethrough, task lists, bare URLs as links, footnotes and definition lists. Lines join into a paragraph, and a blank line starts the next. A line that starts with a dash begins a list even right after text. Straight quotes and dashes turn typographic. A fenced block marked `dot` or `graphviz` becomes a diagram, drawn by Graphviz's `dot` when it is installed and kept by the hash of its source; without `dot`, or when `dot` rejects the graph, the block shows as code under a line that says why. `dot` may not read image files the graph names. Code in a fenced block with a known language is highlighted by chroma, as are code pages, whose language follows the file name. Go is lexed by Go's own scanner from the standard library instead, about 150 times faster, with the same colours. Python, JavaScript and shell have lexers of www's own, which follow chroma's rules and give every byte the class chroma gives it; chroma took 180 ms to lex argparse.py, 2,633 lines, and they take 2 ms. A whole page of it renders in about 5 ms, and jquery.js, 10,907 lines, in 28 ms instead of 400. Other languages go through chroma, at about 70 µs a line. The server keeps each coloured page by its content's hash, within 16 MB, so a later view takes a few milliseconds. Every gap between blocks is the same, whether the block is a paragraph, list, table or code. Headings sit close to their text.+Docs follow CommonMark with GitHub's extensions, rendered by goldmark: tables, strikethrough, task lists, bare URLs as links, footnotes and definition lists. Wiki links work as relative links do: `[[name]]` opens `name.md` in the doc's directory, `[[sub/name]]` opens `sub/name.md`, and a name with an extension, eg `[[main.go]]`, keeps it. `[[name|label]]` shows the label and `[[name#heading]]` opens at the heading. Lines join into a paragraph, and a blank line starts the next. A line that starts with a dash begins a list even right after text. Straight quotes and dashes turn typographic. A fenced block marked `dot` or `graphviz` becomes a diagram, drawn by Graphviz's `dot` when it is installed and kept by the hash of its source; without `dot`, or when `dot` rejects the graph, the block shows as code under a line that says why. `dot` may not read image files the graph names. Code in a fenced block with a known language is highlighted by chroma, as are code pages, whose language follows the file name. Go is lexed by Go's own scanner from the standard library instead, about 150 times faster, with the same colours. Python, JavaScript and shell have lexers of www's own, which follow chroma's rules and give every byte the class chroma gives it; chroma took 180 ms to lex argparse.py, 2,633 lines, and they take 2 ms. A whole page of it renders in about 5 ms, and jquery.js, 10,907 lines, in 28 ms instead of 400. Other languages go through chroma, at about 70 µs a line. The server keeps each coloured page by its content's hash, within 16 MB, so a later view takes a few milliseconds. Every gap between blocks is the same, whether the block is a paragraph, list, table or code. Headings sit close to their text.  www refuses to serve the home directory and every directory above it, such as `/home` and `/`. A tree exposes everything below it, and home holds `~/.ssh`, `~/.gnupg` and every token file. Symlinks inside the tree are followed, absolute ones included. A link that leads out of the tree is followed only into a directory that holds a file named `.www-follow`, or below one, and only when other users could read the target. A file needs the others-read bit and a directory others-read and others-execute, checked from the marker's directory down. So nobody reads through www what `cat` would not show them.

backlinks.go

@@ -131,7 +131,7 @@ func (t *tree) backlinks(ctx context.Context, name string) backlinks { func linkTargets(p string, src []byte) []string { 	// Most of a walk's time goes to parsing, so a doc without a link's 	// brackets is not parsed.-	if !bytes.Contains(src, []byte("](")) && !bytes.Contains(src, []byte("]:")) {+	if !bytes.Contains(src, []byte("](")) && !bytes.Contains(src, []byte("]:")) && !bytes.Contains(src, []byte("[[")) { 		return nil 	} 	var out []string

backlinks_test.go

@@ -23,6 +23,7 @@ func TestBacklinks(t *testing.T) { 	write("d/e.md", "[ref][r]\n\n[r]: ../index.md\n") 	write("d/f.md", "[elsewhere](../a/b.go) and index.md in prose\n") 	write(".git/g.md", "[hidden](../index.md)\n")+	write("w.md", "[[index]]\n") 	links := func(name string) []string { 		t.Helper() 		w := get(s.handler(), name+"?backlinks")@@ -50,7 +51,7 @@ func TestBacklinks(t *testing.T) { 			} 		} 	}-	eq(links("/index.md"), "/a/c.md", "/d/e.md")+	eq(links("/index.md"), "/a/c.md", "/d/e.md", "/w.md") 	eq(links("/a/b.go"), "/d/f.md", "/index.md") 	eq(links("/a/c.md")) @@ -59,5 +60,5 @@ func TestBacklinks(t *testing.T) { 	os.Chtimes(filepath.Join(dir, "d/e.md"), time.Now(), time.Now().Add(time.Second)) 	os.Remove(filepath.Join(dir, "a/c.md")) 	write("h.md", "[i](index.md)\n")-	eq(links("/index.md"), "/h.md")+	eq(links("/index.md"), "/h.md", "/w.md") }

render.go

@@ -26,11 +26,11 @@ import (  // markdown renders docs as CommonMark with GitHub's extensions: lines join // into paragraphs, bare URLs become links and a list may follow a line-// with no blank line between. Raw HTML passes through; the+// with no blank line between. Wiki links, [[name]], become links too. Raw HTML passes through; the // Content-Security-Policy keeps its scripts from running. var markdown = goldmark.New( 	goldmark.WithExtensions(extension.GFM, extension.Footnote, extension.DefinitionList, extension.Typographer),-	goldmark.WithParserOptions(parser.WithAutoHeadingID()),+	goldmark.WithParserOptions(parser.WithAutoHeadingID(), parser.WithInlineParsers(util.Prioritized(wikiLinkParser{}, 199))), 	goldmark.WithRendererOptions( 		html.WithUnsafe(), 		// The lowest priority registers last and wins, so this replaces

render_test.go

@@ -115,3 +115,31 @@ func TestGraphFence(t *testing.T) { 		t.Errorf("bad graph not shown as code with a reason: %s", s) 	} }++// A wiki link resolves as a relative link does, takes a label and a+// heading, is marked broken when its target is missing, and leaves+// CommonMark's own links alone.+func TestWikiLinks(t *testing.T) {+	missing := func(dest string) bool { return dest == "gone.md" }+	for src, want := range map[string]string{+		"[[name]]":                 `<a href="name.md">name</a>`,+		"[[file.md]]":              `<a href="file.md">file.md</a>`,+		"[[sub/a b|the label]]":    `<a href="sub/a%20b.md">the label</a>`,+		"[[name#Two Words]]":       `<a href="name.md#two-words">name#Two Words</a>`,+		"[[#Here]]":                `<a href="#here">#Here</a>`,+		"[[a:b]]":                  `<a href="./a:b.md">a:b</a>`,+		"[[gone]]":                 `<a href="gone.md" class="broken" title="missing: gone.md">gone</a>`,+		"[[x]](y.md)":              `<a href="y.md">[x]</a>`,+		"[[]] and [[a]b]]":         `<p>[[]] and [[a]b]]</p>`,+		"`[[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)+		if err != nil {+			t.Fatal(err)+		}+		if !strings.Contains(string(h), want) {+			t.Errorf("%s: want %s in %s", src, want, h)+		}+	}+}

wikilink.go

new file mode 100644@@ -0,0 +1,89 @@+package main++import (+	"bytes"+	"net/url"+	"path"++	"github.com/yuin/goldmark/ast"+	"github.com/yuin/goldmark/parser"+	"github.com/yuin/goldmark/text"+	"github.com/yuin/goldmark/util"+)++// wikiLinkParser reads [[name]], [[name|label]] and [[name#heading]] as+// links, resolved as relative links are: name.md in the doc's directory,+// or the name as written when it has an extension. It runs before+// goldmark's link parser, which also starts at '[', and leaves anything+// else to it, so the links it makes are marked broken and counted as+// backlinks like any other.+type wikiLinkParser struct{}++func (wikiLinkParser) Trigger() []byte { return []byte{'['} }++func (wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {+	line, seg := block.PeekLine()+	if len(line) < 5 || line[1] != '[' {+		return nil+	}+	end := bytes.Index(line[2:], []byte("]]"))+	if end <= 0 {+		return nil+	}+	inner := line[2 : 2+end]+	// [[x]](y) is a link whose text is [x].+	if bytes.ContainsAny(inner, "[]\n") || bytes.HasPrefix(line[2+end+2:], []byte("(")) {+		return nil+	}+	target, label, labelled := bytes.Cut(inner, []byte("|"))+	name, heading, _ := bytes.Cut(target, []byte("#"))+	name, heading = bytes.TrimSpace(name), bytes.TrimSpace(heading)+	if len(name) == 0 && len(heading) == 0 {+		return nil+	}+	var dest string+	if len(name) > 0 {+		n := string(name)+		if path.Ext(n) == "" {+			n += ".md"+		}+		dest = (&url.URL{Path: n}).String()+	}+	if len(heading) > 0 {+		dest += "#" + headingID(heading)+	}+	link := ast.NewLink()+	link.Destination = []byte(dest)+	start, stop := seg.Start+2, seg.Start+2+len(target)+	if labelled {+		start, stop = stop+1, stop+1+len(label)+	}+	link.AppendChild(link, ast.NewTextSegment(text.NewSegment(start, stop)))+	block.Advance(2 + end + 2)+	return link+}++// headingID is the id goldmark gives a heading with this text, before+// any suffix that tells repeated headings apart.+func headingID(s []byte) string {+	var out []byte+	for i := 0; i < len(s); {+		c := s[i]+		n := util.UTF8Len(c)+		i += int(n)+		switch {+		case n != 1:+		case util.IsAlphaNumeric(c):+			if 'A' <= c && c <= 'Z' {+				c += 'a' - 'A'+			}+			out = append(out, c)+		case util.IsSpace(c) || c == '-' || c == '_':+			out = append(out, '-')+		}+	}+	if len(out) == 0 {+		return "heading"+	}+	return string(out)+}