repos
/ orchard main

orchard

mirror

Every 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

6.2 KB · 190 lines · Go Raw History
  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}