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 "fmt"
5 "strings"
6
7 "github.com/yuin/goldmark/ast"
8 east "github.com/yuin/goldmark/extension/ast"
9 "github.com/yuin/goldmark/text"
10 "github.com/yuin/goldmark/util"
11)
12
13// typstFromMarkdown walks the same AST the HTML renderer uses and emits Typst
14// markup. Raw HTML is dropped, since Typst has no equivalent and passing it
15// through takes the whole PDF down over an inline <kbd>.
16func typstFromMarkdown(md string) string {
17 source := []byte(md)
18 doc := markdownRenderer.Parser().Parse(text.NewReader(source))
19
20 var b strings.Builder
21 b.Grow(len(md) * 2)
22 writeBlocks(&b, doc, source)
23 return b.String()
24}
25
26func writeBlocks(b *strings.Builder, node ast.Node, source []byte) {
27 for child := node.FirstChild(); child != nil; child = child.NextSibling() {
28 writeBlock(b, child, source)
29 }
30}
31
32func writeBlock(b *strings.Builder, node ast.Node, source []byte) {
33 switch n := node.(type) {
34 case *ast.Paragraph:
35 if img, ok := onlyImage(n); ok {
36 writeBlockImage(b, img, source)
37 return
38 }
39 writeInlines(b, n, source)
40 b.WriteString("\n\n")
41
42 case *ast.Heading:
43 b.WriteString(strings.Repeat("=", n.Level))
44 b.WriteByte(' ')
45 writeInlines(b, n, source)
46 b.WriteString("\n\n")
47
48 case *ast.List:
49 for item := n.FirstChild(); item != nil; item = item.NextSibling() {
50 writeListItem(b, item, source, n.IsOrdered())
51 }
52 b.WriteByte('\n')
53
54 case *ast.Blockquote:
55 b.WriteString("#quote(block: true)[\n")
56 writeBlocks(b, n, source)
57 b.WriteString("]\n\n")
58
59 case *ast.ThematicBreak:
60 b.WriteString("#align(center)[#line(length: 30%, stroke: 0.5pt + gray)]\n\n")
61
62 case *ast.FencedCodeBlock:
63 lang := ""
64 if n.Info != nil {
65 lang = string(firstWord(n.Info.Segment.Value(source)))
66 }
67 writeCodeBlock(b, blockText(n, source), lang)
68
69 case *ast.CodeBlock:
70 writeCodeBlock(b, blockText(n, source), "")
71
72 case *ast.HTMLBlock:
73 // Dropped, see typstFromMarkdown.
74
75 case *east.Table:
76 writeTable(b, n, source)
77
78 default:
79 writeBlocks(b, node, source)
80 }
81}
82
83func writeCodeBlock(b *strings.Builder, code, lang string) {
84 b.WriteString("#raw(block: true")
85 if lang != "" {
86 fmt.Fprintf(b, ", lang: %q", lang)
87 }
88 fmt.Fprintf(b, ", %q)\n\n", code)
89}
90
91func writeListItem(b *strings.Builder, item ast.Node, source []byte, ordered bool) {
92 if ordered {
93 b.WriteString("+ ")
94 } else {
95 b.WriteString("- ")
96 }
97 for child := item.FirstChild(); child != nil; child = child.NextSibling() {
98 switch c := child.(type) {
99 case *ast.TextBlock, *ast.Paragraph:
100 writeInlines(b, child, source)
101 case *ast.List:
102 // Typst nests by indentation, so a sublist indents each marker.
103 b.WriteByte('\n')
104 for sub := c.FirstChild(); sub != nil; sub = sub.NextSibling() {
105 b.WriteString(" ")
106 writeListItem(b, sub, source, c.IsOrdered())
107 }
108 default:
109 writeBlock(b, child, source)
110 }
111 }
112 b.WriteByte('\n')
113}
114
115func writeTable(b *strings.Builder, table *east.Table, source []byte) {
116 columns := len(table.Alignments)
117 if columns == 0 {
118 return
119 }
120
121 fmt.Fprintf(b, "#table(columns: %d, align: (", columns)
122 for i, alignment := range table.Alignments {
123 if i > 0 {
124 b.WriteString(", ")
125 }
126 switch alignment {
127 case east.AlignRight:
128 b.WriteString("right")
129 case east.AlignCenter:
130 b.WriteString("center")
131 default:
132 b.WriteString("left")
133 }
134 }
135 b.WriteString("),\n")
136
137 // Body rows are wrapped in a TableBody in some documents and are direct
138 // children in others, so both are walked the same way.
139 var writeRows func(node ast.Node)
140 writeRows = func(node ast.Node) {
141 for row := node.FirstChild(); row != nil; row = row.NextSibling() {
142 switch row.(type) {
143 case *east.TableHeader, *east.TableRow:
144 for cell := row.FirstChild(); cell != nil; cell = cell.NextSibling() {
145 b.WriteString(" [")
146 writeInlines(b, cell, source)
147 b.WriteString("],\n")
148 }
149 default:
150 writeRows(row)
151 }
152 }
153 }
154 writeRows(table)
155
156 b.WriteString(")\n\n")
157}
158
159func writeInlines(b *strings.Builder, node ast.Node, source []byte) {
160 for child := node.FirstChild(); child != nil; child = child.NextSibling() {
161 writeInline(b, child, source)
162 }
163}
164
165func writeInline(b *strings.Builder, node ast.Node, source []byte) {
166 switch n := node.(type) {
167 case *ast.Text:
168 b.WriteString(escapeMarkup(textValue(n, source)))
169 switch {
170 case n.HardLineBreak():
171 b.WriteString(" \\\n")
172 case n.SoftLineBreak():
173 b.WriteByte(' ')
174 }
175
176 case *ast.String:
177 b.WriteString(escapeMarkup(plainText(n.Value)))
178
179 case *ast.CodeSpan:
180 fmt.Fprintf(b, "#raw(%q)", inlineText(n, source))
181
182 case *ast.Emphasis:
183 if n.Level == 2 {
184 wrapInline(b, n, source, "#strong[", "]")
185 } else {
186 wrapInline(b, n, source, "#emph[", "]")
187 }
188
189 case *east.Strikethrough:
190 wrapInline(b, n, source, "#strike[", "]")
191
192 case *ast.Link:
193 fmt.Fprintf(b, "#link(%q)[", string(n.Destination))
194 writeInlines(b, n, source)
195 b.WriteByte(']')
196
197 case *ast.AutoLink:
198 url := string(n.URL(source))
199 fmt.Fprintf(b, "#link(%q)", url)
200
201 case *ast.Image:
202 fmt.Fprintf(b, "#image(%q)", imagePath(string(n.Destination)))
203
204 case *ast.RawHTML:
205 // Dropped, see typstFromMarkdown.
206
207 default:
208 writeInlines(b, node, source)
209 }
210}
211
212func wrapInline(b *strings.Builder, node ast.Node, source []byte, open, close string) {
213 b.WriteString(open)
214 writeInlines(b, node, source)
215 b.WriteString(close)
216}
217
218func writeBlockImage(b *strings.Builder, img *ast.Image, source []byte) {
219 fmt.Fprintf(b, "#align(center)[#image(%q, width: 100%%", imagePath(string(img.Destination)))
220 if alt := inlineText(img, source); alt != "" {
221 fmt.Fprintf(b, ", alt: %q", alt)
222 }
223 b.WriteString(")]\n\n")
224}
225
226func onlyImage(para *ast.Paragraph) (*ast.Image, bool) {
227 first := para.FirstChild()
228 if first == nil || first.NextSibling() != nil {
229 return nil, false
230 }
231 img, ok := first.(*ast.Image)
232 return img, ok
233}
234
235// imagePath resolves a post's relative image reference against the Typst root,
236// and has to stay idempotent, since this walks the AST from
237// markdownRenderer.Parser() whose transformer may have rewritten it already.
238func imagePath(url string) string {
239 if strings.HasPrefix(url, "/") || strings.Contains(url, "://") {
240 return url
241 }
242 return "/content/images/" + strings.TrimPrefix(url, "images/")
243}
244
245// inlineText flattens a node to the plain text Typst wants for alt text.
246func inlineText(node ast.Node, source []byte) string {
247 var b strings.Builder
248 var walk func(ast.Node)
249 walk = func(n ast.Node) {
250 for child := n.FirstChild(); child != nil; child = child.NextSibling() {
251 switch c := child.(type) {
252 case *ast.Text:
253 b.WriteString(textValue(c, source))
254 case *ast.String:
255 if c.IsRaw() {
256 b.Write(c.Value)
257 } else {
258 b.WriteString(plainText(c.Value))
259 }
260 default:
261 walk(child)
262 }
263 }
264 }
265 walk(node)
266 return b.String()
267}
268
269// textValue reads a text node the way goldmark's own HTML renderer does. A node
270// inside a code span is raw and means the exact source bytes, so a post writing
271// `/` in backticks keeps the entity. Everywhere else they are resolved.
272func textValue(n *ast.Text, source []byte) string {
273 raw := n.Segment.Value(source)
274 if n.IsRaw() {
275 return string(raw)
276 }
277 return plainText(raw)
278}
279
280// plainText resolves the Markdown escapes and entities goldmark leaves in the
281// AST for a renderer to handle. Without it the Typst escaper escapes the
282// leftover backslash and the PDF reads "reverse\_proxy".
283func plainText(raw []byte) string {
284 resolved := util.ResolveEntityNames(util.ResolveNumericReferences(raw))
285 return string(util.UnescapePunctuations(resolved))
286}
287
288// markupEscaper covers the characters that mean something in Typst markup, so a
289// post mentioning #hashtags or an @handle does not compile into a reference.
290var markupEscaper = strings.NewReplacer(
291 `\`, `\\`, `[`, `\[`, `]`, `\]`, `*`, `\*`, `_`, `\_`,
292 "`", "\\`", `#`, `\#`, `$`, `\$`, `<`, `\<`, `@`, `\@`, `~`, `\~`,
293)
294
295func escapeMarkup(s string) string { return markupEscaper.Replace(s) }
296
297func blockText(node ast.Node, source []byte) string {
298 var b strings.Builder
299 lines := node.Lines()
300 for i := 0; i < lines.Len(); i++ {
301 line := lines.At(i)
302 b.Write(line.Value(source))
303 }
304 return b.String()
305}
306
307// typstSource wraps a post's body in the blog_post.typ template call. Typst
308// string literals take the same escapes %q produces.
309func typstSource(post *Post) string {
310 var b strings.Builder
311 b.WriteString("#import \"/typst/blog_post.typ\": render\n#render(\n")
312 fmt.Fprintf(&b, " title: %q,\n", post.Title)
313 fmt.Fprintf(&b, " date: %q,\n", post.Date)
314 fmt.Fprintf(&b, " read_time: %d,\n", post.ReadTime)
315
316 b.WriteString(" tags: (")
317 for i, tag := range post.Tags {
318 if i > 0 {
319 b.WriteString(", ")
320 }
321 fmt.Fprintf(&b, "%q", tag)
322 }
323 // A one-element Typst array needs the trailing comma or it is just a
324 // parenthesised string, and `for tag in tags` iterates its characters.
325 if len(post.Tags) == 1 {
326 b.WriteByte(',')
327 }
328 b.WriteString("),\n")
329
330 fmt.Fprintf(&b, " description: %q,\n", post.Description)
331 if post.CoverImage == "" {
332 b.WriteString(" cover_image: none,\n")
333 } else {
334 fmt.Fprintf(&b, " cover_image: %q,\n", "/content/images/"+post.CoverImage)
335 }
336
337 b.WriteString(" body: [\n")
338 b.WriteString(post.BodyTypst)
339 b.WriteString("\n ],\n)\n")
340 return b.String()
341}