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// The resume, as Typst.
2//
3// It replaces a Chromium: the Next.js era rendered an HTML template through
4// headless Playwright and printed it to PDF, which meant a browser, a Markdown
5// parser and two npm parsing libraries in the build path of a one page
6// document. Every other PDF in this repo is already Typst, so this is the one
7// that was out of step.
8//
9// The design is a port, not a redesign. Every number below is the old
10// stylesheet's, converted once: CSS pixels are 1/96in and Typst points are
11// 1/72in, so a CSS px is 0.75pt and that factor is the whole translation. The
12// original PDF embedded Liberation Sans, because "Helvetica Neue, Helvetica,
13// Arial" resolved to it on the machine that generated it, so naming Liberation
14// Sans directly is what keeps the metrics identical rather than merely close.
15
16#let data = yaml("content.yml")
17
18// One CSS pixel. Kept as a function so the stylesheet's numbers can be
19// transcribed as they were written instead of pre-multiplied by hand.
20#let px(n) = n * 0.75pt
21
22// Liberation Sans, in em above and below the baseline. These are the font's
23// own metrics and they are what makes the line maths below exact rather than
24// tuned by eye.
25#let ascent = 0.9053
26#let descent = 0.2119
27
28// CSS `line-height`, reproduced.
29//
30// This is the one place the two systems genuinely disagree and it is why a
31// first attempt at this port came out vertically cramped on every line. A CSS
32// line box is `line-height` tall and the glyphs sit in the middle of it, with
33// the leftover split evenly above and below (half-leading). Typst's line box
34// is the font's own ascender-to-descender extent, and `leading` adds space
35// only BETWEEN lines, never above the first or below the last.
36//
37// So block heights came out short by the half-leading at both ends, and every
38// margin measured from a block edge landed too tight. Setting the text edges
39// to include the half-leading makes a Typst line box the same height as a CSS
40// one, which lets `leading` go to zero and every margin below be the
41// stylesheet's number unmodified.
42#let line-box(line-height) = (
43 top-edge: (ascent + (line-height - ascent - descent) / 2) * 1em,
44 bottom-edge: -(descent + (line-height - ascent - descent) / 2) * 1em,
45)
46
47#let ink = rgb("#1a1a1a")
48#let heading-ink = rgb("#111111")
49#let summary-ink = rgb("#444444")
50#let meta-ink = rgb("#666666")
51#let bullet-ink = rgb("#333333")
52#let accent = rgb("#2563eb")
53#let sidebar-bg = rgb("#1e293b")
54#let sidebar-ink = rgb("#e2e8f0")
55#let sidebar-muted = rgb("#cbd5e1")
56#let sidebar-label = rgb("#94a3b8")
57#let sidebar-rule = rgb("#334155")
58#let link-ink = rgb("#93c5fd")
59#let contact-muted = rgb("#dbeafe")
60
61#let sidebar-width = px(240)
62#let page-width = px(816)
63#let page-height = px(1056)
64
65#set page(width: page-width, height: page-height, margin: 0pt)
66#set text(
67 font: "Liberation Sans",
68 size: 9.5pt,
69 fill: ink,
70 // The stylesheet never hyphenated, because browsers do not unless asked.
71 // Typst does, so leaving it on breaks words in the narrow right column that
72 // the reference PDF keeps whole.
73 hyphenate: false,
74 ..line-box(1.35),
75)
76// Zero, because line-box above already carries the full line height. Anything
77// here would be added on top of it.
78#set par(leading: 0pt, spacing: 0pt)
79#show link: it => it
80
81// A sidebar section: a muted uppercase label over a hairline, then content.
82#let sidebar-section(label, body) = block(width: 100%)[
83 #block(below: px(3))[
84 #block(below: px(3))[
85 #text(size: 9.5pt, weight: "bold", tracking: px(0.5), fill: sidebar-label)[
86 #upper(label)
87 ]
88 ]
89 #line(length: 100%, stroke: px(1) + sidebar-rule)
90 ]
91 #body
92]
93
94// The dark right column. Placed rather than laid out in the flow, because it
95// is full bleed to three edges and the main column has to sit beside it, which
96// is what `position: absolute` bought in the stylesheet.
97#place(
98 top + right,
99 block(width: sidebar-width, height: page-height, fill: sidebar-bg)[
100 // `place(right)` sets the alignment for everything inside it too, so
101 // without this every line in the column is flush right.
102 #set align(left)
103 #block(
104 width: 100%,
105 fill: accent,
106 inset: (top: px(36), right: px(24), bottom: px(20), left: px(24)),
107 )[
108 #block(below: px(4))[
109 #text(size: 11pt, weight: "bold", fill: sidebar-ink)[#data.title]
110 ]
111 #block(below: px(8))[
112 #text(size: 9pt, fill: contact-muted)[#data.location]
113 ]
114 #block(below: px(2))[
115 #text(size: 9pt, weight: "bold", fill: white)[#data.phone]
116 ]
117 #text(size: 9pt, fill: white)[
118 #link("mailto:" + data.email)[#data.email]
119 ]
120 ]
121
122 #block(inset: (x: px(24), y: px(14)), width: 100%)[
123 // The stylesheet used flex `gap: 12px`, which is space between children
124 // and not after the last one.
125 #let sections = (
126 sidebar-section("Links")[
127 #for l in data.links [
128 #block(below: px(1))[
129 #text(size: 9pt, fill: link-ink)[#link(l.url)[#l.label]]
130 ]
131 ]
132 ],
133 sidebar-section("Skills")[
134 #for s in data.skills [
135 #block(below: px(1))[#text(size: 9pt, fill: sidebar-muted)[#s]]
136 ]
137 ],
138 // Inline and comma joined, which the stylesheet did with
139 // `display: inline` plus an `::after` comma on all but the last.
140 sidebar-section("Technologies")[
141 #text(size: 9pt, fill: sidebar-muted)[#data.technologies.join(", ")]
142 ],
143 sidebar-section("Education")[
144 #for e in data.education [
145 #text(size: 9pt, fill: sidebar-muted)[
146 #text(weight: "bold", fill: sidebar-ink)[#e.institution,]
147 #e.years \
148 #e.degree
149 ]
150 ]
151 ],
152 )
153 #for (i, s) in sections.enumerate() [
154 #block(below: if i + 1 == sections.len() { 0pt } else { px(12) })[#s]
155 ]
156 ]
157 ],
158)
159
160// The main column. `margin-right: 240px` in the stylesheet, so its box is the
161// page less the sidebar and its own padding sits inside that.
162#block(
163 width: page-width - sidebar-width,
164 inset: (top: px(36), right: px(32), bottom: px(36), left: px(38)),
165)[
166 #block(below: px(5))[
167 #text(size: 26pt, weight: "bold", fill: heading-ink, ..line-box(1.1))[
168 #data.name
169 ]
170 ]
171
172 #block(below: px(14))[
173 #text(size: 9.5pt, fill: summary-ink, ..line-box(1.4))[#data.summary]
174 ]
175
176 #for (i, section) in data.sections.enumerate() [
177 // `margin-top: 12px`, except on the first heading.
178 #block(above: if i == 0 { 0pt } else { px(12) }, below: px(8))[
179 #block(below: px(2))[
180 #text(size: 10.5pt, weight: "bold", tracking: px(0.5), fill: accent)[
181 #upper(section.heading)#if "note" in section {
182 text(size: 8.5pt, weight: "regular", tracking: 0pt)[ — #section.note]
183 }
184 ]
185 ]
186 #line(length: 100%, stroke: px(1) + accent)
187 ]
188
189 #for entry in section.entries [
190 #block(below: px(8), width: 100%)[
191 #block(below: px(1))[
192 #text(size: 10.5pt, fill: heading-ink)[
193 #text(weight: "bold")[#entry.org —]
194 #text(weight: "regular", style: "italic")[#entry.role]
195 ]
196 ]
197 #block(below: px(3))[
198 #text(size: 8.5pt, fill: meta-ink)[#entry.meta]
199 ]
200 #set text(size: 9pt, fill: bullet-ink)
201 #list(
202 // `ul { padding-left: 16px }` with an outside marker, so the body
203 // edge is 16px in and the disc hangs back into that space.
204 indent: px(3),
205 body-indent: px(6),
206 spacing: px(1),
207 marker: text(size: 9pt)[•],
208 ..entry.bullets,
209 )
210 ]
211 ]
212 ]
213]