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

7.6 KB · 216 lines · Typst Raw History
  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 }}