- Go 75%
- Python 21%
- HTML 2%
- Shell <1%
- CSS <1%
- 1 more language <1%

foldsite
If you have a folder, you have a website. One binary, no config file.

foldsite serve ./data # dynamic: serves straight from the folder
foldsite build ./data --out public # static: renders everything to html
foldsite syndicate ./data --url https://you.example # POSSE: plan what to post elsewhere
Point it at a folder of markdown and images and you get a site with the built-in theme: plain, standard web defaults
(system sans; --theme serif for a serif body). Add a style/ folder when you want to change how it looks.
Try the demo (a 12-year blog, a wiki, photo albums, every feature):
go build -o foldsite . && ./foldsite serve --style demo/style --url http://localhost:8080 demo/data
Features #
- Folders are the site. Markdown becomes pages, image folders become albums, everything else is served raw.
- Two modes, one output.
serverenders live from disk;buildwrites the same html for any static host. - Markdown with batteries. GFM, KaTeX math, syntax highlighting, footnotes,
==marks==, Obsidian-style[[wikilinks]]. - Listings for free. Folder indexes, blog trees, pagination and Atom feeds, all from frontmatter.
- Real thumbnails. Four fixed widths with
srcset, one decode per photo, EXIF stripped, bounded memory. - Theme by folder. Drop a template or
override.cssinto any folder ofstyle/; the deepest one wins. - POSSE. Syndicate to Mastodon, Bluesky or a webhook, at most once, with backlinks rendered on the page.
- Locked down.
os.Rootfile access, hidden_/.names, sandboxed raw html,script-src 'self'. - One static binary. Pure Go, no cgo, KaTeX and the theme embedded.
The two folders #
data/ the site: this is what gets published
index.md
about.md
photos/trip1/*.jpg a folder of images becomes an album
blog/2025/post1.md
style/ how it looks (optional; the built-in theme is the fallback)
base.html base.css page shell: header, footer, layout, colors
document.html content of a single document
documents.html content of a folder listing (mostly docs)
album.html content of a folder listing (mostly images)
blog/override.css overlay on base.css for /blog/**
photos/base.html full replacement of the shell for /photos/**
Routing #
| URL | What you get |
|---|---|
/about/ |
about.md through document.html |
/blog/ |
blog/index.md if present, else an auto listing |
/photos/trip1/ |
no index: mostly images → album.html, otherwise documents.html |
/photos/trip1/a.jpg |
the file, with its real mime type |
/about, /about.md |
redirect to /about/ |
/blog/page/2/ |
page 2 of a paginated listing |
/_fs/... |
bundled css, KaTeX, syntax-highlight css |
Anything foldsite doesn't render (pdf, zip, csv, …) is served as-is, so a folder can double as a file server.
Hidden: a name starting with _ or . doesn't exist. Not in listings, links, routes or the static build.
It returns 404, never 403. A symlink to a hidden name is hidden too. draft: true in frontmatter does the same,
links to it included (--drafts previews them).
Markdown #
GitHub-flavored (tables, task lists, strikethrough, autolinks), plus:
$inline$and$$display$$LaTeX, typeset by KaTeX bundled in the binary. No CDN, and pages without math don't load it.- fenced code with syntax highlighting (light and dark)
==highlighter==→ marks- footnotes, definition lists, heading ids
- wikilinks, below
![]() |
![]() |
KaTeX, --theme serif |
chroma highlighting, dark mode |
Frontmatter is optional YAML:
---
title: Hello # else first "# H1", else the filename
date: 2025-03-01 # undated pages are pages, dated ones are posts
tags: [a, b]
summary: shown in listings and feeds
draft: true
list: tree # list posts under this index (see Listings)
per_page: 10
feed: true # publish /<this folder>/feed.xml
---
A leading # Title that matches the title is dropped, since the template prints the title itself.
Wikilinks #
| written | meaning |
|---|---|
[[page]] |
relative to this doc first; else any file or doc with that name, anywhere (nearest wins), Obsidian-style |
[[page|label]] |
custom label |
[[page#Heading]] |
heading fragment |
[[/abs/path]] |
from the data root |
[[../x]], [[./x]] |
relative to this doc |
![[photo.jpg]] |
embed an image |
Regular links work the same way: [x](../other.md) and  are rewritten to site URLs.
Unresolved wikilinks render as plain text.
Listings #
A folder with no index.md is listed automatically. A folder with one renders it, and the index can
pull a listing under its own text:
list: |
lists |
|---|---|
here |
docs directly in the folder |
tree |
every doc below, newest first (a blog index) |
blog |
docs below another folder (a home page showing latest posts) |
Images in an index folder always render as a grid below the text.
Listings paginate at 20 (per_page: in the index, or --per-page): /blog/, /blog/page/2/, …
It's the same in the server and in the static build.
Themes #
For a page in /photos/nested/x/ foldsite walks photos/nested, photos, then the root, and the
deepest copy of each file wins. The built-in theme is the last fallback, so a style dir can be tiny.
| file | rule |
|---|---|
base.html, base.css |
full replacement |
document.*, documents.*, album.* |
full replacement of that content template and its css |
override.css |
overlay: all of them stack after base.css, root first |
![]() |
![]() |
photos/base.html: a replaced shell |
photos/film-sims/override.css: stacked overlays |
![]() |
![]() |
wiki/documents.html: a custom listing |
blog/override.css: one section recolored |
Content templates are fragments, not full pages. They are injected where base.html says
{{ template "content" . }}, much like Flask's blocks. base.html should include {{ .Head }} inside
<head>, which carries the css, syntax and math assets, and the feed link. If you forget it, foldsite
adds it before </head>.
A replaced
base.cssremoves the default theme's component styles too (grid, pager, listing). To recolor or retype a section, preferoverride.css.
Go html/template, with this data and these helpers:
.Site.Title .Site.Base .Title .URL .Path .Crumbs .Nav
.Doc .Title .HTML .Date .Dated .Meta .TOC .Tags .ReadMinutes .Excerpt (single documents)
.Dirs .Docs .Images .Files []Item {Title URL Path Kind Date Dated Excerpt Size Tags Cover Thumb Srcset Alt Caption}
.Pager .Page .Pages .Prev .Next .Links [{N URL Current}] (N == 0 is a gap)
docs "dir" | dirs "dir" | images "dir" | files "dir" items directly in a folder
tree "dir" docs below a folder, newest first
doc "path" one document (html, meta)
sortby "date|title|name|size" xs | reverse xs | first n xs
toc .Doc.TOC nested contents list
thumb "path" 640 thumbnail url (next width up)
Helpers only read inside the data folder.
Feeds #
Set feed: true in a folder's index.md and pass --url https://example.com. You get
/<folder>/feed.xml (Atom, full content, absolute links) and a <link rel="alternate"> on every page
beneath it. Only dated documents become entries.
Thumbnails #
Albums and covers never ship originals. Every decodable image (jpg, png, webp, bmp) is available resized at
/_fs/thumb/<width>/<path>.jpg, with widths 320, 640, 1280 and 2048 only. A free-form width would let anyone fill the
cache, so anything else is a 404. The path form (not ?w=) is why it works the same in the static build.
- Templates: image items carry
.Thumb(the 320px URL) and.Srcset(every useful size plus the original as the last candidate);{{ thumb "photos/a.jpg" 640 }}gives one URL. An image too small to shrink gets no thumbs and its own URL. - One decode per photo. The first request for any width makes all of them: a box-reduce straight off the YCbCr planes, then each width from the previous one. A 100 MP original (11656×8742) takes about 2 s cold, then every width is a cache read.
- Bounded. Decodes are budgeted by pixels in flight (about 600 MB: two 100 MP photos at once, or many small ones), concurrent requests for the same file share one decode, and anything over 120 MP is refused (decompression bomb) and served as the original. Originals are streamed, so the 32 MB cap that applies to documents doesn't apply to photos. Five cold 100 MP originals requested at once peak at about 1 GB RSS.
- Cache.
--cache DIR(default: the OS cache dir;--cache memoryfor RAM only, capped at 128 MB). Keys include the path, mtime and size, so an edited photo is never served stale. Old entries aren't evicted; delete the dir any time. - Static build writes all widths into
out/_fs/thumb/on a worker pool, skipping widths that wouldn't shrink the image. - Output is re-encoded JPEG (q80, orientation applied), which also strips EXIF, GPS included. Not covered: GIF, SVG,
AVIF, HEIC, RAW and TIFF (no pure-Go decoder, and I'd rather keep one static binary), served as the original.
Images inside markdown (
) are still the originals.
POSSE (publish here, syndicate elsewhere) #
Your site is the original; Mastodon, Bluesky or a webhook get a copy that links back. Nothing syndicates unless you say so.
---
title: Hello
date: 2026-03-01
syndicate: [mastodon, bluesky]
syndicate_text: optional hand-written text # default: title + summary
---
| what | how it opts in |
|---|---|
| a document | syndicate: in its frontmatter |
| one image | a sidecar photo.jpg.md: body is the caption, alt: the alt text |
| an album | syndicate: in the folder's index.md; posts its first N images (syndicate_images:, default 4) |
Sidecars are metadata only. They never render as pages or appear in listings.
Published means: opted in, not draft: true, not dated in the future, not older than --max-age (30 days).
export FOLDSITE_MASTODON_URL=https://mastodon.social FOLDSITE_MASTODON_TOKEN=...
export FOLDSITE_BLUESKY_HANDLE=you.bsky.social FOLDSITE_BLUESKY_APP_PASSWORD=... # an app password
export FOLDSITE_WEBHOOK_URL=https://... FOLDSITE_WEBHOOK_SECRET=... # optional, HMAC-signed JSON
foldsite syndicate ./data --url https://you.example # prints the plan, sends nothing
foldsite syndicate ./data --url https://you.example --post # sends
- Explicit.
serveandbuildnever post. - At most once. A sqlite ledger (
--state, defaultfoldsite.db; refused inside the data dir) claims each (page, target) before sending. A definite rejection releases the claim. A timeout leaves itSTUCKand is never retried automatically, because the post may have gone out. Check the target, then--forget PATH:TARGET. Edits after posting don't re-post. - No dead links. The permalink must answer 2xx first (
--no-verifyskips). - Mastodon: images with alt text,
Idempotency-Key, URLs counted at 23 chars. Bluesky: clickable link facet, link card or up to 4 images, auto-shrunk under the 1 MB blob cap. Text is trimmed at a word boundary and the link always survives. - Backlinks.
serve/buildread the ledger (--state, if it exists) and render syndicated copies asu-syndicationlinks. The default theme marks up pages ash-entry(p-name,dt-published,e-content,u-url), which is what webmention and Bridgy tooling look for.
Flags #
foldsite [serve|build] [flags] [DATA_DIR] DATA_DIR defaults to ./data; there's no fallback to .
--style DIR style dir (default ./style if it exists)
--theme sans|serif built-in theme variant
--no-default-theme where the style dir has no template, serve the raw file
--title TEXT site title (default: root index.md title, else the folder name)
--url URL public url, needed for feeds
--base /sub url prefix when hosted under a subpath
--per-page N default page size
--drafts include draft: true documents
--state FILE syndication ledger, read for backlinks if it exists (default foldsite.db)
--cache DIR thumbnail cache (default: OS cache dir; "memory" keeps it in RAM)
--addr HOST:PORT listen address (serve; default 127.0.0.1:8080, :8080 for every interface)
--out DIR output dir (build; may not be inside DATA_DIR)
--allow-active build: copy raw .html/.svg/.xml too (skipped by default, see Security)
foldsite syndicate [flags] [DATA_DIR]
--url URL public site url (required)
--post actually send (default: plan only)
--max-age DUR skip older items (default 720h; 0 = no limit)
--only PATH just this data path
--forget PATH:TARGET delete a ledger row so it can be posted again
--no-verify don't check the permalink is live
--state FILE ledger (default foldsite.db, never inside DATA_DIR)
Security (serve mode) #
- Every file access goes through Go's
os.Root, so traversal and symlink escapes are blocked by the OS, not by string checks. A symlink that points outside the data folder doesn't exist as far as foldsite is concerned. - Requests resolve against an in-memory index of the tree, so a URL can only reach a file that the index already listed.
.., NUL, backslashes, and_/.-prefixed names are rejected in the lowest layer; every failure is a plain 404.- Raw html and anything xml (
.svg,.rss,.xhtml, …) is served withContent-Security-Policy: sandbox, so a stray file can't run script on your origin. A static host can't send that header, sobuildskips those files unless you pass--allow-active. X-Content-Type-Options: nosniff, GET/HEAD only, read/write/idle timeouts, header size cap.- Raw HTML inside markdown passes through, but every page carries
script-src 'self': inline<script>,on*=handlers andjavascript:links don't run, and neither do third-party embeds (gists, tweets). - Listens on
127.0.0.1unless told otherwise, and never serves the cwd by accident.
Run it in a container, and mount the data folder read-only.
Architecture #
main
└─ serve | gen | posse HTTP · static files · syndication
└─ site URL → page; the only layer that knows all the others
└─ content | md | theme
└─ vfs the only code that touches the OS filesystem
Each layer talks only to the one beneath it. See PLAN.md for the design notes.
Build #
go build -o foldsite .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o foldsite .
go test ./...
scripts/release.sh v0.1.0 # vet, test, then every platform into dist/v0.1.0/ with SHA256SUMS
Pure Go, no cgo, ~19 MB. KaTeX, fonts, and the default theme are embedded.
The demo #
demo/data is generated by scripts/gen_demo.py (seeded, so reruns are identical): a blog with a post
every month for twelve years across blog/<year>/, two series nested deeper, a 30-note wiki, a features
tour that demonstrates each feature on itself (including POSSE: run with --state demo/state.db to see backlinks), and sample raw files. demo/style shows a replaced page
shell (photos/base.html), stacked overrides (photos/override.css, photos/film-sims/override.css),
a section overlay (blog/override.css), and a custom listing (wiki/documents.html). The photos are
hand-copied, 1400px.
Not yet #
- webmention receiving, and POSSE backfeed (likes/replies coming back)
- WebP/AVIF output
- org / rtf rendering (served raw)





