Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions EXPLAINME.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: CC-BY-SA-4.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
= EXPLAINME — casket architecture & invariants
:toc: left
:toclevels: 3

A developer deep-dive: how casket is built, what the Gnosis layer is, the
invariants it upholds, the known gaps, and the direction of travel. For the
user-facing overview see link:README.adoc[README]; for the engine decision see
link:docs/decisions/ADR-0001-content-engine.adoc[ADR-0001].

== One paragraph

casket is a Pandoc-based static site generator with a bespoke *Gnosis* layer.
The built executable (`CasketGnosis.hs`) discovers Markdown, runs each file
through Gnosis (metadata-driven substitution) and then Pandoc (Markdown → HTML),
fills a file-based template, and writes an accessible HTML tree plus collections,
tag pages, a sitemap and an Atom feed. Accessibility is enforced structurally,
not linted after the fact.

== Build pipeline

`buildSiteWithGnosis` (in `src/CasketGnosis.hs`) orchestrates one build:

[source]
----
loadAll6SCM .machine_readable ; metadata context (Gnosis.SixSCM)
loadSiteConfig (site.conf) ; {{site.*}} vars
findMarkdown <input> ; recursive .md/.markdown discovery
└─ per page: renderPage
parseFrontmatter ; ----fenced key: value (tolerates SPDX comment)
mergeFrontmatter ctx fm ; front-matter overrides 6scm context
DAX.processTemplate ; {{#if}} / {{#for}} over the Markdown body
Render.renderWithBadges ; (:placeholder) → value / Shields badge
markdownToHtml (Pandoc) ; body + TOC, syntax highlighting
addThScope ; inject scope="col" on <th> (WCAG 1.3.1)
filter drafts (unless --drafts)
buildNav ; from site.conf `nav` or derived
per page: writePage → loadLayout → fillTemplate → write
writeCollections ; date-sorted index per directory (≥2 dated)
writeTagPages ; /tags/<tag>/ + /tags/ index
writeSitemap ; writeFeed ; from site.baseurl
copyAssets ; writeHighlightCss ; copyPublic
----

Template substitution order in `fillTemplate` matters: `{{site.*}}` →
front-matter keys → standard vars (`{{nav}}`, `{{toc}}`, `{{title}}`, …) →
`{{content}}` *last*, so rendered body HTML is never re-processed.

== Module map (`src/`)

[cols="1,3"]
|===
| Module | Responsibility

| `CasketGnosis.hs` (`Main`)
| Build orchestration, CLI (`build` / `clean` / `--version`), frontmatter,
templates/partials, collections, tags, sitemap, Atom feed, assets.

| `Gnosis.Types`
| `FlexiText` (visual + alt-text) and `Context`. Smart constructor `mkFlexiText`
returns `Left` on empty alt-text — the Tri-Guard accessibility primitive.

| `Gnosis.Render`
| `(:placeholder)` rendering with optional `| filter` chains; `PlainText` or
`ShieldsIOBadge` mode. `renderWithBadges` is used in the build.

| `Gnosis.DAX`
| Data-eXpression layer: `{{#if cond}}`, `{{#for x in list}}`, and value filters
(`thousands-separator`, `uppercase`, …).

| `Gnosis.SixSCM`
| Loads the six descriptive metadata files into a `Context`.
*Known gap:* currently reads `.machine_readable/STATE.scm` … (the old flat
`.scm` layout) — it does **not** yet read `.machine_readable/descriptiles/*.a2ml`.

| `Gnosis.SExp`
| Recursive-descent S-expression parser (for the legacy `.scm` metadata).

| `Gnosis.Paxos`
| Timestamp-ballot last-write-wins merge for concurrent `STATE` updates by
automation. Aspirational; not on the render path.

| `Casket.hs`
| *Legacy.* A standalone hand-rolled "HakyllLite" (own Markdown parser, no
Pandoc). **Not** in the cabal's build — `CasketGnosis.hs` is the real binary.
Slated for removal/relocation to `examples/`.
|===

== Invariants

* *Alt-text is non-optional.* `FlexiText` pairs a visual with mandatory alt-text;
`mkFlexiText "" ""`-style empty alt-text is a `Left`, not output. Generated
badges cannot ship without alt text.
* *Tables are screen-reader navigable.* `addThScope` adds `scope="col"` to every
`<th>` Pandoc emits that lacks one (never double-adds).
* *The default page is accessible.* The built-in fallback template ships
`<html lang>`, a skip link, labelled `header`/`nav`/`main#main`/`footer`
landmarks, viewport meta, and `color-scheme: light dark`.
* *Body HTML is substituted last* so it is never re-templated.
* *Drafts are excluded* unless `--drafts` / `CASKET_DRAFTS=1`.

== Content & metadata model

* *Front-matter:* `---`-fenced `key: value`, tolerant of a leading SPDX HTML
comment. Arbitrary keys become `{{key}}` template vars.
* *Site config:* `site.conf`, simple `key = value`, exposed as `{{site.*}}`.
* *Gnosis context (descriptiles):* the six `.a2ml` files under
`.machine_readable/descriptiles/` are intended to feed `(:placeholder)` /
DAX. Wiring `Gnosis.SixSCM` to read `descriptiles/*.a2ml` (an `a2ml` reader)
is the first migration step (see below).

== Known gaps (current state)

* *Build is red on `main`* — `CasketGnosis.hs` imports
`Text.Pandoc.Options(HighlightMethod)`, which the resolved Pandoc no longer
exports. Retired by the ADR-0001 engine change, not patched in place.
* *Gnosis metadata is dormant against this repo* — `SixSCM` reads `.scm`, but the
metadata now lives at `descriptiles/*.a2ml`; until wired, `(:placeholder)`
resolves only against front-matter.
* *Two codebases* — `Casket.hs` (legacy) vs `CasketGnosis.hs` (built); only the
latter ships.
* *No incremental build, no dev server* — full rebuild each run.

== Direction (ADR-0001)

* *Demote Pandoc* to a one-shot authoring-time importer (Markdown → Djot).
* *Djot* as the canonical prose syntax (clean, attribute-bearing, far easier to
parse — and to verify — than CommonMark): `jotdown` (Rust) now, a verified
Zig/Idris2 Djot core later behind a stable AST.
* *a2ml* as the typed-data face (Djot replaces CommonMark, not a2ml).
* *boj cartridge* — consume boj's verified MCP/ABI/distribution/credential
isolation instead of building an MCP server.
* *Layered proofs* — Haskell carries cheap invariants; Idris2/Agda carry the
load-bearing ones. The accessibility invariant is an `echo-types`
`Echo f y := Σ (x:A),(f x ≡ y)` — `FlexiText` is its unverified shadow, and
ddraig owns the native proof.

== Extending casket

* *Templates:* drop `templates/<layout>.html`; select per page via `layout:`
front-matter; partials via `{{> name}}` from `templates/partials/`.
* *Theme:* override `assets/style.css`; highlight CSS is emitted to
`/assets/highlight.css`.
* *Verbatim files:* anything under `<input>/public/` is copied to the output root
(`CNAME`, `.well-known/`, `robots.txt`, …).
203 changes: 121 additions & 82 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,128 +2,167 @@
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>

= casket-ssg
= casket
image:https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github[Sponsor,link="https://github.com/sponsors/hyperpolymath"]
image:https://img.shields.io/badge/OpenSSF-Best_Practices-green?logo=opensourcesecurity[OpenSSF Best Practices,link="https://www.bestpractices.dev/en/projects/new?repo_url=https://github.com/hyperpolymath/casket-ssg"]
image:https://img.shields.io/badge/License-MPL_2.0--1.0-blue.svg[License: MPL-2.0,link="https://github.com/hyperpolymath/palimpsest-license"]
image:https://api.thegreenwebfoundation.org/greencheckimage/github.com[Green Web,link="https://www.thegreenwebfoundation.org/green-web-check/?url=github.com"]
image:https://img.shields.io/badge/License-MPL_2.0-blue.svg[License: MPL-2.0,link="https://github.com/hyperpolymath/casket-ssg/blob/main/LICENSE"]
image:https://img.shields.io/badge/a11y-WCAG--aware_by_default-success[Accessibility]
image:https://img.shields.io/badge/part_of-poly--ssg-indigo[poly-ssg,link="https://github.com/hyperpolymath/poly-ssg"]
image:https://img.shields.io/badge/RSR-compliant-gold[RSR Compliant,link="https://github.com/hyperpolymath/rhodium-standard-repositories"]




image:https://img.shields.io/badge/Philosophy-Palimpsest-indigo.svg[Palimpsest,link="https://github.com/hyperpolymath/palimpsest-license"]


:toc: auto
:toc: macro
:toclevels: 2

image:https://img.shields.io/badge/RSR-compliant-gold[RSR Compliant,link=https://github.com/hyperpolymath/rhodium-standard-repositories]

**A Pandoc-based static site generator in Haskell, with an accessibility-first, metadata-driven rendering layer (Gnosis).**
**An accessibility-first, metadata-driven static site generator.** Markdown in,
an accessible website out — WCAG-aware output _by default_, plus a metadata layer
(*Gnosis*) that renders pages from typed project data. The Haskell member of
https://github.com/hyperpolymath/poly-ssg[poly-ssg].

[NOTE]
====
*Status: alpha.* The `build` / `clean` pipeline works and generates real sites.
Several items below are *planned, not yet implemented* — they are called out
explicitly so nothing here over-promises. The content engine's direction
(demote Pandoc; adopt a Djot prose core + `a2ml` typed content) is recorded in
link:docs/decisions/ADR-0001-content-engine.adoc[ADR-0001].
Items marked _Planned_ below are not yet implemented. The engine's direction —
demote Pandoc, adopt a Djot prose core + `a2ml` typed content on the
https://github.com/hyperpolymath/boj-server[boj] substrate — is recorded in
link:docs/decisions/ADR-0001-content-engine.adoc[ADR-0001]. Architecture &
invariants: link:EXPLAINME.adoc[EXPLAINME].
====

== Who Is This For?
toc::[]

* **Haskell developers** who want a small, scriptable content-site generator
* **Accessibility-conscious authors** who want WCAG-aware output by default
* **poly-ssg / estate** users who want metadata-driven pages (the Gnosis layer)
== Example

== Why casket-ssg?
A minimal site is a tree of Markdown with front-matter:

=== Accessibility by construction
[source]
----
content/
├── index.md
└── posts/
└── 2026-06-23-hello.md
----

[source,markdown]
----
---
title: Hello, world
date: 2026-06-23
tags: [intro]
description: A first page built with casket.
---
# Hello

Markdown with **rich** formatting, tables, footnotes and highlighted code.
----

Build it:

[source,bash]
----
casket-ssg build ./content ./_site
----

The built-in default theme is accessible by default — `<html lang>`, a skip link,
labelled `header`/`nav`/`main`/`footer` landmarks, viewport meta, and a light/dark
`color-scheme`. casket also rewrites Pandoc's table headers to add `scope="col"`
(WCAG 1.3.1), and the Gnosis `FlexiText` type makes *empty alt-text a constructor
error* — generated badges cannot ship without alt text.
You get an *accessible* `_site/` with no configuration:

=== Metadata-driven rendering (Gnosis)
* `index.html` and `posts/2026-06-23-hello/index.html` rendered through an
accessible default theme (skip link, labelled landmarks, light/dark
`color-scheme`), with table headers auto-scoped for WCAG 1.3.1;
* a date-sorted collection index for `posts/`, and per-tag pages under `/tags/`;
* `sitemap.xml` and an Atom `feed.xml`.

An optional layer runs over the Markdown before Pandoc: `(:placeholder)`
substitution from a metadata context, `{{#if}}` / `{{#for}}` conditionals and loops
(DAX), and Shields.io badges generated from typed metadata.
No Node, no npm, no required config file.

=== Pandoc-based content (today)
== Installation

Rich Markdown via Pandoc — anchors, pipe tables, fenced code with syntax
highlighting, footnotes, task lists, strikethrough, smart punctuation. See
link:docs/decisions/ADR-0001-content-engine.adoc[ADR-0001] for the planned move to
a Djot core with Pandoc relegated to a one-shot importer.
casket is not yet on Hackage — build from source (GHC 9.4+, Cabal 3 or Stack 2):

== Quick Start
[source,bash]
----
git clone https://github.com/hyperpolymath/casket-ssg
cd casket-ssg
stack build # or: cabal build
stack run casket-ssg -- --version # or put the built binary on PATH
----

casket-ssg is not yet published to Hackage; build it from source.
CLI:

[source,bash]
----
# Build (Stack or Cabal)
stack build # or: cabal build
casket-ssg build <input-dir> [output-dir] # default output: _site
casket-ssg build --drafts <input-dir> # include draft: true pages (or CASKET_DRAFTS=1)
casket-ssg build --no-clean-urls <input-dir> # emit foo.html, not foo/index.html
casket-ssg clean [output-dir]
casket-ssg --version
----

# Build a site: build <input-dir> [output-dir] (default output: _site)
stack run casket-ssg -- build ./content ./_site
# ...or, with the built binary on PATH:
casket-ssg build ./content ./_site
== What makes it different

# Include drafts (or set CASKET_DRAFTS=1)
casket-ssg build --drafts ./content
Accessibility by construction::
The default theme is accessible by default, casket rewrites Pandoc table headers
to add `scope="col"`, and the Gnosis `FlexiText` type makes *empty alt-text a
constructor error* — generated badges cannot ship without alt text. Accessibility
is a property of the engine, not a checklist for the author.

# Emit foo.html instead of foo/index.html
casket-ssg build --no-clean-urls ./content
Metadata-driven (Gnosis)::
An optional layer renders pages from a typed metadata context: `(:placeholder)`
substitution, `{{#if}}` / `{{#for}}` conditionals and loops (DAX), and Shields.io
badges generated from typed values — sourced from the repo's
`.machine_readable/descriptiles/` data.

# Remove an output directory
casket-ssg clean ./_site
Verified & agent-native — the trajectory::
casket is heading toward typed `a2ml` content (build fails on malformed data), a
formally-verifiable Djot core, and a verified agent surface as a
https://github.com/hyperpolymath/boj-server[boj] cartridge rather than an embedded
MCP server. See link:docs/decisions/ADR-0001-content-engine.adoc[ADR-0001].

# Version
casket-ssg --version
----
Permissively licensed::
*MPL-2.0* — build proprietary or differently-licensed sites on top, unlike
copyleft alternatives.

== Features

*Implemented:*
*Implemented*

* **Recursive multi-page build** mirroring the source tree into the output
* **Frontmatter** — `title`, `date`, `description`, `layout`, `draft`, `tags`,
`slug`, plus arbitrary keys exposed as `{{key}}` template vars
* **Rich Markdown via Pandoc** — anchors, pipe tables, fenced/highlighted code,
* Recursive multi-page build mirroring the source tree
* Front-matter: `title`, `date`, `description`, `layout`, `draft`, `tags`,
`slug`, plus arbitrary keys as `{{key}}` template vars
* Rich Markdown via Pandoc — anchors, pipe tables, fenced/highlighted code,
footnotes, task lists, strikethrough, smart punctuation
* **File templates** in `templates/` with per-page `layout` and partials
(`{{> name}}`), plus `{{nav}}`, `{{toc}}`
* **Site config** (`site.conf`, simple `key = value`) exposed as `{{site.*}}`
* **Collections** (a directory index sorted by date) and **per-tag pages**
(`/tags/<tag>/`)
* **`sitemap.xml`** and an Atom **`feed.xml`**
* **Accessible default theme** + automatic `scope="col"` on table headers
* **Gnosis layer** — `(:placeholder)` rendering, `{{#if}}` / `{{#for}}` (DAX),
and `FlexiText` (alt-text-required) badge generation
* **Clean URLs** (`foo.md` → `foo/index.html`), toggleable with `--no-clean-urls`

*Planned / not yet implemented* (do not assume these work today):
* File templates with per-page `layout`, partials (`{{> name}}`), `{{nav}}`,
`{{toc}}`, `{{site.*}}` (from `site.conf`)
* Collections (date-sorted directory index) and per-tag pages (`/tags/<tag>/`)
* `sitemap.xml` + Atom `feed.xml`
* Accessible default theme + automatic `scope="col"` on table headers
* Gnosis layer — `(:placeholder)`, `{{#if}}`/`{{#for}}` (DAX), `FlexiText` badges
* Clean URLs, toggleable

*Planned* (do not assume these work today — see ADR-0001)

* `init` scaffolding and a `serve` / live-reload dev server
* Incremental rebuilds (the build is currently a full rebuild)
* Asset fingerprinting / hash-based cache-busting
* Schema-enforced frontmatter and compile-time template validation
* `a2ml`-native typed content and the Djot core (see
link:docs/decisions/ADR-0001-content-engine.adoc[ADR-0001])
* Incremental rebuilds; asset fingerprinting
* `a2ml`-native typed content + the Djot core; Pandoc relegated to an importer
* `boj` cartridge registration; `llms.txt` / per-page machine views

== Requirements
== Documentation

* GHC 9.4+ (CI builds with GHC 9.4.8) and Cabal 3.0+ or Stack 2.0+
* Pandoc (pulled in as a library dependency)
* link:EXPLAINME.adoc[EXPLAINME] — architecture & invariants (developer deep-dive)
* link:docs/decisions/ADR-0001-content-engine.adoc[ADR-0001] — content-engine direction
* link:CONTRIBUTING.adoc[Contributing] · link:GOVERNANCE.adoc[Governance]

== Part of poly-ssg
== Where it fits

* https://github.com/hyperpolymath/poly-ssg[*poly-ssg*] — the family of
language-native SSGs casket belongs to, unified via MCP.
* https://github.com/hyperpolymath/ddraig-ssg[*ddraig*] — the dependently-typed
(Idris2) sibling; casket's "proven twin" (casket validates, ddraig proves).
* https://github.com/hyperpolymath/boj-server[*boj-server*] — the estate's
verified MCP/capability server casket plugs into as a cartridge.

== Requirements

casket-ssg is part of the https://github.com/hyperpolymath/poly-ssg[poly-ssg] family of language-native static site generators, unified through https://github.com/hyperpolymath/poly-ssg-mcp[MCP integration].
* GHC 9.4+ (CI builds with 9.4.8), Cabal 3.0+ or Stack 2.0+
* Pandoc (current library dependency; see ADR-0001 for the planned move off it)

== License

MPL-2.0
Code: *MPL-2.0* (link:LICENSE[LICENSE]). Prose documentation: `CC-BY-SA-4.0`.
Loading