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
1{{- /* Property report, rendered by text/template into Typst markup and then
2 compiled to PDF by the typst CLI (see typst.go). Mirrors analytics'
3 report.typ: monochrome, hairline rules, letterspaced uppercase section
4 headers, mono for technical strings.
5
6 Every value that came from a monitored site goes through typstMD, which
7 escapes Typst's markup characters. That includes "/", because "//"
8 starts a Typst line comment and a URL is full of them: without it, a
9 tracked URL would silently swallow the rest of its line. */ -}}
10#let dim = rgb("#555")
11#let muted = rgb("#888")
12#let mono = ("JetBrains Mono", "DejaVu Sans Mono", "Liberation Mono")
13
14#set page(
15 paper: "a4",
16 margin: (top: 14mm, bottom: 18mm, left: 14mm, right: 14mm),
17 footer: context {
18 set text(size: 7.5pt, fill: dim)
19 grid(
20 columns: (1fr, auto),
21 align: (left + horizon, right + horizon),
22 [Status · self-hosted · {{ typstMD .BaseURL }} · {{ typstMD .Property.Name }}],
23 [Page #counter(page).display() of #counter(page).final().first()],
24 )
25 },
26)
27
28#set text(
29 font: ("DejaVu Sans", "Liberation Sans", "Arial"),
30 size: 9.5pt,
31 fill: black,
32)
33#set par(leading: 0.5em, justify: false)
34
35#show heading.where(level: 1): set text(size: 22pt, weight: "bold")
36#show heading.where(level: 1): set block(above: 8pt, below: 4pt)
37
38#show heading.where(level: 2): it => block(
39 above: 16pt,
40 below: 6pt,
41 width: 100%,
42 stroke: (bottom: 0.6pt + black),
43 inset: (bottom: 3pt),
44)[#text(size: 11pt, weight: "bold", tracking: 0.6pt, upper(it.body))]
45
46#show heading.where(level: 3): it => block(
47 above: 8pt,
48 below: 3pt,
49)[#text(size: 8.5pt, weight: "bold", tracking: 0.5pt, fill: rgb("#333"), upper(it.body))]
50
51// Header strip
52#grid(
53 columns: (1fr, auto),
54 align: (left + top, right + top),
55 text(size: 8.5pt, tracking: 0.9pt, fill: dim, upper("// Status · property report")),
56 text(size: 8pt, fill: dim)[Generated {{ typstMD .GeneratedAt }}],
57)
58{{ with .Property }}
59= {{ typstMD .Name }}
60
61// Meta dl: 2-col with thin rules above and below.
62#block(
63 above: 8pt,
64 below: 0pt,
65 width: 100%,
66 stroke: (top: 0.6pt + black, bottom: 0.6pt + black),
67 inset: (top: 6pt, bottom: 6pt),
68)[
69 #grid(
70 columns: (1fr, 1fr),
71 column-gutter: 16pt,
72 row-gutter: 4pt,
73 [
74 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Property ID")) \
75 #text(font: mono, size: 8.5pt)[{{ typstMD .ID }}]
76 ],
77 [
78 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("URL")) \
79 #text(font: mono, size: 8.5pt)[{{ typstMD .URL }}]
80 ],
81 [
82 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Alert state")) \
83 #text(weight: "semibold")[{{ typstMD .AlertState }}]
84 ],
85 [
86 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Visibility")) \
87 #text(weight: "semibold")[{{ if .IsPublic }}public{{ else }}private{{ end }}]
88 ],
89 )
90]
91
92== Live signals
93
94#grid(
95 columns: (1fr, 1fr, 1fr, 1fr),
96 gutter: 4pt,
97 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
98 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[STATUS]
99 #v(2pt)
100 #text(size: 14pt, weight: "bold")[{{ .CurrentStatus }}]
101 ],
102 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
103 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[AVG RESPONSE]
104 #v(2pt)
105 #text(size: 14pt, weight: "bold")[{{ .AvgResponseTime }} ms]
106 ],
107 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
108 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[UPTIME]
109 #v(2pt)
110 #text(size: 14pt, weight: "bold")[{{ if .RecentUptimePct }}{{ pct1 .RecentUptimePct }}%{{ else }}—{{ end }}]
111 ],
112 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
113 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[CHECKS]
114 #v(2pt)
115 #text(size: 14pt, weight: "bold")[{{ .TotalChecks }}]
116 ],
117)
118
119== Lighthouse
120{{ with .LighthouseScores }}
121#grid(
122 columns: (1fr, 1fr, 1fr, 1fr),
123 gutter: 4pt,
124 {{- range .Pairs }}
125 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
126 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[{{ typstMD (upper .Label) }}]
127 #v(2pt)
128 #text(size: 14pt, weight: "bold")[{{ .Score }}]
129 ],
130 {{- end }}
131)
132{{ else }}
133#text(size: 8pt, fill: muted, style: "italic")[No lighthouse data yet.]
134{{ end }}
135{{- with .LighthouseDetails }}
136{{- if .Metrics }}
137=== Performance metrics
138
139#block(breakable: false, table(
140 columns: (auto, 1fr, auto),
141 align: (left + top, left + top, right + top),
142 inset: (x: 3pt, y: 2pt),
143 stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
144 table.header(
145 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Metric")),
146 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Title")),
147 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Value")),
148 ),
149 {{- range .Metrics }}
150 text(font: mono, size: 7pt, weight: "bold")[{{ typstMD .Acronym }}], text(size: 7.5pt)[{{ typstMD .Title }}], text(size: 7.5pt)[{{ typstMD .DisplayValue }}],
151 {{- end }}
152))
153{{ end }}
154{{- if .Opportunities }}
155=== Top opportunities
156
157#block(breakable: false, table(
158 columns: (1fr, auto),
159 align: (left + top, right + top),
160 inset: (x: 3pt, y: 2pt),
161 stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
162 table.header(
163 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Opportunity")),
164 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Savings")),
165 ),
166 {{- range .Opportunities }}
167 text(size: 7.5pt)[{{ typstMD .Title }}], text(size: 7.5pt)[{{ typstMD (msSavings .SavingsMS) }}],
168 {{- end }}
169))
170{{ end }}
171{{- end }}
172
173== Security
174
175#block(breakable: false, table(
176 columns: (1fr, auto),
177 align: (left + top, right + top),
178 inset: (x: 3pt, y: 2pt),
179 stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
180 table.header(
181 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Check")),
182 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Result")),
183 ),
184 text(size: 7.5pt)[HTTPS], text(size: 7.5pt)[{{ if .IsHTTPS }}OK{{ else }}Issue{{ end }}],
185 text(size: 7.5pt)[TLS certificate valid], text(size: 7.5pt)[{{ if .InvalidCert }}Issue{{ else }}OK{{ end }}],
186 text(size: 7.5pt)[HSTS (≥1y max-age)], text(size: 7.5pt)[{{ if .HasHSTS }}OK{{ else }}Issue{{ end }}],
187 text(size: 7.5pt)[HSTS preload], text(size: 7.5pt)[{{ if .HasHSTSPreload }}OK{{ else }}Issue{{ end }}],
188 text(size: 7.5pt)[X-Frame-Options], text(size: 7.5pt)[{{ if .HasClickjackProtection }}OK{{ else }}Issue{{ end }}],
189 text(size: 7.5pt)[X-Content-Type-Options], text(size: 7.5pt)[{{ if .HasContentSniffProtection }}OK{{ else }}Issue{{ end }}],
190 text(size: 7.5pt)[Server header hidden], text(size: 7.5pt)[{{ if .HidesServerVersion }}OK{{ else }}Issue{{ end }}],
191))
192{{ end }}
193== SEO insights
194{{ if .InsightGroups }}
195{{- range .InsightGroups }}
196=== {{ typstMD .Type }}
197
198#block(breakable: false, table(
199 columns: (auto, 1fr, 1fr),
200 align: (left + top, left + top, left + top),
201 inset: (x: 3pt, y: 2pt),
202 stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
203 table.header(
204 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Severity")),
205 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Issue")),
206 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("URL")),
207 ),
208 {{- range .Items }}
209 text(size: 7.5pt, weight: "bold")[{{ typstMD .Severity }}], text(size: 7.5pt)[{{ typstMD .Issue }}], text(font: mono, size: 7pt, fill: dim)[{{ typstMD .URL }}],
210 {{- end }}
211))
212{{ end }}
213{{- else }}
214#text(size: 8pt, fill: muted, style: "italic")[No crawl results yet.]
215{{ end }}