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

9.0 KB · 342 lines · Go Raw History
  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// `&#x2f;` 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}