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 "strings"
5 "testing"
6 "time"
7)
8
9// A mistake in the Markdown to Typst walker is quiet: the page still renders,
10// the PDF still compiles, and the text is wrong somewhere on page two.
11func TestTypstEscapes(t *testing.T) {
12 tests := []struct {
13 name string
14 md string
15 want string
16 }{
17 {
18 // goldmark resolves Markdown's escapes in its renderer, not its
19 // parser, so the Typst side has to do the same.
20 name: "markdown backslash escape is resolved before Typst escaping",
21 md: `a reverse\_proxy line`,
22 want: `a reverse\_proxy line`,
23 },
24 {
25 // In backticks the post means the entity, not what it resolves to.
26 name: "entity inside a code span stays literal",
27 md: "Jinja2 escapes it to `/` in URLs",
28 want: `#raw("/")`,
29 },
30 {
31 name: "entity outside a code span is resolved",
32 md: `an & ampersand`,
33 want: `an & ampersand`,
34 },
35 {
36 // Typst reads # as a code expression and @ as a label reference, so
37 // an unescaped one is a compile error rather than a typo.
38 name: "typst sigils are escaped",
39 md: `see #tag and @handle`,
40 want: `see \#tag and \@handle`,
41 },
42 }
43
44 for _, tt := range tests {
45 t.Run(tt.name, func(t *testing.T) {
46 if got := typstFromMarkdown(tt.md); !strings.Contains(got, tt.want) {
47 t.Errorf("typstFromMarkdown(%q)\n got: %q\nwant substring: %q", tt.md, got, tt.want)
48 }
49 })
50 }
51}
52
53// A single-element Typst array without a trailing comma is a parenthesised
54// string, so the PDF renders one letter per pill and nothing errors.
55func TestTypstSourceSingleTag(t *testing.T) {
56 post := &Post{Title: "T", Slug: "t", Date: "2026-01-01", ReadTime: 1, Tags: []string{"rust"}}
57 if got := typstSource(post); !strings.Contains(got, `tags: ("rust",),`) {
58 t.Errorf("single tag array missing its trailing comma:\n%s", got)
59 }
60
61 post.Tags = []string{"rust", "go"}
62 if got := typstSource(post); !strings.Contains(got, `tags: ("rust", "go"),`) {
63 t.Errorf("multi tag array wrong:\n%s", got)
64 }
65}
66
67func TestParseFrontmatter(t *testing.T) {
68 // The closing delimiter has to start a line, or a "---" inside a value
69 // ends the frontmatter early.
70 meta, body := parseFrontmatter("---\ntitle: A --- B\ndate: 2026-01-01\n---\n\nBody text.\n")
71
72 if meta["title"] != "A --- B" {
73 t.Errorf("title = %q, want %q", meta["title"], "A --- B")
74 }
75 if meta["date"] != "2026-01-01" {
76 t.Errorf("date = %q", meta["date"])
77 }
78 if body != "Body text.\n" {
79 t.Errorf("body = %q", body)
80 }
81
82 if _, body := parseFrontmatter("Just a body.\n"); body != "Just a body.\n" {
83 t.Errorf("bodyless parse = %q", body)
84 }
85}
86
87// Getting the publish date comparison backwards publishes a draft, so it is
88// asserted from both sides.
89func TestPublishDateGating(t *testing.T) {
90 now := time.Now()
91 yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
92 tomorrow := now.AddDate(0, 0, 1).Format("2006-01-02")
93
94 lib := &Library{
95 all: []*Post{
96 {Slug: "live", Date: yesterday, PublishDate: yesterday, Tags: []string{"go"}},
97 {Slug: "scheduled", Date: tomorrow, PublishDate: tomorrow, Tags: []string{"rust"}},
98 },
99 bySlug: map[string]*Post{},
100 }
101 for _, p := range lib.all {
102 lib.bySlug[p.Slug] = p
103 }
104
105 published, tags, years := lib.Published()
106 if len(published) != 1 || published[0].Slug != "live" {
107 t.Fatalf("published = %v, want just [live]", slugsOf(published))
108 }
109 // Facets come off the visible set, or a tag page exists for a hidden post.
110 if len(tags) != 1 || tags[0].Name != "go" {
111 t.Errorf("tags = %v, want just [go]", tags)
112 }
113 if len(years) != 1 {
114 t.Errorf("years = %v, want one", years)
115 }
116
117 if _, ok := lib.Lookup("scheduled"); ok {
118 t.Error("Lookup returned a post whose publish date has not arrived")
119 }
120 if _, ok := lib.Lookup("live"); !ok {
121 t.Error("Lookup missed a published post")
122 }
123
124 // All() ignores the date, so a scheduled post's PDF is built ahead of it.
125 if len(lib.All()) != 2 {
126 t.Errorf("All() = %d posts, want 2", len(lib.All()))
127 }
128}
129
130func slugsOf(posts []*Post) []string {
131 out := make([]string, 0, len(posts))
132 for _, p := range posts {
133 out = append(out, p.Slug)
134 }
135 return out
136}
137
138func TestTitleCase(t *testing.T) {
139 for in, want := range map[string]string{
140 "rust": "Rust",
141 "dark mode": "Dark Mode",
142 "self-host": "Self-Host",
143 "SQL": "SQL",
144 "": "",
145 } {
146 if got := titleCase(in); got != want {
147 t.Errorf("titleCase(%q) = %q, want %q", in, got, want)
148 }
149 }
150}
151
152// The card and the title size are both fixed, so a long title has to wrap and
153// then stop rather than running off the edge.
154func TestWrapTitle(t *testing.T) {
155 lines := wrapTitle("Cool URIs don't change unless an AI rewrites your blog and keeps going", 35, 3)
156 if len(lines) > 3 {
157 t.Fatalf("got %d lines, want at most 3", len(lines))
158 }
159 for _, line := range lines {
160 if len(line) > 45 {
161 t.Errorf("line too long for the card: %q", line)
162 }
163 }
164 if got := wrapTitle("Short", 35, 3); len(got) != 1 || got[0] != "Short" {
165 t.Errorf("short title = %v", got)
166 }
167}
168
169// A tag with a slash in it would invent a route that matches nothing, and
170// url.PathEscape leaves "/" alone.
171func TestTagURLEscaping(t *testing.T) {
172 if got := tagURL("c++"); got != "/blog/tag/c++/" {
173 t.Errorf("tagURL(c++) = %q", got)
174 }
175 if got := tagURL("a/b"); strings.Count(got, "/") != 4 {
176 t.Errorf("tagURL(a/b) = %q, slash should be encoded", got)
177 }
178}
179
180// Both renderers have to agree on how a relative image reference resolves, and
181// they share one parser, so this goes through their real entry points. Asserting
182// the two helpers alone misses Typst prefixing what the HTML side rewrote.
183func TestRelativeImagesResolveToContent(t *testing.T) {
184 const want = "/content/images/foo.webp"
185
186 html := string(renderMarkdown(""))
187 if !strings.Contains(html, `src="`+want+`"`) {
188 t.Fatalf("html: relative image not rewritten to its served path: %s", html)
189 }
190
191 typ := typstFromMarkdown("")
192 if !strings.Contains(typ, `"`+want+`"`) {
193 t.Fatalf("typst: image path wrong: %s", typ)
194 }
195
196 // The double prefix, asserted directly in both.
197 for name, out := range map[string]string{"html": html, "typst": typ} {
198 if strings.Contains(out, "/content/images//content/") ||
199 strings.Contains(out, "/content/content/") {
200 t.Fatalf("%s: path prefixed twice: %s", name, out)
201 }
202 }
203
204 for _, dest := range []string{"/content/images/x.webp", "https://example.com/x.webp"} {
205 md := ""
206 for name, out := range map[string]string{
207 "html": string(renderMarkdown(md)),
208 "typst": typstFromMarkdown(md),
209 } {
210 if !strings.Contains(out, dest) {
211 t.Fatalf("%s: rewrote an absolute destination %q: %s", name, dest, out)
212 }
213 }
214 }
215}