Skip to main content

spork-site

Tests

Spork-native static site generation with immutable markup, structural Markdown, and deterministic content builds.

Capabilities

  • immutable Element, Fragment, Text, and RawHtml nodes;
  • locally scoped (markup ...) blocks with $tag lowering;
  • deterministic, escaped HTML serialization;
  • CommonMark AST conversion into the shared node model;
  • YAML front matter and recursive Markdown discovery;
  • eager persistent filtering, sorting, and limiting of content collections;
  • generated clean routes and duplicate/conflicting output detection;
  • ordinary functions for components and layouts;
  • Pygments syntax highlighting over structural code nodes;
  • static asset discovery and copying;
  • deterministic full builds with safe output cleanup;
  • a full-rebuild development server with isolated generations and browser reload;
  • a project-local spork site ... command provider for source-only sites;
  • XML sitemap, RSS 2.0, and Atom 1.0 generation;
  • generic immutable post-order node transformations.

Content-driven site

Markdown documents use optional YAML front matter:

---
title: First Post
date: 2026-08-30T12:00:00Z
summary: A post built with Spork.
tags: [spork, release]
---
## Hello

```python
print("Spork")
```

Discovering a content directory returns an eager persistent vector of document maps:

(ns example.site
  (:require
    [spork-site.build :as build]
    [spork-site.collections :as collections]
    [spork-site.content :as content]
    [spork-site.core :as site :refer [element fragment markup]]
    [spork-site.feeds :as feeds]
    [spork-site.routing :as routing]
    [spork-site.sitemap :as sitemap]))

(def documents (content.load-documents "content"))

Each document contains front-matter fields at the top level plus canonical fields:

{:source-path   #p"content/blog/first.md"
 :relative-path "blog/first.md"
 :id            "blog/first"
 :slug          "first"
 :route         "/blog/first/"
 :metadata      {:title "First Post" ...}
 :body          "## Hello\n..."
 :content       (Fragment [...])
 :title         "First Post"
 :date          #inst"2026-08-30T12:00:00Z"}

YAML mappings and sequences become persistent maps and vectors. YAML dates remain Python date/datetime values. Front-matter sets are rejected because they are unordered.

Routes are derived from relative paths:

Source Route Output
index.md / index.html
docs/index.md /docs/ docs/index.html
blog/hello.md /blog/hello/ blog/hello/index.html

Use slug, route, permalink, or url front matter to override the derived route. Explicit routes are validated and canonicalized.

Pass :patterns to select several deterministic globs, or disable highlighting when loading:

(content.load-documents
  "content"
  * :patterns ["docs/**/*.md" "blog/**/*.md"]
    :highlight? false)

Collections

Collections use ordinary Spork predicates and callable keys rather than a query language:

(defn published? [document]
  (not (is (:draft document) true)))

(def posts
  (collections.collection
    documents
    * :where published?
      :sort-by :date
      :order :desc
      :limit 20))

collection, filter-documents, and sort-documents eagerly return persistent vectors. Standard functions such as filter, group-by, take, and eager (for ...) expressions remain available for further composition.

Components, layouts, and generated routes

Components and layouts are ordinary functions returning node-like values:

(defn document-layout [document]
  (markup
    ($html {:lang "en"}
      ($head
        ($meta {:charset "utf-8"})
        ($meta {:name "viewport"
                :content "width=device-width, initial-scale=1"})
        ($title (:title document))
        ($link {:rel "stylesheet" :href "/site.css"}))
      ($body
        ($main {:class "prose"}
          ($h1 (:title document))
          (:content document))))))

(def content-pages
  (routing.pages-for posts document-layout))

pages-for is equivalent to an eager generated route expression:

(for [document posts]
  (routing.page (:route document)
                (document-layout document)))

routing.page serializes structural content as escaped HTML. Use routing.output-file for already serialized text or bytes such as robots.txt, XML, or JSON.

Duplicate canonical routes, page/asset collisions, and file/directory output conflicts fail before output is cleaned or written.

Syntax highlighting

Fenced Markdown code gets a language-* class during Markdown conversion. Content loading applies Pygments by default and retains the shared structure:

(def highlighted
  (site.highlight-syntax
    (site.render-markdown "```spork\n(+ 1 2)\n```")))

The result remains Element("pre") containing Element("code"); only Pygments' trusted, escaped span markup is represented as RawHtml. Unknown lexer names leave the original code block unchanged.

Sitemap and feeds

Sitemap entries are any route-bearing maps. :lastmod, :changefreq, and :priority are optional, and :sitemap false excludes an entry:

(def sitemap-output
  (sitemap.sitemap "https://example.com" posts))

RSS and Atom consume the same document maps. Entries require :title, :route, and one of :updated, :date, or :published. Drafts and entries with :feed false are excluded.

(def feed-config
  {:title "Example Blog"
   :description "News from Example"
   :url "https://example.com"
   :author "Example Authors"})

(def rss-output (feeds.rss feed-config posts))
(def atom-output (feeds.atom feed-config posts))

Feed timestamps derive from content dates, never the wall clock, so repeated builds are byte-for-byte stable. Empty feeds require an explicit :updated value.

Complete build

A site is an ordinary persistent map constructed by build.site:

(defn make-site []
  (build.site
    * :output "public"
      :pages [content-pages
              (routing.output-file "/sitemap.xml" sitemap-output)
              (routing.output-file "/feed.xml" rss-output)
              (routing.output-file "/atom.xml" atom-output)]
      :assets (build.discover-assets "static")
      :transforms []))

The factory is an ordinary source function. Configure it independently from the application's :main:

{:name "example"
 :version "0.1.0"
 :spork-version ">=0.6,<0.7"
 :dependencies ["spork-site>=0.1,<0.2"]
 :source-paths ["src"]

 :site
 {:target "example.site:make-site"
  :watch ["spork.it" "src" "content" "static"]}

 :main "example.app:main"}

Synchronize once, then use the project-local provider:

spork sync
spork site check
spork site routes
spork site build
spork site clean
spork site serve

spork-site loads example.site:make-site directly from configured source paths through the Spork command context. The site does not need an ahead-of-time build, a Python-importable adapter, a replacement application entry point, or manual virtualenv activation.

A build:

  1. validates and canonicalizes every route;
  2. detects duplicate, page/asset, and parent/child path conflicts;
  3. renders all page content and transformations;
  4. validates asset sources and output safety;
  5. cleans the output directory by default;
  6. writes pages and assets in lexical output-path order;
  7. returns a persistent summary map.
{:output #p"/project/public"
 :pages 12
 :assets 4
 :written ["atom.xml" "feed.xml" "index.html" ...]}

Set :clean? false to retain unrelated output files. Output paths cannot be the project directory, an ancestor of it, or a filesystem root. Asset sources inside a cleaned output directory are rejected before deletion.

Site commands

The package owns one complete top-level CLI:

spork site build [--output PATH] [--no-clean] [--json]
spork site check [--json]
spork site clean [--output PATH]
spork site routes [--json]
spork site serve [--host HOST] [--port PORT] [--open] [--no-reload]
spork site version

check loads the source factory and constructs the complete rendered page, asset, conflict, and output plan without creating, cleaning, or writing the output directory. routes performs the same validation and reports canonical route/output pairs. clean uses the factory's configured output by default and applies the same project-root and filesystem-root protections as builds. version reports the selected provider version, Spork host version, command API, and project/active scope.

Development server

Start the default development server from any directory below the project root:

spork site serve
spork site serve --host 0.0.0.0 --port 8000 --open

Development mode performs correct full builds rather than mutating loaded source namespaces. The supervisor:

  1. launches each build in a fresh project spork process;
  2. writes it into a new temporary generation directory;
  3. switches HTTP serving only after that complete build succeeds;
  4. retains the last successful generation when a rebuild fails;
  5. broadcasts a server-sent reload event after successful rebuilds.

HTML responses receive a small reload client while they are served. The generated files themselves are never modified, and serve does not write to the site's configured output directory. Static responses include an X-Spork-Site-Generation header for development inspection.

The :site :watch vector controls watched project-relative files and directories:

:site
{:target "example.site:make-site"
 :watch ["spork.it" "src" "content" "static"]}

When omitted, those four paths are the defaults. Related filesystem events are debounced into one rebuild. The configured output plus .venv, .spork-out, VCS metadata, Python caches, and common tool caches are ignored. If the first build fails, the reload-enabled server starts with a temporary error response and continues watching for a successful repair.

Use --open to launch the development URL in the default browser. --port 0 selects an available port. --no-reload builds and serves one immutable generation without watching, SSE, or HTML client injection. Stop either mode with Ctrl-C.

Markup

markup only gives special meaning to lists headed by a $-prefixed symbol. Components, conditionals, calls, data access, and iteration remain ordinary Spork:

(defn post-card [post]
  (markup
    ($article {:class "post"}
      ($h2 ($a {:href (:route post)} (:title post)))
      ($p (:summary post)))))

(defn homepage [posts]
  (markup
    ($main {:class ["content" nil]}
      ($h1 "Spork")
      (for [post posts]
        (post-card post)))))

The macro lowers to public element and fragment bindings, so refer those names with markup. A qualified macro call also works:

(site.markup
  ($spork-playground {:source "(+ 1 2)"}))

Markdown and transformations

spork-site.markdown parses CommonMark into Fragment, Element, Text, and explicit RawHtml values, never an opaque HTML string:

(def ast (site.parse-markdown source))
(def nodes (site.markdown-ast-to-nodes ast))
(def content (site.render-markdown source))

With the default parser, inline and block HTML tokens become explicit RawHtml nodes. Surrounding text remains escaped Text. Use (site.make-markdown-parser false) to disable Markdown raw HTML.

transform walks a node tree depth-first and invokes a function post-order after transformed children have been installed in a fresh immutable parent:

(defn mark-heading [node]
  (if (and (isinstance node site.Element)
           (contains? #{"h1" "h2" "h3" "h4" "h5" "h6"} node.tag))
    (site.Element node.tag
                  (assoc node.attrs "data-heading" true)
                  node.children)
    node))

(def transformed (site.transform content mark-heading))

A transform may return a node, nil to remove it, a deterministic sequence to splice through a fragment, or a printable scalar to create a Text node.

Low-level node API

(site.element :a {:class ["button" "primary"]
                  :style {:display "inline-flex" :gap "0.5rem"}
                  :href "/docs/"}
  "Read the docs")

(site.fragment
  (site.text "escaped: <tag>")
  (site.raw-html "<strong>trusted HTML</strong>"))

Child rules are intentionally small:

  • nodes are retained;
  • fragments and deterministic sequences are recursively flattened;
  • strings and printable scalars become Text nodes;
  • nil emits nothing;
  • maps and unordered sets are rejected as children;
  • RawHtml is the explicit escape hatch for trusted, unescaped markup.

Attributes are normalized during construction. :class accepts nested sequences and ignores nil; :style accepts a string or deterministically ordered map. nil and false attributes are omitted, while true attributes serialize in HTML boolean form.

Development

The project requires Spork 0.6 in order to publish and exercise the command-provider API:

spork sync --dev
spork check
spork test
spork build --clean
spork dist --clean

The test suite includes structural unit tests and an end-to-end documentation-and-blog fixture covering front matter, recursive discovery, collections, generated routes, layouts, syntax highlighting, assets, deterministic rebuilding, sitemap, RSS, and Atom.

Project layout

src/spork_site/
├── build.spork        # deterministic output planning and execution
├── cli.spork          # package-owned build/check/clean/routes/serve/version CLI
├── collections.spork  # eager document filtering and sorting
├── content.spork      # front matter, discovery, and document loading
├── core.spork         # general public facade
├── dev.spork          # generation server, watcher, workers, and browser reload
├── feeds.spork        # RSS and Atom
├── highlight.spork    # structural Pygments integration
├── markdown.spork     # CommonMark AST conversion
├── markup.spork       # locally scoped $tag macro
├── nodes.spork        # immutable nodes and normalization
├── render.spork       # deterministic HTML serialization
├── routing.spork      # routes and generated pages
├── sitemap.spork      # sitemap XML
├── transforms.spork   # generic immutable tree transformations
└── xml.spork          # shared XML/date/URL helpers

Focused namespaces are supported APIs; spork-site.core re-exports the general application-facing surface.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

spork_site-0.1.1.tar.gz (149.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

spork_site-0.1.1-py3-none-any.whl (161.2 kB view details)

Uploaded Python 3

File details

Details for the file spork_site-0.1.1.tar.gz.

File metadata

  • Download URL: spork_site-0.1.1.tar.gz
  • Upload date:
  • Size: 149.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for spork_site-0.1.1.tar.gz
Algorithm Hash digest
SHA256 770d206de39ba5f640ab9f7f984bcd871feb9802b5c0565377d524e6704bd8d2
MD5 5b654f719e915d70ffb0f6321a957a7c
BLAKE2b-256 422181e6952e65669de54f67191eb73d4fc366ad1ef27b6e42eb29f6b631eb3c

See more details on using hashes here.

Provenance

The following attestation bundles were made for spork_site-0.1.1.tar.gz:

Publisher: build.yml on spork-it/spork-site

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spork_site-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: spork_site-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 161.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for spork_site-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 cce67fb22bc79a14cd53dbfe1a85ca63889c5fc699bff5a322e7cc056986b190
MD5 d08ac061c1cf2834699c4f97c933d841
BLAKE2b-256 6904ec7159d6c5023376bb7b50ff5858cb2a37939695e5a28e2e3054a96a370b

See more details on using hashes here.

Provenance

The following attestation bundles were made for spork_site-0.1.1-py3-none-any.whl:

Publisher: build.yml on spork-it/spork-site

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page