Initial commit
This commit is contained in:
commit
bda33ef628
36 changed files with 4233 additions and 0 deletions
17
.github/workflows/ci.yml
vendored
Normal file
17
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.14.1
|
||||
- run: ./test.sh
|
||||
38
.github/workflows/pages.yml
vendored
Normal file
38
.github/workflows/pages.yml
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
name: pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./ # build with the ypsilanti action in this repo
|
||||
with:
|
||||
source: ./site
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./_site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
zig-out/
|
||||
.zig-cache/
|
||||
output/
|
||||
_site/
|
||||
.ypsilanti_serve/
|
||||
32
CONTRIBUTING.md
Normal file
32
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Contributing to ypsilanti
|
||||
|
||||
Thanks for your interest in ypsilanti.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Zig](https://ziglang.org/download/) 0.14 or newer.
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
zig build # debug build to zig-out/bin/ypsilanti
|
||||
zig build run -- serve ./example # run without installing
|
||||
./test.sh # build (ReleaseFast) and run the test suite
|
||||
```
|
||||
|
||||
## Before opening a pull request
|
||||
|
||||
- Run `./test.sh` and make sure it passes. CI runs the same script.
|
||||
- Add or update tests in `test.sh` when you change behavior.
|
||||
- Keep changes focused and the diff minimal; match the surrounding style.
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Open an issue with a minimal site (`content/`, `layouts/`, config) that
|
||||
reproduces the problem, the command you ran, and what you expected.
|
||||
|
||||
## Security
|
||||
|
||||
ypsilanti escapes template variables, sanitizes URL schemes, and validates
|
||||
internal links at build time. If you find a way around these, please report it
|
||||
privately by email rather than opening a public issue.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Maxfield Luke
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
204
README.md
Normal file
204
README.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# ypsilanti
|
||||
|
||||
minimal static site generator in zig.
|
||||
|
||||
`example/` is a demo site you can build and serve to see how everything fits together.
|
||||
|
||||
## install
|
||||
|
||||
requires [zig](https://ziglang.org/download/) (0.14+).
|
||||
|
||||
```
|
||||
zig build -Doptimize=ReleaseFast
|
||||
cp zig-out/bin/ypsilanti /usr/local/bin/
|
||||
```
|
||||
|
||||
`-Doptimize` options: `Debug` (default), `ReleaseSafe`, `ReleaseFast`, `ReleaseSmall`. use `ReleaseFast` for production, skip the flag for development.
|
||||
|
||||
## usage
|
||||
|
||||
```
|
||||
ypsilanti build ./site ./output # build site
|
||||
ypsilanti serve ./site # dev server on :3000
|
||||
ypsilanti serve ./site 8080 # custom port
|
||||
```
|
||||
|
||||
## site structure
|
||||
|
||||
```
|
||||
site/
|
||||
├── content/ # markdown files → html
|
||||
├── layouts/ # page templates
|
||||
├── partials/ # reusable snippets
|
||||
├── static/ # copied as-is (css, images, fonts)
|
||||
└── config # optional site config
|
||||
```
|
||||
|
||||
## config
|
||||
|
||||
```text
|
||||
title: example.com
|
||||
url: https://example.com
|
||||
author: your name
|
||||
description: site description
|
||||
theme: darkode
|
||||
paginate: 10
|
||||
nav: home=/, posts=/posts/, tags=/tags/, rss=/feed.xml
|
||||
```
|
||||
|
||||
## front matter
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Hello World
|
||||
date: 2025-01-09
|
||||
description: post summary for rss
|
||||
layout: post
|
||||
tags: linux, software
|
||||
categories: technology
|
||||
draft: false
|
||||
aliases: /old-post/, /legacy/post.html
|
||||
---
|
||||
|
||||
your content here
|
||||
```
|
||||
|
||||
- `title` - page title, available as `{{title}}`
|
||||
- `date` - includes page in rss feed
|
||||
- `description` - rss item description
|
||||
- `layout` - which layout to use from `layouts/`
|
||||
- `tags` - comma-separated tags, used for generated tag pages
|
||||
- `categories` - comma-separated categories, used for generated category pages
|
||||
- `draft` - set to `true` to skip the page
|
||||
- `aliases` / `alias` - comma-separated local URLs that emit static redirect pages
|
||||
|
||||
any key except reserved `content`, `nav_html`, and `toc` is available as `{{key}}` in templates.
|
||||
|
||||
## templates
|
||||
|
||||
layouts go in `layouts/`. use `{{content}}` for the rendered markdown:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>{{title}}</title></head>
|
||||
<body>
|
||||
{{> header}}
|
||||
{{{toc}}}
|
||||
<main>{{{content}}}</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Template variables are HTML-escaped by default. Variables rendered directly inside `href` and `src` attributes are also checked for safe URL schemes. Triple braces render raw HTML only for built-in `content` and `nav_html` variables.
|
||||
|
||||
partials go in `partials/`. include with `{{> name}}`:
|
||||
|
||||
```html
|
||||
<!-- partials/header.html -->
|
||||
<nav><a href="/">home</a></nav>
|
||||
```
|
||||
|
||||
## markdown
|
||||
|
||||
supported:
|
||||
- `# headers` (h1-h6)
|
||||
- `**bold**` and `*italic*`
|
||||
- `[links](url)` with safe `http`, `https`, `mailto`, anchor, or relative URLs
|
||||
- `` `inline code` ``
|
||||
- fenced code blocks with language class
|
||||
- basic syntax highlighting spans for common fenced code languages
|
||||
- `- unordered lists`
|
||||
- `> blockquotes`
|
||||
- pipe tables
|
||||
- footnotes with `[^id]` and `[^id]: text`
|
||||
- whole-line shortcodes: `figure`, `youtube`, `vimeo`, and `callout`
|
||||
|
||||
Headings get generated `id` attributes and a raw `{{{toc}}}` template variable.
|
||||
|
||||
shortcode examples:
|
||||
|
||||
```markdown
|
||||
{{< figure src="/img/photo.jpg" alt="Photo" caption="A caption" >}}
|
||||
{{< youtube dQw4w9WgXcQ >}}
|
||||
{{< vimeo 123456789 >}}
|
||||
{{< callout type="warning" title="heads up" text="important note" >}}
|
||||
```
|
||||
|
||||
## error pages
|
||||
|
||||
create `content/404.md` for custom 404 pages. works in dev server and most static hosts.
|
||||
|
||||
pages are generated with clean URLs: `content/about.md` becomes `about/index.html` and is served at `/about/`.
|
||||
|
||||
## rss + sitemap
|
||||
|
||||
auto-generated on build:
|
||||
- `sitemap.xml` - all pages
|
||||
- `feed.xml` - pages with `date:` front matter
|
||||
- `posts/` - dated posts sorted newest first, paginated with `paginate:`
|
||||
- `tags/` and `categories/` - taxonomy indexes and paginated term pages
|
||||
|
||||
set base url in `site/config` with `url:`. `site/url` is still supported as a fallback.
|
||||
|
||||
## link checking
|
||||
|
||||
`build` validates internal `href` and `src` links after writing pages and static files. Root-relative links, relative links, and fragment anchors must point to generated output or the build fails.
|
||||
|
||||
## deploy
|
||||
|
||||
### github pages (recommended)
|
||||
|
||||
ypsilanti ships a GitHub Action, so a full deploy is one `uses:` line. Copy
|
||||
[`docs/github-pages.yml`](docs/github-pages.yml) into your site repo as
|
||||
`.github/workflows/pages.yml`, then set Settings → Pages → Source to *GitHub
|
||||
Actions*. The core of it:
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@v4
|
||||
- uses: maxf1eld/ypsilanti@main # pin to a tag or SHA for stability
|
||||
with:
|
||||
source: ./site # your site source dir
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./_site
|
||||
```
|
||||
|
||||
The action builds ypsilanti and your site with no local Zig install needed.
|
||||
|
||||
Internal links are root-relative (`/about/`), so deploy at a **root domain** —
|
||||
a user/org page (`you.github.io`) or a custom domain. A *project* page
|
||||
(`you.github.io/repo/`) serves under a subpath and will break those links.
|
||||
|
||||
### manual / other hosts
|
||||
|
||||
Build locally and push the output anywhere that serves static files (Netlify,
|
||||
Cloudflare Pages, S3, a plain web server):
|
||||
|
||||
```
|
||||
ypsilanti build ./site ./docs
|
||||
git add docs && git commit -m "build" && git push
|
||||
```
|
||||
|
||||
## dev workflow
|
||||
|
||||
```
|
||||
ypsilanti serve ./site
|
||||
```
|
||||
|
||||
opens dev server with live reload. edit any file in content/, layouts/, partials/, or static/ and browser refreshes automatically.
|
||||
|
||||
## tests
|
||||
|
||||
```
|
||||
./test.sh
|
||||
```
|
||||
|
||||
## contributing
|
||||
|
||||
Contributions are welcome. Run `./test.sh` before opening a pull request; CI
|
||||
runs the same suite. See [CONTRIBUTING.md](CONTRIBUTING.md) for more.
|
||||
|
||||
## license
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
36
action.yml
Normal file
36
action.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: ypsilanti
|
||||
description: Build a static site with the ypsilanti static site generator.
|
||||
branding:
|
||||
icon: file-text
|
||||
color: purple
|
||||
|
||||
inputs:
|
||||
source:
|
||||
description: Path to the site source directory (holds content/, layouts/, static/, config).
|
||||
required: false
|
||||
default: ./site
|
||||
output:
|
||||
description: Directory to write the built site to.
|
||||
required: false
|
||||
default: ./_site
|
||||
zig-version:
|
||||
description: Zig version used to build ypsilanti.
|
||||
required: false
|
||||
default: 0.14.1
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: ${{ inputs.zig-version }}
|
||||
|
||||
- name: Build ypsilanti
|
||||
shell: bash
|
||||
working-directory: ${{ github.action_path }}
|
||||
run: zig build -Doptimize=ReleaseFast
|
||||
|
||||
- name: Build site
|
||||
shell: bash
|
||||
run: '"${{ github.action_path }}/zig-out/bin/ypsilanti" build "${{ inputs.source }}" "${{ inputs.output }}"'
|
||||
39
build.zig
Normal file
39
build.zig
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "ypsilanti",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
|
||||
b.installArtifact(exe);
|
||||
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
|
||||
if (b.args) |args| {
|
||||
run_cmd.addArgs(args);
|
||||
}
|
||||
|
||||
const run_step = b.step("run", "Run the SSG");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
|
||||
const unit_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
|
||||
const run_unit_tests = b.addRunArtifact(unit_tests);
|
||||
const test_step = b.step("test", "Run unit tests");
|
||||
test_step.dependOn(&run_unit_tests.step);
|
||||
}
|
||||
48
docs/github-pages.yml
Normal file
48
docs/github-pages.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Deploy a ypsilanti site to GitHub Pages.
|
||||
#
|
||||
# Copy this file into your SITE repository as .github/workflows/pages.yml,
|
||||
# then enable Pages (Settings → Pages → Source: GitHub Actions).
|
||||
#
|
||||
# Assumes your site source lives in ./site. Change `source:` if it differs.
|
||||
#
|
||||
# Note: internal links are root-relative (e.g. /about/), so serve at a root
|
||||
# domain — a user/org page (you.github.io) or a custom domain. A project page
|
||||
# (you.github.io/repo/) serves under a subpath and will break those links.
|
||||
|
||||
name: pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: maxf1eld/ypsilanti@main # pin to a tag or commit SHA for stability
|
||||
with:
|
||||
source: ./site
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./_site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
6
example/config
Normal file
6
example/config
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
title: Example Site
|
||||
url: https://example.com
|
||||
author: Example Author
|
||||
description: A small ypsilanti demo site.
|
||||
theme: default
|
||||
nav: home=/, about=/about/, posts=/posts/, tags=/tags/, rss=/feed.xml
|
||||
8
example/content/404.md
Normal file
8
example/content/404.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
title: Not Found
|
||||
layout: base
|
||||
---
|
||||
|
||||
# 404
|
||||
|
||||
Page not found. [Go home](/).
|
||||
9
example/content/about.md
Normal file
9
example/content/about.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
title: About
|
||||
description: About this example site.
|
||||
layout: base
|
||||
---
|
||||
|
||||
# About
|
||||
|
||||
This page shows a simple content page.
|
||||
13
example/content/index.md
Normal file
13
example/content/index.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
title: Home
|
||||
description: Example home page.
|
||||
layout: base
|
||||
---
|
||||
|
||||
# Example Site
|
||||
|
||||
This is a small demo site for ypsilanti.
|
||||
|
||||
## Posts
|
||||
|
||||
- [First Post](/posts/first-post/)
|
||||
21
example/content/posts/first-post.md
Normal file
21
example/content/posts/first-post.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
title: First Post
|
||||
date: 2026-01-01
|
||||
description: A demo post with tags and markdown features.
|
||||
layout: post
|
||||
tags: demo, linux
|
||||
categories: technology
|
||||
---
|
||||
|
||||
This is a dated post. It appears in the generated post index, feed, tags, and categories.
|
||||
|
||||
## Markdown Features
|
||||
|
||||
| feature | status |
|
||||
| - | - |
|
||||
| tables | yes |
|
||||
| footnotes | yes |
|
||||
|
||||
Here is a footnote.[^1]
|
||||
|
||||
[^1]: This is a footnote.
|
||||
23
example/layouts/base.html
Normal file
23
example/layouts/base.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="{{description}}">
|
||||
<meta name="author" content="{{author}}">
|
||||
<meta property="og:title" content="{{title}} - {{site_title}}">
|
||||
<meta property="og:description" content="{{description}}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{permalink}}">
|
||||
<title>{{title}} - {{site_title}}</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
{{> nav}}
|
||||
<main>{{{content}}}</main>
|
||||
{{> footer}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
27
example/layouts/post.html
Normal file
27
example/layouts/post.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="{{description}}">
|
||||
<meta name="author" content="{{author}}">
|
||||
<meta property="og:title" content="{{title}} - {{site_title}}">
|
||||
<meta property="og:description" content="{{description}}">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="{{permalink}}">
|
||||
<title>{{title}} - {{site_title}}</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
{{> nav}}
|
||||
<main>
|
||||
<h1>{{title}}</h1>
|
||||
<p class="post-date">posted {{date}}</p>
|
||||
{{{content}}}
|
||||
</main>
|
||||
{{> footer}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
3
example/partials/footer.html
Normal file
3
example/partials/footer.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<footer>
|
||||
<p>built with ypsilanti</p>
|
||||
</footer>
|
||||
8
example/partials/nav.html
Normal file
8
example/partials/nav.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<header>
|
||||
<div class="identity">
|
||||
<a class="site-title" href="/">{{site_title}}</a>
|
||||
</div>
|
||||
<nav>
|
||||
{{{nav_html}}}
|
||||
</nav>
|
||||
</header>
|
||||
49
example/static/style.css
Normal file
49
example/static/style.css
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
color: #222;
|
||||
font: 16px/1.55 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0645ad;
|
||||
}
|
||||
|
||||
header,
|
||||
main,
|
||||
footer {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
nav a {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.post-date,
|
||||
.post-list span {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
2
mise.toml
Normal file
2
mise.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[tools]
|
||||
zig = "0.14"
|
||||
6
site/config
Normal file
6
site/config
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
title: maxfield.lol
|
||||
url: https://maxfield.lol
|
||||
author: maxf1eld
|
||||
description: notes from maxfield.lol
|
||||
theme: darkode
|
||||
nav: home=/, about=/about/
|
||||
8
site/content/404.md
Normal file
8
site/content/404.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
title: not found
|
||||
layout: base
|
||||
---
|
||||
|
||||
# 404
|
||||
|
||||
dead link. bad portal. [go home](/).
|
||||
8
site/content/about.md
Normal file
8
site/content/about.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
title: about
|
||||
layout: base
|
||||
---
|
||||
|
||||
# about
|
||||
|
||||
github: [maxf1eld](https://github.com/maxf1eld)
|
||||
6
site/content/index.md
Normal file
6
site/content/index.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: home
|
||||
layout: base
|
||||
---
|
||||
|
||||
# notes
|
||||
24
site/layouts/base.html
Normal file
24
site/layouts/base.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="{{description}}">
|
||||
<meta name="author" content="{{author}}">
|
||||
<meta property="og:title" content="{{title}} - {{site_title}}">
|
||||
<meta property="og:description" content="{{description}}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{permalink}}">
|
||||
<title>{{title}} - {{site_title}}</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body class="theme-{{theme}}">
|
||||
<div class="page">
|
||||
{{> nav}}
|
||||
<main>
|
||||
{{{content}}}
|
||||
</main>
|
||||
{{> footer}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
27
site/layouts/post.html
Normal file
27
site/layouts/post.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="{{description}}">
|
||||
<meta name="author" content="{{author}}">
|
||||
<meta property="og:title" content="{{title}} - {{site_title}}">
|
||||
<meta property="og:description" content="{{description}}">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="{{permalink}}">
|
||||
<title>{{title}} - {{site_title}}</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body class="theme-{{theme}}">
|
||||
<div class="page">
|
||||
{{> nav}}
|
||||
<main>
|
||||
<h1>{{title}}</h1>
|
||||
<p class="post-date">posted {{date}}</p>
|
||||
{{{toc}}}
|
||||
{{{content}}}
|
||||
</main>
|
||||
{{> footer}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
3
site/partials/footer.html
Normal file
3
site/partials/footer.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<footer>
|
||||
<p>maxfield.lol</p>
|
||||
</footer>
|
||||
8
site/partials/nav.html
Normal file
8
site/partials/nav.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<header>
|
||||
<div class="identity">
|
||||
<a class="site-title" href="/">{{site_title}}</a>
|
||||
</div>
|
||||
<nav>
|
||||
{{{nav_html}}}
|
||||
</nav>
|
||||
</header>
|
||||
390
site/static/style.css
Normal file
390
site/static/style.css
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #040303;
|
||||
--surface: #0b0b0c;
|
||||
--panel: #121111;
|
||||
--panel-soft: #1a1715;
|
||||
--ink: #d9d2ca;
|
||||
--muted: #9a8f88;
|
||||
--line: #302a28;
|
||||
--accent: #b5c0d1;
|
||||
--accent-dark: #edf3ff;
|
||||
--accent-light: #fff6ef;
|
||||
--red: #9c1715;
|
||||
--red-dark: #dd3a32;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
padding: clamp(0.45rem, 1.4vw, 0.9rem);
|
||||
background: linear-gradient(180deg, #15100f 0, #050404 18rem, #030303 100%);
|
||||
color: var(--ink);
|
||||
font: 14px/1.6 Tahoma, Verdana, Arial, sans-serif;
|
||||
-webkit-font-smoothing: none;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-smooth: never;
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 58rem;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #171313;
|
||||
background: rgba(5, 4, 4, 0.9);
|
||||
box-shadow: 0 0 0 1px #000, 0 18px 60px rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
.identity,
|
||||
nav,
|
||||
main,
|
||||
footer {
|
||||
border: 1px solid var(--line);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 1px 0 rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.identity {
|
||||
padding: clamp(0.9rem, 2.4vw, 1.25rem);
|
||||
border: 0;
|
||||
border-top: 1px solid #2a2422;
|
||||
border-bottom: 4px solid var(--red);
|
||||
background:
|
||||
linear-gradient(180deg, #241d1b 0, #0a0808 68%, #040303 100%);
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
.site-title {
|
||||
display: block;
|
||||
color: #fff8f2;
|
||||
font: 700 clamp(1.9rem, 5vw, 3.45rem)/0.92 'Trebuchet MS', Tahoma, Verdana, Arial, sans-serif;
|
||||
letter-spacing: -0.04em;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 2px 0 #000, 0 0 20px rgba(221, 58, 50, 0.52);
|
||||
}
|
||||
|
||||
.site-title::first-letter {
|
||||
color: var(--red-dark);
|
||||
}
|
||||
|
||||
.tagline {
|
||||
max-width: 34rem;
|
||||
margin-top: 0.35rem;
|
||||
color: #b9aaa2;
|
||||
font: 0.78rem/1.45 Tahoma, Verdana, Arial, sans-serif;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #292321;
|
||||
border-bottom: 1px solid #000;
|
||||
background: #030303;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: block;
|
||||
padding: 0.62rem 0.9rem;
|
||||
border: 0;
|
||||
border-right: 1px solid #000;
|
||||
background: linear-gradient(180deg, #211d1b 0, #0d0b0b 100%);
|
||||
color: #fff7ef;
|
||||
font: 700 0.78rem/1 Tahoma, Verdana, Arial, sans-serif;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
background: linear-gradient(180deg, #7e1b19 0, #260706 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
main {
|
||||
margin: 0.5rem;
|
||||
padding: clamp(1rem, 2vw, 1.35rem);
|
||||
border: 1px solid #332b28;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 246, 239, 0.04) 0, transparent 3rem),
|
||||
var(--panel);
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
color: #fff4ea;
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: -1.01rem -1.35rem 1rem;
|
||||
padding: 0.62rem 0.85rem;
|
||||
border-bottom: 1px solid #050303;
|
||||
background: linear-gradient(180deg, #851917 0, #360807 100%);
|
||||
color: #fff6ef;
|
||||
font-size: clamp(1.25rem, 2.4vw, 1.7rem);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 1.25rem 0 0.65rem;
|
||||
padding: 0.46rem 0.65rem;
|
||||
border: 1px solid #332b28;
|
||||
border-left: 4px solid var(--red);
|
||||
background: linear-gradient(180deg, #211d1b 0, #0d0b0b 100%);
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 1rem 0 0.45rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
display: inline-block;
|
||||
margin: 0.25rem 0 1.25rem;
|
||||
padding: 0.32rem 0.55rem;
|
||||
border: 1px solid #332b28;
|
||||
border-left: 4px solid var(--red);
|
||||
background: linear-gradient(180deg, #211d1b 0, #0d0b0b 100%);
|
||||
color: #b9aaa2;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 46rem;
|
||||
margin-bottom: 0.95rem;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 0 0 1rem 1.75rem;
|
||||
}
|
||||
|
||||
.post-list span {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #332b28;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid #332b28;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: linear-gradient(180deg, #211d1b 0, #0d0b0b 100%);
|
||||
color: #fff6ef;
|
||||
}
|
||||
|
||||
.footnotes {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #332b28;
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-dark);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0.85rem 0;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #332b28;
|
||||
background: #070606;
|
||||
color: #fff7ef;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Fixedsys, 'Terminal', 'Lucida Console', 'Courier New', monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
p code {
|
||||
border: 1px solid #332b28;
|
||||
background: #090808;
|
||||
color: #fff6ef;
|
||||
padding: 0.18em 0.35em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
max-width: 42rem;
|
||||
margin: 0.85rem 0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid #332b28;
|
||||
border-left: 4px solid var(--red);
|
||||
background: var(--panel-soft);
|
||||
color: #d0c5bc;
|
||||
}
|
||||
|
||||
.toc,
|
||||
.pagination,
|
||||
.callout,
|
||||
figure,
|
||||
.embed {
|
||||
max-width: 46rem;
|
||||
margin: 1rem 0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid #332b28;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.toc strong {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
color: #fff6ef;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toc ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toc-level-3 { margin-left: 1rem; }
|
||||
.toc-level-4,
|
||||
.toc-level-5,
|
||||
.toc-level-6 { margin-left: 2rem; }
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
figure img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.embed {
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.embed iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.callout {
|
||||
border-left: 4px solid var(--red);
|
||||
}
|
||||
|
||||
.callout strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #fff6ef;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.callout p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tok-keyword { color: #ff7d74; }
|
||||
.tok-string { color: #f1d08a; }
|
||||
.tok-comment { color: #8e837d; }
|
||||
.tok-number { color: #b5c0d1; }
|
||||
|
||||
footer {
|
||||
align-self: start;
|
||||
margin: 0 0.5rem 0.5rem;
|
||||
padding: 0.55rem;
|
||||
border: 1px solid #332b28;
|
||||
background: #070606;
|
||||
color: #9f928a;
|
||||
font: 0.78rem/1.45 Tahoma, Verdana, Arial, sans-serif;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
background: #211d1b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page,
|
||||
header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.identity,
|
||||
nav,
|
||||
main,
|
||||
footer {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: initial;
|
||||
}
|
||||
|
||||
nav a {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
1
site/url
Normal file
1
site/url
Normal file
|
|
@ -0,0 +1 @@
|
|||
https://maxfield.lol
|
||||
54
src/frontmatter.zig
Normal file
54
src/frontmatter.zig
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub const FrontMatter = struct {
|
||||
meta: std.StringHashMap([]const u8),
|
||||
content: []const u8,
|
||||
|
||||
pub fn deinit(self: *FrontMatter) void {
|
||||
self.meta.deinit();
|
||||
}
|
||||
|
||||
pub fn get(self: *const FrontMatter, key: []const u8) ?[]const u8 {
|
||||
return self.meta.get(key);
|
||||
}
|
||||
};
|
||||
|
||||
pub fn parse(allocator: std.mem.Allocator, input: []const u8) !FrontMatter {
|
||||
var meta = std.StringHashMap([]const u8).init(allocator);
|
||||
errdefer meta.deinit();
|
||||
|
||||
if (!std.mem.startsWith(u8, input, "---")) {
|
||||
return FrontMatter{ .meta = meta, .content = input };
|
||||
}
|
||||
|
||||
const after_open = input[3..];
|
||||
const close_idx = std.mem.indexOf(u8, after_open, "\n---");
|
||||
if (close_idx == null) {
|
||||
return FrontMatter{ .meta = meta, .content = input };
|
||||
}
|
||||
|
||||
const front_matter_block = after_open[0..close_idx.?];
|
||||
const content_start = 3 + close_idx.? + 4;
|
||||
const content = if (content_start < input.len) skipLeadingNewlines(input[content_start..]) else "";
|
||||
|
||||
var lines = std.mem.splitScalar(u8, front_matter_block, '\n');
|
||||
while (lines.next()) |line| {
|
||||
const trimmed = std.mem.trim(u8, line, " \t\r");
|
||||
if (trimmed.len == 0) continue;
|
||||
|
||||
if (std.mem.indexOf(u8, trimmed, ":")) |colon_idx| {
|
||||
const key = std.mem.trim(u8, trimmed[0..colon_idx], " \t");
|
||||
const value = std.mem.trim(u8, trimmed[colon_idx + 1 ..], " \t");
|
||||
if (std.mem.eql(u8, key, "content") or std.mem.eql(u8, key, "nav_html") or std.mem.eql(u8, key, "toc")) return error.ReservedFrontmatterKey;
|
||||
try meta.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return FrontMatter{ .meta = meta, .content = content };
|
||||
}
|
||||
|
||||
fn skipLeadingNewlines(s: []const u8) []const u8 {
|
||||
var i: usize = 0;
|
||||
while (i < s.len and (s[i] == '\n' or s[i] == '\r')) : (i += 1) {}
|
||||
return s[i..];
|
||||
}
|
||||
1389
src/main.zig
Normal file
1389
src/main.zig
Normal file
File diff suppressed because it is too large
Load diff
840
src/markdown.zig
Normal file
840
src/markdown.zig
Normal file
|
|
@ -0,0 +1,840 @@
|
|||
const std = @import("std");
|
||||
|
||||
const max_inline_depth = 64;
|
||||
|
||||
pub const Rendered = struct {
|
||||
html: []u8,
|
||||
toc: []u8,
|
||||
|
||||
pub fn deinit(self: Rendered, allocator: std.mem.Allocator) void {
|
||||
allocator.free(self.html);
|
||||
allocator.free(self.toc);
|
||||
}
|
||||
};
|
||||
|
||||
pub fn toHtml(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
|
||||
const rendered = try render(allocator, input);
|
||||
allocator.free(rendered.toc);
|
||||
return rendered.html;
|
||||
}
|
||||
|
||||
pub fn render(allocator: std.mem.Allocator, input: []const u8) !Rendered {
|
||||
var output: std.ArrayListUnmanaged(u8) = .empty;
|
||||
errdefer output.deinit(allocator);
|
||||
|
||||
var toc_items: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer toc_items.deinit(allocator);
|
||||
|
||||
var raw_lines: std.ArrayListUnmanaged([]const u8) = .empty;
|
||||
defer raw_lines.deinit(allocator);
|
||||
var line_iter = std.mem.splitScalar(u8, input, '\n');
|
||||
while (line_iter.next()) |raw_line| {
|
||||
try raw_lines.append(allocator, std.mem.trimRight(u8, raw_line, "\r"));
|
||||
}
|
||||
|
||||
var footnotes = std.StringHashMap([]const u8).init(allocator);
|
||||
defer footnotes.deinit();
|
||||
|
||||
var in_code_block = false;
|
||||
var code_lang: []const u8 = "";
|
||||
var code_buf: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer code_buf.deinit(allocator);
|
||||
var in_list = false;
|
||||
var in_blockquote = false;
|
||||
var paragraph_buf: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer paragraph_buf.deinit(allocator);
|
||||
|
||||
var line_index: usize = 0;
|
||||
while (line_index < raw_lines.items.len) : (line_index += 1) {
|
||||
const line = raw_lines.items[line_index];
|
||||
|
||||
if (std.mem.startsWith(u8, line, "```")) {
|
||||
if (in_code_block) {
|
||||
try flushCodeBlock(allocator, &output, code_lang, code_buf.items);
|
||||
code_buf.clearRetainingCapacity();
|
||||
code_lang = "";
|
||||
in_code_block = false;
|
||||
} else {
|
||||
try flushParagraph(allocator, ¶graph_buf, &output, &footnotes);
|
||||
if (in_list) {
|
||||
try output.appendSlice(allocator, "</ul>\n");
|
||||
in_list = false;
|
||||
}
|
||||
if (in_blockquote) {
|
||||
try output.appendSlice(allocator, "</blockquote>\n");
|
||||
in_blockquote = false;
|
||||
}
|
||||
const lang = std.mem.trim(u8, line[3..], " \t");
|
||||
code_lang = if (isSafeLanguageClass(lang)) lang else "";
|
||||
in_code_block = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_code_block) {
|
||||
try code_buf.appendSlice(allocator, line);
|
||||
try code_buf.append(allocator, '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isShortcodeLine(line)) {
|
||||
try flushParagraph(allocator, ¶graph_buf, &output, &footnotes);
|
||||
if (in_list) {
|
||||
try output.appendSlice(allocator, "</ul>\n");
|
||||
in_list = false;
|
||||
}
|
||||
if (in_blockquote) {
|
||||
try output.appendSlice(allocator, "</blockquote>\n");
|
||||
in_blockquote = false;
|
||||
}
|
||||
if (try renderShortcodeLine(allocator, &output, line)) continue;
|
||||
}
|
||||
|
||||
if (parseFootnoteDef(line)) |def| {
|
||||
try footnotes.put(def.id, def.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line_index + 1 < raw_lines.items.len and isTableRow(line) and isTableDelimiter(raw_lines.items[line_index + 1])) {
|
||||
try flushParagraph(allocator, ¶graph_buf, &output, &footnotes);
|
||||
if (in_list) {
|
||||
try output.appendSlice(allocator, "</ul>\n");
|
||||
in_list = false;
|
||||
}
|
||||
if (in_blockquote) {
|
||||
try output.appendSlice(allocator, "</blockquote>\n");
|
||||
in_blockquote = false;
|
||||
}
|
||||
line_index = try renderTable(allocator, &output, raw_lines.items, line_index, &footnotes);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.len == 0) {
|
||||
try flushParagraph(allocator, ¶graph_buf, &output, &footnotes);
|
||||
if (in_list) {
|
||||
try output.appendSlice(allocator, "</ul>\n");
|
||||
in_list = false;
|
||||
}
|
||||
if (in_blockquote) {
|
||||
try output.appendSlice(allocator, "</blockquote>\n");
|
||||
in_blockquote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line[0] == '#') {
|
||||
try flushParagraph(allocator, ¶graph_buf, &output, &footnotes);
|
||||
if (in_list) {
|
||||
try output.appendSlice(allocator, "</ul>\n");
|
||||
in_list = false;
|
||||
}
|
||||
const level = countPrefix(line, '#');
|
||||
if (level <= 6 and level < line.len and line[level] == ' ') {
|
||||
const content = std.mem.trim(u8, line[level + 1 ..], " \t");
|
||||
const id = try headingId(allocator, content);
|
||||
defer allocator.free(id);
|
||||
try appendTocItem(allocator, &toc_items, level, id, content);
|
||||
try appendFmt(allocator, &output, "<h{d} id=\"", .{level});
|
||||
try appendEscaped(allocator, &output, id);
|
||||
try output.appendSlice(allocator, "\">");
|
||||
try appendInline(allocator, &output, content, &footnotes);
|
||||
try appendFmt(allocator, &output, "</h{d}>\n", .{level});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (std.mem.startsWith(u8, line, "- ") or std.mem.startsWith(u8, line, "* ")) {
|
||||
try flushParagraph(allocator, ¶graph_buf, &output, &footnotes);
|
||||
if (in_blockquote) {
|
||||
try output.appendSlice(allocator, "</blockquote>\n");
|
||||
in_blockquote = false;
|
||||
}
|
||||
if (!in_list) {
|
||||
try output.appendSlice(allocator, "<ul>\n");
|
||||
in_list = true;
|
||||
}
|
||||
try output.appendSlice(allocator, "<li>");
|
||||
try appendInline(allocator, &output, line[2..], &footnotes);
|
||||
try output.appendSlice(allocator, "</li>\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.startsWith(u8, line, "> ")) {
|
||||
try flushParagraph(allocator, ¶graph_buf, &output, &footnotes);
|
||||
if (in_list) {
|
||||
try output.appendSlice(allocator, "</ul>\n");
|
||||
in_list = false;
|
||||
}
|
||||
if (!in_blockquote) {
|
||||
try output.appendSlice(allocator, "<blockquote>");
|
||||
in_blockquote = true;
|
||||
}
|
||||
try appendInline(allocator, &output, line[2..], &footnotes);
|
||||
try output.append(allocator, '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (paragraph_buf.items.len > 0) {
|
||||
try paragraph_buf.append(allocator, ' ');
|
||||
}
|
||||
try paragraph_buf.appendSlice(allocator, line);
|
||||
}
|
||||
|
||||
if (in_code_block) {
|
||||
try flushCodeBlock(allocator, &output, code_lang, code_buf.items);
|
||||
}
|
||||
try flushParagraph(allocator, ¶graph_buf, &output, &footnotes);
|
||||
if (in_list) {
|
||||
try output.appendSlice(allocator, "</ul>\n");
|
||||
}
|
||||
if (in_blockquote) {
|
||||
try output.appendSlice(allocator, "</blockquote>\n");
|
||||
}
|
||||
try appendFootnotes(allocator, &output, &footnotes);
|
||||
|
||||
const toc = if (toc_items.items.len > 0) blk: {
|
||||
var toc: std.ArrayListUnmanaged(u8) = .empty;
|
||||
errdefer toc.deinit(allocator);
|
||||
try toc.appendSlice(allocator, "<nav class=\"toc\" aria-label=\"Table of contents\">\n<strong>contents</strong>\n<ol>\n");
|
||||
try toc.appendSlice(allocator, toc_items.items);
|
||||
try toc.appendSlice(allocator, "</ol>\n</nav>\n");
|
||||
break :blk try toc.toOwnedSlice(allocator);
|
||||
} else try allocator.dupe(u8, "");
|
||||
|
||||
return .{ .html = try output.toOwnedSlice(allocator), .toc = toc };
|
||||
}
|
||||
|
||||
fn flushCodeBlock(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), lang: []const u8, code: []const u8) !void {
|
||||
if (lang.len > 0) {
|
||||
try output.appendSlice(allocator, "<pre><code class=\"language-");
|
||||
try output.appendSlice(allocator, lang);
|
||||
try output.appendSlice(allocator, "\">");
|
||||
} else {
|
||||
try output.appendSlice(allocator, "<pre><code>");
|
||||
}
|
||||
try appendHighlightedCode(allocator, output, lang, code);
|
||||
try output.appendSlice(allocator, "</code></pre>\n");
|
||||
}
|
||||
|
||||
fn appendHighlightedCode(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), lang: []const u8, code: []const u8) !void {
|
||||
var lines = std.mem.splitScalar(u8, code, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len > 0) try appendHighlightedLine(allocator, output, lang, line);
|
||||
try output.append(allocator, '\n');
|
||||
}
|
||||
}
|
||||
|
||||
fn appendHighlightedLine(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), lang: []const u8, line: []const u8) !void {
|
||||
var i: usize = 0;
|
||||
while (i < line.len) {
|
||||
if (isLineCommentStart(lang, line[i..])) {
|
||||
try output.appendSlice(allocator, "<span class=\"tok-comment\">");
|
||||
try appendEscaped(allocator, output, line[i..]);
|
||||
try output.appendSlice(allocator, "</span>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (line[i] == '"' or line[i] == '\'') {
|
||||
const quote = line[i];
|
||||
const start = i;
|
||||
i += 1;
|
||||
while (i < line.len) : (i += 1) {
|
||||
if (line[i] == '\\' and i + 1 < line.len) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (line[i] == quote) {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
try output.appendSlice(allocator, "<span class=\"tok-string\">");
|
||||
try appendEscaped(allocator, output, line[start..i]);
|
||||
try output.appendSlice(allocator, "</span>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.ascii.isDigit(line[i])) {
|
||||
const start = i;
|
||||
i += 1;
|
||||
while (i < line.len and (std.ascii.isAlphanumeric(line[i]) or line[i] == '_' or line[i] == '.')) : (i += 1) {}
|
||||
try output.appendSlice(allocator, "<span class=\"tok-number\">");
|
||||
try appendEscaped(allocator, output, line[start..i]);
|
||||
try output.appendSlice(allocator, "</span>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.ascii.isAlphabetic(line[i]) or line[i] == '_') {
|
||||
const start = i;
|
||||
i += 1;
|
||||
while (i < line.len and (std.ascii.isAlphanumeric(line[i]) or line[i] == '_')) : (i += 1) {}
|
||||
const word = line[start..i];
|
||||
if (isKeyword(lang, word)) {
|
||||
try output.appendSlice(allocator, "<span class=\"tok-keyword\">");
|
||||
try appendEscaped(allocator, output, word);
|
||||
try output.appendSlice(allocator, "</span>");
|
||||
} else {
|
||||
try appendEscaped(allocator, output, word);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (line[i]) {
|
||||
'<' => try output.appendSlice(allocator, "<"),
|
||||
'>' => 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, "<li class=\"toc-level-");
|
||||
try appendFmt(allocator, output, "{d}", .{level});
|
||||
try output.appendSlice(allocator, "\"><a href=\"#");
|
||||
try appendEscaped(allocator, output, id);
|
||||
try output.appendSlice(allocator, "\">");
|
||||
try appendEscaped(allocator, output, title);
|
||||
try output.appendSlice(allocator, "</a></li>\n");
|
||||
}
|
||||
|
||||
fn flushParagraph(allocator: std.mem.Allocator, buf: *std.ArrayListUnmanaged(u8), output: *std.ArrayListUnmanaged(u8), footnotes: *const std.StringHashMap([]const u8)) !void {
|
||||
if (buf.items.len == 0) return;
|
||||
try output.appendSlice(allocator, "<p>");
|
||||
try appendInline(allocator, output, buf.items, footnotes);
|
||||
try output.appendSlice(allocator, "</p>\n");
|
||||
buf.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
fn countPrefix(line: []const u8, char: u8) usize {
|
||||
var count: usize = 0;
|
||||
for (line) |c| {
|
||||
if (c == char) count += 1 else break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
fn appendEscaped(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), text: []const u8) !void {
|
||||
for (text) |c| {
|
||||
switch (c) {
|
||||
'<' => try output.appendSlice(allocator, "<"),
|
||||
'>' => 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, "<code>");
|
||||
try appendEscaped(allocator, output, text[i + 1 .. i + 1 + end]);
|
||||
try output.appendSlice(allocator, "</code>");
|
||||
i += end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (i + 1 < text.len and text[i] == '*' and text[i + 1] == '*') {
|
||||
if (findClosingDouble(text[i + 2 ..], '*')) |end| {
|
||||
try output.appendSlice(allocator, "<strong>");
|
||||
try appendInlineDepth(allocator, output, text[i + 2 .. i + 2 + end], footnotes, depth + 1);
|
||||
try output.appendSlice(allocator, "</strong>");
|
||||
i += end + 4;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (text[i] == '*') {
|
||||
if (findClosing(text[i + 1 ..], '*')) |end| {
|
||||
if (end > 0 and text[i + 1] != '*') {
|
||||
try output.appendSlice(allocator, "<em>");
|
||||
try appendInlineDepth(allocator, output, text[i + 1 .. i + 1 + end], footnotes, depth + 1);
|
||||
try output.appendSlice(allocator, "</em>");
|
||||
i += end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (i + 2 < text.len and text[i] == '[' and text[i + 1] == '^') {
|
||||
if (findClosing(text[i + 2 ..], ']')) |end| {
|
||||
const id = text[i + 2 .. i + 2 + end];
|
||||
if (footnotes.get(id) != null) {
|
||||
try output.appendSlice(allocator, "<sup id=\"fnref-");
|
||||
try appendEscaped(allocator, output, id);
|
||||
try output.appendSlice(allocator, "\"><a href=\"#fn-");
|
||||
try appendEscaped(allocator, output, id);
|
||||
try output.appendSlice(allocator, "\">[ ");
|
||||
try appendEscaped(allocator, output, id);
|
||||
try output.appendSlice(allocator, " ]</a></sup>");
|
||||
i += end + 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (text[i] == '[') {
|
||||
if (parseLink(text[i..])) |link| {
|
||||
try output.appendSlice(allocator, "<a href=\"");
|
||||
if (isSafeUrl(link.url)) {
|
||||
try appendEscaped(allocator, output, link.url);
|
||||
} else {
|
||||
try output.append(allocator, '#');
|
||||
}
|
||||
try output.appendSlice(allocator, "\">");
|
||||
try appendInlineDepth(allocator, output, link.text, footnotes, depth + 1);
|
||||
try output.appendSlice(allocator, "</a>");
|
||||
i += link.total_len;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
switch (text[i]) {
|
||||
'<' => try output.appendSlice(allocator, "<"),
|
||||
'>' => 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, "<section class=\"footnotes\">\n<ol>\n");
|
||||
var iter = footnotes.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
try output.appendSlice(allocator, "<li id=\"fn-");
|
||||
try appendEscaped(allocator, output, entry.key_ptr.*);
|
||||
try output.appendSlice(allocator, "\">");
|
||||
try appendInline(allocator, output, entry.value_ptr.*, footnotes);
|
||||
try output.appendSlice(allocator, "</li>\n");
|
||||
}
|
||||
try output.appendSlice(allocator, "</ol>\n</section>\n");
|
||||
}
|
||||
|
||||
fn isTableRow(line: []const u8) bool {
|
||||
return std.mem.indexOfScalar(u8, line, '|') != null;
|
||||
}
|
||||
|
||||
fn isTableDelimiter(line: []const u8) bool {
|
||||
var saw_dash = false;
|
||||
for (std.mem.trim(u8, line, " \t|")) |c| {
|
||||
if (c == '-') saw_dash = true else if (c != ':' and c != ' ' and c != '\t' and c != '|') return false;
|
||||
}
|
||||
return saw_dash and isTableRow(line);
|
||||
}
|
||||
|
||||
fn renderTable(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), lines: []const []const u8, start: usize, footnotes: *const std.StringHashMap([]const u8)) !usize {
|
||||
try output.appendSlice(allocator, "<table>\n<thead><tr>");
|
||||
try renderTableCells(allocator, output, lines[start], "th", footnotes);
|
||||
try output.appendSlice(allocator, "</tr></thead>\n<tbody>\n");
|
||||
|
||||
var i = start + 2;
|
||||
while (i < lines.len and isTableRow(lines[i]) and lines[i].len > 0) : (i += 1) {
|
||||
try output.appendSlice(allocator, "<tr>");
|
||||
try renderTableCells(allocator, output, lines[i], "td", footnotes);
|
||||
try output.appendSlice(allocator, "</tr>\n");
|
||||
}
|
||||
try output.appendSlice(allocator, "</tbody>\n</table>\n");
|
||||
return i - 1;
|
||||
}
|
||||
|
||||
fn renderTableCells(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), line: []const u8, comptime tag: []const u8, footnotes: *const std.StringHashMap([]const u8)) !void {
|
||||
const trimmed = std.mem.trim(u8, line, " \t|");
|
||||
var cells = std.mem.splitScalar(u8, trimmed, '|');
|
||||
while (cells.next()) |cell| {
|
||||
try output.appendSlice(allocator, "<" ++ tag ++ ">");
|
||||
try appendInline(allocator, output, std.mem.trim(u8, cell, " \t"), footnotes);
|
||||
try output.appendSlice(allocator, "</" ++ tag ++ ">");
|
||||
}
|
||||
}
|
||||
|
||||
fn renderShortcodeLine(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), raw_line: []const u8) !bool {
|
||||
const line = std.mem.trim(u8, raw_line, " \t");
|
||||
if (!isShortcodeLine(line)) return false;
|
||||
|
||||
const inner = std.mem.trim(u8, line[3 .. line.len - 3], " \t");
|
||||
if (inner.len == 0) return false;
|
||||
const name_end = std.mem.indexOfAny(u8, inner, " \t") orelse inner.len;
|
||||
const name = inner[0..name_end];
|
||||
const args = std.mem.trim(u8, inner[name_end..], " \t");
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(name, "figure")) {
|
||||
try renderFigureShortcode(allocator, output, args);
|
||||
return true;
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(name, "youtube")) {
|
||||
try renderYoutubeShortcode(allocator, output, args);
|
||||
return true;
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(name, "vimeo")) {
|
||||
try renderVimeoShortcode(allocator, output, args);
|
||||
return true;
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(name, "callout")) {
|
||||
try renderCalloutShortcode(allocator, output, args);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
fn isShortcodeLine(raw_line: []const u8) bool {
|
||||
const line = std.mem.trim(u8, raw_line, " \t");
|
||||
return std.mem.startsWith(u8, line, "{{<") and std.mem.endsWith(u8, line, ">}}");
|
||||
}
|
||||
|
||||
fn renderFigureShortcode(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), args: []const u8) !void {
|
||||
const src = findArg(args, "src") orelse firstPositionalArg(args) orelse "";
|
||||
if (!isSafeUrl(src)) return;
|
||||
const alt = findArg(args, "alt") orelse "";
|
||||
const caption = findArg(args, "caption") orelse "";
|
||||
|
||||
try output.appendSlice(allocator, "<figure>\n<img src=\"");
|
||||
try appendEscaped(allocator, output, src);
|
||||
try output.appendSlice(allocator, "\" alt=\"");
|
||||
try appendEscaped(allocator, output, alt);
|
||||
try output.appendSlice(allocator, "\">\n");
|
||||
if (caption.len > 0) {
|
||||
try output.appendSlice(allocator, "<figcaption>");
|
||||
try appendEscaped(allocator, output, caption);
|
||||
try output.appendSlice(allocator, "</figcaption>\n");
|
||||
}
|
||||
try output.appendSlice(allocator, "</figure>\n");
|
||||
}
|
||||
|
||||
fn renderYoutubeShortcode(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), args: []const u8) !void {
|
||||
const id = findArg(args, "id") orelse firstPositionalArg(args) orelse "";
|
||||
if (!isSafeMediaId(id)) return;
|
||||
try output.appendSlice(allocator, "<div class=\"embed\"><iframe src=\"https://www.youtube-nocookie.com/embed/");
|
||||
try appendEscaped(allocator, output, id);
|
||||
try output.appendSlice(allocator, "\" title=\"YouTube video\" loading=\"lazy\" allowfullscreen></iframe></div>\n");
|
||||
}
|
||||
|
||||
fn renderVimeoShortcode(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), args: []const u8) !void {
|
||||
const id = findArg(args, "id") orelse firstPositionalArg(args) orelse "";
|
||||
if (!isSafeMediaId(id)) return;
|
||||
try output.appendSlice(allocator, "<div class=\"embed\"><iframe src=\"https://player.vimeo.com/video/");
|
||||
try appendEscaped(allocator, output, id);
|
||||
try output.appendSlice(allocator, "\" title=\"Vimeo video\" loading=\"lazy\" allowfullscreen></iframe></div>\n");
|
||||
}
|
||||
|
||||
fn renderCalloutShortcode(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), args: []const u8) !void {
|
||||
const kind = findArg(args, "type") orelse "note";
|
||||
const title = findArg(args, "title") orelse kind;
|
||||
const text = findArg(args, "text") orelse firstPositionalArg(args) orelse "";
|
||||
if (!isSafeLanguageClass(kind)) return;
|
||||
|
||||
try output.appendSlice(allocator, "<aside class=\"callout callout-");
|
||||
try appendEscaped(allocator, output, kind);
|
||||
try output.appendSlice(allocator, "\"><strong>");
|
||||
try appendEscaped(allocator, output, title);
|
||||
try output.appendSlice(allocator, "</strong>");
|
||||
if (text.len > 0) {
|
||||
try output.appendSlice(allocator, "<p>");
|
||||
try appendEscaped(allocator, output, text);
|
||||
try output.appendSlice(allocator, "</p>");
|
||||
}
|
||||
try output.appendSlice(allocator, "</aside>\n");
|
||||
}
|
||||
|
||||
fn findArg(args: []const u8, key: []const u8) ?[]const u8 {
|
||||
var index: usize = 0;
|
||||
while (nextArg(args, &index)) |token| {
|
||||
const eq_idx = std.mem.indexOfScalar(u8, token, '=') orelse continue;
|
||||
const token_key = token[0..eq_idx];
|
||||
if (!std.mem.eql(u8, token_key, key)) continue;
|
||||
return stripQuotes(token[eq_idx + 1 ..]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn firstPositionalArg(args: []const u8) ?[]const u8 {
|
||||
var index: usize = 0;
|
||||
while (nextArg(args, &index)) |token| {
|
||||
if (std.mem.indexOfScalar(u8, token, '=') == null) return stripQuotes(token);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn nextArg(args: []const u8, index: *usize) ?[]const u8 {
|
||||
while (index.* < args.len and std.ascii.isWhitespace(args[index.*])) index.* += 1;
|
||||
if (index.* >= args.len) return null;
|
||||
|
||||
const start = index.*;
|
||||
var quote: ?u8 = null;
|
||||
while (index.* < args.len) : (index.* += 1) {
|
||||
const c = args[index.*];
|
||||
if (quote) |q| {
|
||||
if (c == q) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c == '"' or c == '\'') {
|
||||
quote = c;
|
||||
continue;
|
||||
}
|
||||
if (std.ascii.isWhitespace(c)) break;
|
||||
}
|
||||
|
||||
const end = index.*;
|
||||
while (index.* < args.len and std.ascii.isWhitespace(args[index.*])) index.* += 1;
|
||||
return args[start..end];
|
||||
}
|
||||
|
||||
fn stripQuotes(value: []const u8) []const u8 {
|
||||
if (value.len >= 2 and ((value[0] == '"' and value[value.len - 1] == '"') or (value[0] == '\'' and value[value.len - 1] == '\''))) {
|
||||
return value[1 .. value.len - 1];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
fn isSafeMediaId(id: []const u8) bool {
|
||||
if (id.len == 0 or id.len > 128) return false;
|
||||
for (id) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn headingId(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
|
||||
var out: std.ArrayListUnmanaged(u8) = .empty;
|
||||
errdefer out.deinit(allocator);
|
||||
var last_dash = false;
|
||||
for (text) |c| {
|
||||
if (std.ascii.isAlphanumeric(c)) {
|
||||
try out.append(allocator, std.ascii.toLower(c));
|
||||
last_dash = false;
|
||||
} else if (!last_dash and out.items.len > 0) {
|
||||
try out.append(allocator, '-');
|
||||
last_dash = true;
|
||||
}
|
||||
}
|
||||
if (out.items.len > 0 and out.items[out.items.len - 1] == '-') _ = out.pop();
|
||||
if (out.items.len == 0) try out.appendSlice(allocator, "section");
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn parseLink(text: []const u8) ?Link {
|
||||
if (text.len < 4 or text[0] != '[') return null;
|
||||
const text_end = findClosing(text[1..], ']') orelse return null;
|
||||
const after_bracket = 1 + text_end + 1;
|
||||
if (after_bracket >= text.len or text[after_bracket] != '(') return null;
|
||||
const url_end = findClosing(text[after_bracket + 1 ..], ')') orelse return null;
|
||||
return Link{
|
||||
.text = text[1 .. 1 + text_end],
|
||||
.url = text[after_bracket + 1 .. after_bracket + 1 + url_end],
|
||||
.total_len = after_bracket + 1 + url_end + 1,
|
||||
};
|
||||
}
|
||||
|
||||
test "markdown rejects unsafe code language attributes" {
|
||||
const html = try toHtml(std.testing.allocator,
|
||||
\\\`\`\`\" onmouseover=\"alert(1)
|
||||
\\code
|
||||
\\\`\`\`
|
||||
);
|
||||
defer std.testing.allocator.free(html);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "onmouseover") == null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "<pre><code>") != null);
|
||||
}
|
||||
|
||||
test "markdown replaces unsafe link URLs" {
|
||||
const html = try toHtml(std.testing.allocator, "[bad](javascript:alert(1))");
|
||||
defer std.testing.allocator.free(html);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "javascript:") == null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "href=\"#\"") != null);
|
||||
}
|
||||
|
||||
test "markdown rejects protocol-relative link URLs" {
|
||||
const html = try toHtml(std.testing.allocator, "[bad](//attacker.example/path)");
|
||||
defer std.testing.allocator.free(html);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "//attacker.example") == null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "href=\"#\"") != null);
|
||||
}
|
||||
|
||||
test "markdown adds heading ids" {
|
||||
const html = try toHtml(std.testing.allocator, "## Hello World!");
|
||||
defer std.testing.allocator.free(html);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "<h2 id=\"hello-world\">") != null);
|
||||
}
|
||||
|
||||
test "markdown exposes table of contents" {
|
||||
const rendered = try render(std.testing.allocator,
|
||||
\\## First
|
||||
\\### Second
|
||||
);
|
||||
defer rendered.deinit(std.testing.allocator);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, rendered.toc, "class=\"toc\"") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, rendered.toc, "#first") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, rendered.toc, "toc-level-3") != null);
|
||||
}
|
||||
|
||||
test "markdown renders safe shortcodes" {
|
||||
const html = try toHtml(std.testing.allocator,
|
||||
\\{{< figure src="/photo.jpg" alt="Photo" caption="Caption" >}}
|
||||
\\{{< youtube dQw4w9WgXcQ >}}
|
||||
);
|
||||
defer std.testing.allocator.free(html);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "<figure>") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "youtube-nocookie.com/embed/dQw4w9WgXcQ") != null);
|
||||
}
|
||||
|
||||
test "markdown adds syntax highlight spans" {
|
||||
const html = try toHtml(std.testing.allocator,
|
||||
\\```zig
|
||||
\\const x = 1;
|
||||
\\```
|
||||
);
|
||||
defer std.testing.allocator.free(html);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "tok-keyword") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "tok-number") != null);
|
||||
}
|
||||
|
||||
test "markdown renders tables" {
|
||||
const html = try toHtml(std.testing.allocator,
|
||||
\\| a | b |
|
||||
\\| - | - |
|
||||
\\| 1 | 2 |
|
||||
);
|
||||
defer std.testing.allocator.free(html);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "<table>") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "<td>1</td>") != null);
|
||||
}
|
||||
|
||||
test "markdown renders footnotes" {
|
||||
const html = try toHtml(std.testing.allocator,
|
||||
\\note[^1]
|
||||
\\
|
||||
\\[^1]: footnote text
|
||||
);
|
||||
defer std.testing.allocator.free(html);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "class=\"footnotes\"") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, html, "footnote text") != null);
|
||||
}
|
||||
356
src/server.zig
Normal file
356
src/server.zig
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
const std = @import("std");
|
||||
|
||||
var version = std.atomic.Value(u32).init(0);
|
||||
const max_connections = 64;
|
||||
const read_timeout_ms = 2000;
|
||||
const write_timeout_ms = 2000;
|
||||
var active_connections = std.atomic.Value(usize).init(0);
|
||||
|
||||
const RequestPathError = error{
|
||||
InvalidPath,
|
||||
OutOfMemory,
|
||||
};
|
||||
|
||||
const ServedFile = struct {
|
||||
path: []u8,
|
||||
file: std.fs.File,
|
||||
|
||||
fn deinit(self: ServedFile, allocator: std.mem.Allocator) void {
|
||||
self.file.close();
|
||||
allocator.free(self.path);
|
||||
}
|
||||
};
|
||||
|
||||
pub fn bump_version() void {
|
||||
_ = version.fetchAdd(1, .seq_cst);
|
||||
}
|
||||
|
||||
pub fn listen(port: u16) !std.net.Server {
|
||||
const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port);
|
||||
const sockfd = try std.posix.socket(addr.any.family, std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP);
|
||||
var srv = std.net.Server{
|
||||
.listen_address = addr,
|
||||
.stream = .{ .handle = sockfd },
|
||||
};
|
||||
errdefer srv.stream.close();
|
||||
|
||||
try std.posix.setsockopt(sockfd, std.posix.SOL.SOCKET, std.posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
|
||||
var socklen = addr.getOsSockLen();
|
||||
try std.posix.bind(sockfd, &addr.any, socklen);
|
||||
try std.posix.listen(sockfd, 128);
|
||||
try std.posix.getsockname(sockfd, &srv.listen_address.any, &socklen);
|
||||
return srv;
|
||||
}
|
||||
|
||||
pub fn run(allocator: std.mem.Allocator, root_dir: []const u8, listener: std.net.Server) void {
|
||||
var srv = listener;
|
||||
defer srv.deinit();
|
||||
|
||||
const root_dir_copy = allocator.dupe(u8, root_dir) catch return;
|
||||
|
||||
while (true) {
|
||||
const conn = srv.accept() catch continue;
|
||||
const active = active_connections.fetchAdd(1, .seq_cst);
|
||||
if (active >= max_connections) {
|
||||
_ = active_connections.fetchSub(1, .seq_cst);
|
||||
conn.stream.close();
|
||||
continue;
|
||||
}
|
||||
const thread = std.Thread.spawn(.{}, handleConnection, .{ allocator, conn, root_dir_copy }) catch {
|
||||
_ = active_connections.fetchSub(1, .seq_cst);
|
||||
conn.stream.close();
|
||||
continue;
|
||||
};
|
||||
thread.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn handleConnection(allocator: std.mem.Allocator, conn: std.net.Server.Connection, root_dir: []const u8) void {
|
||||
defer _ = active_connections.fetchSub(1, .seq_cst);
|
||||
defer conn.stream.close();
|
||||
setSocketTimeouts(conn.stream) catch return;
|
||||
handleRequest(allocator, conn, root_dir) catch {};
|
||||
}
|
||||
|
||||
fn handleRequest(allocator: std.mem.Allocator, conn: std.net.Server.Connection, root_dir: []const u8) !void {
|
||||
var buf: [4096]u8 = undefined;
|
||||
const n = try readWithTimeout(conn.stream, &buf, read_timeout_ms);
|
||||
if (n == 0) return;
|
||||
|
||||
const request = buf[0..n];
|
||||
const path = parsePath(request) orelse return;
|
||||
const decoded_path = decodeRequestPath(allocator, path) catch {
|
||||
try sendResponse(conn.stream, "400 Bad Request", "text/plain", "bad request");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(decoded_path);
|
||||
|
||||
if (std.mem.eql(u8, decoded_path, "/_reload")) {
|
||||
var version_buf: [16]u8 = undefined;
|
||||
const version_str = std.fmt.bufPrint(&version_buf, "{d}", .{version.load(.seq_cst)}) catch "0";
|
||||
try sendResponse(conn.stream, "200 OK", "text/plain", version_str);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSafeRequestPath(decoded_path)) {
|
||||
try sendResponse(conn.stream, "403 Forbidden", "text/plain", "forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const served_file = readRequestFile(allocator, root_dir, decoded_path) catch {
|
||||
const error_page = std.fs.path.join(allocator, &.{ root_dir, "404.html" }) catch {
|
||||
try sendResponse(conn.stream, "404 Not Found", "text/plain", "not found");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(error_page);
|
||||
ensureNoSymlinkComponents(allocator, root_dir, "404.html") catch {
|
||||
try sendResponse(conn.stream, "404 Not Found", "text/plain", "not found");
|
||||
return;
|
||||
};
|
||||
const error_content = openRegularFile(error_page) catch {
|
||||
try sendResponse(conn.stream, "404 Not Found", "text/plain", "not found");
|
||||
return;
|
||||
};
|
||||
defer error_content.close();
|
||||
try sendFileResponse(conn.stream, "404 Not Found", "text/html", error_content);
|
||||
return;
|
||||
};
|
||||
defer served_file.deinit(allocator);
|
||||
|
||||
const mime = getMime(served_file.path);
|
||||
try sendFileResponse(conn.stream, "200 OK", mime, served_file.file);
|
||||
}
|
||||
|
||||
fn setSocketTimeouts(stream: std.net.Stream) !void {
|
||||
const read_timeout = std.posix.timeval{
|
||||
.sec = @intCast(read_timeout_ms / 1000),
|
||||
.usec = @intCast((read_timeout_ms % 1000) * 1000),
|
||||
};
|
||||
const write_timeout = std.posix.timeval{
|
||||
.sec = @intCast(write_timeout_ms / 1000),
|
||||
.usec = @intCast((write_timeout_ms % 1000) * 1000),
|
||||
};
|
||||
try std.posix.setsockopt(stream.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, std.mem.asBytes(&read_timeout));
|
||||
try std.posix.setsockopt(stream.handle, std.posix.SOL.SOCKET, std.posix.SO.SNDTIMEO, std.mem.asBytes(&write_timeout));
|
||||
}
|
||||
|
||||
fn readWithTimeout(stream: std.net.Stream, buf: []u8, timeout_ms: u64) !usize {
|
||||
const start = std.time.milliTimestamp();
|
||||
while (true) {
|
||||
const n = stream.read(buf) catch |err| switch (err) {
|
||||
error.WouldBlock => 0,
|
||||
else => return err,
|
||||
};
|
||||
if (n > 0) return n;
|
||||
|
||||
const elapsed_ms = std.time.milliTimestamp() - start;
|
||||
if (elapsed_ms >= timeout_ms) return 0;
|
||||
std.Thread.sleep(10 * std.time.ns_per_ms);
|
||||
}
|
||||
}
|
||||
|
||||
fn readRequestFile(allocator: std.mem.Allocator, root_dir: []const u8, path: []const u8) !ServedFile {
|
||||
const relative_path = if (std.mem.eql(u8, path, "/")) "index.html" else path[1..];
|
||||
if (relative_path.len == 0 or std.fs.path.isAbsolute(relative_path)) return error.InvalidPath;
|
||||
|
||||
const direct_path = try std.fs.path.join(allocator, &.{ root_dir, relative_path });
|
||||
try ensureNoSymlinkComponents(allocator, root_dir, relative_path);
|
||||
const direct_stat = std.fs.cwd().statFile(direct_path) catch {
|
||||
allocator.free(direct_path);
|
||||
const index_path = try std.fs.path.join(allocator, &.{ root_dir, relative_path, "index.html" });
|
||||
const index_relative_path = try std.fs.path.join(allocator, &.{ relative_path, "index.html" });
|
||||
defer allocator.free(index_relative_path);
|
||||
try ensureNoSymlinkComponents(allocator, root_dir, index_relative_path);
|
||||
const index_stat = std.fs.cwd().statFile(index_path) catch |err| {
|
||||
allocator.free(index_path);
|
||||
return err;
|
||||
};
|
||||
if (index_stat.kind != .file) {
|
||||
allocator.free(index_path);
|
||||
return error.InvalidPath;
|
||||
}
|
||||
const file = openRegularFile(index_path) catch |err| {
|
||||
allocator.free(index_path);
|
||||
return err;
|
||||
};
|
||||
return .{ .path = index_path, .file = file };
|
||||
};
|
||||
|
||||
if (direct_stat.kind != .file) {
|
||||
allocator.free(direct_path);
|
||||
const index_path = try std.fs.path.join(allocator, &.{ root_dir, relative_path, "index.html" });
|
||||
const index_relative_path = try std.fs.path.join(allocator, &.{ relative_path, "index.html" });
|
||||
defer allocator.free(index_relative_path);
|
||||
try ensureNoSymlinkComponents(allocator, root_dir, index_relative_path);
|
||||
const index_stat = std.fs.cwd().statFile(index_path) catch |err| {
|
||||
allocator.free(index_path);
|
||||
return err;
|
||||
};
|
||||
if (index_stat.kind != .file) {
|
||||
allocator.free(index_path);
|
||||
return error.InvalidPath;
|
||||
}
|
||||
const file = openRegularFile(index_path) catch |err| {
|
||||
allocator.free(index_path);
|
||||
return err;
|
||||
};
|
||||
return .{ .path = index_path, .file = file };
|
||||
}
|
||||
|
||||
const file = openRegularFile(direct_path) catch |err| {
|
||||
allocator.free(direct_path);
|
||||
return err;
|
||||
};
|
||||
return .{ .path = direct_path, .file = file };
|
||||
}
|
||||
|
||||
fn openRegularFile(path: []const u8) !std.fs.File {
|
||||
const file = try openFileNoFollow(path);
|
||||
errdefer file.close();
|
||||
const stat = try file.stat();
|
||||
if (stat.kind != .file) return error.InvalidPath;
|
||||
return file;
|
||||
}
|
||||
|
||||
fn openFileNoFollow(path: []const u8) !std.fs.File {
|
||||
var flags: std.posix.O = .{ .ACCMODE = .RDONLY };
|
||||
if (@hasField(std.posix.O, "CLOEXEC")) flags.CLOEXEC = true;
|
||||
if (@hasField(std.posix.O, "LARGEFILE")) flags.LARGEFILE = true;
|
||||
if (@hasField(std.posix.O, "NOCTTY")) flags.NOCTTY = true;
|
||||
if (@hasField(std.posix.O, "NOFOLLOW")) flags.NOFOLLOW = true;
|
||||
const fd = try std.posix.openat(std.fs.cwd().fd, path, flags, 0);
|
||||
return .{ .handle = fd };
|
||||
}
|
||||
|
||||
fn ensureNoSymlinkComponents(allocator: std.mem.Allocator, root_dir: []const u8, relative_path: []const u8) !void {
|
||||
var current = try allocator.dupe(u8, root_dir);
|
||||
defer allocator.free(current);
|
||||
|
||||
var parts = std.mem.tokenizeScalar(u8, relative_path, std.fs.path.sep);
|
||||
while (parts.next()) |part| {
|
||||
var dir = try std.fs.cwd().openDir(current, .{ .iterate = true });
|
||||
defer dir.close();
|
||||
|
||||
var iter = dir.iterate();
|
||||
var found = false;
|
||||
while (try iter.next()) |entry| {
|
||||
if (!std.mem.eql(u8, entry.name, part)) continue;
|
||||
if (entry.kind == .sym_link) return error.SymlinkInServedPath;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!found) return error.FileNotFound;
|
||||
if (parts.peek() != null) {
|
||||
const next = try std.fs.path.join(allocator, &.{ current, part });
|
||||
allocator.free(current);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parsePath(request: []const u8) ?[]const u8 {
|
||||
if (!std.mem.startsWith(u8, request, "GET ")) return null;
|
||||
const start = 4;
|
||||
const end = std.mem.indexOf(u8, request[start..], " ") orelse return null;
|
||||
return request[start .. start + end];
|
||||
}
|
||||
|
||||
fn decodeRequestPath(allocator: std.mem.Allocator, request_target: []const u8) RequestPathError![]u8 {
|
||||
if (request_target.len == 0 or request_target[0] != '/') return RequestPathError.InvalidPath;
|
||||
|
||||
const query_idx = std.mem.indexOfAny(u8, request_target, "?#") orelse request_target.len;
|
||||
const raw_path = request_target[0..query_idx];
|
||||
|
||||
var decoded: std.ArrayListUnmanaged(u8) = .empty;
|
||||
errdefer decoded.deinit(allocator);
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < raw_path.len) {
|
||||
if (raw_path[i] == '%') {
|
||||
if (i + 2 >= raw_path.len) return RequestPathError.InvalidPath;
|
||||
const hi = hexValue(raw_path[i + 1]) orelse return RequestPathError.InvalidPath;
|
||||
const lo = hexValue(raw_path[i + 2]) orelse return RequestPathError.InvalidPath;
|
||||
try decoded.append(allocator, (hi << 4) | lo);
|
||||
i += 3;
|
||||
} else {
|
||||
try decoded.append(allocator, raw_path[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return decoded.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn isSafeRequestPath(path: []const u8) bool {
|
||||
if (path.len == 0 or path[0] != '/') return false;
|
||||
|
||||
var segments = std.mem.splitScalar(u8, path, '/');
|
||||
while (segments.next()) |segment| {
|
||||
if (std.mem.eql(u8, segment, "..")) return false;
|
||||
for (segment) |c| {
|
||||
if (c == 0 or c == '\\') return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
fn hexValue(c: u8) ?u8 {
|
||||
return switch (c) {
|
||||
'0'...'9' => c - '0',
|
||||
'a'...'f' => c - 'a' + 10,
|
||||
'A'...'F' => c - 'A' + 10,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn sendResponse(stream: std.net.Stream, status: []const u8, content_type: []const u8, body: []const u8) !void {
|
||||
var header_buf: [512]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ status, content_type, body.len }) catch return;
|
||||
try stream.writeAll(header);
|
||||
try stream.writeAll(body);
|
||||
}
|
||||
|
||||
fn sendFileResponse(stream: std.net.Stream, status: []const u8, content_type: []const u8, file: std.fs.File) !void {
|
||||
const stat = try file.stat();
|
||||
var header_buf: [512]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ status, content_type, stat.size }) catch return;
|
||||
try stream.writeAll(header);
|
||||
|
||||
var buf: [8192]u8 = undefined;
|
||||
while (true) {
|
||||
const n = try file.read(&buf);
|
||||
if (n == 0) break;
|
||||
try stream.writeAll(buf[0..n]);
|
||||
}
|
||||
}
|
||||
|
||||
fn getMime(path: []const u8) []const u8 {
|
||||
if (std.mem.endsWith(u8, path, ".html")) return "text/html";
|
||||
if (std.mem.endsWith(u8, path, ".css")) return "text/css";
|
||||
if (std.mem.endsWith(u8, path, ".js")) return "application/javascript";
|
||||
if (std.mem.endsWith(u8, path, ".json")) return "application/json";
|
||||
if (std.mem.endsWith(u8, path, ".xml")) return "application/xml";
|
||||
if (std.mem.endsWith(u8, path, ".png")) return "image/png";
|
||||
if (std.mem.endsWith(u8, path, ".jpg") or std.mem.endsWith(u8, path, ".jpeg")) return "image/jpeg";
|
||||
if (std.mem.endsWith(u8, path, ".svg")) return "image/svg+xml";
|
||||
if (std.mem.endsWith(u8, path, ".woff2")) return "font/woff2";
|
||||
if (std.mem.endsWith(u8, path, ".woff")) return "font/woff";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
test "request path validation rejects encoded traversal" {
|
||||
const decoded = try decodeRequestPath(std.testing.allocator, "/safe/%2e%2e/secret");
|
||||
defer std.testing.allocator.free(decoded);
|
||||
|
||||
try std.testing.expect(!isSafeRequestPath(decoded));
|
||||
}
|
||||
|
||||
test "request path validation strips query strings" {
|
||||
const decoded = try decodeRequestPath(std.testing.allocator, "/about/?x=1");
|
||||
defer std.testing.allocator.free(decoded);
|
||||
|
||||
try std.testing.expectEqualStrings("/about/", decoded);
|
||||
try std.testing.expect(isSafeRequestPath(decoded));
|
||||
}
|
||||
319
src/template.zig
Normal file
319
src/template.zig
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub const TemplateError = error{
|
||||
PartialNotFound,
|
||||
LayoutNotFound,
|
||||
UnclosedTag,
|
||||
OutOfMemory,
|
||||
InvalidPath,
|
||||
PartialDepthExceeded,
|
||||
};
|
||||
|
||||
const max_partial_depth = 32;
|
||||
|
||||
pub const Engine = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
layouts_dir: []const u8,
|
||||
partials_dir: []const u8,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, layouts_dir: []const u8, partials_dir: []const u8) Engine {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.layouts_dir = layouts_dir,
|
||||
.partials_dir = partials_dir,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn render(self: *const Engine, tpl: []const u8, variables: std.StringHashMap([]const u8)) TemplateError![]u8 {
|
||||
return self.renderDepth(tpl, variables, 0);
|
||||
}
|
||||
|
||||
fn renderDepth(self: *const Engine, tpl: []const u8, variables: std.StringHashMap([]const u8), depth: usize) TemplateError![]u8 {
|
||||
if (depth > max_partial_depth) return TemplateError.PartialDepthExceeded;
|
||||
|
||||
var output: std.ArrayListUnmanaged(u8) = .empty;
|
||||
errdefer output.deinit(self.allocator);
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < tpl.len) {
|
||||
if (i + 2 < tpl.len and tpl[i] == '{' and tpl[i + 1] == '{' and tpl[i + 2] == '{') {
|
||||
const tag_start = i + 3;
|
||||
const close_idx = std.mem.indexOf(u8, tpl[tag_start..], "}}}") orelse
|
||||
return TemplateError.UnclosedTag;
|
||||
const tag_content = std.mem.trim(u8, tpl[tag_start .. tag_start + close_idx], " \t");
|
||||
|
||||
if (variables.get(tag_content)) |value| {
|
||||
if (isRawBuiltin(tag_content)) {
|
||||
output.appendSlice(self.allocator, value) catch return TemplateError.OutOfMemory;
|
||||
} else {
|
||||
appendVariable(self.allocator, &output, value) catch return TemplateError.OutOfMemory;
|
||||
}
|
||||
}
|
||||
i = tag_start + close_idx + 3;
|
||||
} else if (i + 1 < tpl.len and tpl[i] == '{' and tpl[i + 1] == '{') {
|
||||
const tag_start = i + 2;
|
||||
const close_idx = std.mem.indexOf(u8, tpl[tag_start..], "}}") orelse
|
||||
return TemplateError.UnclosedTag;
|
||||
const tag_content = std.mem.trim(u8, tpl[tag_start .. tag_start + close_idx], " \t");
|
||||
|
||||
if (tag_content.len > 0 and tag_content[0] == '>') {
|
||||
const partial_name = std.mem.trim(u8, tag_content[1..], " \t");
|
||||
const partial_content = self.loadPartial(partial_name) catch |err| {
|
||||
if (err == error.FileNotFound) return TemplateError.PartialNotFound;
|
||||
return TemplateError.OutOfMemory;
|
||||
};
|
||||
defer self.allocator.free(partial_content);
|
||||
const rendered = try self.renderDepth(partial_content, variables, depth + 1);
|
||||
defer self.allocator.free(rendered);
|
||||
output.appendSlice(self.allocator, rendered) catch return TemplateError.OutOfMemory;
|
||||
} else {
|
||||
if (variables.get(tag_content)) |value| {
|
||||
if (isRawBuiltin(tag_content)) {
|
||||
output.appendSlice(self.allocator, value) catch return TemplateError.OutOfMemory;
|
||||
} else {
|
||||
appendVariable(self.allocator, &output, value) catch return TemplateError.OutOfMemory;
|
||||
}
|
||||
}
|
||||
}
|
||||
i = tag_start + close_idx + 2;
|
||||
} else {
|
||||
output.append(self.allocator, tpl[i]) catch return TemplateError.OutOfMemory;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return output.toOwnedSlice(self.allocator) catch return TemplateError.OutOfMemory;
|
||||
}
|
||||
|
||||
pub fn renderWithLayout(self: *const Engine, layout_name: []const u8, content: []const u8, variables: std.StringHashMap([]const u8)) TemplateError![]u8 {
|
||||
const layout = self.loadLayout(layout_name) catch |err| {
|
||||
if (err == error.FileNotFound) return TemplateError.LayoutNotFound;
|
||||
return TemplateError.OutOfMemory;
|
||||
};
|
||||
defer self.allocator.free(layout);
|
||||
|
||||
var vars_with_content = std.StringHashMap([]const u8).init(self.allocator);
|
||||
defer vars_with_content.deinit();
|
||||
|
||||
var iter = variables.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
vars_with_content.put(entry.key_ptr.*, entry.value_ptr.*) catch return TemplateError.OutOfMemory;
|
||||
}
|
||||
vars_with_content.put("content", content) catch return TemplateError.OutOfMemory;
|
||||
|
||||
return self.render(layout, vars_with_content);
|
||||
}
|
||||
|
||||
fn loadPartial(self: *const Engine, name: []const u8) ![]u8 {
|
||||
if (std.mem.indexOf(u8, name, "..") != null or std.mem.indexOf(u8, name, "/") != null) {
|
||||
return TemplateError.InvalidPath;
|
||||
}
|
||||
const path = try std.fs.path.join(self.allocator, &.{ self.partials_dir, name });
|
||||
defer self.allocator.free(path);
|
||||
const path_with_ext = try std.mem.concat(self.allocator, u8, &.{ path, ".html" });
|
||||
defer self.allocator.free(path_with_ext);
|
||||
return readFile(self.allocator, path_with_ext) catch |err| {
|
||||
if (err == error.FileNotFound) return readFile(self.allocator, path);
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn loadLayout(self: *const Engine, name: []const u8) ![]u8 {
|
||||
if (std.mem.indexOf(u8, name, "..") != null or std.mem.indexOf(u8, name, "/") != null) {
|
||||
return TemplateError.InvalidPath;
|
||||
}
|
||||
const path = try std.fs.path.join(self.allocator, &.{ self.layouts_dir, name });
|
||||
defer self.allocator.free(path);
|
||||
const path_with_ext = try std.mem.concat(self.allocator, u8, &.{ path, ".html" });
|
||||
defer self.allocator.free(path_with_ext);
|
||||
return readFile(self.allocator, path_with_ext) catch |err| {
|
||||
if (err == error.FileNotFound) return readFile(self.allocator, path);
|
||||
return err;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
fn readFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
|
||||
try ensureNoSymlinkComponents(allocator, path);
|
||||
const file = openFileNoFollow(path) catch |err| return err;
|
||||
defer file.close();
|
||||
const stat = try file.stat();
|
||||
if (stat.kind != .file) return error.InvalidPath;
|
||||
return file.readToEndAlloc(allocator, 1024 * 1024) catch |err| return err;
|
||||
}
|
||||
|
||||
fn openFileNoFollow(path: []const u8) !std.fs.File {
|
||||
var flags: std.posix.O = .{ .ACCMODE = .RDONLY };
|
||||
if (@hasField(std.posix.O, "CLOEXEC")) flags.CLOEXEC = true;
|
||||
if (@hasField(std.posix.O, "LARGEFILE")) flags.LARGEFILE = true;
|
||||
if (@hasField(std.posix.O, "NOCTTY")) flags.NOCTTY = true;
|
||||
if (@hasField(std.posix.O, "NOFOLLOW")) flags.NOFOLLOW = true;
|
||||
const fd = try std.posix.openat(std.fs.cwd().fd, path, flags, 0);
|
||||
return .{ .handle = fd };
|
||||
}
|
||||
|
||||
fn appendHtmlEscaped(allocator: std.mem.Allocator, output: *std.ArrayListUnmanaged(u8), text: []const u8) !void {
|
||||
for (text) |c| {
|
||||
switch (c) {
|
||||
'<' => try output.appendSlice(allocator, "<"),
|
||||
'>' => 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", "<script>alert(1)</script>");
|
||||
try vars.put("content", "<p>safe html</p>");
|
||||
|
||||
const engine = Engine.init(std.testing.allocator, "", "");
|
||||
const rendered = try engine.render("<title>{{title}}</title>{{{title}}}{{{content}}}", vars);
|
||||
defer std.testing.allocator.free(rendered);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, rendered, "<script>") == null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, rendered, "<script>") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, rendered, "<p>safe html</p>") != null);
|
||||
}
|
||||
|
||||
test "template renders built-in content raw with double braces" {
|
||||
var vars = std.StringHashMap([]const u8).init(std.testing.allocator);
|
||||
defer vars.deinit();
|
||||
try vars.put("content", "<h1>notes</h1>");
|
||||
|
||||
const engine = Engine.init(std.testing.allocator, "", "");
|
||||
const rendered = try engine.render("<main>{{content}}</main>", vars);
|
||||
defer std.testing.allocator.free(rendered);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, rendered, "<h1>notes</h1>") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, rendered, "<h1>") == null);
|
||||
}
|
||||
|
||||
test "template sanitizes unsafe url attribute variables" {
|
||||
var vars = std.StringHashMap([]const u8).init(std.testing.allocator);
|
||||
defer vars.deinit();
|
||||
try vars.put("link", "javascript:alert(1)");
|
||||
|
||||
const engine = Engine.init(std.testing.allocator, "", "");
|
||||
const rendered = try engine.render("<a href=\"{{link}}\">x</a>", vars);
|
||||
defer std.testing.allocator.free(rendered);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, rendered, "javascript:") == null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, rendered, "href=\"#\"") != null);
|
||||
}
|
||||
185
test.sh
Executable file
185
test.sh
Executable file
|
|
@ -0,0 +1,185 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "building..."
|
||||
zig build -Doptimize=ReleaseFast
|
||||
|
||||
BIN=./zig-out/bin/ypsilanti
|
||||
TMP=$(mktemp -d)
|
||||
trap "rm -rf $TMP" EXIT
|
||||
|
||||
echo "test: no args shows usage"
|
||||
$BIN 2>&1 | grep -q "commands:" || { echo "FAIL: no args"; exit 1; }
|
||||
|
||||
echo "test: invalid command shows usage"
|
||||
$BIN invalid 2>&1 | grep -q "commands:" || { echo "FAIL: invalid cmd"; exit 1; }
|
||||
|
||||
echo "test: build missing args"
|
||||
$BIN build 2>&1 | grep -q "usage:" || { echo "FAIL: build no args"; exit 1; }
|
||||
|
||||
echo "test: serve missing args"
|
||||
$BIN serve 2>&1 | grep -q "usage:" || { echo "FAIL: serve no args"; exit 1; }
|
||||
|
||||
echo "test: build example site"
|
||||
$BIN build ./example $TMP/out
|
||||
[ -f "$TMP/out/index.html" ] || { echo "FAIL: no index.html"; exit 1; }
|
||||
[ -f "$TMP/out/about/index.html" ] || { echo "FAIL: no about/index.html"; exit 1; }
|
||||
[ -f "$TMP/out/404.html" ] || { echo "FAIL: no 404.html"; exit 1; }
|
||||
[ -f "$TMP/out/posts/index.html" ] || { echo "FAIL: no generated posts index"; exit 1; }
|
||||
[ -f "$TMP/out/tags/linux/index.html" ] || { echo "FAIL: no generated tag page"; exit 1; }
|
||||
[ -f "$TMP/out/categories/technology/index.html" ] || { echo "FAIL: no generated category page"; exit 1; }
|
||||
[ -f "$TMP/out/sitemap.xml" ] || { echo "FAIL: no sitemap"; exit 1; }
|
||||
[ -f "$TMP/out/feed.xml" ] || { echo "FAIL: no feed"; exit 1; }
|
||||
[ -f "$TMP/out/style.css" ] || { echo "FAIL: no static files"; exit 1; }
|
||||
|
||||
echo "test: html contains content"
|
||||
grep -q "Example Site" $TMP/out/index.html || { echo "FAIL: missing content"; exit 1; }
|
||||
grep -q "<nav>" $TMP/out/index.html || { echo "FAIL: missing partial"; exit 1; }
|
||||
grep -q "og:title" "$TMP/out/posts/first-post/index.html" || { echo "FAIL: missing seo metadata"; exit 1; }
|
||||
grep -q 'id="markdown-features"' "$TMP/out/posts/first-post/index.html" || { echo "FAIL: missing heading id"; exit 1; }
|
||||
grep -q 'href="/posts/first-post/"' "$TMP/out/posts/index.html" || { echo "FAIL: post index link is not root-relative"; exit 1; }
|
||||
|
||||
echo "test: sitemap has urls"
|
||||
grep -q "<loc>" $TMP/out/sitemap.xml || { echo "FAIL: sitemap empty"; exit 1; }
|
||||
|
||||
echo "test: rss generated"
|
||||
grep -q "<rss" $TMP/out/feed.xml || { echo "FAIL: rss missing"; exit 1; }
|
||||
|
||||
echo "test: static collision rejected"
|
||||
mkdir -p "$TMP/collision/site/content" "$TMP/collision/site/layouts" "$TMP/collision/site/static"
|
||||
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/collision/site/layouts/base.html"
|
||||
printf '%s\n' '---' 'title: Collision' 'layout: base' '---' '# Collision' > "$TMP/collision/site/content/index.md"
|
||||
printf '%s\n' 'bad sitemap' > "$TMP/collision/site/static/sitemap.xml"
|
||||
if $BIN build "$TMP/collision/site" "$TMP/collision/out" >/dev/null 2>&1; then
|
||||
echo "FAIL: static collision allowed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "test: invalid base url rejected"
|
||||
mkdir -p "$TMP/baseurl/site/content" "$TMP/baseurl/site/layouts"
|
||||
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/baseurl/site/layouts/base.html"
|
||||
printf '%s\n' 'javascript:alert(1)' > "$TMP/baseurl/site/url"
|
||||
printf '%s\n' '---' 'title: Base URL' 'layout: base' '---' '# Base URL' > "$TMP/baseurl/site/content/index.md"
|
||||
if $BIN build "$TMP/baseurl/site" "$TMP/baseurl/out" >/dev/null 2>&1; then
|
||||
echo "FAIL: invalid base url allowed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "test: protocol-relative markdown links rejected"
|
||||
mkdir -p "$TMP/link/site/content" "$TMP/link/site/layouts"
|
||||
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/link/site/layouts/base.html"
|
||||
printf '%s\n' '---' 'title: Link' 'layout: base' '---' '[bad](//attacker.example/path)' > "$TMP/link/site/content/index.md"
|
||||
$BIN build "$TMP/link/site" "$TMP/link/out" >/dev/null
|
||||
grep -q 'href="#"' "$TMP/link/out/index.html" || { echo "FAIL: protocol-relative link not blocked"; exit 1; }
|
||||
|
||||
echo "test: symlinked static rejected"
|
||||
mkdir -p "$TMP/staticlink/site/content" "$TMP/staticlink/site/layouts" "$TMP/staticlink/target"
|
||||
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/staticlink/site/layouts/base.html"
|
||||
printf '%s\n' '---' 'title: Static Link' 'layout: base' '---' '# Static Link' > "$TMP/staticlink/site/content/index.md"
|
||||
ln -s "$TMP/staticlink/target" "$TMP/staticlink/site/static"
|
||||
if $BIN build "$TMP/staticlink/site" "$TMP/staticlink/target/out" >/dev/null 2>&1; then
|
||||
echo "FAIL: symlinked static allowed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "test: draft pages skipped"
|
||||
mkdir -p "$TMP/draft/site/content" "$TMP/draft/site/layouts"
|
||||
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/draft/site/layouts/base.html"
|
||||
printf '%s\n' '---' 'title: Draft' 'layout: base' 'draft: true' '---' '# Draft' > "$TMP/draft/site/content/draft.md"
|
||||
$BIN build "$TMP/draft/site" "$TMP/draft/out" >/dev/null
|
||||
[ ! -e "$TMP/draft/out/draft/index.html" ] || { echo "FAIL: draft was built"; exit 1; }
|
||||
|
||||
echo "test: markdown tables and footnotes"
|
||||
mkdir -p "$TMP/md/site/content" "$TMP/md/site/layouts"
|
||||
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/md/site/layouts/base.html"
|
||||
printf '%s\n' '---' 'title: Markdown' 'layout: base' '---' '| a | b |' '| - | - |' '| 1 | 2 |' '' 'note[^1]' '' '[^1]: footnote text' > "$TMP/md/site/content/index.md"
|
||||
$BIN build "$TMP/md/site" "$TMP/md/out" >/dev/null
|
||||
grep -q '<table>' "$TMP/md/out/index.html" || { echo "FAIL: table missing"; exit 1; }
|
||||
grep -q 'class="footnotes"' "$TMP/md/out/index.html" || { echo "FAIL: footnotes missing"; exit 1; }
|
||||
|
||||
echo "test: toc shortcodes syntax aliases pagination"
|
||||
mkdir -p "$TMP/features/site/content/posts" "$TMP/features/site/layouts"
|
||||
printf '%s\n' '<main>{{{toc}}}{{{content}}}</main>' > "$TMP/features/site/layouts/base.html"
|
||||
printf '%s\n' 'paginate: 1' > "$TMP/features/site/config"
|
||||
printf '%s\n' '---' 'title: One' 'date: 2026-01-02' 'layout: base' 'tags: zig' 'aliases: /old-one/' '---' '## Heading One' '' '{{< callout text="note" >}}' '' '```zig' 'const x = 1;' '```' > "$TMP/features/site/content/posts/one.md"
|
||||
printf '%s\n' '---' 'title: Two' 'date: 2026-01-01' 'layout: base' 'tags: zig' '---' '# Two' > "$TMP/features/site/content/posts/two.md"
|
||||
$BIN build "$TMP/features/site" "$TMP/features/out" >/dev/null
|
||||
[ -f "$TMP/features/out/posts/page/2/index.html" ] || { echo "FAIL: posts page 2 missing"; exit 1; }
|
||||
[ -f "$TMP/features/out/tags/zig/page/2/index.html" ] || { echo "FAIL: tag page 2 missing"; exit 1; }
|
||||
[ -f "$TMP/features/out/old-one/index.html" ] || { echo "FAIL: alias redirect missing"; exit 1; }
|
||||
grep -q 'class="toc"' "$TMP/features/out/posts/one/index.html" || { echo "FAIL: toc missing"; exit 1; }
|
||||
grep -q 'class="callout callout-note"' "$TMP/features/out/posts/one/index.html" || { echo "FAIL: shortcode missing"; exit 1; }
|
||||
grep -q 'tok-keyword' "$TMP/features/out/posts/one/index.html" || { echo "FAIL: syntax highlight missing"; exit 1; }
|
||||
grep -q 'url=/posts/one/' "$TMP/features/out/old-one/index.html" || { echo "FAIL: alias target missing"; exit 1; }
|
||||
|
||||
echo "test: broken internal links rejected"
|
||||
mkdir -p "$TMP/brokenlink/site/content" "$TMP/brokenlink/site/layouts"
|
||||
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/brokenlink/site/layouts/base.html"
|
||||
printf '%s\n' '---' 'title: Broken Link' 'layout: base' '---' '[missing](/missing/)' > "$TMP/brokenlink/site/content/index.md"
|
||||
if $BIN build "$TMP/brokenlink/site" "$TMP/brokenlink/out" >/dev/null 2>&1; then
|
||||
echo "FAIL: broken internal link allowed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "test: broken fragment links rejected"
|
||||
mkdir -p "$TMP/brokenfragment/site/content" "$TMP/brokenfragment/site/layouts"
|
||||
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/brokenfragment/site/layouts/base.html"
|
||||
printf '%s\n' '---' 'title: Broken Fragment' 'layout: base' '---' '# Exists' '' '[missing](#missing)' > "$TMP/brokenfragment/site/content/index.md"
|
||||
if $BIN build "$TMP/brokenfragment/site" "$TMP/brokenfragment/out" >/dev/null 2>&1; then
|
||||
echo "FAIL: broken fragment link allowed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "test: symlinked layout rejected"
|
||||
mkdir -p "$TMP/layoutlink/site/content" "$TMP/layoutlink/site/layouts"
|
||||
printf '%s\n' 'SECRET_LAYOUT_SENTINEL' > "$TMP/layoutlink/secret.html"
|
||||
ln -s "$TMP/layoutlink/secret.html" "$TMP/layoutlink/site/layouts/leak.html"
|
||||
printf '%s\n' '---' 'title: Layout Link' 'layout: leak' '---' '# Layout Link' > "$TMP/layoutlink/site/content/index.md"
|
||||
if $BIN build "$TMP/layoutlink/site" "$TMP/layoutlink/out" >/dev/null 2>&1; then
|
||||
echo "FAIL: symlinked layout allowed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "test: reserved content frontmatter rejected"
|
||||
mkdir -p "$TMP/rawcontent/site/content" "$TMP/rawcontent/site/layouts"
|
||||
printf '%s\n' '---' 'content: <img src=x onerror=alert(1)>' '---' '{{{content}}}' > "$TMP/rawcontent/site/content/index.md"
|
||||
if $BIN build "$TMP/rawcontent/site" "$TMP/rawcontent/out" >/dev/null 2>&1; then
|
||||
echo "FAIL: reserved content frontmatter allowed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "test: template url variables sanitized"
|
||||
mkdir -p "$TMP/templateurl/site/content" "$TMP/templateurl/site/layouts"
|
||||
printf '%s\n' '<a href = "{{link}}">profile</a>{{{content}}}' > "$TMP/templateurl/site/layouts/base.html"
|
||||
printf '%s\n' '---' 'title: Template URL' 'layout: base' 'link: javascript:alert(1)' '---' '# Template URL' > "$TMP/templateurl/site/content/index.md"
|
||||
$BIN build "$TMP/templateurl/site" "$TMP/templateurl/out" >/dev/null
|
||||
grep -q 'href = "#"' "$TMP/templateurl/out/index.html" || { echo "FAIL: template url not sanitized"; exit 1; }
|
||||
|
||||
echo "test: xml control chars rejected"
|
||||
mkdir -p "$TMP/xmlcontrol/site/content" "$TMP/xmlcontrol/site/layouts"
|
||||
printf '%s\n' '<main>{{{content}}}</main>' > "$TMP/xmlcontrol/site/layouts/base.html"
|
||||
printf '%s' $'---\ntitle: Bad\001Title\ndate: 2026-01-01\nlayout: base\n---\n# XML\n' > "$TMP/xmlcontrol/site/content/index.md"
|
||||
if $BIN build "$TMP/xmlcontrol/site" "$TMP/xmlcontrol/out" >/dev/null 2>&1; then
|
||||
echo "FAIL: xml control char allowed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "test: serve starts and responds"
|
||||
cp -a ./example "$TMP/serve-site"
|
||||
$BIN serve "$TMP/serve-site" 3456 &
|
||||
PID=$!
|
||||
sleep 1
|
||||
curl -s http://localhost:3456/ | grep -q "Example Site" || { kill $PID 2>/dev/null; echo "FAIL: serve"; exit 1; }
|
||||
curl -s http://localhost:3456/posts/ | grep -q '<h1>posts</h1>' || { kill $PID 2>/dev/null; echo "FAIL: serve posts index"; exit 1; }
|
||||
curl -s http://localhost:3456/posts/ | grep -q 'href="/posts/first-post/"' || { kill $PID 2>/dev/null; echo "FAIL: served post link is not root-relative"; exit 1; }
|
||||
curl -s http://localhost:3456/posts/first-post/ | grep -q "First Post" || { kill $PID 2>/dev/null; echo "FAIL: serve post page"; exit 1; }
|
||||
curl -s http://localhost:3456/nope | grep -q "404" || { kill $PID 2>/dev/null; echo "FAIL: 404 page"; exit 1; }
|
||||
before=$(curl -s http://localhost:3456/_reload)
|
||||
touch "$TMP/serve-site/content/posts/first-post.md"
|
||||
sleep 1
|
||||
after=$(curl -s http://localhost:3456/_reload)
|
||||
[ "$after" -gt "$before" ] || { kill $PID 2>/dev/null; echo "FAIL: nested live reload"; exit 1; }
|
||||
kill $PID 2>/dev/null
|
||||
|
||||
echo ""
|
||||
echo "all tests passed"
|
||||
Loading…
Add table
Add a link
Reference in a new issue