commit bda33ef628e285dd782f7aff4e51249799904816 Author: Maxfield Luke Date: Mon Jul 6 02:46:37 2026 -0400 Initial commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ddcb283 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..d7ad027 --- /dev/null +++ b/.github/workflows/pages.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5fc55d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +zig-out/ +.zig-cache/ +output/ +_site/ +.ypsilanti_serve/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3ec0123 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e0e5f59 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..72dffad --- /dev/null +++ b/README.md @@ -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 + + +{{title}} + + {{> header}} + {{{toc}}} +
{{{content}}}
+ + +``` + +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 + + +``` + +## 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). diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..458d481 --- /dev/null +++ b/action.yml @@ -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 }}"' diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..7c3cabe --- /dev/null +++ b/build.zig @@ -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); +} diff --git a/docs/github-pages.yml b/docs/github-pages.yml new file mode 100644 index 0000000..2996afb --- /dev/null +++ b/docs/github-pages.yml @@ -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 diff --git a/example/config b/example/config new file mode 100644 index 0000000..f846cc5 --- /dev/null +++ b/example/config @@ -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 diff --git a/example/content/404.md b/example/content/404.md new file mode 100644 index 0000000..d577045 --- /dev/null +++ b/example/content/404.md @@ -0,0 +1,8 @@ +--- +title: Not Found +layout: base +--- + +# 404 + +Page not found. [Go home](/). diff --git a/example/content/about.md b/example/content/about.md new file mode 100644 index 0000000..df5ca13 --- /dev/null +++ b/example/content/about.md @@ -0,0 +1,9 @@ +--- +title: About +description: About this example site. +layout: base +--- + +# About + +This page shows a simple content page. diff --git a/example/content/index.md b/example/content/index.md new file mode 100644 index 0000000..bb6edd5 --- /dev/null +++ b/example/content/index.md @@ -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/) diff --git a/example/content/posts/first-post.md b/example/content/posts/first-post.md new file mode 100644 index 0000000..88fceb6 --- /dev/null +++ b/example/content/posts/first-post.md @@ -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. diff --git a/example/layouts/base.html b/example/layouts/base.html new file mode 100644 index 0000000..c41228b --- /dev/null +++ b/example/layouts/base.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + {{title}} - {{site_title}} + + + + +
+ {{> nav}} +
{{{content}}}
+ {{> footer}} +
+ + diff --git a/example/layouts/post.html b/example/layouts/post.html new file mode 100644 index 0000000..a1e4e68 --- /dev/null +++ b/example/layouts/post.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + {{title}} - {{site_title}} + + + + +
+ {{> nav}} +
+

{{title}}

+ + {{{content}}} +
+ {{> footer}} +
+ + diff --git a/example/partials/footer.html b/example/partials/footer.html new file mode 100644 index 0000000..148dcb9 --- /dev/null +++ b/example/partials/footer.html @@ -0,0 +1,3 @@ + diff --git a/example/partials/nav.html b/example/partials/nav.html new file mode 100644 index 0000000..b57b807 --- /dev/null +++ b/example/partials/nav.html @@ -0,0 +1,8 @@ +
+ + +
diff --git a/example/static/style.css b/example/static/style.css new file mode 100644 index 0000000..8485a34 --- /dev/null +++ b/example/static/style.css @@ -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; +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..e35212a --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +zig = "0.14" diff --git a/site/config b/site/config new file mode 100644 index 0000000..17b3dc1 --- /dev/null +++ b/site/config @@ -0,0 +1,6 @@ +title: maxfield.lol +url: https://maxfield.lol +author: maxf1eld +description: notes from maxfield.lol +theme: darkode +nav: home=/, about=/about/ diff --git a/site/content/404.md b/site/content/404.md new file mode 100644 index 0000000..ba01bdf --- /dev/null +++ b/site/content/404.md @@ -0,0 +1,8 @@ +--- +title: not found +layout: base +--- + +# 404 + +dead link. bad portal. [go home](/). diff --git a/site/content/about.md b/site/content/about.md new file mode 100644 index 0000000..d7c0f0b --- /dev/null +++ b/site/content/about.md @@ -0,0 +1,8 @@ +--- +title: about +layout: base +--- + +# about + +github: [maxf1eld](https://github.com/maxf1eld) diff --git a/site/content/index.md b/site/content/index.md new file mode 100644 index 0000000..a5ca9f3 --- /dev/null +++ b/site/content/index.md @@ -0,0 +1,6 @@ +--- +title: home +layout: base +--- + +# notes diff --git a/site/layouts/base.html b/site/layouts/base.html new file mode 100644 index 0000000..325aaae --- /dev/null +++ b/site/layouts/base.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + {{title}} - {{site_title}} + + + +
+ {{> nav}} +
+ {{{content}}} +
+ {{> footer}} +
+ + diff --git a/site/layouts/post.html b/site/layouts/post.html new file mode 100644 index 0000000..32501f0 --- /dev/null +++ b/site/layouts/post.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + {{title}} - {{site_title}} + + + +
+ {{> nav}} +
+

{{title}}

+ + {{{toc}}} + {{{content}}} +
+ {{> footer}} +
+ + diff --git a/site/partials/footer.html b/site/partials/footer.html new file mode 100644 index 0000000..ed40733 --- /dev/null +++ b/site/partials/footer.html @@ -0,0 +1,3 @@ + diff --git a/site/partials/nav.html b/site/partials/nav.html new file mode 100644 index 0000000..b57b807 --- /dev/null +++ b/site/partials/nav.html @@ -0,0 +1,8 @@ +
+ + +
diff --git a/site/static/style.css b/site/static/style.css new file mode 100644 index 0000000..c4643be --- /dev/null +++ b/site/static/style.css @@ -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; + } +} diff --git a/site/url b/site/url new file mode 100644 index 0000000..c0aec71 --- /dev/null +++ b/site/url @@ -0,0 +1 @@ +https://maxfield.lol diff --git a/src/frontmatter.zig b/src/frontmatter.zig new file mode 100644 index 0000000..cdf6d2d --- /dev/null +++ b/src/frontmatter.zig @@ -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..]; +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..3444c25 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,1389 @@ +const std = @import("std"); +const frontmatter = @import("frontmatter.zig"); +const markdown = @import("markdown.zig"); +const template = @import("template.zig"); +const server = @import("server.zig"); + +const max_directory_depth = 128; +const max_pages = 10_000; +const max_static_files = 10_000; +const max_static_file_size = 100 * 1024 * 1024; +const max_total_static_size = 1024 * 1024 * 1024; +const max_total_markdown_input_size = 100 * 1024 * 1024; +const max_total_generated_output_size = 250 * 1024 * 1024; + +const Page = struct { + url: []const u8, + title: []const u8, + date: []const u8, + description: []const u8, + tags: []const u8, + categories: []const u8, + + fn deinit(self: Page, allocator: std.mem.Allocator) void { + allocator.free(self.url); + if (self.title.len > 0) allocator.free(self.title); + if (self.date.len > 0) allocator.free(self.date); + if (self.description.len > 0) allocator.free(self.description); + if (self.tags.len > 0) allocator.free(self.tags); + if (self.categories.len > 0) allocator.free(self.categories); + } +}; + +const Config = struct { + title: []const u8, + url: []const u8, + author: []const u8, + description: []const u8, + theme: []const u8, + nav_html: []const u8, + paginate: usize, + + fn deinit(self: Config, allocator: std.mem.Allocator) void { + if (self.title.len > 0) allocator.free(self.title); + if (self.url.len > 0) allocator.free(self.url); + if (self.author.len > 0) allocator.free(self.author); + if (self.description.len > 0) allocator.free(self.description); + if (self.theme.len > 0) allocator.free(self.theme); + if (self.nav_html.len > 0) allocator.free(self.nav_html); + } +}; + +const TemplateVars = struct { + map: std.StringHashMap([]const u8), + permalink: []u8, + + fn deinit(self: *TemplateVars, allocator: std.mem.Allocator) void { + self.map.deinit(); + if (self.permalink.len > 0) allocator.free(self.permalink); + } +}; + +const StaticCopyStats = struct { + count: usize = 0, + bytes: u64 = 0, +}; + +const OpenParent = struct { + dir: std.fs.Dir, + name: []u8, + + fn deinit(self: *OpenParent, allocator: std.mem.Allocator) void { + self.dir.close(); + allocator.free(self.name); + } +}; + +const BuildStats = struct { + pages: usize = 0, + markdown_bytes: u64 = 0, + generated_bytes: u64 = 0, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + if (args.len < 2) { + printUsage(); + return; + } + + const cmd = args[1]; + + if (std.mem.eql(u8, cmd, "build")) { + if (args.len < 3) { + std.debug.print("usage: ypsilanti build [output_dir]\n", .{}); + return; + } + const site_dir = args[2]; + const output_dir = if (args.len > 3) args[3] else "output"; + try build(allocator, site_dir, output_dir, false); + } else if (std.mem.eql(u8, cmd, "serve")) { + if (args.len < 3) { + std.debug.print("usage: ypsilanti serve [port]\n", .{}); + return; + } + const site_dir = args[2]; + const port: u16 = if (args.len > 3) std.fmt.parseInt(u16, args[3], 10) catch 3000 else 3000; + try serve(allocator, site_dir, port); + } else { + printUsage(); + } +} + +fn printUsage() void { + std.debug.print( + \\ypsilanti - static site generator + \\ + \\commands: + \\ build [output_dir] generate site + \\ serve [port] dev server with live reload + \\ + , .{}); +} + +pub fn build(allocator: std.mem.Allocator, site_dir: []const u8, output_dir: []const u8, inject_reload: bool) !void { + const content_dir = try std.fs.path.join(allocator, &.{ site_dir, "content" }); + defer allocator.free(content_dir); + const layouts_dir = try std.fs.path.join(allocator, &.{ site_dir, "layouts" }); + defer allocator.free(layouts_dir); + const partials_dir = try std.fs.path.join(allocator, &.{ site_dir, "partials" }); + defer allocator.free(partials_dir); + const static_dir = try std.fs.path.join(allocator, &.{ site_dir, "static" }); + defer allocator.free(static_dir); + + try ensureNoSymlinkComponents(allocator, content_dir); + try ensureNoSymlinkComponents(allocator, layouts_dir); + try ensureNoSymlinkComponents(allocator, partials_dir); + try ensureNoSymlinkComponents(allocator, static_dir); + try ensureNoSymlinkComponents(allocator, output_dir); + if (try isSameOrChildPath(allocator, static_dir, output_dir)) { + return error.OutputInsideStaticDir; + } + + std.fs.cwd().makePath(output_dir) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + try ensureNoSymlinkComponents(allocator, output_dir); + + const config = try loadConfig(allocator, site_dir); + defer config.deinit(allocator); + + const engine = template.Engine.init(allocator, layouts_dir, partials_dir); + + var pages: std.ArrayListUnmanaged(Page) = .empty; + defer { + for (pages.items) |page| page.deinit(allocator); + pages.deinit(allocator); + } + + var stats: BuildStats = .{}; + try processDirectory(allocator, &engine, content_dir, content_dir, output_dir, inject_reload, config, &pages, &stats, 0); + std.debug.print("{d} pages\n", .{stats.pages}); + + try generatePostIndex(allocator, &engine, output_dir, inject_reload, config, &pages, &stats); + try generateTaxonomy(allocator, &engine, output_dir, inject_reload, config, &pages, &stats, "tags", "tags"); + try generateTaxonomy(allocator, &engine, output_dir, inject_reload, config, &pages, &stats, "categories", "categories"); + try generateSitemap(allocator, output_dir, config.url, pages.items); + try generateRss(allocator, output_dir, config, pages.items); + + const static_copied = copyStaticFiles(allocator, static_dir, output_dir) catch |err| switch (err) { + error.FileNotFound => 0, + else => return err, + }; + std.debug.print("{d} static files\n", .{static_copied}); + try validateInternalLinks(allocator, output_dir); +} + +fn loadConfig(allocator: std.mem.Allocator, site_dir: []const u8) !Config { + var title: []const u8 = "ypsilanti"; + var url: []const u8 = ""; + var author: []const u8 = ""; + var description: []const u8 = ""; + var theme: []const u8 = "darkode"; + var nav: []const u8 = "home=/,about=/about/,posts=/posts/,rss=/feed.xml"; + var paginate: usize = 10; + var config_content: ?[]u8 = null; + defer if (config_content) |content| allocator.free(content); + var url_content: ?[]u8 = null; + defer if (url_content) |content| allocator.free(content); + + const config_path = try std.fs.path.join(allocator, &.{ site_dir, "config" }); + defer allocator.free(config_path); + if (readFile(allocator, config_path)) |content| { + config_content = content; + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (line.len == 0 or line[0] == '#') continue; + const colon_idx = std.mem.indexOfScalar(u8, line, ':') orelse continue; + const key = std.mem.trim(u8, line[0..colon_idx], " \t"); + const value = std.mem.trim(u8, line[colon_idx + 1 ..], " \t"); + if (std.mem.eql(u8, key, "title")) title = value; + if (std.mem.eql(u8, key, "url")) url = value; + if (std.mem.eql(u8, key, "author")) author = value; + if (std.mem.eql(u8, key, "description")) description = value; + if (std.mem.eql(u8, key, "theme")) theme = value; + if (std.mem.eql(u8, key, "nav")) nav = value; + if (std.mem.eql(u8, key, "paginate")) paginate = std.fmt.parseInt(usize, value, 10) catch paginate; + } + } else |err| switch (err) { + error.FileNotFound => { + const url_path = try std.fs.path.join(allocator, &.{ site_dir, "url" }); + defer allocator.free(url_path); + url_content = readFile(allocator, url_path) catch null; + if (url_content) |content| url = std.mem.trim(u8, content, " \t\r\n"); + }, + else => return err, + } + + if (!isSafeBaseUrl(url)) return error.InvalidBaseUrl; + + return .{ + .title = try allocator.dupe(u8, title), + .url = try allocator.dupe(u8, url), + .author = try allocator.dupe(u8, author), + .description = try allocator.dupe(u8, description), + .theme = try allocator.dupe(u8, theme), + .nav_html = try buildNavHtml(allocator, nav), + .paginate = if (paginate == 0) 10 else paginate, + }; +} + +fn isSafeBaseUrl(url: []const u8) bool { + if (url.len == 0) return true; + for (url) |c| { + if (c <= 0x20 or c == 0x7f) return false; + } + return std.mem.startsWith(u8, url, "https://") or std.mem.startsWith(u8, url, "http://"); +} + +fn buildNavHtml(allocator: std.mem.Allocator, nav: []const u8) ![]u8 { + var buf: std.ArrayListUnmanaged(u8) = .empty; + errdefer buf.deinit(allocator); + + var items = std.mem.splitScalar(u8, nav, ','); + while (items.next()) |raw_item| { + const item = std.mem.trim(u8, raw_item, " \t"); + if (item.len == 0) continue; + const eq_idx = std.mem.indexOfScalar(u8, item, '=') orelse continue; + const label = std.mem.trim(u8, item[0..eq_idx], " \t"); + const href = std.mem.trim(u8, item[eq_idx + 1 ..], " \t"); + if (!isSafeTemplateUrl(href)) continue; + try buf.appendSlice(allocator, ""); + try appendHtmlEscaped(allocator, &buf, label); + try buf.appendSlice(allocator, "\n"); + } + + return buf.toOwnedSlice(allocator); +} + +fn isSafeTemplateUrl(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 appendHtmlEscaped(allocator: std.mem.Allocator, buf: *std.ArrayListUnmanaged(u8), text: []const u8) !void { + for (text) |c| { + switch (c) { + '<' => try buf.appendSlice(allocator, "<"), + '>' => try buf.appendSlice(allocator, ">"), + '&' => try buf.appendSlice(allocator, "&"), + '"' => try buf.appendSlice(allocator, """), + '\'' => try buf.appendSlice(allocator, "'"), + else => try buf.append(allocator, c), + } + } +} + +fn appendFmt(allocator: std.mem.Allocator, buf: *std.ArrayListUnmanaged(u8), comptime fmt: []const u8, args: anytype) !void { + var tmp: [96]u8 = undefined; + const formatted = std.fmt.bufPrint(&tmp, fmt, args) catch return error.OutOfMemory; + try buf.appendSlice(allocator, formatted); +} + +test "base url validation requires http or https" { + try std.testing.expect(isSafeBaseUrl("")); + try std.testing.expect(isSafeBaseUrl("https://example.com")); + try std.testing.expect(isSafeBaseUrl("http://example.com")); + try std.testing.expect(!isSafeBaseUrl("//example.com")); + try std.testing.expect(!isSafeBaseUrl("javascript:alert(1)")); + try std.testing.expect(!isSafeBaseUrl("https://exa mple.com")); +} + +fn generateSitemap(allocator: std.mem.Allocator, output_dir: []const u8, base_url: []const u8, pages: []const Page) !void { + var buf: std.ArrayListUnmanaged(u8) = .empty; + defer buf.deinit(allocator); + + try buf.appendSlice(allocator, "\n"); + try buf.appendSlice(allocator, "\n"); + + for (pages) |page| { + try buf.appendSlice(allocator, " "); + try appendAbsoluteXmlUrl(allocator, &buf, base_url, page.url); + try buf.appendSlice(allocator, "\n"); + } + + try buf.appendSlice(allocator, "\n"); + + const path = try std.fs.path.join(allocator, &.{ output_dir, "sitemap.xml" }); + defer allocator.free(path); + try writeFileAtomic(allocator, path, buf.items); +} + +fn generateRss(allocator: std.mem.Allocator, output_dir: []const u8, config: Config, pages: []const Page) !void { + var buf: std.ArrayListUnmanaged(u8) = .empty; + defer buf.deinit(allocator); + + try buf.appendSlice(allocator, "\n"); + try buf.appendSlice(allocator, "\n\n"); + try buf.appendSlice(allocator, " "); + try appendXmlEscaped(allocator, &buf, config.title); + try buf.appendSlice(allocator, "\n"); + try buf.appendSlice(allocator, " "); + try appendXmlEscaped(allocator, &buf, config.url); + try buf.appendSlice(allocator, "\n"); + try buf.appendSlice(allocator, " "); + try appendXmlEscaped(allocator, &buf, config.description); + try buf.appendSlice(allocator, "\n"); + + for (pages) |page| { + if (page.date.len == 0) continue; + try buf.appendSlice(allocator, " \n"); + try buf.appendSlice(allocator, " "); + try appendXmlEscaped(allocator, &buf, page.title); + try buf.appendSlice(allocator, "\n"); + try buf.appendSlice(allocator, " "); + try appendAbsoluteXmlUrl(allocator, &buf, config.url, page.url); + try buf.appendSlice(allocator, "\n"); + if (page.description.len > 0) { + try buf.appendSlice(allocator, " "); + try appendXmlEscaped(allocator, &buf, page.description); + try buf.appendSlice(allocator, "\n"); + } + try buf.appendSlice(allocator, " \n"); + } + + try buf.appendSlice(allocator, "\n\n"); + + const path = try std.fs.path.join(allocator, &.{ output_dir, "feed.xml" }); + defer allocator.free(path); + try writeFileAtomic(allocator, path, buf.items); +} + +fn appendAbsoluteXmlUrl(allocator: std.mem.Allocator, buf: *std.ArrayListUnmanaged(u8), base_url: []const u8, page_url: []const u8) !void { + const url = try absoluteUrl(allocator, base_url, page_url); + defer allocator.free(url); + try appendXmlEscaped(allocator, buf, url); +} + +fn appendXmlEscaped(allocator: std.mem.Allocator, buf: *std.ArrayListUnmanaged(u8), text: []const u8) !void { + for (text) |c| { + if (!isValidXmlByte(c)) return error.InvalidXmlCharacter; + switch (c) { + '<' => try buf.appendSlice(allocator, "<"), + '>' => try buf.appendSlice(allocator, ">"), + '&' => try buf.appendSlice(allocator, "&"), + '"' => try buf.appendSlice(allocator, """), + else => try buf.append(allocator, c), + } + } +} + +fn isValidXmlByte(c: u8) bool { + return c == 0x09 or c == 0x0a or c == 0x0d or c >= 0x20; +} + +fn serve(allocator: std.mem.Allocator, site_dir: []const u8, port: u16) !void { + const output_dir = ".ypsilanti_serve"; + + var listener: ?std.net.Server = try server.listen(port); + errdefer if (listener) |*srv| srv.deinit(); + + std.fs.cwd().deleteTree(output_dir) catch |err| { + if (err != error.FileNotFound) return err; + }; + try build(allocator, site_dir, output_dir, true); + + const server_thread = try std.Thread.spawn(.{}, server.run, .{ allocator, output_dir, listener.? }); + listener = null; + defer server_thread.join(); + + std.debug.print("\nserving at http://localhost:{d}\n", .{port}); + std.debug.print("watching for changes...\n\n", .{}); + + try watch(allocator, site_dir, output_dir); +} + +fn watch(allocator: std.mem.Allocator, site_dir: []const u8, output_dir: []const u8) !void { + const dirs = [_][]const u8{ "content", "layouts", "partials", "static" }; + var last_mod: i128 = 0; + + while (true) { + std.Thread.sleep(500 * std.time.ns_per_ms); + + var current_mod: i128 = 0; + for (dirs) |sub| { + const path = try std.fs.path.join(allocator, &.{ site_dir, sub }); + defer allocator.free(path); + const mod = getLatestMod(allocator, path, 0) catch 0; + if (mod > current_mod) current_mod = mod; + } + + if (current_mod > last_mod and last_mod != 0) { + std.debug.print("rebuilding...\n", .{}); + std.fs.cwd().deleteTree(output_dir) catch |err| { + if (err != error.FileNotFound) { + std.debug.print("build error: {}\n", .{err}); + last_mod = current_mod; + continue; + } + }; + build(allocator, site_dir, output_dir, true) catch |err| { + std.debug.print("build error: {}\n", .{err}); + }; + server.bump_version(); + } + last_mod = current_mod; + } +} + +fn getLatestMod(allocator: std.mem.Allocator, dir_path: []const u8, depth: usize) !i128 { + if (depth > max_directory_depth) return error.DirectoryDepthExceeded; + + var latest: i128 = 0; + var dir = std.fs.cwd().openDir(dir_path, .{ .iterate = true, .no_follow = true }) catch return 0; + defer dir.close(); + + var iter = dir.iterate(); + while (try iter.next()) |entry| { + const stat = dir.statFile(entry.name) catch continue; + const mtime = stat.mtime; + if (mtime > latest) latest = mtime; + + if (entry.kind == .directory) { + const child_path = try std.fs.path.join(allocator, &.{ dir_path, entry.name }); + defer allocator.free(child_path); + const child_mod = getLatestMod(allocator, child_path, depth + 1) catch 0; + if (child_mod > latest) latest = child_mod; + } + } + return latest; +} + +fn processDirectory( + allocator: std.mem.Allocator, + engine: *const template.Engine, + base_dir: []const u8, + current_dir: []const u8, + output_dir: []const u8, + inject_reload: bool, + config: Config, + pages: *std.ArrayListUnmanaged(Page), + stats: *BuildStats, + depth: usize, +) !void { + if (depth > max_directory_depth) return error.DirectoryDepthExceeded; + + var dir = std.fs.cwd().openDir(current_dir, .{ .iterate = true, .no_follow = true }) catch |err| { + if (err == error.FileNotFound) return; + return err; + }; + defer dir.close(); + + var iter = dir.iterate(); + while (try iter.next()) |entry| { + const full_path = try std.fs.path.join(allocator, &.{ current_dir, entry.name }); + defer allocator.free(full_path); + + if (entry.kind == .directory) { + try processDirectory(allocator, engine, base_dir, full_path, output_dir, inject_reload, config, pages, stats, depth + 1); + } else if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".md")) { + if (stats.pages >= max_pages) return error.PageLimitExceeded; + if (try processMarkdownFile(allocator, engine, base_dir, full_path, output_dir, inject_reload, config, stats)) |page| { + try pages.append(allocator, page); + stats.pages += 1; + } + } + } +} + +fn processMarkdownFile( + allocator: std.mem.Allocator, + engine: *const template.Engine, + base_dir: []const u8, + file_path: []const u8, + output_dir: []const u8, + inject_reload: bool, + config: Config, + stats: *BuildStats, +) !?Page { + const content = try readFile(allocator, file_path); + defer allocator.free(content); + stats.markdown_bytes += content.len; + if (stats.markdown_bytes > max_total_markdown_input_size) return error.MarkdownInputLimitExceeded; + + var fm = try frontmatter.parse(allocator, content); + defer fm.deinit(); + if (isTruthy(fm.get("draft") orelse "")) return null; + + const rendered_markdown = try markdown.render(allocator, fm.content); + defer rendered_markdown.deinit(allocator); + + const relative_path = file_path[base_dir.len..]; + const relative_trimmed = if (relative_path.len > 0 and relative_path[0] == '/') relative_path[1..] else relative_path; + + const output_filename = try outputPathForMarkdown(allocator, relative_trimmed); + defer allocator.free(output_filename); + + const page_url = try urlForMarkdown(allocator, relative_trimmed); + errdefer allocator.free(page_url); + + const output_path = try std.fs.path.join(allocator, &.{ output_dir, output_filename }); + defer allocator.free(output_path); + + try ensureOutputParentSafe(allocator, output_dir, output_filename); + + if (std.fs.path.dirname(output_path)) |dir| { + std.fs.cwd().makePath(dir) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + } + + try ensureOutputParentSafe(allocator, output_dir, output_filename); + + var vars = try buildTemplateVars(allocator, fm.meta, config, page_url, rendered_markdown.toc); + defer vars.deinit(allocator); + + const rendered = if (fm.get("layout")) |layout_name| + try engine.renderWithLayout(layout_name, rendered_markdown.html, vars.map) + else + try engine.render(rendered_markdown.html, vars.map); + defer allocator.free(rendered); + + const final_html = if (inject_reload) try injectLiveReload(allocator, rendered) else rendered; + defer if (inject_reload) allocator.free(final_html); + stats.generated_bytes += final_html.len; + if (stats.generated_bytes > max_total_generated_output_size) return error.GeneratedOutputLimitExceeded; + + try writeFileAtomic(allocator, output_path, final_html); + + if (fm.get("aliases") orelse fm.get("alias")) |aliases| { + try writeAliases(allocator, output_dir, aliases, page_url, stats); + } + + return Page{ + .url = page_url, + .title = try allocator.dupe(u8, fm.get("title") orelse ""), + .date = try allocator.dupe(u8, fm.get("date") orelse ""), + .description = try allocator.dupe(u8, fm.get("description") orelse ""), + .tags = try allocator.dupe(u8, fm.get("tags") orelse ""), + .categories = try allocator.dupe(u8, fm.get("categories") orelse fm.get("category") orelse ""), + }; +} + +fn writeAliases(allocator: std.mem.Allocator, output_dir: []const u8, aliases: []const u8, target_url: []const u8, stats: *BuildStats) !void { + var split = std.mem.tokenizeAny(u8, aliases, ","); + while (split.next()) |raw_alias| { + const alias = std.mem.trim(u8, raw_alias, " \t"); + if (alias.len == 0) continue; + if (!isSafeLocalUrl(alias)) return error.InvalidAliasUrl; + + const filename = try outputPathForAlias(allocator, alias); + defer allocator.free(filename); + try ensureOutputParentSafe(allocator, output_dir, filename); + + const output_path = try std.fs.path.join(allocator, &.{ output_dir, filename }); + defer allocator.free(output_path); + if (std.fs.path.dirname(output_path)) |dir| { + std.fs.cwd().makePath(dir) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + } + + var html: std.ArrayListUnmanaged(u8) = .empty; + defer html.deinit(allocator); + try html.appendSlice(allocator, "\n\n\n\n\n\n\nredirecting\n\n\nredirecting\n\n\n"); + + stats.generated_bytes += html.items.len; + if (stats.generated_bytes > max_total_generated_output_size) return error.GeneratedOutputLimitExceeded; + try writeFileAtomic(allocator, output_path, html.items); + } +} + +fn isSafeLocalUrl(url: []const u8) bool { + if (url.len == 0 or url[0] != '/') return false; + if (url.len > 1 and url[1] == '/') return false; + for (url) |c| if (c <= 0x20 or c == 0x7f or c == '\\') return false; + var segments = std.mem.splitScalar(u8, url, '/'); + while (segments.next()) |segment| { + if (std.mem.eql(u8, segment, "..")) return false; + } + return true; +} + +fn outputPathForAlias(allocator: std.mem.Allocator, alias: []const u8) ![]u8 { + const trimmed = std.mem.trim(u8, alias, "/"); + if (trimmed.len == 0) return error.InvalidAliasUrl; + if (std.mem.endsWith(u8, trimmed, ".html")) return allocator.dupe(u8, trimmed); + return std.mem.concat(allocator, u8, &.{ trimmed, "/index.html" }); +} + +fn generatePostIndex(allocator: std.mem.Allocator, engine: *const template.Engine, output_dir: []const u8, inject_reload: bool, config: Config, pages: *std.ArrayListUnmanaged(Page), stats: *BuildStats) !void { + var posts: std.ArrayListUnmanaged(Page) = .empty; + defer posts.deinit(allocator); + for (pages.items) |page| if (page.date.len > 0) try posts.append(allocator, page); + std.sort.insertion(Page, posts.items, {}, pageDateDesc); + + const page_size = config.paginate; + const page_count = paginatedPageCount(posts.items.len, page_size); + var page_number: usize = 1; + while (page_number <= page_count) : (page_number += 1) { + const start = (page_number - 1) * page_size; + const end = @min(start + page_size, posts.items.len); + const page_url = try paginatedUrl(allocator, "/posts/", page_number); + defer allocator.free(page_url); + const page_title = if (page_number == 1) + try allocator.dupe(u8, "posts") + else + try std.fmt.allocPrint(allocator, "posts page {d}", .{page_number}); + defer allocator.free(page_title); + + var html: std.ArrayListUnmanaged(u8) = .empty; + defer html.deinit(allocator); + try html.appendSlice(allocator, "

posts

\n
    \n"); + for (posts.items[start..end]) |post| try appendPostListItem(allocator, &html, post, true); + try html.appendSlice(allocator, "
\n"); + try appendPagination(allocator, &html, "/posts/", page_number, page_count); + + try writeGeneratedPage(allocator, engine, output_dir, inject_reload, config, page_url, page_title, "", html.items, stats); + try pages.append(allocator, try makePage(allocator, page_url, page_title, "", "", "", "")); + } +} + +fn generateTaxonomy(allocator: std.mem.Allocator, engine: *const template.Engine, output_dir: []const u8, inject_reload: bool, config: Config, pages: *std.ArrayListUnmanaged(Page), stats: *BuildStats, comptime field: []const u8, root: []const u8) !void { + var terms: std.ArrayListUnmanaged([]u8) = .empty; + defer { + for (terms.items) |term| allocator.free(term); + terms.deinit(allocator); + } + + for (pages.items) |page| { + const raw = if (std.mem.eql(u8, field, "tags")) page.tags else page.categories; + try collectTerms(allocator, &terms, raw); + } + if (terms.items.len == 0) return; + std.sort.insertion([]u8, terms.items, {}, stringLessThan); + + var index_html: std.ArrayListUnmanaged(u8) = .empty; + defer index_html.deinit(allocator); + try index_html.appendSlice(allocator, "

"); + try appendHtmlEscaped(allocator, &index_html, root); + try index_html.appendSlice(allocator, "

\n
    \n"); + + for (terms.items) |term| { + const slug = try slugify(allocator, term); + defer allocator.free(slug); + try index_html.appendSlice(allocator, "
  • "); + try appendHtmlEscaped(allocator, &index_html, term); + try index_html.appendSlice(allocator, "
  • \n"); + + var term_posts: std.ArrayListUnmanaged(Page) = .empty; + defer term_posts.deinit(allocator); + for (pages.items) |page| { + if (page.date.len == 0) continue; + const raw = if (std.mem.eql(u8, field, "tags")) page.tags else page.categories; + if (!hasTerm(raw, term)) continue; + try term_posts.append(allocator, page); + } + std.sort.insertion(Page, term_posts.items, {}, pageDateDesc); + + const term_base_url = try std.mem.concat(allocator, u8, &.{ "/", root, "/", slug, "/" }); + defer allocator.free(term_base_url); + const page_size = config.paginate; + const page_count = paginatedPageCount(term_posts.items.len, page_size); + var page_number: usize = 1; + while (page_number <= page_count) : (page_number += 1) { + const start = (page_number - 1) * page_size; + const end = @min(start + page_size, term_posts.items.len); + const term_url = try paginatedUrl(allocator, term_base_url, page_number); + defer allocator.free(term_url); + const term_title = if (page_number == 1) + try std.mem.concat(allocator, u8, &.{ root, ": ", term }) + else + try std.fmt.allocPrint(allocator, "{s}: {s} page {d}", .{ root, term, page_number }); + defer allocator.free(term_title); + + var term_html: std.ArrayListUnmanaged(u8) = .empty; + defer term_html.deinit(allocator); + try term_html.appendSlice(allocator, "

    "); + try appendHtmlEscaped(allocator, &term_html, term); + try term_html.appendSlice(allocator, "

    \n
      \n"); + for (term_posts.items[start..end]) |post| try appendPostListItem(allocator, &term_html, post, false); + try term_html.appendSlice(allocator, "
    \n"); + try appendPagination(allocator, &term_html, term_base_url, page_number, page_count); + + try writeGeneratedPage(allocator, engine, output_dir, inject_reload, config, term_url, term_title, "", term_html.items, stats); + try pages.append(allocator, try makePage(allocator, term_url, term_title, "", "", "", "")); + } + } + try index_html.appendSlice(allocator, "
\n"); + + const index_url = try std.mem.concat(allocator, u8, &.{ "/", root, "/" }); + defer allocator.free(index_url); + try writeGeneratedPage(allocator, engine, output_dir, inject_reload, config, index_url, root, "", index_html.items, stats); + try pages.append(allocator, try makePage(allocator, index_url, root, "", "", "", "")); +} + +fn paginatedPageCount(item_count: usize, page_size: usize) usize { + if (item_count == 0) return 1; + return (item_count + page_size - 1) / page_size; +} + +fn paginatedUrl(allocator: std.mem.Allocator, base_url: []const u8, page_number: usize) ![]u8 { + if (page_number == 1) return allocator.dupe(u8, base_url); + return std.fmt.allocPrint(allocator, "{s}page/{d}/", .{ base_url, page_number }); +} + +fn appendPostListItem(allocator: std.mem.Allocator, html: *std.ArrayListUnmanaged(u8), post: Page, include_date: bool) !void { + try html.appendSlice(allocator, "
  • "); + try appendHtmlEscaped(allocator, html, post.title); + try html.appendSlice(allocator, ""); + if (include_date and post.date.len > 0) { + try html.appendSlice(allocator, " "); + try appendHtmlEscaped(allocator, html, post.date); + try html.appendSlice(allocator, ""); + } + try html.appendSlice(allocator, "
  • \n"); +} + +fn appendPagination(allocator: std.mem.Allocator, html: *std.ArrayListUnmanaged(u8), base_url: []const u8, page_number: usize, page_count: usize) !void { + if (page_count <= 1) return; + try html.appendSlice(allocator, "\n"); +} + +fn writeGeneratedPage(allocator: std.mem.Allocator, engine: *const template.Engine, output_dir: []const u8, inject_reload: bool, config: Config, page_url: []const u8, title: []const u8, description: []const u8, html: []const u8, stats: *BuildStats) !void { + var meta = std.StringHashMap([]const u8).init(allocator); + defer meta.deinit(); + try meta.put("title", title); + try meta.put("description", description); + var vars = try buildTemplateVars(allocator, meta, config, page_url, ""); + defer vars.deinit(allocator); + const rendered = try engine.renderWithLayout("base", html, vars.map); + defer allocator.free(rendered); + const final_html = if (inject_reload) try injectLiveReload(allocator, rendered) else rendered; + defer if (inject_reload) allocator.free(final_html); + stats.generated_bytes += final_html.len; + if (stats.generated_bytes > max_total_generated_output_size) return error.GeneratedOutputLimitExceeded; + const filename = try outputPathForGeneratedUrl(allocator, page_url); + defer allocator.free(filename); + const output_path = try std.fs.path.join(allocator, &.{ output_dir, filename }); + defer allocator.free(output_path); + if (std.fs.path.dirname(output_path)) |dir| try std.fs.cwd().makePath(dir); + try writeFileAtomic(allocator, output_path, final_html); +} + +fn outputPathForGeneratedUrl(allocator: std.mem.Allocator, page_url: []const u8) ![]u8 { + const trimmed = std.mem.trim(u8, page_url, "/"); + if (trimmed.len == 0) return allocator.dupe(u8, "index.html"); + return std.mem.concat(allocator, u8, &.{ trimmed, "/index.html" }); +} + +fn validateInternalLinks(allocator: std.mem.Allocator, output_dir: []const u8) !void { + try validateInternalLinksInDir(allocator, output_dir, output_dir, 0); +} + +fn validateInternalLinksInDir(allocator: std.mem.Allocator, output_dir: []const u8, current_dir: []const u8, depth: usize) !void { + if (depth > max_directory_depth) return error.DirectoryDepthExceeded; + + var dir = try std.fs.cwd().openDir(current_dir, .{ .iterate = true, .no_follow = true }); + defer dir.close(); + + var iter = dir.iterate(); + while (try iter.next()) |entry| { + const path = try std.fs.path.join(allocator, &.{ current_dir, entry.name }); + defer allocator.free(path); + + if (entry.kind == .directory) { + try validateInternalLinksInDir(allocator, output_dir, path, depth + 1); + } else if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".html")) { + try validateHtmlFileLinks(allocator, output_dir, path); + } + } +} + +fn validateHtmlFileLinks(allocator: std.mem.Allocator, output_dir: []const u8, html_path: []const u8) !void { + const html = try readFile(allocator, html_path); + defer allocator.free(html); + + var index: usize = 0; + while (findNextUrlAttribute(html, index)) |attr| { + index = attr.end; + const url = std.mem.trim(u8, attr.value, " \t\r\n"); + if (!shouldValidateLocalUrl(url)) continue; + + const resolved = try resolveLocalUrl(allocator, output_dir, html_path, url); + defer resolved.deinit(allocator); + if (!try localOutputTargetExists(allocator, output_dir, resolved.path, resolved.fragment)) { + std.debug.print("broken internal link in {s}: {s}\n", .{ html_path, url }); + return error.BrokenInternalLink; + } + } +} + +const UrlAttribute = struct { + value: []const u8, + end: usize, +}; + +const ResolvedLocalUrl = struct { + path: []u8, + fragment: []u8, + + fn deinit(self: ResolvedLocalUrl, allocator: std.mem.Allocator) void { + allocator.free(self.path); + allocator.free(self.fragment); + } +}; + +fn findNextUrlAttribute(html: []const u8, start: usize) ?UrlAttribute { + var i = start; + while (i < html.len) : (i += 1) { + if (!startsWithUrlAttributeName(html[i..], "href") and !startsWithUrlAttributeName(html[i..], "src")) continue; + if (i > 0 and (std.ascii.isAlphanumeric(html[i - 1]) or html[i - 1] == '-' or html[i - 1] == '_')) continue; + + var j = i + if (std.ascii.eqlIgnoreCase(html[i..@min(i + 4, html.len)], "href")) @as(usize, 4) else @as(usize, 3); + while (j < html.len and std.ascii.isWhitespace(html[j])) : (j += 1) {} + if (j >= html.len or html[j] != '=') continue; + j += 1; + while (j < html.len and std.ascii.isWhitespace(html[j])) : (j += 1) {} + if (j >= html.len or (html[j] != '"' and html[j] != '\'')) continue; + const quote = html[j]; + const value_start = j + 1; + const value_end_offset = std.mem.indexOfScalar(u8, html[value_start..], quote) orelse return null; + const value_end = value_start + value_end_offset; + return .{ .value = html[value_start..value_end], .end = value_end + 1 }; + } + return null; +} + +fn startsWithUrlAttributeName(text: []const u8, comptime name: []const u8) bool { + if (text.len < name.len) return false; + if (!std.ascii.eqlIgnoreCase(text[0..name.len], name)) return false; + if (text.len == name.len) return true; + const next = text[name.len]; + return std.ascii.isWhitespace(next) or next == '='; +} + +fn shouldValidateLocalUrl(url: []const u8) bool { + if (url.len == 0) return false; + if (std.mem.startsWith(u8, url, "//")) return false; + if (url[0] == '#') return true; + const colon_idx = std.mem.indexOfScalar(u8, url, ':') orelse return true; + const first_delim = firstUrlDelimiter(url) orelse url.len; + return colon_idx > first_delim; +} + +fn resolveLocalUrl(allocator: std.mem.Allocator, output_dir: []const u8, html_path: []const u8, url: []const u8) !ResolvedLocalUrl { + const query_idx = std.mem.indexOfAny(u8, url, "?#") orelse url.len; + const fragment_idx = std.mem.indexOfScalar(u8, url, '#'); + const path_part = url[0..query_idx]; + const fragment_part = if (fragment_idx) |idx| blk: { + const end = std.mem.indexOfScalar(u8, url[idx + 1 ..], '?') orelse (url.len - idx - 1); + break :blk url[idx + 1 .. idx + 1 + end]; + } else ""; + + const resolved_path = if (path_part.len == 0) blk: { + break :blk try currentPageUrlPath(allocator, output_dir, html_path); + } else if (path_part[0] == '/') blk: { + break :blk try allocator.dupe(u8, path_part); + } else blk: { + const base = try currentPageUrlDir(allocator, output_dir, html_path); + defer allocator.free(base); + const joined = try std.fs.path.join(allocator, &.{ base, path_part }); + defer allocator.free(joined); + break :blk try normalizeUrlPath(allocator, joined); + }; + errdefer allocator.free(resolved_path); + + return .{ .path = resolved_path, .fragment = try allocator.dupe(u8, fragment_part) }; +} + +fn currentPageUrlPath(allocator: std.mem.Allocator, output_dir: []const u8, html_path: []const u8) ![]u8 { + const relative = relativeOutputPath(output_dir, html_path); + if (std.mem.eql(u8, relative, "index.html")) return allocator.dupe(u8, "/"); + if (std.mem.endsWith(u8, relative, "/index.html")) return std.mem.concat(allocator, u8, &.{ "/", relative[0 .. relative.len - "/index.html".len], "/" }); + return std.mem.concat(allocator, u8, &.{ "/", relative }); +} + +fn currentPageUrlDir(allocator: std.mem.Allocator, output_dir: []const u8, html_path: []const u8) ![]u8 { + const page_url = try currentPageUrlPath(allocator, output_dir, html_path); + defer allocator.free(page_url); + if (std.mem.endsWith(u8, page_url, "/")) return allocator.dupe(u8, page_url); + const slash_idx = std.mem.lastIndexOfScalar(u8, page_url, '/') orelse return allocator.dupe(u8, "/"); + return allocator.dupe(u8, page_url[0 .. slash_idx + 1]); +} + +fn relativeOutputPath(output_dir: []const u8, path: []const u8) []const u8 { + var relative = path[output_dir.len..]; + if (relative.len > 0 and relative[0] == std.fs.path.sep) relative = relative[1..]; + return relative; +} + +fn normalizeUrlPath(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + var parts_out: std.ArrayListUnmanaged([]const u8) = .empty; + defer parts_out.deinit(allocator); + + var parts = std.mem.tokenizeScalar(u8, path, '/'); + while (parts.next()) |part| { + if (std.mem.eql(u8, part, ".")) continue; + if (std.mem.eql(u8, part, "..")) { + if (parts_out.items.len == 0) return error.InvalidInternalLink; + _ = parts_out.pop(); + continue; + } + try parts_out.append(allocator, part); + } + + var out: std.ArrayListUnmanaged(u8) = .empty; + errdefer out.deinit(allocator); + try out.append(allocator, '/'); + for (parts_out.items, 0..) |part, idx| { + if (idx > 0) try out.append(allocator, '/'); + try out.appendSlice(allocator, part); + } + if (std.mem.endsWith(u8, path, "/") and out.items[out.items.len - 1] != '/') try out.append(allocator, '/'); + return out.toOwnedSlice(allocator); +} + +fn localOutputTargetExists(allocator: std.mem.Allocator, output_dir: []const u8, url_path: []const u8, fragment: []const u8) !bool { + if (!isSafeLocalUrl(url_path)) return false; + const output_path = try outputPathForLocalUrl(allocator, output_dir, url_path); + defer allocator.free(output_path); + + const stat = std.fs.cwd().statFile(output_path) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + if (stat.kind != .file) return false; + if (fragment.len == 0) return true; + if (!std.mem.endsWith(u8, output_path, ".html")) return false; + + const html = try readFile(allocator, output_path); + defer allocator.free(html); + return htmlHasAnchor(html, fragment); +} + +fn outputPathForLocalUrl(allocator: std.mem.Allocator, output_dir: []const u8, url_path: []const u8) ![]u8 { + const trimmed = std.mem.trim(u8, url_path, "/"); + if (trimmed.len == 0) return std.fs.path.join(allocator, &.{ output_dir, "index.html" }); + + const direct_path = try std.fs.path.join(allocator, &.{ output_dir, trimmed }); + const direct_stat = std.fs.cwd().statFile(direct_path) catch |err| { + allocator.free(direct_path); + return switch (err) { + error.FileNotFound => std.fs.path.join(allocator, &.{ output_dir, trimmed, "index.html" }), + else => err, + }; + }; + if (direct_stat.kind == .file) return direct_path; + allocator.free(direct_path); + return std.fs.path.join(allocator, &.{ output_dir, trimmed, "index.html" }); +} + +fn htmlHasAnchor(html: []const u8, fragment: []const u8) bool { + return htmlHasAttributeValue(html, "id", fragment) or htmlHasAttributeValue(html, "name", fragment); +} + +fn htmlHasAttributeValue(html: []const u8, comptime name: []const u8, value: []const u8) bool { + var i: usize = 0; + while (i < html.len) : (i += 1) { + if (!startsWithUrlAttributeName(html[i..], name)) continue; + var j = i + name.len; + while (j < html.len and std.ascii.isWhitespace(html[j])) : (j += 1) {} + if (j >= html.len or html[j] != '=') continue; + j += 1; + while (j < html.len and std.ascii.isWhitespace(html[j])) : (j += 1) {} + if (j >= html.len or (html[j] != '"' and html[j] != '\'')) continue; + const quote = html[j]; + const value_start = j + 1; + const value_end_offset = std.mem.indexOfScalar(u8, html[value_start..], quote) orelse return false; + const value_end = value_start + value_end_offset; + if (std.mem.eql(u8, html[value_start..value_end], value)) return true; + i = value_end; + } + return false; +} + +fn makePage(allocator: std.mem.Allocator, url: []const u8, title: []const u8, date: []const u8, description: []const u8, tags: []const u8, categories: []const u8) !Page { + return .{ .url = try allocator.dupe(u8, url), .title = try allocator.dupe(u8, title), .date = try allocator.dupe(u8, date), .description = try allocator.dupe(u8, description), .tags = try allocator.dupe(u8, tags), .categories = try allocator.dupe(u8, categories) }; +} + +fn pageDateDesc(_: void, lhs: Page, rhs: Page) bool { + return std.mem.order(u8, lhs.date, rhs.date) == .gt; +} + +fn stringLessThan(_: void, lhs: []u8, rhs: []u8) bool { + return std.mem.order(u8, lhs, rhs) == .lt; +} + +fn collectTerms(allocator: std.mem.Allocator, terms: *std.ArrayListUnmanaged([]u8), raw: []const u8) !void { + var split = std.mem.tokenizeAny(u8, raw, ","); + while (split.next()) |part| { + const term = std.mem.trim(u8, part, " \t"); + if (term.len == 0 or containsTermSlice(terms.items, term)) continue; + try terms.append(allocator, try allocator.dupe(u8, term)); + } +} + +fn containsTermSlice(terms: [][]u8, term: []const u8) bool { + for (terms) |existing| if (std.ascii.eqlIgnoreCase(existing, term)) return true; + return false; +} + +fn hasTerm(raw: []const u8, term: []const u8) bool { + var split = std.mem.tokenizeAny(u8, raw, ","); + while (split.next()) |part| { + if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, part, " \t"), term)) return true; + } + return false; +} + +fn slugify(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, "x"); + return out.toOwnedSlice(allocator); +} + +fn injectLiveReload(allocator: std.mem.Allocator, html: []const u8) ![]u8 { + const script = + \\ + ; + if (std.mem.indexOf(u8, html, "")) |idx| { + return std.mem.concat(allocator, u8, &.{ html[0..idx], script, html[idx..] }); + } + return std.mem.concat(allocator, u8, &.{ html, script }); +} + +fn buildTemplateVars(allocator: std.mem.Allocator, meta: std.StringHashMap([]const u8), config: Config, page_url: []const u8, toc: []const u8) !TemplateVars { + var vars = std.StringHashMap([]const u8).init(allocator); + errdefer vars.deinit(); + + try vars.put("site_title", config.title); + try vars.put("site_description", config.description); + try vars.put("author", config.author); + try vars.put("base_url", config.url); + try vars.put("theme", config.theme); + try vars.put("nav_html", config.nav_html); + try vars.put("page_url", page_url); + try vars.put("toc", toc); + + var iter = meta.iterator(); + while (iter.next()) |entry| { + try vars.put(entry.key_ptr.*, entry.value_ptr.*); + } + + if (!vars.contains("description")) try vars.put("description", config.description); + + const permalink = try absoluteUrl(allocator, config.url, page_url); + errdefer allocator.free(permalink); + try vars.put("permalink", permalink); + return .{ .map = vars, .permalink = permalink }; +} + +fn isTruthy(value: []const u8) bool { + return std.ascii.eqlIgnoreCase(value, "true") or std.mem.eql(u8, value, "1") or std.ascii.eqlIgnoreCase(value, "yes"); +} + +fn absoluteUrl(allocator: std.mem.Allocator, base_url: []const u8, page_url: []const u8) ![]u8 { + if (base_url.len == 0) return allocator.dupe(u8, page_url); + const base = std.mem.trimRight(u8, base_url, "/"); + if (page_url.len == 0) return allocator.dupe(u8, base); + if (std.mem.startsWith(u8, page_url, "/")) return std.mem.concat(allocator, u8, &.{ base, page_url }); + return std.mem.concat(allocator, u8, &.{ base, "/", page_url }); +} + +fn outputPathForMarkdown(allocator: std.mem.Allocator, filename: []const u8) ![]u8 { + const path = withoutExtension(filename); + if (std.mem.eql(u8, path, "404")) return allocator.dupe(u8, "404.html"); + if (isIndexPath(path)) return std.mem.concat(allocator, u8, &.{ path, ".html" }); + return std.mem.concat(allocator, u8, &.{ path, "/index.html" }); +} + +fn urlForMarkdown(allocator: std.mem.Allocator, filename: []const u8) ![]u8 { + const path = withoutExtension(filename); + if (std.mem.eql(u8, path, "404")) return allocator.dupe(u8, "/404.html"); + if (std.mem.eql(u8, path, "index")) return allocator.dupe(u8, "/"); + if (std.mem.endsWith(u8, path, "/index")) return std.mem.concat(allocator, u8, &.{ "/", path[0 .. path.len - "/index".len], "/" }); + return std.mem.concat(allocator, u8, &.{ "/", path, "/" }); +} + +fn withoutExtension(filename: []const u8) []const u8 { + const dot_idx = std.mem.lastIndexOf(u8, filename, ".") orelse filename.len; + return filename[0..dot_idx]; +} + +fn isIndexPath(path: []const u8) bool { + return std.mem.eql(u8, path, "index") or std.mem.endsWith(u8, path, "/index"); +} + +fn copyStaticFiles(allocator: std.mem.Allocator, src_dir: []const u8, dest_dir: []const u8) !usize { + var stats: StaticCopyStats = .{}; + try copyDir(allocator, src_dir, dest_dir, &stats, 0); + return stats.count; +} + +fn copyDir(allocator: std.mem.Allocator, src_dir: []const u8, dest_dir: []const u8, stats: *StaticCopyStats, depth: usize) !void { + if (depth > max_directory_depth) return error.DirectoryDepthExceeded; + + var dir = try std.fs.cwd().openDir(src_dir, .{ .iterate = true, .no_follow = true }); + defer dir.close(); + + std.fs.cwd().makePath(dest_dir) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + + var iter = dir.iterate(); + while (try iter.next()) |entry| { + const src_path = try std.fs.path.join(allocator, &.{ src_dir, entry.name }); + defer allocator.free(src_path); + const dest_path = try std.fs.path.join(allocator, &.{ dest_dir, entry.name }); + defer allocator.free(dest_path); + + if (entry.kind == .directory) { + try copyDir(allocator, src_path, dest_path, stats, depth + 1); + } else if (entry.kind == .file) { + if (stats.count >= max_static_files) return error.StaticFileLimitExceeded; + try ensureNoSymlinkComponents(allocator, dest_dir); + try ensureStaticDestinationAvailable(dest_path); + const copied = try copyFile(allocator, src_path, dest_path, max_total_static_size - stats.bytes); + stats.count += 1; + stats.bytes += copied; + } + } +} + +fn ensureStaticDestinationAvailable(dest: []const u8) !void { + _ = std.fs.cwd().statFile(dest) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + return error.StaticOutputCollision; +} + +fn ensureStaticDestinationAvailableInDir(dir: std.fs.Dir, name: []const u8) !void { + _ = dir.statFile(name) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + return error.StaticOutputCollision; +} + +fn copyFile(allocator: std.mem.Allocator, src: []const u8, dest: []const u8, max_remaining: u64) !u64 { + const src_file = try openFileNoFollow(allocator, src); + defer src_file.close(); + const src_stat = try src_file.stat(); + if (src_stat.kind != .file) return error.InvalidStaticFile; + + var dest_parent = try openParentDirNoFollow(allocator, dest); + defer dest_parent.deinit(allocator); + const temp_name = try tempName(allocator, dest_parent.name); + defer allocator.free(temp_name); + errdefer dest_parent.dir.deleteFile(temp_name) catch {}; + + const dest_file = try dest_parent.dir.createFile(temp_name, .{ .exclusive = true }); + var dest_file_closed = false; + defer if (!dest_file_closed) dest_file.close(); + + var copied: u64 = 0; + var buf: [8192]u8 = undefined; + while (true) { + const bytes_read = try src_file.read(&buf); + if (bytes_read == 0) break; + copied += bytes_read; + if (copied > max_static_file_size or copied > max_remaining) return error.StaticSizeLimitExceeded; + try dest_file.writeAll(buf[0..bytes_read]); + } + + dest_file.close(); + dest_file_closed = true; + try ensureStaticDestinationAvailableInDir(dest_parent.dir, dest_parent.name); + try dest_parent.dir.rename(temp_name, dest_parent.name); + return copied; +} + +fn writeFileAtomic(allocator: std.mem.Allocator, path: []const u8, content: []const u8) !void { + var parent = try openParentDirNoFollow(allocator, path); + defer parent.deinit(allocator); + const temp_name = try tempName(allocator, parent.name); + defer allocator.free(temp_name); + errdefer parent.dir.deleteFile(temp_name) catch {}; + + const file = try parent.dir.createFile(temp_name, .{ .exclusive = true }); + var file_closed = false; + defer if (!file_closed) file.close(); + try file.writeAll(content); + + file.close(); + file_closed = true; + try parent.dir.rename(temp_name, parent.name); +} + +fn ensureOutputParentSafe(allocator: std.mem.Allocator, output_dir: []const u8, output_filename: []const u8) !void { + const output_path = try std.fs.path.join(allocator, &.{ output_dir, output_filename }); + defer allocator.free(output_path); + + if (std.fs.path.dirname(output_path)) |parent| { + try ensureNoSymlinkComponents(allocator, parent); + } +} + +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.SymlinkInOutputPath; + + 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; +} + +fn tempName(allocator: std.mem.Allocator, name: []const u8) ![]u8 { + var random_bytes: [8]u8 = undefined; + std.crypto.random.bytes(&random_bytes); + const suffix = std.fmt.bytesToHex(random_bytes, .lower); + return std.mem.concat(allocator, u8, &.{ name, ".", &suffix, ".tmp" }); +} + +fn isSameOrChildPath(allocator: std.mem.Allocator, parent_path: []const u8, child_path: []const u8) !bool { + const parent = try std.fs.path.resolve(allocator, &.{parent_path}); + defer allocator.free(parent); + const child = try std.fs.path.resolve(allocator, &.{child_path}); + defer allocator.free(child); + + const normalized_parent = std.mem.trimRight(u8, parent, &.{std.fs.path.sep}); + if (std.mem.eql(u8, normalized_parent, child)) return true; + if (!std.mem.startsWith(u8, child, normalized_parent)) return false; + return child.len > normalized_parent.len and child[normalized_parent.len] == std.fs.path.sep; +} + +fn readFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const file = try openFileNoFollow(allocator, path); + defer file.close(); + return file.readToEndAlloc(allocator, 10 * 1024 * 1024); +} + +fn openFileNoFollow(allocator: std.mem.Allocator, path: []const u8) !std.fs.File { + var parent = try openParentDirNoFollow(allocator, path); + defer parent.deinit(allocator); + return openFileNoFollowAt(parent.dir, parent.name); +} + +fn openFileNoFollowAt(dir: std.fs.Dir, name: []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(dir.fd, name, flags, 0); + return .{ .handle = fd }; +} + +fn openParentDirNoFollow(allocator: std.mem.Allocator, path: []const u8) !OpenParent { + const resolved = try std.fs.path.resolve(allocator, &.{path}); + defer allocator.free(resolved); + const name = std.fs.path.basename(resolved); + if (name.len == 0) return error.InvalidPath; + const parent_path = std.fs.path.dirname(resolved) orelse std.fs.path.sep_str; + var dir = try openDirNoFollowPath(allocator, parent_path); + errdefer dir.close(); + return .{ .dir = dir, .name = try allocator.dupe(u8, name) }; +} + +fn openDirNoFollowPath(allocator: std.mem.Allocator, path: []const u8) !std.fs.Dir { + try ensureNoSymlinkComponents(allocator, path); + return std.fs.cwd().openDir(path, .{ .access_sub_paths = true, .iterate = true, .no_follow = true }); +} diff --git a/src/markdown.zig b/src/markdown.zig new file mode 100644 index 0000000..3efcd02 --- /dev/null +++ b/src/markdown.zig @@ -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, ¶graph_buf, &output, &footnotes); + if (in_list) { + try output.appendSlice(allocator, "\n"); + in_list = false; + } + if (in_blockquote) { + try output.appendSlice(allocator, "\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, ¶graph_buf, &output, &footnotes); + if (in_list) { + try output.appendSlice(allocator, "\n"); + in_list = false; + } + if (in_blockquote) { + try output.appendSlice(allocator, "\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, ¶graph_buf, &output, &footnotes); + if (in_list) { + try output.appendSlice(allocator, "\n"); + in_list = false; + } + if (in_blockquote) { + try output.appendSlice(allocator, "\n"); + in_blockquote = false; + } + line_index = try renderTable(allocator, &output, raw_lines.items, line_index, &footnotes); + continue; + } + + if (line.len == 0) { + try flushParagraph(allocator, ¶graph_buf, &output, &footnotes); + if (in_list) { + try output.appendSlice(allocator, "\n"); + in_list = false; + } + if (in_blockquote) { + try output.appendSlice(allocator, "\n"); + in_blockquote = false; + } + continue; + } + + if (line[0] == '#') { + try flushParagraph(allocator, ¶graph_buf, &output, &footnotes); + if (in_list) { + try output.appendSlice(allocator, "\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, ""); + try appendInline(allocator, &output, content, &footnotes); + try appendFmt(allocator, &output, "\n", .{level}); + continue; + } + } + + if (std.mem.startsWith(u8, line, "- ") or std.mem.startsWith(u8, line, "* ")) { + try flushParagraph(allocator, ¶graph_buf, &output, &footnotes); + if (in_blockquote) { + try output.appendSlice(allocator, "\n"); + in_blockquote = false; + } + if (!in_list) { + try output.appendSlice(allocator, "
      \n"); + in_list = true; + } + try output.appendSlice(allocator, "
    • "); + try appendInline(allocator, &output, line[2..], &footnotes); + try output.appendSlice(allocator, "
    • \n"); + continue; + } + + if (std.mem.startsWith(u8, line, "> ")) { + try flushParagraph(allocator, ¶graph_buf, &output, &footnotes); + if (in_list) { + try output.appendSlice(allocator, "
    \n"); + in_list = false; + } + if (!in_blockquote) { + try output.appendSlice(allocator, "
    "); + 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, ¶graph_buf, &output, &footnotes); + if (in_list) { + try output.appendSlice(allocator, "\n"); + } + if (in_blockquote) { + try output.appendSlice(allocator, "
    \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, "\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, "
    ");
    +    } else {
    +        try output.appendSlice(allocator, "
    ");
    +    }
    +    try appendHighlightedCode(allocator, output, lang, code);
    +    try output.appendSlice(allocator, "
    \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, ""); + try appendEscaped(allocator, output, line[i..]); + try output.appendSlice(allocator, ""); + 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, ""); + try appendEscaped(allocator, output, line[start..i]); + try output.appendSlice(allocator, ""); + 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, ""); + try appendEscaped(allocator, output, line[start..i]); + try output.appendSlice(allocator, ""); + 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, ""); + try appendEscaped(allocator, output, word); + try output.appendSlice(allocator, ""); + } else { + try appendEscaped(allocator, output, word); + } + continue; + } + + switch (line[i]) { + '<' => try output.appendSlice(allocator, "<"), + '>' => try output.appendSlice(allocator, ">"), + '&' => try output.appendSlice(allocator, "&"), + '"' => try output.appendSlice(allocator, """), + 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, "
  • "); + try appendEscaped(allocator, output, title); + try output.appendSlice(allocator, "
  • \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, "

    "); + try appendInline(allocator, output, buf.items, footnotes); + try output.appendSlice(allocator, "

    \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, "<"), + '>' => try output.appendSlice(allocator, ">"), + '&' => try output.appendSlice(allocator, "&"), + '"' => try output.appendSlice(allocator, """), + 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, ""); + try appendEscaped(allocator, output, text[i + 1 .. i + 1 + end]); + try output.appendSlice(allocator, ""); + 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, ""); + try appendInlineDepth(allocator, output, text[i + 2 .. i + 2 + end], footnotes, depth + 1); + try output.appendSlice(allocator, ""); + i += end + 4; + continue; + } + } + + if (text[i] == '*') { + if (findClosing(text[i + 1 ..], '*')) |end| { + if (end > 0 and text[i + 1] != '*') { + try output.appendSlice(allocator, ""); + try appendInlineDepth(allocator, output, text[i + 1 .. i + 1 + end], footnotes, depth + 1); + try output.appendSlice(allocator, ""); + 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, "[ "); + try appendEscaped(allocator, output, id); + try output.appendSlice(allocator, " ]"); + i += end + 3; + continue; + } + } + } + + if (text[i] == '[') { + if (parseLink(text[i..])) |link| { + try output.appendSlice(allocator, ""); + try appendInlineDepth(allocator, output, link.text, footnotes, depth + 1); + try output.appendSlice(allocator, ""); + i += link.total_len; + continue; + } + } + + switch (text[i]) { + '<' => try output.appendSlice(allocator, "<"), + '>' => try output.appendSlice(allocator, ">"), + '&' => try output.appendSlice(allocator, "&"), + 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, "
    \n
      \n"); + var iter = footnotes.iterator(); + while (iter.next()) |entry| { + try output.appendSlice(allocator, "
    1. "); + try appendInline(allocator, output, entry.value_ptr.*, footnotes); + try output.appendSlice(allocator, "
    2. \n"); + } + try output.appendSlice(allocator, "
    \n
    \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, "\n"); + try renderTableCells(allocator, output, lines[start], "th", footnotes); + try output.appendSlice(allocator, "\n\n"); + + var i = start + 2; + while (i < lines.len and isTableRow(lines[i]) and lines[i].len > 0) : (i += 1) { + try output.appendSlice(allocator, ""); + try renderTableCells(allocator, output, lines[i], "td", footnotes); + try output.appendSlice(allocator, "\n"); + } + try output.appendSlice(allocator, "\n
    \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, ""); + } +} + +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, "
    \n\"");\n"); + if (caption.len > 0) { + try output.appendSlice(allocator, "
    "); + try appendEscaped(allocator, output, caption); + try output.appendSlice(allocator, "
    \n"); + } + try output.appendSlice(allocator, "
    \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, "
    \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, "
    \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, "\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, "
    ") != 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, "

    ") != 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, "
    ") != 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, "") != null); + try std.testing.expect(std.mem.indexOf(u8, html, "") != 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); +} diff --git a/src/server.zig b/src/server.zig new file mode 100644 index 0000000..31e3b29 --- /dev/null +++ b/src/server.zig @@ -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)); +} diff --git a/src/template.zig b/src/template.zig new file mode 100644 index 0000000..b95c4f5 --- /dev/null +++ b/src/template.zig @@ -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, "<"), + '>' => try output.appendSlice(allocator, ">"), + '&' => try output.appendSlice(allocator, "&"), + '"' => try output.appendSlice(allocator, """), + '\'' => try output.appendSlice(allocator, "'"), + 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", ""); + try vars.put("content", "

    safe html

    "); + + const engine = Engine.init(std.testing.allocator, "", ""); + const rendered = try engine.render("{{title}}{{{title}}}{{{content}}}", vars); + defer std.testing.allocator.free(rendered); + + try std.testing.expect(std.mem.indexOf(u8, rendered, "
    1