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.5 KB · 216 lines · Go Raw History
  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("![alt](images/foo.webp)"))
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("![alt](images/foo.webp)")
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 := "![a](" + dest + ")"
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}