orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "bytes"
5 "html/template"
6 "strings"
7
8 "github.com/alecthomas/chroma/v2"
9 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
10 "github.com/alecthomas/chroma/v2/lexers"
11 "github.com/yuin/goldmark"
12 "github.com/yuin/goldmark/ast"
13 "github.com/yuin/goldmark/extension"
14 "github.com/yuin/goldmark/parser"
15 "github.com/yuin/goldmark/renderer"
16 "github.com/yuin/goldmark/renderer/html"
17 "github.com/yuin/goldmark/text"
18 "github.com/yuin/goldmark/util"
19)
20
21// Autolinks and heading anchors stay off, since turning them on would silently
22// rewrite every existing post.
23var markdownRenderer = goldmark.New(
24 goldmark.WithExtensions(extension.Table, extension.Strikethrough),
25 goldmark.WithParserOptions(
26 parser.WithASTTransformers(util.Prioritized(&imagePathTransformer{}, 100)),
27 ),
28 goldmark.WithRendererOptions(
29 html.WithUnsafe(),
30 renderer.WithNodeRenderers(util.Prioritized(&codeRenderer{}, 100)),
31 ),
32)
33
34// imagePathTransformer rewrites a post's relative "images/foo.webp", which a
35// browser would resolve against /posts/<slug>/ and 404, to the /content/images/
36// path it is served at. Absolute and off-site URLs are left alone.
37type imagePathTransformer struct{}
38
39func (t *imagePathTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
40 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
41 if !entering {
42 return ast.WalkContinue, nil
43 }
44 img, ok := n.(*ast.Image)
45 if !ok {
46 return ast.WalkContinue, nil
47 }
48 if dest := string(img.Destination); strings.HasPrefix(dest, "images/") {
49 img.Destination = []byte("/content/" + dest)
50 }
51 return ast.WalkContinue, nil
52 })
53}
54
55func renderMarkdown(md string) template.HTML {
56 var buf bytes.Buffer
57 if err := markdownRenderer.Convert([]byte(md), &buf); err != nil {
58 // Convert only fails on a writer error, and this writer is a buffer.
59 panic("render markdown: " + err.Error())
60 }
61 return template.HTML(buf.String())
62}
63
64// base16OceanDark is spelled out because chroma does not bundle it, and every
65// existing code block was written under it.
66var base16OceanDark = chroma.MustNewStyle("base16-ocean-dark", chroma.StyleEntries{
67 chroma.Background: "#c0c5ce bg:#2b303b",
68 chroma.Comment: "#65737e",
69 chroma.CommentHashbang: "#65737e",
70 chroma.CommentMultiline: "#65737e",
71 chroma.CommentSingle: "#65737e",
72 chroma.CommentSpecial: "#65737e",
73 chroma.CommentPreproc: "#b48ead",
74 chroma.Keyword: "#b48ead",
75 chroma.KeywordConstant: "#d08770",
76 chroma.KeywordDeclaration: "#b48ead",
77 chroma.KeywordNamespace: "#b48ead",
78 chroma.KeywordPseudo: "#b48ead",
79 chroma.KeywordReserved: "#b48ead",
80 chroma.KeywordType: "#ebcb8b",
81 chroma.Operator: "#c0c5ce",
82 chroma.OperatorWord: "#b48ead",
83 chroma.Punctuation: "#c0c5ce",
84 chroma.Name: "#c0c5ce",
85 chroma.NameAttribute: "#d08770",
86 chroma.NameBuiltin: "#96b5b4",
87 chroma.NameBuiltinPseudo: "#96b5b4",
88 chroma.NameClass: "#ebcb8b",
89 chroma.NameConstant: "#d08770",
90 chroma.NameDecorator: "#96b5b4",
91 chroma.NameEntity: "#96b5b4",
92 chroma.NameException: "#bf616a",
93 chroma.NameFunction: "#8fa1b3",
94 chroma.NameLabel: "#bf616a",
95 chroma.NameNamespace: "#ebcb8b",
96 chroma.NameTag: "#bf616a",
97 chroma.NameVariable: "#bf616a",
98 chroma.LiteralString: "#a3be8c",
99 chroma.LiteralStringChar: "#a3be8c",
100 chroma.LiteralStringDoc: "#a3be8c",
101 chroma.LiteralStringEscape: "#96b5b4",
102 chroma.LiteralStringInterpol: "#96b5b4",
103 chroma.LiteralStringRegex: "#96b5b4",
104 chroma.LiteralStringSymbol: "#a3be8c",
105 chroma.LiteralNumber: "#d08770",
106 chroma.GenericDeleted: "#bf616a",
107 chroma.GenericEmph: "italic #b48ead",
108 chroma.GenericHeading: "bold #8fa1b3",
109 chroma.GenericInserted: "#a3be8c",
110 chroma.GenericStrong: "bold #ebcb8b",
111 chroma.GenericSubheading: "bold #8fa1b3",
112 chroma.Error: "#ab7967",
113})
114
115// Spans only. post.scss styles `article > pre` as a direct child, and chroma's
116// own wrapper div would break that selector on every code block.
117var chromaFormatter = chromahtml.New(
118 chromahtml.WithClasses(false),
119 chromahtml.PreventSurroundingPre(true),
120)
121
122// codeRenderer replaces goldmark's code block rendering.
123type codeRenderer struct{}
124
125func (r *codeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
126 reg.Register(ast.KindFencedCodeBlock, r.render)
127 reg.Register(ast.KindCodeBlock, r.render)
128}
129
130func (r *codeRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
131 if !entering {
132 return ast.WalkContinue, nil
133 }
134
135 var lang string
136 if fenced, ok := node.(*ast.FencedCodeBlock); ok && fenced.Info != nil {
137 // The info string can carry more than the language ("go title=x").
138 lang = string(firstWord(fenced.Info.Segment.Value(source)))
139 }
140
141 var code bytes.Buffer
142 lines := node.Lines()
143 for i := 0; i < lines.Len(); i++ {
144 line := lines.At(i)
145 code.Write(line.Value(source))
146 }
147
148 // The colour is on the <pre>, since chroma only wraps tokens it has a rule
149 // for and the rest would inherit article's warm foreground.
150 _, _ = w.WriteString(`<pre style="background-color:#2b303b;color:#c0c5ce;"><code`)
151 if lang != "" {
152 _, _ = w.WriteString(` class="language-`)
153 template.HTMLEscape(w, []byte(lang))
154 _, _ = w.WriteString(`"`)
155 }
156 _, _ = w.WriteString(`>`)
157
158 if err := highlight(w, code.String(), lang); err != nil {
159 // An unhighlightable block is still readable.
160 template.HTMLEscape(w, code.Bytes())
161 }
162
163 _, _ = w.WriteString("</code></pre>\n")
164 return ast.WalkSkipChildren, nil
165}
166
167func highlight(w util.BufWriter, code, lang string) error {
168 lexer := lexers.Get(lang)
169 if lexer == nil {
170 lexer = lexers.Analyse(code)
171 }
172 if lexer == nil {
173 lexer = lexers.Fallback
174 }
175 // Coalesce merges runs of same-type tokens, or a plain-text block becomes
176 // one <span> per character.
177 iterator, err := chroma.Coalesce(lexer).Tokenise(nil, code)
178 if err != nil {
179 return err
180 }
181 return chromaFormatter.Format(w, base16OceanDark, iterator)
182}
183
184func firstWord(b []byte) []byte {
185 if i := bytes.IndexAny(b, " \t"); i >= 0 {
186 return b[:i]
187 }
188 return b
189}