Initial commit
Some checks failed
ci / test (push) Failing after 2s
pages / build (push) Failing after 1s
pages / deploy (push) Has been skipped

This commit is contained in:
Maxfield Luke 2026-07-06 02:46:37 -04:00
commit bda33ef628
36 changed files with 4233 additions and 0 deletions

17
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,17 @@
name: ci
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mlugg/setup-zig@v2
with:
version: 0.14.1
- run: ./test.sh

38
.github/workflows/pages.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./ # build with the ypsilanti action in this repo
with:
source: ./site
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: ./_site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
zig-out/
.zig-cache/
output/
_site/
.ypsilanti_serve/

32
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,32 @@
# Contributing to ypsilanti
Thanks for your interest in ypsilanti.
## Requirements
- [Zig](https://ziglang.org/download/) 0.14 or newer.
## Development
```
zig build # debug build to zig-out/bin/ypsilanti
zig build run -- serve ./example # run without installing
./test.sh # build (ReleaseFast) and run the test suite
```
## Before opening a pull request
- Run `./test.sh` and make sure it passes. CI runs the same script.
- Add or update tests in `test.sh` when you change behavior.
- Keep changes focused and the diff minimal; match the surrounding style.
## Reporting bugs
Open an issue with a minimal site (`content/`, `layouts/`, config) that
reproduces the problem, the command you ran, and what you expected.
## Security
ypsilanti escapes template variables, sanitizes URL schemes, and validates
internal links at build time. If you find a way around these, please report it
privately by email rather than opening a public issue.

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Maxfield Luke
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

204
README.md Normal file
View file

@ -0,0 +1,204 @@
# ypsilanti
minimal static site generator in zig.
`example/` is a demo site you can build and serve to see how everything fits together.
## install
requires [zig](https://ziglang.org/download/) (0.14+).
```
zig build -Doptimize=ReleaseFast
cp zig-out/bin/ypsilanti /usr/local/bin/
```
`-Doptimize` options: `Debug` (default), `ReleaseSafe`, `ReleaseFast`, `ReleaseSmall`. use `ReleaseFast` for production, skip the flag for development.
## usage
```
ypsilanti build ./site ./output # build site
ypsilanti serve ./site # dev server on :3000
ypsilanti serve ./site 8080 # custom port
```
## site structure
```
site/
├── content/ # markdown files → html
├── layouts/ # page templates
├── partials/ # reusable snippets
├── static/ # copied as-is (css, images, fonts)
└── config # optional site config
```
## config
```text
title: example.com
url: https://example.com
author: your name
description: site description
theme: darkode
paginate: 10
nav: home=/, posts=/posts/, tags=/tags/, rss=/feed.xml
```
## front matter
```markdown
---
title: Hello World
date: 2025-01-09
description: post summary for rss
layout: post
tags: linux, software
categories: technology
draft: false
aliases: /old-post/, /legacy/post.html
---
your content here
```
- `title` - page title, available as `{{title}}`
- `date` - includes page in rss feed
- `description` - rss item description
- `layout` - which layout to use from `layouts/`
- `tags` - comma-separated tags, used for generated tag pages
- `categories` - comma-separated categories, used for generated category pages
- `draft` - set to `true` to skip the page
- `aliases` / `alias` - comma-separated local URLs that emit static redirect pages
any key except reserved `content`, `nav_html`, and `toc` is available as `{{key}}` in templates.
## templates
layouts go in `layouts/`. use `{{content}}` for the rendered markdown:
```html
<!DOCTYPE html>
<html>
<head><title>{{title}}</title></head>
<body>
{{> header}}
{{{toc}}}
<main>{{{content}}}</main>
</body>
</html>
```
Template variables are HTML-escaped by default. Variables rendered directly inside `href` and `src` attributes are also checked for safe URL schemes. Triple braces render raw HTML only for built-in `content` and `nav_html` variables.
partials go in `partials/`. include with `{{> name}}`:
```html
<!-- partials/header.html -->
<nav><a href="/">home</a></nav>
```
## markdown
supported:
- `# headers` (h1-h6)
- `**bold**` and `*italic*`
- `[links](url)` with safe `http`, `https`, `mailto`, anchor, or relative URLs
- `` `inline code` ``
- fenced code blocks with language class
- basic syntax highlighting spans for common fenced code languages
- `- unordered lists`
- `> blockquotes`
- pipe tables
- footnotes with `[^id]` and `[^id]: text`
- whole-line shortcodes: `figure`, `youtube`, `vimeo`, and `callout`
Headings get generated `id` attributes and a raw `{{{toc}}}` template variable.
shortcode examples:
```markdown
{{< figure src="/img/photo.jpg" alt="Photo" caption="A caption" >}}
{{< youtube dQw4w9WgXcQ >}}
{{< vimeo 123456789 >}}
{{< callout type="warning" title="heads up" text="important note" >}}
```
## error pages
create `content/404.md` for custom 404 pages. works in dev server and most static hosts.
pages are generated with clean URLs: `content/about.md` becomes `about/index.html` and is served at `/about/`.
## rss + sitemap
auto-generated on build:
- `sitemap.xml` - all pages
- `feed.xml` - pages with `date:` front matter
- `posts/` - dated posts sorted newest first, paginated with `paginate:`
- `tags/` and `categories/` - taxonomy indexes and paginated term pages
set base url in `site/config` with `url:`. `site/url` is still supported as a fallback.
## link checking
`build` validates internal `href` and `src` links after writing pages and static files. Root-relative links, relative links, and fragment anchors must point to generated output or the build fails.
## deploy
### github pages (recommended)
ypsilanti ships a GitHub Action, so a full deploy is one `uses:` line. Copy
[`docs/github-pages.yml`](docs/github-pages.yml) into your site repo as
`.github/workflows/pages.yml`, then set Settings → Pages → Source to *GitHub
Actions*. The core of it:
```yaml
- uses: actions/checkout@v4
- uses: maxf1eld/ypsilanti@main # pin to a tag or SHA for stability
with:
source: ./site # your site source dir
- uses: actions/upload-pages-artifact@v3
with:
path: ./_site
```
The action builds ypsilanti and your site with no local Zig install needed.
Internal links are root-relative (`/about/`), so deploy at a **root domain**
a user/org page (`you.github.io`) or a custom domain. A *project* page
(`you.github.io/repo/`) serves under a subpath and will break those links.
### manual / other hosts
Build locally and push the output anywhere that serves static files (Netlify,
Cloudflare Pages, S3, a plain web server):
```
ypsilanti build ./site ./docs
git add docs && git commit -m "build" && git push
```
## dev workflow
```
ypsilanti serve ./site
```
opens dev server with live reload. edit any file in content/, layouts/, partials/, or static/ and browser refreshes automatically.
## tests
```
./test.sh
```
## contributing
Contributions are welcome. Run `./test.sh` before opening a pull request; CI
runs the same suite. See [CONTRIBUTING.md](CONTRIBUTING.md) for more.
## license
MIT — see [LICENSE](LICENSE).

36
action.yml Normal file
View file

@ -0,0 +1,36 @@
name: ypsilanti
description: Build a static site with the ypsilanti static site generator.
branding:
icon: file-text
color: purple
inputs:
source:
description: Path to the site source directory (holds content/, layouts/, static/, config).
required: false
default: ./site
output:
description: Directory to write the built site to.
required: false
default: ./_site
zig-version:
description: Zig version used to build ypsilanti.
required: false
default: 0.14.1
runs:
using: composite
steps:
- name: Set up Zig
uses: mlugg/setup-zig@v2
with:
version: ${{ inputs.zig-version }}
- name: Build ypsilanti
shell: bash
working-directory: ${{ github.action_path }}
run: zig build -Doptimize=ReleaseFast
- name: Build site
shell: bash
run: '"${{ github.action_path }}/zig-out/bin/ypsilanti" build "${{ inputs.source }}" "${{ inputs.output }}"'

39
build.zig Normal file
View file

@ -0,0 +1,39 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "ypsilanti",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the SSG");
run_step.dependOn(&run_cmd.step);
const unit_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
const run_unit_tests = b.addRunArtifact(unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_unit_tests.step);
}

48
docs/github-pages.yml Normal file
View file

@ -0,0 +1,48 @@
# Deploy a ypsilanti site to GitHub Pages.
#
# Copy this file into your SITE repository as .github/workflows/pages.yml,
# then enable Pages (Settings → Pages → Source: GitHub Actions).
#
# Assumes your site source lives in ./site. Change `source:` if it differs.
#
# Note: internal links are root-relative (e.g. /about/), so serve at a root
# domain — a user/org page (you.github.io) or a custom domain. A project page
# (you.github.io/repo/) serves under a subpath and will break those links.
name: pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: maxf1eld/ypsilanti@main # pin to a tag or commit SHA for stability
with:
source: ./site
- uses: actions/upload-pages-artifact@v3
with:
path: ./_site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4

6
example/config Normal file
View file

@ -0,0 +1,6 @@
title: Example Site
url: https://example.com
author: Example Author
description: A small ypsilanti demo site.
theme: default
nav: home=/, about=/about/, posts=/posts/, tags=/tags/, rss=/feed.xml

8
example/content/404.md Normal file
View file

@ -0,0 +1,8 @@
---
title: Not Found
layout: base
---
# 404
Page not found. [Go home](/).

9
example/content/about.md Normal file
View file

@ -0,0 +1,9 @@
---
title: About
description: About this example site.
layout: base
---
# About
This page shows a simple content page.

13
example/content/index.md Normal file
View file

@ -0,0 +1,13 @@
---
title: Home
description: Example home page.
layout: base
---
# Example Site
This is a small demo site for ypsilanti.
## Posts
- [First Post](/posts/first-post/)

View file

@ -0,0 +1,21 @@
---
title: First Post
date: 2026-01-01
description: A demo post with tags and markdown features.
layout: post
tags: demo, linux
categories: technology
---
This is a dated post. It appears in the generated post index, feed, tags, and categories.
## Markdown Features
| feature | status |
| - | - |
| tables | yes |
| footnotes | yes |
Here is a footnote.[^1]
[^1]: This is a footnote.

23
example/layouts/base.html Normal file
View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{description}}">
<meta name="author" content="{{author}}">
<meta property="og:title" content="{{title}} - {{site_title}}">
<meta property="og:description" content="{{description}}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{permalink}}">
<title>{{title}} - {{site_title}}</title>
<link rel="stylesheet" href="/style.css">
<link rel="alternate" type="application/rss+xml" href="/feed.xml">
</head>
<body>
<div class="page">
{{> nav}}
<main>{{{content}}}</main>
{{> footer}}
</div>
</body>
</html>

27
example/layouts/post.html Normal file
View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{description}}">
<meta name="author" content="{{author}}">
<meta property="og:title" content="{{title}} - {{site_title}}">
<meta property="og:description" content="{{description}}">
<meta property="og:type" content="article">
<meta property="og:url" content="{{permalink}}">
<title>{{title}} - {{site_title}}</title>
<link rel="stylesheet" href="/style.css">
<link rel="alternate" type="application/rss+xml" href="/feed.xml">
</head>
<body>
<div class="page">
{{> nav}}
<main>
<h1>{{title}}</h1>
<p class="post-date">posted {{date}}</p>
{{{content}}}
</main>
{{> footer}}
</div>
</body>
</html>

View file

@ -0,0 +1,3 @@
<footer>
<p>built with ypsilanti</p>
</footer>

View file

@ -0,0 +1,8 @@
<header>
<div class="identity">
<a class="site-title" href="/">{{site_title}}</a>
</div>
<nav>
{{{nav_html}}}
</nav>
</header>

49
example/static/style.css Normal file
View file

@ -0,0 +1,49 @@
* {
box-sizing: border-box;
}
body {
max-width: 52rem;
margin: 0 auto;
padding: 1rem;
background: #f5f5f5;
color: #222;
font: 16px/1.55 system-ui, sans-serif;
}
a {
color: #0645ad;
}
header,
main,
footer {
margin-bottom: 1rem;
padding: 1rem;
border: 1px solid #ddd;
background: #fff;
}
.site-title {
font-size: 1.5rem;
font-weight: 700;
}
nav a {
margin-right: 0.75rem;
}
.post-date,
.post-list span {
color: #666;
}
table {
border-collapse: collapse;
}
th,
td {
padding: 0.35rem 0.5rem;
border: 1px solid #ddd;
}

2
mise.toml Normal file
View file

@ -0,0 +1,2 @@
[tools]
zig = "0.14"

6
site/config Normal file
View file

@ -0,0 +1,6 @@
title: maxfield.lol
url: https://maxfield.lol
author: maxf1eld
description: notes from maxfield.lol
theme: darkode
nav: home=/, about=/about/

8
site/content/404.md Normal file
View file

@ -0,0 +1,8 @@
---
title: not found
layout: base
---
# 404
dead link. bad portal. [go home](/).

8
site/content/about.md Normal file
View file

@ -0,0 +1,8 @@
---
title: about
layout: base
---
# about
github: [maxf1eld](https://github.com/maxf1eld)

6
site/content/index.md Normal file
View file

@ -0,0 +1,6 @@
---
title: home
layout: base
---
# notes

24
site/layouts/base.html Normal file
View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{description}}">
<meta name="author" content="{{author}}">
<meta property="og:title" content="{{title}} - {{site_title}}">
<meta property="og:description" content="{{description}}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{permalink}}">
<title>{{title}} - {{site_title}}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body class="theme-{{theme}}">
<div class="page">
{{> nav}}
<main>
{{{content}}}
</main>
{{> footer}}
</div>
</body>
</html>

27
site/layouts/post.html Normal file
View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{description}}">
<meta name="author" content="{{author}}">
<meta property="og:title" content="{{title}} - {{site_title}}">
<meta property="og:description" content="{{description}}">
<meta property="og:type" content="article">
<meta property="og:url" content="{{permalink}}">
<title>{{title}} - {{site_title}}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body class="theme-{{theme}}">
<div class="page">
{{> nav}}
<main>
<h1>{{title}}</h1>
<p class="post-date">posted {{date}}</p>
{{{toc}}}
{{{content}}}
</main>
{{> footer}}
</div>
</body>
</html>

View file

@ -0,0 +1,3 @@
<footer>
<p>maxfield.lol</p>
</footer>

8
site/partials/nav.html Normal file
View file

@ -0,0 +1,8 @@
<header>
<div class="identity">
<a class="site-title" href="/">{{site_title}}</a>
</div>
<nav>
{{{nav_html}}}
</nav>
</header>

390
site/static/style.css Normal file
View file

@ -0,0 +1,390 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #040303;
--surface: #0b0b0c;
--panel: #121111;
--panel-soft: #1a1715;
--ink: #d9d2ca;
--muted: #9a8f88;
--line: #302a28;
--accent: #b5c0d1;
--accent-dark: #edf3ff;
--accent-light: #fff6ef;
--red: #9c1715;
--red-dark: #dd3a32;
}
html {
min-height: 100vh;
background: var(--bg);
}
body {
min-height: 100vh;
padding: clamp(0.45rem, 1.4vw, 0.9rem);
background: linear-gradient(180deg, #15100f 0, #050404 18rem, #030303 100%);
color: var(--ink);
font: 14px/1.6 Tahoma, Verdana, Arial, sans-serif;
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: grayscale;
font-smooth: never;
text-rendering: optimizeSpeed;
}
.page {
max-width: 58rem;
margin: 0 auto;
border: 1px solid #171313;
background: rgba(5, 4, 4, 0.9);
box-shadow: 0 0 0 1px #000, 0 18px 60px rgba(0, 0, 0, 0.62);
}
header {
border-bottom: 1px solid #000;
}
.identity,
nav,
main,
footer {
border: 1px solid var(--line);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 1px 0 rgba(0, 0, 0, 0.9);
}
.identity {
padding: clamp(0.9rem, 2.4vw, 1.25rem);
border: 0;
border-top: 1px solid #2a2422;
border-bottom: 4px solid var(--red);
background:
linear-gradient(180deg, #241d1b 0, #0a0808 68%, #040303 100%);
color: var(--accent-light);
}
.site-title {
display: block;
color: #fff8f2;
font: 700 clamp(1.9rem, 5vw, 3.45rem)/0.92 'Trebuchet MS', Tahoma, Verdana, Arial, sans-serif;
letter-spacing: -0.04em;
text-decoration: none;
text-shadow: 0 2px 0 #000, 0 0 20px rgba(221, 58, 50, 0.52);
}
.site-title::first-letter {
color: var(--red-dark);
}
.tagline {
max-width: 34rem;
margin-top: 0.35rem;
color: #b9aaa2;
font: 0.78rem/1.45 Tahoma, Verdana, Arial, sans-serif;
letter-spacing: 0.06em;
text-transform: uppercase;
}
nav {
display: flex;
gap: 1px;
padding: 0;
border: 0;
border-top: 1px solid #292321;
border-bottom: 1px solid #000;
background: #030303;
}
nav a {
display: block;
padding: 0.62rem 0.9rem;
border: 0;
border-right: 1px solid #000;
background: linear-gradient(180deg, #211d1b 0, #0d0b0b 100%);
color: #fff7ef;
font: 700 0.78rem/1 Tahoma, Verdana, Arial, sans-serif;
text-decoration: none;
text-transform: uppercase;
}
nav a:hover {
background: linear-gradient(180deg, #7e1b19 0, #260706 100%);
color: #fff;
}
main {
margin: 0.5rem;
padding: clamp(1rem, 2vw, 1.35rem);
border: 1px solid #332b28;
background:
linear-gradient(180deg, rgba(255, 246, 239, 0.04) 0, transparent 3rem),
var(--panel);
}
h1, h2, h3 {
color: #fff4ea;
font-family: Tahoma, Verdana, Arial, sans-serif;
line-height: 1.15;
}
h1 {
margin: -1.01rem -1.35rem 1rem;
padding: 0.62rem 0.85rem;
border-bottom: 1px solid #050303;
background: linear-gradient(180deg, #851917 0, #360807 100%);
color: #fff6ef;
font-size: clamp(1.25rem, 2.4vw, 1.7rem);
letter-spacing: 0;
}
h2 {
margin: 1.25rem 0 0.65rem;
padding: 0.46rem 0.65rem;
border: 1px solid #332b28;
border-left: 4px solid var(--red);
background: linear-gradient(180deg, #211d1b 0, #0d0b0b 100%);
font-size: 1rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
h3 {
margin: 1rem 0 0.45rem;
font-size: 0.95rem;
}
.post-date {
display: inline-block;
margin: 0.25rem 0 1.25rem;
padding: 0.32rem 0.55rem;
border: 1px solid #332b28;
border-left: 4px solid var(--red);
background: linear-gradient(180deg, #211d1b 0, #0d0b0b 100%);
color: #b9aaa2;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
p {
max-width: 46rem;
margin-bottom: 0.95rem;
}
ul, ol {
margin: 0 0 1rem 1.75rem;
}
.post-list span {
color: var(--muted);
font-size: 0.85em;
}
table {
width: 100%;
margin: 1rem 0;
border-collapse: collapse;
border: 1px solid #332b28;
}
th,
td {
padding: 0.45rem 0.55rem;
border: 1px solid #332b28;
text-align: left;
}
th {
background: linear-gradient(180deg, #211d1b 0, #0d0b0b 100%);
color: #fff6ef;
}
.footnotes {
margin-top: 1.25rem;
padding-top: 0.75rem;
border-top: 1px solid #332b28;
color: var(--muted);
font-size: 0.9em;
}
li {
margin-bottom: 0.35rem;
}
a {
color: var(--accent-dark);
font-weight: 700;
text-decoration: none;
}
a:hover {
color: #fff;
text-decoration: underline;
}
pre {
margin: 0.85rem 0;
padding: 0.75rem;
overflow-x: auto;
border: 1px solid #332b28;
background: #070606;
color: #fff7ef;
}
code {
font-family: Fixedsys, 'Terminal', 'Lucida Console', 'Courier New', monospace;
font-size: 0.92em;
}
p code {
border: 1px solid #332b28;
background: #090808;
color: #fff6ef;
padding: 0.18em 0.35em;
}
blockquote {
max-width: 42rem;
margin: 0.85rem 0;
padding: 0.65rem 0.85rem;
border: 1px solid #332b28;
border-left: 4px solid var(--red);
background: var(--panel-soft);
color: #d0c5bc;
}
.toc,
.pagination,
.callout,
figure,
.embed {
max-width: 46rem;
margin: 1rem 0;
padding: 0.65rem 0.85rem;
border: 1px solid #332b28;
background: var(--panel-soft);
}
.toc strong {
display: block;
margin-bottom: 0.35rem;
color: #fff6ef;
font-size: 0.78rem;
text-transform: uppercase;
}
.toc ol {
margin-bottom: 0;
}
.toc-level-3 { margin-left: 1rem; }
.toc-level-4,
.toc-level-5,
.toc-level-6 { margin-left: 2rem; }
.pagination {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.pagination span {
color: var(--muted);
font-size: 0.85rem;
}
figure img {
display: block;
max-width: 100%;
height: auto;
}
figcaption {
margin-top: 0.5rem;
color: var(--muted);
font-size: 0.85rem;
}
.embed {
aspect-ratio: 16 / 9;
padding: 0;
}
.embed iframe {
width: 100%;
height: 100%;
border: 0;
}
.callout {
border-left: 4px solid var(--red);
}
.callout strong {
display: block;
margin-bottom: 0.25rem;
color: #fff6ef;
text-transform: uppercase;
}
.callout p {
margin: 0;
}
.tok-keyword { color: #ff7d74; }
.tok-string { color: #f1d08a; }
.tok-comment { color: #8e837d; }
.tok-number { color: #b5c0d1; }
footer {
align-self: start;
margin: 0 0.5rem 0.5rem;
padding: 0.55rem;
border: 1px solid #332b28;
background: #070606;
color: #9f928a;
font: 0.78rem/1.45 Tahoma, Verdana, Arial, sans-serif;
}
footer a {
color: var(--accent-light);
}
footer a:hover {
background: #211d1b;
color: #fff;
}
footer p {
margin: 0;
}
@media (max-width: 760px) {
.page,
header {
display: block;
}
.identity,
nav,
main,
footer {
margin-bottom: 0.75rem;
}
nav {
display: flex;
flex-wrap: wrap;
align-content: initial;
}
nav a {
flex: 1 1 auto;
}
}

1
site/url Normal file
View file

@ -0,0 +1 @@
https://maxfield.lol

54
src/frontmatter.zig Normal file
View file

@ -0,0 +1,54 @@
const std = @import("std");
pub const FrontMatter = struct {
meta: std.StringHashMap([]const u8),
content: []const u8,
pub fn deinit(self: *FrontMatter) void {
self.meta.deinit();
}
pub fn get(self: *const FrontMatter, key: []const u8) ?[]const u8 {
return self.meta.get(key);
}
};
pub fn parse(allocator: std.mem.Allocator, input: []const u8) !FrontMatter {
var meta = std.StringHashMap([]const u8).init(allocator);
errdefer meta.deinit();
if (!std.mem.startsWith(u8, input, "---")) {
return FrontMatter{ .meta = meta, .content = input };
}
const after_open = input[3..];
const close_idx = std.mem.indexOf(u8, after_open, "\n---");
if (close_idx == null) {
return FrontMatter{ .meta = meta, .content = input };
}
const front_matter_block = after_open[0..close_idx.?];
const content_start = 3 + close_idx.? + 4;
const content = if (content_start < input.len) skipLeadingNewlines(input[content_start..]) else "";
var lines = std.mem.splitScalar(u8, front_matter_block, '\n');
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t\r");
if (trimmed.len == 0) continue;
if (std.mem.indexOf(u8, trimmed, ":")) |colon_idx| {
const key = std.mem.trim(u8, trimmed[0..colon_idx], " \t");
const value = std.mem.trim(u8, trimmed[colon_idx + 1 ..], " \t");
if (std.mem.eql(u8, key, "content") or std.mem.eql(u8, key, "nav_html") or std.mem.eql(u8, key, "toc")) return error.ReservedFrontmatterKey;
try meta.put(key, value);
}
}
return FrontMatter{ .meta = meta, .content = content };
}
fn skipLeadingNewlines(s: []const u8) []const u8 {
var i: usize = 0;
while (i < s.len and (s[i] == '\n' or s[i] == '\r')) : (i += 1) {}
return s[i..];
}

1389
src/main.zig Normal file

File diff suppressed because it is too large Load diff

840
src/markdown.zig Normal file
View file

@ -0,0 +1,840 @@
const std = @import("std");
const max_inline_depth = 64;
pub const Rendered = struct {
html: []u8,
toc: []u8,
pub fn deinit(self: Rendered, allocator: std.mem.Allocator) void {
allocator.free(self.html);
allocator.free(self.toc);
}
};
pub fn toHtml(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
const rendered = try render(allocator, input);
allocator.free(rendered.toc);
return rendered.html;
}
pub fn render(allocator: std.mem.Allocator, input: []const u8) !Rendered {
var output: std.ArrayListUnmanaged(u8) = .empty;
errdefer output.deinit(allocator);
var toc_items: std.ArrayListUnmanaged(u8) = .empty;
defer toc_items.deinit(allocator);
var raw_lines: std.ArrayListUnmanaged([]const u8) = .empty;
defer raw_lines.deinit(allocator);
var line_iter = std.mem.splitScalar(u8, input, '\n');
while (line_iter.next()) |raw_line| {
try raw_lines.append(allocator, std.mem.trimRight(u8, raw_line, "\r"));
}
var footnotes = std.StringHashMap([]const u8).init(allocator);
defer footnotes.deinit();
var in_code_block = false;
var code_lang: []const u8 = "";
var code_buf: std.ArrayListUnmanaged(u8) = .empty;
defer code_buf.deinit(allocator);
var in_list = false;
var in_blockquote = false;
var paragraph_buf: std.ArrayListUnmanaged(u8) = .empty;
defer paragraph_buf.deinit(allocator);
var line_index: usize = 0;
while (line_index < raw_lines.items.len) : (line_index += 1) {
const line = raw_lines.items[line_index];
if (std.mem.startsWith(u8, line, "```")) {
if (in_code_block) {
try flushCodeBlock(allocator, &output, code_lang, code_buf.items);
code_buf.clearRetainingCapacity();
code_lang = "";
in_code_block = false;
} else {
try flushParagraph(allocator, &paragraph_buf, &output, &footnotes);
if (in_list) {
try output.appendSlice(allocator, "</ul>\n");
in_list = false;
}
if (in_blockquote) {
try output.appendSlice(allocator, "</blockquote>\n");
in_blockquote = false;
}
const lang = std.mem.trim(u8, line[3..], " \t");
code_lang = if (isSafeLanguageClass(lang)) lang else "";
in_code_block = true;
}
continue;
}
if (in_code_block) {
try code_buf.appendSlice(allocator, line);
try code_buf.append(allocator, '\n');
continue;
}
if (isShortcodeLine(line)) {
try flushParagraph(allocator, &paragraph_buf, &output, &footnotes);
if (in_list) {
try output.appendSlice(allocator, "</ul>\n");
in_list = false;
}
if (in_blockquote) {
try output.appendSlice(allocator, "</blockquote>\n");
in_blockquote = false;
}
if (try renderShortcodeLine(allocator, &output, line)) continue;
}
if (parseFootnoteDef(line)) |def| {
try footnotes.put(def.id, def.text);
continue;
}
if (line_index + 1 < raw_lines.items.len and isTableRow(line) and isTableDelimiter(raw_lines.items[line_index + 1])) {
try flushParagraph(allocator, &paragraph_buf, &output, &footnotes);
if (in_list) {
try output.appendSlice(allocator, "</ul>\n");
in_list = false;
}
if (in_blockquote) {
try output.appendSlice(allocator, "</blockquote>\n");
in_blockquote = false;
}
line_index = try renderTable(allocator, &output, raw_lines.items, line_index, &footnotes);
continue;
}
if (line.len == 0) {
try flushParagraph(allocator, &paragraph_buf, &output, &footnotes);
if (in_list) {
try output.appendSlice(allocator, "</ul>\n");
in_list = false;
}
if (in_blockquote) {
try output.appendSlice(allocator, "</blockquote>\n");
in_blockquote = false;
}
continue;
}
if (line[0] == '#') {
try flushParagraph(allocator, &paragraph_buf, &output, &footnotes);
if (in_list) {
try output.appendSlice(allocator, "</ul>\n");
in_list = false;
}
const level = countPrefix(line, '#');
if (level <= 6 and level < line.len and line[level] == ' ') {
const content = std.mem.trim(u8, line[level + 1 ..], " \t");
const id = try headingId(allocator, content);
defer allocator.free(id);
try appendTocItem(allocator, &toc_items, level, id, content);
try appendFmt(allocator, &output, "<h{d} id=\"", .{level});
try appendEscaped(allocator, &output, id);
try output.appendSlice(allocator, "\">");
try appendInline(allocator, &output, content, &footnotes);
try appendFmt(allocator, &output, "</h{d}>\n", .{level});
continue;
}
}
if (std.mem.startsWith(u8, line, "- ") or std.mem.startsWith(u8, line, "* ")) {
try flushParagraph(allocator, &paragraph_buf, &output, &footnotes);
if (in_blockquote) {
try output.appendSlice(allocator, "</blockquote>\n");
in_blockquote = false;
}
if (!in_list) {
try output.appendSlice(allocator, "<ul>\n");
in_list = true;
}
try output.appendSlice(allocator, "<li>");
try appendInline(allocator, &output, line[2..], &footnotes);
try output.appendSlice(allocator, "</li>\n");
continue;
}
if (std.mem.startsWith(u8, line, "> ")) {
try flushParagraph(allocator, &paragraph_buf, &output, &footnotes);
if (in_list) {
try output.appendSlice(allocator, "</ul>\n");
in_list = false;
}
if (!in_blockquote) {
try output.appendSlice(allocator, "<blockquote>");
in_blockquote = true;
}
try appendInline(allocator, &output, line[2..], &footnotes);
try output.append(allocator, '\n');
continue;
}
if (paragraph_buf.items.len > 0) {
try paragraph_buf.append(allocator, ' ');
}
try paragraph_buf.appendSlice(allocator, line);
}
if (in_code_block) {
try flushCodeBlock(allocator, &output, code_lang, code_buf.items);
}
try flushParagraph(allocator, &paragraph_buf, &output, &footnotes);
if (in_list) {
try output.appendSlice(allocator, "</ul>\n");
}
if (in_blockquote) {
try output.appendSlice(allocator, "</blockquote>\n");
}
try appendFootnotes(allocator, &output, &footnotes);
const toc = if (toc_items.items.len > 0) blk: {
var toc: std.ArrayListUnmanaged(u8) = .empty;
errdefer toc.deinit(allocator);
try toc.appendSlice(allocator, "<nav class=\"toc\" aria-label=\"Table of contents\">\n<strong>contents</strong>\n<ol>\n");
try toc.appendSlice(allocator, toc_items.items);
try toc.appendSlice(allocator, "</ol>\n</nav>\n");
break :blk try toc.toOwnedSlice(allocator);
} else try allocator.dupe(u8, "");
return .{ .html = try output.toOwnedSlice(allocator), .toc = toc };
}
fn flushCodeBlock(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), lang: []const u8, code: []const u8) !void {
if (lang.len > 0) {
try output.appendSlice(allocator, "<pre><code class=\"language-");
try output.appendSlice(allocator, lang);
try output.appendSlice(allocator, "\">");
} else {
try output.appendSlice(allocator, "<pre><code>");
}
try appendHighlightedCode(allocator, output, lang, code);
try output.appendSlice(allocator, "</code></pre>\n");
}
fn appendHighlightedCode(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), lang: []const u8, code: []const u8) !void {
var lines = std.mem.splitScalar(u8, code, '\n');
while (lines.next()) |line| {
if (line.len > 0) try appendHighlightedLine(allocator, output, lang, line);
try output.append(allocator, '\n');
}
}
fn appendHighlightedLine(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), lang: []const u8, line: []const u8) !void {
var i: usize = 0;
while (i < line.len) {
if (isLineCommentStart(lang, line[i..])) {
try output.appendSlice(allocator, "<span class=\"tok-comment\">");
try appendEscaped(allocator, output, line[i..]);
try output.appendSlice(allocator, "</span>");
return;
}
if (line[i] == '"' or line[i] == '\'') {
const quote = line[i];
const start = i;
i += 1;
while (i < line.len) : (i += 1) {
if (line[i] == '\\' and i + 1 < line.len) {
i += 1;
continue;
}
if (line[i] == quote) {
i += 1;
break;
}
}
try output.appendSlice(allocator, "<span class=\"tok-string\">");
try appendEscaped(allocator, output, line[start..i]);
try output.appendSlice(allocator, "</span>");
continue;
}
if (std.ascii.isDigit(line[i])) {
const start = i;
i += 1;
while (i < line.len and (std.ascii.isAlphanumeric(line[i]) or line[i] == '_' or line[i] == '.')) : (i += 1) {}
try output.appendSlice(allocator, "<span class=\"tok-number\">");
try appendEscaped(allocator, output, line[start..i]);
try output.appendSlice(allocator, "</span>");
continue;
}
if (std.ascii.isAlphabetic(line[i]) or line[i] == '_') {
const start = i;
i += 1;
while (i < line.len and (std.ascii.isAlphanumeric(line[i]) or line[i] == '_')) : (i += 1) {}
const word = line[start..i];
if (isKeyword(lang, word)) {
try output.appendSlice(allocator, "<span class=\"tok-keyword\">");
try appendEscaped(allocator, output, word);
try output.appendSlice(allocator, "</span>");
} else {
try appendEscaped(allocator, output, word);
}
continue;
}
switch (line[i]) {
'<' => try output.appendSlice(allocator, "&lt;"),
'>' => try output.appendSlice(allocator, "&gt;"),
'&' => try output.appendSlice(allocator, "&amp;"),
'"' => try output.appendSlice(allocator, "&quot;"),
else => try output.append(allocator, line[i]),
}
i += 1;
}
}
fn isLineCommentStart(lang: []const u8, text: []const u8) bool {
if (std.mem.startsWith(u8, text, "//")) return true;
if (std.ascii.eqlIgnoreCase(lang, "sh") or std.ascii.eqlIgnoreCase(lang, "bash") or std.ascii.eqlIgnoreCase(lang, "shell") or std.ascii.eqlIgnoreCase(lang, "zig")) {
return std.mem.startsWith(u8, text, "#");
}
return false;
}
fn isKeyword(lang: []const u8, word: []const u8) bool {
if (std.ascii.eqlIgnoreCase(lang, "zig")) {
return isOneOf(word, &.{ "const", "var", "fn", "pub", "return", "if", "else", "while", "for", "switch", "defer", "try", "catch", "struct", "enum", "union", "error", "comptime", "break", "continue", "true", "false", "null" });
}
if (std.ascii.eqlIgnoreCase(lang, "js") or std.ascii.eqlIgnoreCase(lang, "javascript") or std.ascii.eqlIgnoreCase(lang, "ts") or std.ascii.eqlIgnoreCase(lang, "typescript")) {
return isOneOf(word, &.{ "const", "let", "var", "function", "return", "if", "else", "for", "while", "class", "new", "await", "async", "import", "export", "from", "true", "false", "null", "undefined" });
}
if (std.ascii.eqlIgnoreCase(lang, "css")) {
return isOneOf(word, &.{ "display", "position", "color", "background", "border", "margin", "padding", "font", "grid", "flex" });
}
return false;
}
fn isOneOf(word: []const u8, words: []const []const u8) bool {
for (words) |candidate| if (std.mem.eql(u8, word, candidate)) return true;
return false;
}
fn appendTocItem(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), level: usize, id: []const u8, title: []const u8) !void {
try output.appendSlice(allocator, "<li class=\"toc-level-");
try appendFmt(allocator, output, "{d}", .{level});
try output.appendSlice(allocator, "\"><a href=\"#");
try appendEscaped(allocator, output, id);
try output.appendSlice(allocator, "\">");
try appendEscaped(allocator, output, title);
try output.appendSlice(allocator, "</a></li>\n");
}
fn flushParagraph(allocator: std.mem.Allocator, buf: *std.ArrayListUnmanaged(u8), output: *std.ArrayListUnmanaged(u8), footnotes: *const std.StringHashMap([]const u8)) !void {
if (buf.items.len == 0) return;
try output.appendSlice(allocator, "<p>");
try appendInline(allocator, output, buf.items, footnotes);
try output.appendSlice(allocator, "</p>\n");
buf.clearRetainingCapacity();
}
fn countPrefix(line: []const u8, char: u8) usize {
var count: usize = 0;
for (line) |c| {
if (c == char) count += 1 else break;
}
return count;
}
fn appendEscaped(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), text: []const u8) !void {
for (text) |c| {
switch (c) {
'<' => try output.appendSlice(allocator, "&lt;"),
'>' => try output.appendSlice(allocator, "&gt;"),
'&' => try output.appendSlice(allocator, "&amp;"),
'"' => try output.appendSlice(allocator, "&quot;"),
else => try output.append(allocator, c),
}
}
}
fn appendFmt(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), comptime fmt: []const u8, args: anytype) !void {
var buf: [64]u8 = undefined;
const formatted = std.fmt.bufPrint(&buf, fmt, args) catch return error.OutOfMemory;
try output.appendSlice(allocator, formatted);
}
fn appendInline(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), text: []const u8, footnotes: *const std.StringHashMap([]const u8)) !void {
try appendInlineDepth(allocator, output, text, footnotes, 0);
}
fn appendInlineDepth(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), text: []const u8, footnotes: *const std.StringHashMap([]const u8), depth: usize) !void {
if (depth > max_inline_depth) return error.InlineDepthExceeded;
var i: usize = 0;
while (i < text.len) {
if (text[i] == '`') {
if (findClosing(text[i + 1 ..], '`')) |end| {
try output.appendSlice(allocator, "<code>");
try appendEscaped(allocator, output, text[i + 1 .. i + 1 + end]);
try output.appendSlice(allocator, "</code>");
i += end + 2;
continue;
}
}
if (i + 1 < text.len and text[i] == '*' and text[i + 1] == '*') {
if (findClosingDouble(text[i + 2 ..], '*')) |end| {
try output.appendSlice(allocator, "<strong>");
try appendInlineDepth(allocator, output, text[i + 2 .. i + 2 + end], footnotes, depth + 1);
try output.appendSlice(allocator, "</strong>");
i += end + 4;
continue;
}
}
if (text[i] == '*') {
if (findClosing(text[i + 1 ..], '*')) |end| {
if (end > 0 and text[i + 1] != '*') {
try output.appendSlice(allocator, "<em>");
try appendInlineDepth(allocator, output, text[i + 1 .. i + 1 + end], footnotes, depth + 1);
try output.appendSlice(allocator, "</em>");
i += end + 2;
continue;
}
}
}
if (i + 2 < text.len and text[i] == '[' and text[i + 1] == '^') {
if (findClosing(text[i + 2 ..], ']')) |end| {
const id = text[i + 2 .. i + 2 + end];
if (footnotes.get(id) != null) {
try output.appendSlice(allocator, "<sup id=\"fnref-");
try appendEscaped(allocator, output, id);
try output.appendSlice(allocator, "\"><a href=\"#fn-");
try appendEscaped(allocator, output, id);
try output.appendSlice(allocator, "\">[ ");
try appendEscaped(allocator, output, id);
try output.appendSlice(allocator, " ]</a></sup>");
i += end + 3;
continue;
}
}
}
if (text[i] == '[') {
if (parseLink(text[i..])) |link| {
try output.appendSlice(allocator, "<a href=\"");
if (isSafeUrl(link.url)) {
try appendEscaped(allocator, output, link.url);
} else {
try output.append(allocator, '#');
}
try output.appendSlice(allocator, "\">");
try appendInlineDepth(allocator, output, link.text, footnotes, depth + 1);
try output.appendSlice(allocator, "</a>");
i += link.total_len;
continue;
}
}
switch (text[i]) {
'<' => try output.appendSlice(allocator, "&lt;"),
'>' => try output.appendSlice(allocator, "&gt;"),
'&' => try output.appendSlice(allocator, "&amp;"),
else => try output.append(allocator, text[i]),
}
i += 1;
}
}
fn findClosing(text: []const u8, char: u8) ?usize {
for (text, 0..) |c, idx| {
if (c == char) return idx;
}
return null;
}
fn findClosingDouble(text: []const u8, char: u8) ?usize {
var i: usize = 0;
while (i + 1 < text.len) : (i += 1) {
if (text[i] == char and text[i + 1] == char) return i;
}
return null;
}
fn isSafeLanguageClass(lang: []const u8) bool {
if (lang.len == 0) return false;
for (lang) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') return false;
}
return true;
}
fn isSafeUrl(url: []const u8) bool {
if (url.len == 0) return false;
for (url) |c| {
if (c <= 0x20 or c == 0x7f) return false;
}
if (url[0] == '#') return true;
if (url[0] == '/') return url.len == 1 or url[1] != '/';
if (std.mem.startsWith(u8, url, "./") or std.mem.startsWith(u8, url, "../")) return true;
const colon_idx = std.mem.indexOfScalar(u8, url, ':') orelse return true;
const first_delim = firstUrlDelimiter(url) orelse url.len;
if (colon_idx > first_delim) return true;
const scheme = url[0..colon_idx];
return std.ascii.eqlIgnoreCase(scheme, "http") or
std.ascii.eqlIgnoreCase(scheme, "https") or
std.ascii.eqlIgnoreCase(scheme, "mailto");
}
fn firstUrlDelimiter(url: []const u8) ?usize {
var result: ?usize = null;
for ([_]u8{ '/', '?', '#' }) |delimiter| {
if (std.mem.indexOfScalar(u8, url, delimiter)) |idx| {
if (result == null or idx < result.?) result = idx;
}
}
return result;
}
const Link = struct {
text: []const u8,
url: []const u8,
total_len: usize,
};
const FootnoteDef = struct {
id: []const u8,
text: []const u8,
};
fn parseFootnoteDef(line: []const u8) ?FootnoteDef {
if (!std.mem.startsWith(u8, line, "[^")) return null;
const close = std.mem.indexOf(u8, line, "]:") orelse return null;
if (close <= 2) return null;
return .{ .id = line[2..close], .text = std.mem.trim(u8, line[close + 2 ..], " \t") };
}
fn appendFootnotes(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), footnotes: *const std.StringHashMap([]const u8)) !void {
if (footnotes.count() == 0) return;
try output.appendSlice(allocator, "<section class=\"footnotes\">\n<ol>\n");
var iter = footnotes.iterator();
while (iter.next()) |entry| {
try output.appendSlice(allocator, "<li id=\"fn-");
try appendEscaped(allocator, output, entry.key_ptr.*);
try output.appendSlice(allocator, "\">");
try appendInline(allocator, output, entry.value_ptr.*, footnotes);
try output.appendSlice(allocator, "</li>\n");
}
try output.appendSlice(allocator, "</ol>\n</section>\n");
}
fn isTableRow(line: []const u8) bool {
return std.mem.indexOfScalar(u8, line, '|') != null;
}
fn isTableDelimiter(line: []const u8) bool {
var saw_dash = false;
for (std.mem.trim(u8, line, " \t|")) |c| {
if (c == '-') saw_dash = true else if (c != ':' and c != ' ' and c != '\t' and c != '|') return false;
}
return saw_dash and isTableRow(line);
}
fn renderTable(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), lines: []const []const u8, start: usize, footnotes: *const std.StringHashMap([]const u8)) !usize {
try output.appendSlice(allocator, "<table>\n<thead><tr>");
try renderTableCells(allocator, output, lines[start], "th", footnotes);
try output.appendSlice(allocator, "</tr></thead>\n<tbody>\n");
var i = start + 2;
while (i < lines.len and isTableRow(lines[i]) and lines[i].len > 0) : (i += 1) {
try output.appendSlice(allocator, "<tr>");
try renderTableCells(allocator, output, lines[i], "td", footnotes);
try output.appendSlice(allocator, "</tr>\n");
}
try output.appendSlice(allocator, "</tbody>\n</table>\n");
return i - 1;
}
fn renderTableCells(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), line: []const u8, comptime tag: []const u8, footnotes: *const std.StringHashMap([]const u8)) !void {
const trimmed = std.mem.trim(u8, line, " \t|");
var cells = std.mem.splitScalar(u8, trimmed, '|');
while (cells.next()) |cell| {
try output.appendSlice(allocator, "<" ++ tag ++ ">");
try appendInline(allocator, output, std.mem.trim(u8, cell, " \t"), footnotes);
try output.appendSlice(allocator, "</" ++ tag ++ ">");
}
}
fn renderShortcodeLine(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), raw_line: []const u8) !bool {
const line = std.mem.trim(u8, raw_line, " \t");
if (!isShortcodeLine(line)) return false;
const inner = std.mem.trim(u8, line[3 .. line.len - 3], " \t");
if (inner.len == 0) return false;
const name_end = std.mem.indexOfAny(u8, inner, " \t") orelse inner.len;
const name = inner[0..name_end];
const args = std.mem.trim(u8, inner[name_end..], " \t");
if (std.ascii.eqlIgnoreCase(name, "figure")) {
try renderFigureShortcode(allocator, output, args);
return true;
}
if (std.ascii.eqlIgnoreCase(name, "youtube")) {
try renderYoutubeShortcode(allocator, output, args);
return true;
}
if (std.ascii.eqlIgnoreCase(name, "vimeo")) {
try renderVimeoShortcode(allocator, output, args);
return true;
}
if (std.ascii.eqlIgnoreCase(name, "callout")) {
try renderCalloutShortcode(allocator, output, args);
return true;
}
return false;
}
fn isShortcodeLine(raw_line: []const u8) bool {
const line = std.mem.trim(u8, raw_line, " \t");
return std.mem.startsWith(u8, line, "{{<") and std.mem.endsWith(u8, line, ">}}");
}
fn renderFigureShortcode(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), args: []const u8) !void {
const src = findArg(args, "src") orelse firstPositionalArg(args) orelse "";
if (!isSafeUrl(src)) return;
const alt = findArg(args, "alt") orelse "";
const caption = findArg(args, "caption") orelse "";
try output.appendSlice(allocator, "<figure>\n<img src=\"");
try appendEscaped(allocator, output, src);
try output.appendSlice(allocator, "\" alt=\"");
try appendEscaped(allocator, output, alt);
try output.appendSlice(allocator, "\">\n");
if (caption.len > 0) {
try output.appendSlice(allocator, "<figcaption>");
try appendEscaped(allocator, output, caption);
try output.appendSlice(allocator, "</figcaption>\n");
}
try output.appendSlice(allocator, "</figure>\n");
}
fn renderYoutubeShortcode(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), args: []const u8) !void {
const id = findArg(args, "id") orelse firstPositionalArg(args) orelse "";
if (!isSafeMediaId(id)) return;
try output.appendSlice(allocator, "<div class=\"embed\"><iframe src=\"https://www.youtube-nocookie.com/embed/");
try appendEscaped(allocator, output, id);
try output.appendSlice(allocator, "\" title=\"YouTube video\" loading=\"lazy\" allowfullscreen></iframe></div>\n");
}
fn renderVimeoShortcode(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), args: []const u8) !void {
const id = findArg(args, "id") orelse firstPositionalArg(args) orelse "";
if (!isSafeMediaId(id)) return;
try output.appendSlice(allocator, "<div class=\"embed\"><iframe src=\"https://player.vimeo.com/video/");
try appendEscaped(allocator, output, id);
try output.appendSlice(allocator, "\" title=\"Vimeo video\" loading=\"lazy\" allowfullscreen></iframe></div>\n");
}
fn renderCalloutShortcode(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), args: []const u8) !void {
const kind = findArg(args, "type") orelse "note";
const title = findArg(args, "title") orelse kind;
const text = findArg(args, "text") orelse firstPositionalArg(args) orelse "";
if (!isSafeLanguageClass(kind)) return;
try output.appendSlice(allocator, "<aside class=\"callout callout-");
try appendEscaped(allocator, output, kind);
try output.appendSlice(allocator, "\"><strong>");
try appendEscaped(allocator, output, title);
try output.appendSlice(allocator, "</strong>");
if (text.len > 0) {
try output.appendSlice(allocator, "<p>");
try appendEscaped(allocator, output, text);
try output.appendSlice(allocator, "</p>");
}
try output.appendSlice(allocator, "</aside>\n");
}
fn findArg(args: []const u8, key: []const u8) ?[]const u8 {
var index: usize = 0;
while (nextArg(args, &index)) |token| {
const eq_idx = std.mem.indexOfScalar(u8, token, '=') orelse continue;
const token_key = token[0..eq_idx];
if (!std.mem.eql(u8, token_key, key)) continue;
return stripQuotes(token[eq_idx + 1 ..]);
}
return null;
}
fn firstPositionalArg(args: []const u8) ?[]const u8 {
var index: usize = 0;
while (nextArg(args, &index)) |token| {
if (std.mem.indexOfScalar(u8, token, '=') == null) return stripQuotes(token);
}
return null;
}
fn nextArg(args: []const u8, index: *usize) ?[]const u8 {
while (index.* < args.len and std.ascii.isWhitespace(args[index.*])) index.* += 1;
if (index.* >= args.len) return null;
const start = index.*;
var quote: ?u8 = null;
while (index.* < args.len) : (index.* += 1) {
const c = args[index.*];
if (quote) |q| {
if (c == q) quote = null;
continue;
}
if (c == '"' or c == '\'') {
quote = c;
continue;
}
if (std.ascii.isWhitespace(c)) break;
}
const end = index.*;
while (index.* < args.len and std.ascii.isWhitespace(args[index.*])) index.* += 1;
return args[start..end];
}
fn stripQuotes(value: []const u8) []const u8 {
if (value.len >= 2 and ((value[0] == '"' and value[value.len - 1] == '"') or (value[0] == '\'' and value[value.len - 1] == '\''))) {
return value[1 .. value.len - 1];
}
return value;
}
fn isSafeMediaId(id: []const u8) bool {
if (id.len == 0 or id.len > 128) return false;
for (id) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') return false;
}
return true;
}
fn headingId(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
var out: std.ArrayListUnmanaged(u8) = .empty;
errdefer out.deinit(allocator);
var last_dash = false;
for (text) |c| {
if (std.ascii.isAlphanumeric(c)) {
try out.append(allocator, std.ascii.toLower(c));
last_dash = false;
} else if (!last_dash and out.items.len > 0) {
try out.append(allocator, '-');
last_dash = true;
}
}
if (out.items.len > 0 and out.items[out.items.len - 1] == '-') _ = out.pop();
if (out.items.len == 0) try out.appendSlice(allocator, "section");
return out.toOwnedSlice(allocator);
}
fn parseLink(text: []const u8) ?Link {
if (text.len < 4 or text[0] != '[') return null;
const text_end = findClosing(text[1..], ']') orelse return null;
const after_bracket = 1 + text_end + 1;
if (after_bracket >= text.len or text[after_bracket] != '(') return null;
const url_end = findClosing(text[after_bracket + 1 ..], ')') orelse return null;
return Link{
.text = text[1 .. 1 + text_end],
.url = text[after_bracket + 1 .. after_bracket + 1 + url_end],
.total_len = after_bracket + 1 + url_end + 1,
};
}
test "markdown rejects unsafe code language attributes" {
const html = try toHtml(std.testing.allocator,
\\\`\`\`\" onmouseover=\"alert(1)
\\code
\\\`\`\`
);
defer std.testing.allocator.free(html);
try std.testing.expect(std.mem.indexOf(u8, html, "onmouseover") == null);
try std.testing.expect(std.mem.indexOf(u8, html, "<pre><code>") != null);
}
test "markdown replaces unsafe link URLs" {
const html = try toHtml(std.testing.allocator, "[bad](javascript:alert(1))");
defer std.testing.allocator.free(html);
try std.testing.expect(std.mem.indexOf(u8, html, "javascript:") == null);
try std.testing.expect(std.mem.indexOf(u8, html, "href=\"#\"") != null);
}
test "markdown rejects protocol-relative link URLs" {
const html = try toHtml(std.testing.allocator, "[bad](//attacker.example/path)");
defer std.testing.allocator.free(html);
try std.testing.expect(std.mem.indexOf(u8, html, "//attacker.example") == null);
try std.testing.expect(std.mem.indexOf(u8, html, "href=\"#\"") != null);
}
test "markdown adds heading ids" {
const html = try toHtml(std.testing.allocator, "## Hello World!");
defer std.testing.allocator.free(html);
try std.testing.expect(std.mem.indexOf(u8, html, "<h2 id=\"hello-world\">") != null);
}
test "markdown exposes table of contents" {
const rendered = try render(std.testing.allocator,
\\## First
\\### Second
);
defer rendered.deinit(std.testing.allocator);
try std.testing.expect(std.mem.indexOf(u8, rendered.toc, "class=\"toc\"") != null);
try std.testing.expect(std.mem.indexOf(u8, rendered.toc, "#first") != null);
try std.testing.expect(std.mem.indexOf(u8, rendered.toc, "toc-level-3") != null);
}
test "markdown renders safe shortcodes" {
const html = try toHtml(std.testing.allocator,
\\{{< figure src="/photo.jpg" alt="Photo" caption="Caption" >}}
\\{{< youtube dQw4w9WgXcQ >}}
);
defer std.testing.allocator.free(html);
try std.testing.expect(std.mem.indexOf(u8, html, "<figure>") != null);
try std.testing.expect(std.mem.indexOf(u8, html, "youtube-nocookie.com/embed/dQw4w9WgXcQ") != null);
}
test "markdown adds syntax highlight spans" {
const html = try toHtml(std.testing.allocator,
\\```zig
\\const x = 1;
\\```
);
defer std.testing.allocator.free(html);
try std.testing.expect(std.mem.indexOf(u8, html, "tok-keyword") != null);
try std.testing.expect(std.mem.indexOf(u8, html, "tok-number") != null);
}
test "markdown renders tables" {
const html = try toHtml(std.testing.allocator,
\\| a | b |
\\| - | - |
\\| 1 | 2 |
);
defer std.testing.allocator.free(html);
try std.testing.expect(std.mem.indexOf(u8, html, "<table>") != null);
try std.testing.expect(std.mem.indexOf(u8, html, "<td>1</td>") != null);
}
test "markdown renders footnotes" {
const html = try toHtml(std.testing.allocator,
\\note[^1]
\\
\\[^1]: footnote text
);
defer std.testing.allocator.free(html);
try std.testing.expect(std.mem.indexOf(u8, html, "class=\"footnotes\"") != null);
try std.testing.expect(std.mem.indexOf(u8, html, "footnote text") != null);
}

356
src/server.zig Normal file
View file

@ -0,0 +1,356 @@
const std = @import("std");
var version = std.atomic.Value(u32).init(0);
const max_connections = 64;
const read_timeout_ms = 2000;
const write_timeout_ms = 2000;
var active_connections = std.atomic.Value(usize).init(0);
const RequestPathError = error{
InvalidPath,
OutOfMemory,
};
const ServedFile = struct {
path: []u8,
file: std.fs.File,
fn deinit(self: ServedFile, allocator: std.mem.Allocator) void {
self.file.close();
allocator.free(self.path);
}
};
pub fn bump_version() void {
_ = version.fetchAdd(1, .seq_cst);
}
pub fn listen(port: u16) !std.net.Server {
const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port);
const sockfd = try std.posix.socket(addr.any.family, std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP);
var srv = std.net.Server{
.listen_address = addr,
.stream = .{ .handle = sockfd },
};
errdefer srv.stream.close();
try std.posix.setsockopt(sockfd, std.posix.SOL.SOCKET, std.posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
var socklen = addr.getOsSockLen();
try std.posix.bind(sockfd, &addr.any, socklen);
try std.posix.listen(sockfd, 128);
try std.posix.getsockname(sockfd, &srv.listen_address.any, &socklen);
return srv;
}
pub fn run(allocator: std.mem.Allocator, root_dir: []const u8, listener: std.net.Server) void {
var srv = listener;
defer srv.deinit();
const root_dir_copy = allocator.dupe(u8, root_dir) catch return;
while (true) {
const conn = srv.accept() catch continue;
const active = active_connections.fetchAdd(1, .seq_cst);
if (active >= max_connections) {
_ = active_connections.fetchSub(1, .seq_cst);
conn.stream.close();
continue;
}
const thread = std.Thread.spawn(.{}, handleConnection, .{ allocator, conn, root_dir_copy }) catch {
_ = active_connections.fetchSub(1, .seq_cst);
conn.stream.close();
continue;
};
thread.detach();
}
}
fn handleConnection(allocator: std.mem.Allocator, conn: std.net.Server.Connection, root_dir: []const u8) void {
defer _ = active_connections.fetchSub(1, .seq_cst);
defer conn.stream.close();
setSocketTimeouts(conn.stream) catch return;
handleRequest(allocator, conn, root_dir) catch {};
}
fn handleRequest(allocator: std.mem.Allocator, conn: std.net.Server.Connection, root_dir: []const u8) !void {
var buf: [4096]u8 = undefined;
const n = try readWithTimeout(conn.stream, &buf, read_timeout_ms);
if (n == 0) return;
const request = buf[0..n];
const path = parsePath(request) orelse return;
const decoded_path = decodeRequestPath(allocator, path) catch {
try sendResponse(conn.stream, "400 Bad Request", "text/plain", "bad request");
return;
};
defer allocator.free(decoded_path);
if (std.mem.eql(u8, decoded_path, "/_reload")) {
var version_buf: [16]u8 = undefined;
const version_str = std.fmt.bufPrint(&version_buf, "{d}", .{version.load(.seq_cst)}) catch "0";
try sendResponse(conn.stream, "200 OK", "text/plain", version_str);
return;
}
if (!isSafeRequestPath(decoded_path)) {
try sendResponse(conn.stream, "403 Forbidden", "text/plain", "forbidden");
return;
}
const served_file = readRequestFile(allocator, root_dir, decoded_path) catch {
const error_page = std.fs.path.join(allocator, &.{ root_dir, "404.html" }) catch {
try sendResponse(conn.stream, "404 Not Found", "text/plain", "not found");
return;
};
defer allocator.free(error_page);
ensureNoSymlinkComponents(allocator, root_dir, "404.html") catch {
try sendResponse(conn.stream, "404 Not Found", "text/plain", "not found");
return;
};
const error_content = openRegularFile(error_page) catch {
try sendResponse(conn.stream, "404 Not Found", "text/plain", "not found");
return;
};
defer error_content.close();
try sendFileResponse(conn.stream, "404 Not Found", "text/html", error_content);
return;
};
defer served_file.deinit(allocator);
const mime = getMime(served_file.path);
try sendFileResponse(conn.stream, "200 OK", mime, served_file.file);
}
fn setSocketTimeouts(stream: std.net.Stream) !void {
const read_timeout = std.posix.timeval{
.sec = @intCast(read_timeout_ms / 1000),
.usec = @intCast((read_timeout_ms % 1000) * 1000),
};
const write_timeout = std.posix.timeval{
.sec = @intCast(write_timeout_ms / 1000),
.usec = @intCast((write_timeout_ms % 1000) * 1000),
};
try std.posix.setsockopt(stream.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, std.mem.asBytes(&read_timeout));
try std.posix.setsockopt(stream.handle, std.posix.SOL.SOCKET, std.posix.SO.SNDTIMEO, std.mem.asBytes(&write_timeout));
}
fn readWithTimeout(stream: std.net.Stream, buf: []u8, timeout_ms: u64) !usize {
const start = std.time.milliTimestamp();
while (true) {
const n = stream.read(buf) catch |err| switch (err) {
error.WouldBlock => 0,
else => return err,
};
if (n > 0) return n;
const elapsed_ms = std.time.milliTimestamp() - start;
if (elapsed_ms >= timeout_ms) return 0;
std.Thread.sleep(10 * std.time.ns_per_ms);
}
}
fn readRequestFile(allocator: std.mem.Allocator, root_dir: []const u8, path: []const u8) !ServedFile {
const relative_path = if (std.mem.eql(u8, path, "/")) "index.html" else path[1..];
if (relative_path.len == 0 or std.fs.path.isAbsolute(relative_path)) return error.InvalidPath;
const direct_path = try std.fs.path.join(allocator, &.{ root_dir, relative_path });
try ensureNoSymlinkComponents(allocator, root_dir, relative_path);
const direct_stat = std.fs.cwd().statFile(direct_path) catch {
allocator.free(direct_path);
const index_path = try std.fs.path.join(allocator, &.{ root_dir, relative_path, "index.html" });
const index_relative_path = try std.fs.path.join(allocator, &.{ relative_path, "index.html" });
defer allocator.free(index_relative_path);
try ensureNoSymlinkComponents(allocator, root_dir, index_relative_path);
const index_stat = std.fs.cwd().statFile(index_path) catch |err| {
allocator.free(index_path);
return err;
};
if (index_stat.kind != .file) {
allocator.free(index_path);
return error.InvalidPath;
}
const file = openRegularFile(index_path) catch |err| {
allocator.free(index_path);
return err;
};
return .{ .path = index_path, .file = file };
};
if (direct_stat.kind != .file) {
allocator.free(direct_path);
const index_path = try std.fs.path.join(allocator, &.{ root_dir, relative_path, "index.html" });
const index_relative_path = try std.fs.path.join(allocator, &.{ relative_path, "index.html" });
defer allocator.free(index_relative_path);
try ensureNoSymlinkComponents(allocator, root_dir, index_relative_path);
const index_stat = std.fs.cwd().statFile(index_path) catch |err| {
allocator.free(index_path);
return err;
};
if (index_stat.kind != .file) {
allocator.free(index_path);
return error.InvalidPath;
}
const file = openRegularFile(index_path) catch |err| {
allocator.free(index_path);
return err;
};
return .{ .path = index_path, .file = file };
}
const file = openRegularFile(direct_path) catch |err| {
allocator.free(direct_path);
return err;
};
return .{ .path = direct_path, .file = file };
}
fn openRegularFile(path: []const u8) !std.fs.File {
const file = try openFileNoFollow(path);
errdefer file.close();
const stat = try file.stat();
if (stat.kind != .file) return error.InvalidPath;
return file;
}
fn openFileNoFollow(path: []const u8) !std.fs.File {
var flags: std.posix.O = .{ .ACCMODE = .RDONLY };
if (@hasField(std.posix.O, "CLOEXEC")) flags.CLOEXEC = true;
if (@hasField(std.posix.O, "LARGEFILE")) flags.LARGEFILE = true;
if (@hasField(std.posix.O, "NOCTTY")) flags.NOCTTY = true;
if (@hasField(std.posix.O, "NOFOLLOW")) flags.NOFOLLOW = true;
const fd = try std.posix.openat(std.fs.cwd().fd, path, flags, 0);
return .{ .handle = fd };
}
fn ensureNoSymlinkComponents(allocator: std.mem.Allocator, root_dir: []const u8, relative_path: []const u8) !void {
var current = try allocator.dupe(u8, root_dir);
defer allocator.free(current);
var parts = std.mem.tokenizeScalar(u8, relative_path, std.fs.path.sep);
while (parts.next()) |part| {
var dir = try std.fs.cwd().openDir(current, .{ .iterate = true });
defer dir.close();
var iter = dir.iterate();
var found = false;
while (try iter.next()) |entry| {
if (!std.mem.eql(u8, entry.name, part)) continue;
if (entry.kind == .sym_link) return error.SymlinkInServedPath;
found = true;
break;
}
if (!found) return error.FileNotFound;
if (parts.peek() != null) {
const next = try std.fs.path.join(allocator, &.{ current, part });
allocator.free(current);
current = next;
}
}
}
fn parsePath(request: []const u8) ?[]const u8 {
if (!std.mem.startsWith(u8, request, "GET ")) return null;
const start = 4;
const end = std.mem.indexOf(u8, request[start..], " ") orelse return null;
return request[start .. start + end];
}
fn decodeRequestPath(allocator: std.mem.Allocator, request_target: []const u8) RequestPathError![]u8 {
if (request_target.len == 0 or request_target[0] != '/') return RequestPathError.InvalidPath;
const query_idx = std.mem.indexOfAny(u8, request_target, "?#") orelse request_target.len;
const raw_path = request_target[0..query_idx];
var decoded: std.ArrayListUnmanaged(u8) = .empty;
errdefer decoded.deinit(allocator);
var i: usize = 0;
while (i < raw_path.len) {
if (raw_path[i] == '%') {
if (i + 2 >= raw_path.len) return RequestPathError.InvalidPath;
const hi = hexValue(raw_path[i + 1]) orelse return RequestPathError.InvalidPath;
const lo = hexValue(raw_path[i + 2]) orelse return RequestPathError.InvalidPath;
try decoded.append(allocator, (hi << 4) | lo);
i += 3;
} else {
try decoded.append(allocator, raw_path[i]);
i += 1;
}
}
return decoded.toOwnedSlice(allocator);
}
fn isSafeRequestPath(path: []const u8) bool {
if (path.len == 0 or path[0] != '/') return false;
var segments = std.mem.splitScalar(u8, path, '/');
while (segments.next()) |segment| {
if (std.mem.eql(u8, segment, "..")) return false;
for (segment) |c| {
if (c == 0 or c == '\\') return false;
}
}
return true;
}
fn hexValue(c: u8) ?u8 {
return switch (c) {
'0'...'9' => c - '0',
'a'...'f' => c - 'a' + 10,
'A'...'F' => c - 'A' + 10,
else => null,
};
}
fn sendResponse(stream: std.net.Stream, status: []const u8, content_type: []const u8, body: []const u8) !void {
var header_buf: [512]u8 = undefined;
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ status, content_type, body.len }) catch return;
try stream.writeAll(header);
try stream.writeAll(body);
}
fn sendFileResponse(stream: std.net.Stream, status: []const u8, content_type: []const u8, file: std.fs.File) !void {
const stat = try file.stat();
var header_buf: [512]u8 = undefined;
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ status, content_type, stat.size }) catch return;
try stream.writeAll(header);
var buf: [8192]u8 = undefined;
while (true) {
const n = try file.read(&buf);
if (n == 0) break;
try stream.writeAll(buf[0..n]);
}
}
fn getMime(path: []const u8) []const u8 {
if (std.mem.endsWith(u8, path, ".html")) return "text/html";
if (std.mem.endsWith(u8, path, ".css")) return "text/css";
if (std.mem.endsWith(u8, path, ".js")) return "application/javascript";
if (std.mem.endsWith(u8, path, ".json")) return "application/json";
if (std.mem.endsWith(u8, path, ".xml")) return "application/xml";
if (std.mem.endsWith(u8, path, ".png")) return "image/png";
if (std.mem.endsWith(u8, path, ".jpg") or std.mem.endsWith(u8, path, ".jpeg")) return "image/jpeg";
if (std.mem.endsWith(u8, path, ".svg")) return "image/svg+xml";
if (std.mem.endsWith(u8, path, ".woff2")) return "font/woff2";
if (std.mem.endsWith(u8, path, ".woff")) return "font/woff";
return "application/octet-stream";
}
test "request path validation rejects encoded traversal" {
const decoded = try decodeRequestPath(std.testing.allocator, "/safe/%2e%2e/secret");
defer std.testing.allocator.free(decoded);
try std.testing.expect(!isSafeRequestPath(decoded));
}
test "request path validation strips query strings" {
const decoded = try decodeRequestPath(std.testing.allocator, "/about/?x=1");
defer std.testing.allocator.free(decoded);
try std.testing.expectEqualStrings("/about/", decoded);
try std.testing.expect(isSafeRequestPath(decoded));
}

319
src/template.zig Normal file
View file

@ -0,0 +1,319 @@
const std = @import("std");
pub const TemplateError = error{
PartialNotFound,
LayoutNotFound,
UnclosedTag,
OutOfMemory,
InvalidPath,
PartialDepthExceeded,
};
const max_partial_depth = 32;
pub const Engine = struct {
allocator: std.mem.Allocator,
layouts_dir: []const u8,
partials_dir: []const u8,
pub fn init(allocator: std.mem.Allocator, layouts_dir: []const u8, partials_dir: []const u8) Engine {
return .{
.allocator = allocator,
.layouts_dir = layouts_dir,
.partials_dir = partials_dir,
};
}
pub fn render(self: *const Engine, tpl: []const u8, variables: std.StringHashMap([]const u8)) TemplateError![]u8 {
return self.renderDepth(tpl, variables, 0);
}
fn renderDepth(self: *const Engine, tpl: []const u8, variables: std.StringHashMap([]const u8), depth: usize) TemplateError![]u8 {
if (depth > max_partial_depth) return TemplateError.PartialDepthExceeded;
var output: std.ArrayListUnmanaged(u8) = .empty;
errdefer output.deinit(self.allocator);
var i: usize = 0;
while (i < tpl.len) {
if (i + 2 < tpl.len and tpl[i] == '{' and tpl[i + 1] == '{' and tpl[i + 2] == '{') {
const tag_start = i + 3;
const close_idx = std.mem.indexOf(u8, tpl[tag_start..], "}}}") orelse
return TemplateError.UnclosedTag;
const tag_content = std.mem.trim(u8, tpl[tag_start .. tag_start + close_idx], " \t");
if (variables.get(tag_content)) |value| {
if (isRawBuiltin(tag_content)) {
output.appendSlice(self.allocator, value) catch return TemplateError.OutOfMemory;
} else {
appendVariable(self.allocator, &output, value) catch return TemplateError.OutOfMemory;
}
}
i = tag_start + close_idx + 3;
} else if (i + 1 < tpl.len and tpl[i] == '{' and tpl[i + 1] == '{') {
const tag_start = i + 2;
const close_idx = std.mem.indexOf(u8, tpl[tag_start..], "}}") orelse
return TemplateError.UnclosedTag;
const tag_content = std.mem.trim(u8, tpl[tag_start .. tag_start + close_idx], " \t");
if (tag_content.len > 0 and tag_content[0] == '>') {
const partial_name = std.mem.trim(u8, tag_content[1..], " \t");
const partial_content = self.loadPartial(partial_name) catch |err| {
if (err == error.FileNotFound) return TemplateError.PartialNotFound;
return TemplateError.OutOfMemory;
};
defer self.allocator.free(partial_content);
const rendered = try self.renderDepth(partial_content, variables, depth + 1);
defer self.allocator.free(rendered);
output.appendSlice(self.allocator, rendered) catch return TemplateError.OutOfMemory;
} else {
if (variables.get(tag_content)) |value| {
if (isRawBuiltin(tag_content)) {
output.appendSlice(self.allocator, value) catch return TemplateError.OutOfMemory;
} else {
appendVariable(self.allocator, &output, value) catch return TemplateError.OutOfMemory;
}
}
}
i = tag_start + close_idx + 2;
} else {
output.append(self.allocator, tpl[i]) catch return TemplateError.OutOfMemory;
i += 1;
}
}
return output.toOwnedSlice(self.allocator) catch return TemplateError.OutOfMemory;
}
pub fn renderWithLayout(self: *const Engine, layout_name: []const u8, content: []const u8, variables: std.StringHashMap([]const u8)) TemplateError![]u8 {
const layout = self.loadLayout(layout_name) catch |err| {
if (err == error.FileNotFound) return TemplateError.LayoutNotFound;
return TemplateError.OutOfMemory;
};
defer self.allocator.free(layout);
var vars_with_content = std.StringHashMap([]const u8).init(self.allocator);
defer vars_with_content.deinit();
var iter = variables.iterator();
while (iter.next()) |entry| {
vars_with_content.put(entry.key_ptr.*, entry.value_ptr.*) catch return TemplateError.OutOfMemory;
}
vars_with_content.put("content", content) catch return TemplateError.OutOfMemory;
return self.render(layout, vars_with_content);
}
fn loadPartial(self: *const Engine, name: []const u8) ![]u8 {
if (std.mem.indexOf(u8, name, "..") != null or std.mem.indexOf(u8, name, "/") != null) {
return TemplateError.InvalidPath;
}
const path = try std.fs.path.join(self.allocator, &.{ self.partials_dir, name });
defer self.allocator.free(path);
const path_with_ext = try std.mem.concat(self.allocator, u8, &.{ path, ".html" });
defer self.allocator.free(path_with_ext);
return readFile(self.allocator, path_with_ext) catch |err| {
if (err == error.FileNotFound) return readFile(self.allocator, path);
return err;
};
}
fn loadLayout(self: *const Engine, name: []const u8) ![]u8 {
if (std.mem.indexOf(u8, name, "..") != null or std.mem.indexOf(u8, name, "/") != null) {
return TemplateError.InvalidPath;
}
const path = try std.fs.path.join(self.allocator, &.{ self.layouts_dir, name });
defer self.allocator.free(path);
const path_with_ext = try std.mem.concat(self.allocator, u8, &.{ path, ".html" });
defer self.allocator.free(path_with_ext);
return readFile(self.allocator, path_with_ext) catch |err| {
if (err == error.FileNotFound) return readFile(self.allocator, path);
return err;
};
}
};
fn readFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
try ensureNoSymlinkComponents(allocator, path);
const file = openFileNoFollow(path) catch |err| return err;
defer file.close();
const stat = try file.stat();
if (stat.kind != .file) return error.InvalidPath;
return file.readToEndAlloc(allocator, 1024 * 1024) catch |err| return err;
}
fn openFileNoFollow(path: []const u8) !std.fs.File {
var flags: std.posix.O = .{ .ACCMODE = .RDONLY };
if (@hasField(std.posix.O, "CLOEXEC")) flags.CLOEXEC = true;
if (@hasField(std.posix.O, "LARGEFILE")) flags.LARGEFILE = true;
if (@hasField(std.posix.O, "NOCTTY")) flags.NOCTTY = true;
if (@hasField(std.posix.O, "NOFOLLOW")) flags.NOFOLLOW = true;
const fd = try std.posix.openat(std.fs.cwd().fd, path, flags, 0);
return .{ .handle = fd };
}
fn appendHtmlEscaped(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), text: []const u8) !void {
for (text) |c| {
switch (c) {
'<' => try output.appendSlice(allocator, "&lt;"),
'>' => try output.appendSlice(allocator, "&gt;"),
'&' => try output.appendSlice(allocator, "&amp;"),
'"' => try output.appendSlice(allocator, "&quot;"),
'\'' => try output.appendSlice(allocator, "&#39;"),
else => try output.append(allocator, c),
}
}
}
fn isRawBuiltin(name: []const u8) bool {
return std.mem.eql(u8, name, "content") or std.mem.eql(u8, name, "nav_html") or std.mem.eql(u8, name, "toc");
}
fn appendVariable(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), value: []const u8) !void {
if (isUrlAttributeContext(output.items)) {
if (isSafeUrl(value)) {
try appendHtmlEscaped(allocator, output, value);
} else {
try output.append(allocator, '#');
}
} else {
try appendHtmlEscaped(allocator, output, value);
}
}
fn isUrlAttributeContext(output: []const u8) bool {
const trimmed = std.mem.trimRight(u8, output, " \t\r\n");
if (trimmed.len == 0) return false;
const quote = trimmed[trimmed.len - 1];
if (quote != '"' and quote != '\'') return false;
const before_quote = std.mem.trimRight(u8, trimmed[0 .. trimmed.len - 1], " \t\r\n");
if (before_quote.len == 0 or before_quote[before_quote.len - 1] != '=') return false;
const before_equals = std.mem.trimRight(u8, before_quote[0 .. before_quote.len - 1], " \t\r\n");
if (before_equals.len < 3) return false;
return endsWithAttributeName(before_equals, "href") or endsWithAttributeName(before_equals, "src");
}
fn endsWithAttributeName(text: []const u8, name: []const u8) bool {
if (text.len < name.len) return false;
const start = text.len - name.len;
if (!std.ascii.eqlIgnoreCase(text[start..], name)) return false;
if (start == 0) return true;
const prev = text[start - 1];
return std.ascii.isWhitespace(prev) or prev == '<';
}
fn isSafeUrl(url: []const u8) bool {
if (url.len == 0) return false;
for (url) |c| {
if (c <= 0x20 or c == 0x7f) return false;
}
if (url[0] == '#') return true;
if (url[0] == '/') return url.len == 1 or url[1] != '/';
if (std.mem.startsWith(u8, url, "./") or std.mem.startsWith(u8, url, "../")) return true;
const colon_idx = std.mem.indexOfScalar(u8, url, ':') orelse return true;
const first_delim = firstUrlDelimiter(url) orelse url.len;
if (colon_idx > first_delim) return true;
const scheme = url[0..colon_idx];
return std.ascii.eqlIgnoreCase(scheme, "http") or
std.ascii.eqlIgnoreCase(scheme, "https") or
std.ascii.eqlIgnoreCase(scheme, "mailto");
}
fn firstUrlDelimiter(url: []const u8) ?usize {
var result: ?usize = null;
for ([_]u8{ '/', '?', '#' }) |delimiter| {
if (std.mem.indexOfScalar(u8, url, delimiter)) |idx| {
if (result == null or idx < result.?) result = idx;
}
}
return result;
}
fn ensureNoSymlinkComponents(allocator: std.mem.Allocator, path: []const u8) !void {
const resolved = try std.fs.path.resolve(allocator, &.{path});
defer allocator.free(resolved);
if (std.mem.eql(u8, resolved, std.fs.path.sep_str)) return;
var current = if (std.fs.path.isAbsolute(resolved)) try allocator.dupe(u8, std.fs.path.sep_str) else try allocator.dupe(u8, ".");
defer allocator.free(current);
var parts = std.mem.tokenizeScalar(u8, resolved, std.fs.path.sep);
while (parts.next()) |part| {
const next = try std.fs.path.join(allocator, &.{ current, part });
defer allocator.free(next);
const kind = entryKind(current, part) catch |err| switch (err) {
error.FileNotFound => {
allocator.free(current);
current = try allocator.dupe(u8, next);
continue;
},
else => return err,
};
if (kind == .sym_link) return error.SymlinkInTemplatePath;
allocator.free(current);
current = try allocator.dupe(u8, next);
}
}
fn entryKind(dir_path: []const u8, name: []const u8) !std.fs.File.Kind {
var dir = try std.fs.cwd().openDir(dir_path, .{ .iterate = true });
defer dir.close();
var iter = dir.iterate();
while (try iter.next()) |entry| {
if (std.mem.eql(u8, entry.name, name)) return entry.kind;
}
return error.FileNotFound;
}
test "template escapes double braces and only renders content triple braces raw" {
var vars = std.StringHashMap([]const u8).init(std.testing.allocator);
defer vars.deinit();
try vars.put("title", "<script>alert(1)</script>");
try vars.put("content", "<p>safe html</p>");
const engine = Engine.init(std.testing.allocator, "", "");
const rendered = try engine.render("<title>{{title}}</title>{{{title}}}{{{content}}}", vars);
defer std.testing.allocator.free(rendered);
try std.testing.expect(std.mem.indexOf(u8, rendered, "<script>") == null);
try std.testing.expect(std.mem.indexOf(u8, rendered, "&lt;script&gt;") != null);
try std.testing.expect(std.mem.indexOf(u8, rendered, "<p>safe html</p>") != null);
}
test "template renders built-in content raw with double braces" {
var vars = std.StringHashMap([]const u8).init(std.testing.allocator);
defer vars.deinit();
try vars.put("content", "<h1>notes</h1>");
const engine = Engine.init(std.testing.allocator, "", "");
const rendered = try engine.render("<main>{{content}}</main>", vars);
defer std.testing.allocator.free(rendered);
try std.testing.expect(std.mem.indexOf(u8, rendered, "<h1>notes</h1>") != null);
try std.testing.expect(std.mem.indexOf(u8, rendered, "&lt;h1&gt;") == null);
}
test "template sanitizes unsafe url attribute variables" {
var vars = std.StringHashMap([]const u8).init(std.testing.allocator);
defer vars.deinit();
try vars.put("link", "javascript:alert(1)");
const engine = Engine.init(std.testing.allocator, "", "");
const rendered = try engine.render("<a href=\"{{link}}\">x</a>", vars);
defer std.testing.allocator.free(rendered);
try std.testing.expect(std.mem.indexOf(u8, rendered, "javascript:") == null);
try std.testing.expect(std.mem.indexOf(u8, rendered, "href=\"#\"") != null);
}

185
test.sh Executable file
View file

@ -0,0 +1,185 @@
#!/bin/bash
set -e
echo "building..."
zig build -Doptimize=ReleaseFast
BIN=./zig-out/bin/ypsilanti
TMP=$(mktemp -d)
trap "rm -rf $TMP" EXIT
echo "test: no args shows usage"
$BIN 2>&1 | grep -q "commands:" || { echo "FAIL: no args"; exit 1; }
echo "test: invalid command shows usage"
$BIN invalid 2>&1 | grep -q "commands:" || { echo "FAIL: invalid cmd"; exit 1; }
echo "test: build missing args"
$BIN build 2>&1 | grep -q "usage:" || { echo "FAIL: build no args"; exit 1; }
echo "test: serve missing args"
$BIN serve 2>&1 | grep -q "usage:" || { echo "FAIL: serve no args"; exit 1; }
echo "test: build example site"
$BIN build ./example $TMP/out
[ -f "$TMP/out/index.html" ] || { echo "FAIL: no index.html"; exit 1; }
[ -f "$TMP/out/about/index.html" ] || { echo "FAIL: no about/index.html"; exit 1; }
[ -f "$TMP/out/404.html" ] || { echo "FAIL: no 404.html"; exit 1; }
[ -f "$TMP/out/posts/index.html" ] || { echo "FAIL: no generated posts index"; exit 1; }
[ -f "$TMP/out/tags/linux/index.html" ] || { echo "FAIL: no generated tag page"; exit 1; }
[ -f "$TMP/out/categories/technology/index.html" ] || { echo "FAIL: no generated category page"; exit 1; }
[ -f "$TMP/out/sitemap.xml" ] || { echo "FAIL: no sitemap"; exit 1; }
[ -f "$TMP/out/feed.xml" ] || { echo "FAIL: no feed"; exit 1; }
[ -f "$TMP/out/style.css" ] || { echo "FAIL: no static files"; exit 1; }
echo "test: html contains content"
grep -q "Example Site" $TMP/out/index.html || { echo "FAIL: missing content"; exit 1; }
grep -q "<nav>" $TMP/out/index.html || { echo "FAIL: missing partial"; exit 1; }
grep -q "og:title" "$TMP/out/posts/first-post/index.html" || { echo "FAIL: missing seo metadata"; exit 1; }
grep -q 'id="markdown-features"' "$TMP/out/posts/first-post/index.html" || { echo "FAIL: missing heading id"; exit 1; }
grep -q 'href="/posts/first-post/"' "$TMP/out/posts/index.html" || { echo "FAIL: post index link is not root-relative"; exit 1; }
echo "test: sitemap has urls"
grep -q "<loc>" $TMP/out/sitemap.xml || { echo "FAIL: sitemap empty"; exit 1; }
echo "test: rss generated"
grep -q "<rss" $TMP/out/feed.xml || { echo "FAIL: rss missing"; exit 1; }
echo "test: static collision rejected"
mkdir -p "$TMP/collision/site/content" "$TMP/collision/site/layouts" "$TMP/collision/site/static"
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/collision/site/layouts/base.html"
printf '%s\n' '---' 'title: Collision' 'layout: base' '---' '# Collision' > "$TMP/collision/site/content/index.md"
printf '%s\n' 'bad sitemap' > "$TMP/collision/site/static/sitemap.xml"
if $BIN build "$TMP/collision/site" "$TMP/collision/out" >/dev/null 2>&1; then
echo "FAIL: static collision allowed"
exit 1
fi
echo "test: invalid base url rejected"
mkdir -p "$TMP/baseurl/site/content" "$TMP/baseurl/site/layouts"
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/baseurl/site/layouts/base.html"
printf '%s\n' 'javascript:alert(1)' > "$TMP/baseurl/site/url"
printf '%s\n' '---' 'title: Base URL' 'layout: base' '---' '# Base URL' > "$TMP/baseurl/site/content/index.md"
if $BIN build "$TMP/baseurl/site" "$TMP/baseurl/out" >/dev/null 2>&1; then
echo "FAIL: invalid base url allowed"
exit 1
fi
echo "test: protocol-relative markdown links rejected"
mkdir -p "$TMP/link/site/content" "$TMP/link/site/layouts"
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/link/site/layouts/base.html"
printf '%s\n' '---' 'title: Link' 'layout: base' '---' '[bad](//attacker.example/path)' > "$TMP/link/site/content/index.md"
$BIN build "$TMP/link/site" "$TMP/link/out" >/dev/null
grep -q 'href="#"' "$TMP/link/out/index.html" || { echo "FAIL: protocol-relative link not blocked"; exit 1; }
echo "test: symlinked static rejected"
mkdir -p "$TMP/staticlink/site/content" "$TMP/staticlink/site/layouts" "$TMP/staticlink/target"
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/staticlink/site/layouts/base.html"
printf '%s\n' '---' 'title: Static Link' 'layout: base' '---' '# Static Link' > "$TMP/staticlink/site/content/index.md"
ln -s "$TMP/staticlink/target" "$TMP/staticlink/site/static"
if $BIN build "$TMP/staticlink/site" "$TMP/staticlink/target/out" >/dev/null 2>&1; then
echo "FAIL: symlinked static allowed"
exit 1
fi
echo "test: draft pages skipped"
mkdir -p "$TMP/draft/site/content" "$TMP/draft/site/layouts"
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/draft/site/layouts/base.html"
printf '%s\n' '---' 'title: Draft' 'layout: base' 'draft: true' '---' '# Draft' > "$TMP/draft/site/content/draft.md"
$BIN build "$TMP/draft/site" "$TMP/draft/out" >/dev/null
[ ! -e "$TMP/draft/out/draft/index.html" ] || { echo "FAIL: draft was built"; exit 1; }
echo "test: markdown tables and footnotes"
mkdir -p "$TMP/md/site/content" "$TMP/md/site/layouts"
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/md/site/layouts/base.html"
printf '%s\n' '---' 'title: Markdown' 'layout: base' '---' '| a | b |' '| - | - |' '| 1 | 2 |' '' 'note[^1]' '' '[^1]: footnote text' > "$TMP/md/site/content/index.md"
$BIN build "$TMP/md/site" "$TMP/md/out" >/dev/null
grep -q '<table>' "$TMP/md/out/index.html" || { echo "FAIL: table missing"; exit 1; }
grep -q 'class="footnotes"' "$TMP/md/out/index.html" || { echo "FAIL: footnotes missing"; exit 1; }
echo "test: toc shortcodes syntax aliases pagination"
mkdir -p "$TMP/features/site/content/posts" "$TMP/features/site/layouts"
printf '%s\n' '<main>{{{toc}}}{{{content}}}</main>' > "$TMP/features/site/layouts/base.html"
printf '%s\n' 'paginate: 1' > "$TMP/features/site/config"
printf '%s\n' '---' 'title: One' 'date: 2026-01-02' 'layout: base' 'tags: zig' 'aliases: /old-one/' '---' '## Heading One' '' '{{< callout text="note" >}}' '' '```zig' 'const x = 1;' '```' > "$TMP/features/site/content/posts/one.md"
printf '%s\n' '---' 'title: Two' 'date: 2026-01-01' 'layout: base' 'tags: zig' '---' '# Two' > "$TMP/features/site/content/posts/two.md"
$BIN build "$TMP/features/site" "$TMP/features/out" >/dev/null
[ -f "$TMP/features/out/posts/page/2/index.html" ] || { echo "FAIL: posts page 2 missing"; exit 1; }
[ -f "$TMP/features/out/tags/zig/page/2/index.html" ] || { echo "FAIL: tag page 2 missing"; exit 1; }
[ -f "$TMP/features/out/old-one/index.html" ] || { echo "FAIL: alias redirect missing"; exit 1; }
grep -q 'class="toc"' "$TMP/features/out/posts/one/index.html" || { echo "FAIL: toc missing"; exit 1; }
grep -q 'class="callout callout-note"' "$TMP/features/out/posts/one/index.html" || { echo "FAIL: shortcode missing"; exit 1; }
grep -q 'tok-keyword' "$TMP/features/out/posts/one/index.html" || { echo "FAIL: syntax highlight missing"; exit 1; }
grep -q 'url=/posts/one/' "$TMP/features/out/old-one/index.html" || { echo "FAIL: alias target missing"; exit 1; }
echo "test: broken internal links rejected"
mkdir -p "$TMP/brokenlink/site/content" "$TMP/brokenlink/site/layouts"
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/brokenlink/site/layouts/base.html"
printf '%s\n' '---' 'title: Broken Link' 'layout: base' '---' '[missing](/missing/)' > "$TMP/brokenlink/site/content/index.md"
if $BIN build "$TMP/brokenlink/site" "$TMP/brokenlink/out" >/dev/null 2>&1; then
echo "FAIL: broken internal link allowed"
exit 1
fi
echo "test: broken fragment links rejected"
mkdir -p "$TMP/brokenfragment/site/content" "$TMP/brokenfragment/site/layouts"
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/brokenfragment/site/layouts/base.html"
printf '%s\n' '---' 'title: Broken Fragment' 'layout: base' '---' '# Exists' '' '[missing](#missing)' > "$TMP/brokenfragment/site/content/index.md"
if $BIN build "$TMP/brokenfragment/site" "$TMP/brokenfragment/out" >/dev/null 2>&1; then
echo "FAIL: broken fragment link allowed"
exit 1
fi
echo "test: symlinked layout rejected"
mkdir -p "$TMP/layoutlink/site/content" "$TMP/layoutlink/site/layouts"
printf '%s\n' 'SECRET_LAYOUT_SENTINEL' > "$TMP/layoutlink/secret.html"
ln -s "$TMP/layoutlink/secret.html" "$TMP/layoutlink/site/layouts/leak.html"
printf '%s\n' '---' 'title: Layout Link' 'layout: leak' '---' '# Layout Link' > "$TMP/layoutlink/site/content/index.md"
if $BIN build "$TMP/layoutlink/site" "$TMP/layoutlink/out" >/dev/null 2>&1; then
echo "FAIL: symlinked layout allowed"
exit 1
fi
echo "test: reserved content frontmatter rejected"
mkdir -p "$TMP/rawcontent/site/content" "$TMP/rawcontent/site/layouts"
printf '%s\n' '---' 'content: <img src=x onerror=alert(1)>' '---' '{{{content}}}' > "$TMP/rawcontent/site/content/index.md"
if $BIN build "$TMP/rawcontent/site" "$TMP/rawcontent/out" >/dev/null 2>&1; then
echo "FAIL: reserved content frontmatter allowed"
exit 1
fi
echo "test: template url variables sanitized"
mkdir -p "$TMP/templateurl/site/content" "$TMP/templateurl/site/layouts"
printf '%s\n' '<a href = "{{link}}">profile</a>{{{content}}}' > "$TMP/templateurl/site/layouts/base.html"
printf '%s\n' '---' 'title: Template URL' 'layout: base' 'link: javascript:alert(1)' '---' '# Template URL' > "$TMP/templateurl/site/content/index.md"
$BIN build "$TMP/templateurl/site" "$TMP/templateurl/out" >/dev/null
grep -q 'href = "#"' "$TMP/templateurl/out/index.html" || { echo "FAIL: template url not sanitized"; exit 1; }
echo "test: xml control chars rejected"
mkdir -p "$TMP/xmlcontrol/site/content" "$TMP/xmlcontrol/site/layouts"
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/xmlcontrol/site/layouts/base.html"
printf '%s' $'---\ntitle: Bad\001Title\ndate: 2026-01-01\nlayout: base\n---\n# XML\n' > "$TMP/xmlcontrol/site/content/index.md"
if $BIN build "$TMP/xmlcontrol/site" "$TMP/xmlcontrol/out" >/dev/null 2>&1; then
echo "FAIL: xml control char allowed"
exit 1
fi
echo "test: serve starts and responds"
cp -a ./example "$TMP/serve-site"
$BIN serve "$TMP/serve-site" 3456 &
PID=$!
sleep 1
curl -s http://localhost:3456/ | grep -q "Example Site" || { kill $PID 2>/dev/null; echo "FAIL: serve"; exit 1; }
curl -s http://localhost:3456/posts/ | grep -q '<h1>posts</h1>' || { kill $PID 2>/dev/null; echo "FAIL: serve posts index"; exit 1; }
curl -s http://localhost:3456/posts/ | grep -q 'href="/posts/first-post/"' || { kill $PID 2>/dev/null; echo "FAIL: served post link is not root-relative"; exit 1; }
curl -s http://localhost:3456/posts/first-post/ | grep -q "First Post" || { kill $PID 2>/dev/null; echo "FAIL: serve post page"; exit 1; }
curl -s http://localhost:3456/nope | grep -q "404" || { kill $PID 2>/dev/null; echo "FAIL: 404 page"; exit 1; }
before=$(curl -s http://localhost:3456/_reload)
touch "$TMP/serve-site/content/posts/first-post.md"
sleep 1
after=$(curl -s http://localhost:3456/_reload)
[ "$after" -gt "$before" ] || { kill $PID 2>/dev/null; echo "FAIL: nested live reload"; exit 1; }
kill $PID 2>/dev/null
echo ""
echo "all tests passed"