Static/Dynamic site generator. You have a folder? You have a (fold)site.

README.md

foldsite logo

foldsite

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

The demo home page in the built-in theme

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. serve renders live from disk; build writes 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.css into any folder of style/; the deepest one wins.
  • POSSE. Syndicate to Mastodon, Bluesky or a webhook, at most once, with backlinks rendered on the page.
  • Locked down. os.Root file 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 math, serif variant Syntax highlighting in dark mode
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.

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 ![](pic.jpg) 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.

An album: a folder that's mostly images 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 replaces the page shell film-sims/override.css stacks on photos/override.css
photos/base.html: a replaced shell photos/film-sims/override.css: stacked overlays
wiki/documents.html: a custom listing blog/override.css: a section accent, dark mode
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.css removes the default theme's component styles too (grid, pager, listing). To recolor or retype a section, prefer override.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 memory for 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 (![](x.jpg)) 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. serve and build never post.
  • At most once. A sqlite ledger (--state, default foldsite.db; refused inside the data dir) claims each (page, target) before sending. A definite rejection releases the claim. A timeout leaves it STUCK and 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-verify skips).
  • 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/build read the ledger (--state, if it exists) and render syndicated copies as u-syndication links. The default theme marks up pages as h-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 with Content-Security-Policy: sandbox, so a stray file can't run script on your origin. A static host can't send that header, so build skips 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 and javascript: links don't run, and neither do third-party embeds (gists, tweets).
  • Listens on 127.0.0.1 unless 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)