Reads the Markdown and YAML in content/ plus the files in static/, and
renders a portable dist/ β every page, style and script, with no server at runtime. Any static host
works; the deploy target here is Vercel.
- Zero runtime β a portable
dist/of static files; any host works - Fail-loud validation β all content is checked up front, listing every error at once
- No Node toolchain β standalone Tailwind 4.3.3 and esbuild 0.28.1, cached in
.bin/ - OG share cards β 1200Γ630 PNGs rendered with SkiaSharp
- Reference-checked output β every emitted
href/srcis verified before the build succeeds
.NET 10 SDK, pinned in global.json. On first run the generator downloads the standalone Tailwind
4.3.3 and esbuild 0.28.1 binaries into .bin/.
Platforms are bounded by Tailwind's standalone releases β macOS, Linux (glibc and musl) and Windows, on x64 and arm64; anything else fails immediately, naming the detected platform. CI runs the full generate on Ubuntu, macOS and Windows.
dotnet run --project src/Yggdrasil.GenerateValidates all content (listing every error at once), recreates dist/, copies static/**, builds
minified CSS/JS, renders the 15 route types β notes, projects and tags expand per item β plus
404.html, then verifies every emitted href/src resolves. Paths resolve from global.json, so it
runs from any working directory.
Preview it with python3 -m http.server 8799 --directory dist. A plain file server redirects
/notes/foo β /notes/foo/; Vercel serves the canonical no-slash form via vercel.json (cleanUrls,
trailingSlash: false). Both resolve either way.
Build-time environment variables, all validated in
Config.fs before anything is written.
| Variable | Default | Purpose |
|---|---|---|
SITE_URL |
site.yaml's url |
Base URL for canonical links, Open Graph and feeds. Must be an absolute http(s) URL; the trailing slash is normalised. |
SKIP_ASSETS |
(unset) | Any value skips the CSS/JS and OG-card build. The HTML still references them, so output is incomplete β fast iteration only. |
SITE_ROOT |
auto, via global.json |
Project root override, or the first CLI argument. Must contain global.json and site.yaml β checked because the generator deletes dist/ beneath it. |
dotnet test # VSTest adapter; what CI runs
dotnet run --project tests/Yggdrasil.Tests # Expecto console app
dotnet test --collect:"XPlat Code Coverage" # Cobertura under TestResults/The console app forwards Expecto arguments after -- (e.g. -- --filter-test-list feeds); a new
[<Tests>] module must be registered in Main.fs or only the VSTest adapter finds it. Content and
Web sit around 90β96% line coverage; Generate reads lower β Assets downloads binaries and runs
external processes.
content/ Markdown/YAML source (+ source PNGs)
static/ copied verbatim into dist/
assets/css, assets/js Tailwind and esbuild inputs
assets/grammars/ TextMate grammars + theme β see its README
src/Yggdrasil.Content/ domain and content pipeline
src/Yggdrasil.Web/ views, components, layouts, feeds, routes
src/Yggdrasil.Generate/ the entry point you run
tests/Yggdrasil.Tests/ Expecto suite
dist/ and .bin/ are build output and gitignored.
Identity lives in data β pointing this at someone else needs no .fs edit:
site.yamlβ name, author, tagline, URL, avatar, socials, and each index page's title and description. Unknown keys are an error, so a typo fails the build.content/pages/home/index.mdandcontent/pages/about/index.mdβ required; the build fails without them. Frontmatter carries the title, description, heading and optional emoji.static/avatar*.{png,webp}andstatic/favicon/β paths come fromsite.yaml, so nothing else needs editing.
Layout, components, colours and route structure are the theme, and are meant to be edited in code.
content/fragrances/ is a personal collection you'll want to delete.
The site is built in CI and uploaded prebuilt β Vercel's build image has no .NET, so the
generator cannot run there. The deploy job in ci.yml ships with
vercel deploy --prebuilt, gated on needs: [test, generate] β so CI is the only path to production,
and a commit that fails dotnet test cannot deploy.
One-time setup:
- Repository secrets
VERCEL_TOKEN,VERCEL_ORG_ID,VERCEL_PROJECT_IDβ the latter two from.vercel/project.jsonaftervercel link, or the project settings page. - Turn off the Git integration's production deploys, or every push deploys twice (Vercel's ungated
one fails anyway for lack of
dotnet). - Set
SITE_URLin the Vercel environment if it differs fromsite.yaml'surl. - Repository secret
VERCEL_AUTOMATION_BYPASS_SECRETβ generate it under Settings β Deployment Protection β Protection Bypass for Automation and copy the value into a GitHub Actions secret of the same name.
Deployment Protection makes the deploy URL 302. With it enabled, the unique
*.vercel.appdeploy URL redirects unauthenticated requests to an SSO login, so the smoke test would see302instead of200. The deploy job sends thex-vercel-protection-bypassheader (VERCEL_AUTOMATION_BYPASS_SECRET) so it can verify the live deployment without turning protection off for humans.
Do not hand-write
.vercel/output/config.json.vercel buildtranslatescleanUrls,trailingSlashandheadersfromvercel.jsoninto Build Output API routes; that format supports none of those keys, so translating by hand silently drops the CSP and HSTS headers β which the deploy job's smoke test then catches.
Roll back by promoting the previous deployment in the Vercel dashboard (an already-built artifact, effective immediately); revert the commit afterwards β restore the site first, fix the repo second.
- An unhighlightable code fence fails the build, a typo'd label included. Supported labels are
fsharp/fs/f#,scalaandbash/shell/sh/shellscriptβ the languages this site uses. Leave a fence untagged for a plain block; to add a language seeassets/grammars/README.md. - Highlighting emits light colours inline and dark ones as
--tm-dark*variables thatapp.csspromotes underhtml.dark. Both slots are Catppuccin FrappΓ© (ThemesinHighlight.fs), so code looks the same in either site theme β hence the copy button is styled light-on-dark unconditionally. - Source PNGs must live next to their Markdown in
content/: the image rewrite reads their real dimensions at build time and points the rendered<img>at the prebuilt.webpunderstatic/images/. assets/css/app.csscarries two edits over a stock Tailwind entry β the@sourcescan ofsrc/Yggdrasil.Web, and the dark-mode block targeting the.tmhighlight wrapper.- The footer year is frozen at generate time; rebuild to refresh it.
- OG share cards render from TTF, not the WOFF2 the site serves.
SkiaSharp.NativeAssets.Linux.NoDependenciesships without Brotli, soSKTypeface.FromFilecan't decode WOFF2 on Linux. The three faces the cards use live as lossless TTF conversions underassets/fonts/β a build input, never copied intodist/. macOS Skia reads WOFF2 directly; TTF keeps the Linux CI generate working too.
- The generator is MIT β
src/,tests/,assets/, build config and docs. Take it and build your own site. SeeLICENSE.md. - The content is not β notes, project write-ups, fragrance reviews and the avatar are reserved
(
LICENSE-CONTENT.mdhas the exact paths). Replacecontent/with your own and you're clear;site.yamlis yours to edit. - Fragrance photographs under
static/images/fragrances/are third-party product shots, not mine to sublicense β a fork should delete them.
Redistributed third-party files carry attribution beside themselves:
assets/grammars/README.md for the grammars and theme, and
static/fonts/OFL.txt for the fonts (shipped at /fonts/OFL.txt, as the SIL OFL requires).
Everything else is a build-time NuGet dependency, never redistributed.