Personal homepage of Sascha Mayr — saschamayr.com
A one-page site styled as a markdown document rendered in a code block: solarized/tomorrow-night colors, monospace, dark-mode toggle; on desktop it fits the screen without scrolling (down to ~800 px of height).
Static HTML · ~50 lines of TypeScript · Vite 8 · Lightning CSS · Biome · pnpm ·
Node 26 (the build scripts are TypeScript, run directly by Node).
No framework, no analytics script: the built page is a single ~10 kB
(gzipped) index.html with the CSS, JS and photo placeholder inlined.
| Command | Description |
|---|---|
pnpm install |
Install dependencies |
pnpm dev |
Dev server at localhost:3000 |
pnpm build |
Production build to build/ (runs scripts/generate.ts and tsc -b first, scripts/verify-build.ts after) |
pnpm check |
build, then fail if it changed a committed file (what CI runs) |
pnpm lint / pnpm format |
Biome: check, or fix and format |
pnpm dlx @lhci/cli@0.15.1 autorun |
Lighthouse CI locally (CI runs it from a GitHub Action) |
pnpm preview |
Preview the production build |
node scripts/serve.ts |
Serve build/ with its _headers (CSP, caching), as Netlify does, at localhost:5180 |
pnpm generate |
Sync content, placeholder and photo sizes into index.html |
scripts/test-netlify-fallback.sh |
Test the photo fallback with cwebp/avifenc hidden, as on Netlify |
node scripts/generate.ts --force |
Re-encode the photos even though profile.png didn't change |
index.htmlis the page. All markup lives there; nothing is rendered by JavaScript. Its footer's first line links to/how-its-built/, a write-up of the engineering in the same Markdown look (see Editing content).src/main.tsimports the CSS and owns the theme toggle, following OS theme changes, thetheme-colormeta tags and the footer year. A click is remembered; choosing what the OS shows anyway forgets the choice (the page follows the OS again). Open tabs stay in sync.- Inline scripts in
index.htmlset the theme before first paint (localStorage → OS preference) and wire the photo fade-in as early as possible. vite.config.tshas small plugins that: embed the built CSS and JS intoindex.html, so the page needs no follow-up requests besides images; minify the hand-written inline scripts and strip HTML comments; hash every inline script and style (SHA-256) into a Content-Security-Policy (with Trusted Types) inbuild/_headers, so only exactly those can run, together with the other security headers and the caching rules (see Profile photo); and fail the build over the size budget (see Checks). Every header the site sets is in that file.tsconfig.app.jsonchecks the page's TypeScript,tsconfig.node.jsonthe Vite config andscripts/*.ts(tsc -bruns both). Biome (biome.json) lints the TypeScript, CSS and JSON and formats the TypeScript and JSON (the CSS keeps its compact one-line rules).- Printing always uses the light colours (printers skip the dark background) and fits one page with the URLs, whatever the paper.
- Without JavaScript the page still works: a
<noscript>style shows the real photo directly, hides the theme toggle and follows the OS dark mode.
src/Intro.converged.html holds the pre-highlighted markdown markup
(highlight.js-style, with short class names styled in src/style.css), plus
the accessibility layer: aria-labels on links and headings so screen
readers skip the markdown syntax (and hear "opens in a new tab"), and list
roles on the - lines. It goes
into divs styled like <pre><code> (headings aren't allowed inside those).
Edit it, then run pnpm generate to copy it into index.html; pnpm build
does this automatically, and CI fails if the committed index.html is out
of sync.
The engineering page is written in src/how-its-built.md. generate.ts
converts it (a small Markdown subset: #/## headings, paragraphs, -
lists, fenced code, `code`, **bold**, links) into
how-its-built/index.html, with the Markdown syntax visible but hidden
from screen readers, and fills in {{lines-page}} and {{lines-tooling}}
(non-blank lines of src/main.ts and of the build code). The build fills
in {{gzip}}, the homepage's measured size. Its extra styles are in
src/article.css, inlined into that page only; Lighthouse CI checks it too. Its link
preview (Open Graph, twitter:*, TechArticle JSON-LD) uses
public/og-how-its-built.png, a 1200×630 screenshot of
scripts/og/how-its-built.html; re-take it by hand if that changes.
Layouts (src/style.css):
- Phones (< 720 px, or < 640 px in landscape): the text (12–14 px, growing with the screen
height) fills at least the first screen; the whole photo
and the footer follow on scrolling (never cut in half by the screen
edge). Links render reference-style (
[Shumi]): each link's(https://…)part sits in aspan.uthat the CSS hides. A row that wraps stays visibly one item (hanging indent), and the email address stays visible. - Portrait tablets, phones in landscape, landscape windows under 904 px: the same stack with full links (12 px under 720 px wide); the photo takes the height that's left. Where even its 280 px minimum doesn't fit, it moves whole to the second screen, as on phones.
- Landscape (≥ 904 px wide, ≥ 500 px tall): text and photo side by side, scaled to fit the screen. Links always show in full. Where width is short, the photo gives way first, so the text never drops below 12 px; the text is at most 13 px up to 1440 px wide and grows beyond that, and the photo grows with it. Below ~800 px of height the page scrolls a little rather than shrink the text further.
Link rows are at least 24 px apart on every layout, for tapping and
clicking. Sizes are in rem and breakpoints in em (720 and 904 px at
the default 16 px), so both follow the browser's font-size setting: larger
text gets the stacked layouts sooner. The CSS is built for Safari 15 /
Chrome 100 (build.cssTarget), so the media queries use min-width, not
range syntax, which iOS 15 ignores.
The photo is a cross-fade stack: a placeholder paints underneath, the real
photo sits on top at opacity: 0 and fades in once it has loaded and
decoded. The placeholder is then hidden. It is decoded synchronously,
and where it still isn't loaded when the page is parsed (Safari loads even
data URIs after the first paint), the text waits for it (at most 300 ms),
so text and photo always appear in the same frame and a reload never
blinks the photo.
generate.ts builds these from src/assets/profile.png (the 1280×1920
source photo; in the repo, but not deployed with the site):
src/assets/profile-{500,800,1280}.avif: the real photo, ~20 / 48 / 120 KB, picked bysrcset/sizes(and the matching preload) so phones get the small one and big or high-DPI screens a sharp one. Theprofile-{500,800,1280}.webpfiles are the fallback for browsers without AVIF.src/assets/profile-lqip.avif: placeholder, 500 px, ~4.4 KB (alpha at a higher quality than the colour, for cleaner edges), embedded inindex.htmlas a data URI so it paints with the first responseprofile-lqip.webp: placeholder fallback, half resolution, ~4.4 KB
The real photo goes through Vite, so it ships with a content hash in its
name (/assets/profile-500-<hash>.avif) and is cached for a year as immutable
(build/_headers, file by file, so a 404 under /assets/ isn't).
The unhashed files (icons including favicon.svg, og.png, sascha-mayr.jpg, the WebP
placeholder) get 30 days, also named one by one (MONTH_CACHED in
vite.config.ts); the HTML is always revalidated.
The tools (avifenc, cwebp; brew install libavif webp; plus macOS
sips for resizing) aren't on Netlify, so the build there uses the
committed files. After changing
profile.png, run pnpm generate locally and commit the regenerated
files.
generate.ts skips the encoding when src/assets/profile.stamp (a hash of
profile.png and the encoder commands) matches; --force re-encodes. On
Netlify and CI a mismatch fails the build rather than deploy stale photos.
The photo's srcset and sizes in index.html are written by
generate.ts too, from the same layout measurements as src/style.css.
public/og.png (the 1200×630 link-preview image) and public/sascha-mayr.jpg
(800×1200 on the page background, the stable photo URL in the JSON-LD) were
generated once and are not part of the pipeline. Regenerate them by hand if
the photo or tagline changes.
- Size budget:
vite.config.tsfails the build if the page is over 10.5 kB gzipped, so the footer's "10 kB" stays true. It also minifies the hand-written inline scripts and strips HTML comments. scripts/verify-build.ts(part ofpnpm build) fails if a security header is missing or weakened inbuild/_headers, if any inline script or style in the built pages lacks its hash there, or if the pages use anything the policy blocks: external stylesheets or scripts (the build itself also fails when it can't inline one), style attributes, event handlers,javascript:URLs. It also checks the theme copies thatsrc/style.csscan't reach (thenoscriptstyle, the inline scripts, thetheme-colortags,src/main.ts,404.html): each copy must be complete and match its own theme, and the storage key must match.- CI (
.github/workflows/check.yml, on every push and PR; actions pinned by commit, kept current by Dependabot, which waits 7 days after a release; pnpm refuses versions younger than 3 days,pnpm-workspace.yaml):pnpm check(the build passes the budget andverify-build, and leaves the generated pages and the photos unchanged), Biome, the Netlify fallback test, and Lighthouse CI (treosh/lighthouse-ci-action, so the repo doesn't install it;lighthouserc.json, 3 mobile runs) againstscripts/serve.ts, so the page loads under its real CSP: a blocked script is a console error, which fails best practices. It must score 100 for accessibility, best practices and SEO, and at least 98 for performance (2 points of slack for CI timing noise).
Netlify, auto-deploys from master (Node from .nvmrc, pnpm run build,
publish build/). The headers come from build/_headers (HSTS with
includeSubDomains; preload, CSP, COOP, Permissions-Policy, framing, MIME
sniffing, caching); netlify.toml holds the build settings and a few
redirects: /index.html goes to /, and
/404 and /404.html answer 404. Unknown URLs get 404.html (built by
Vite, so it's covered by the CSP) with a real 404 status; it follows the
same remembered light/dark choice.
The code is MIT (LICENSE). The photos and the personal content are not:
© Sascha Mayr, all rights reserved (NOTICE).