docs/content-model.md opens with "Engine specification". It is also where the rule lives that a leading underscore makes a file unaddressable — and the human who owns this site did not know that rule, because nothing in this repository is addressed to an author. Twelve documents named docs/ while being exclusively about building the parser is a signpost pointing at the wrong room. Naming the directory for its audience makes the gap visible instead of hiding it. docs/ is now reserved and deliberately absent: an empty docs/ is an honest statement that end-user documentation does not exist, where docs/ full of parser specs was a claim that it did. HARNESS.md stays at the root. Root holds the three entry points — README.md for a human, CLAUDE.md for an agent, HARNESS.md for whoever maintains the machine — and harness/README.md is the map of the directory, so moving the guide inside would have collided with it for nothing. Mechanical and wide: 100 path references across 24 files. Every verify.sh gate that names a doc by path, the directory lists the dangling-path and ADR-number gates scan, surface.sh's output target, the Makefile, CLAUDE.md's read order, the skill, four commands, and two Go package comments. A first pass with a shell loop silently edited only four files and the rest still said docs/; the fix was to write the file list out and check the remaining count was zero rather than trust the loop's exit status. No rule, threshold, gate or obligation moved — this is a rename, and the gates demonstrated it twice: they stayed green on the new paths, and the ADR-number gate caught ADR-0082 before the entry existed. Deferred, both on the human's call: the end-user documentation site itself, which wants its own decision about where it lives and whether its claims are gated; and moving examples/ under docs/, since demo-site is a live site root that verify.sh, the coverage test and make demo all point at, and moving it would couple a rename to a design nobody has made. 31 files, +146/-106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.7 KiB
Extensions
The plugin story, and the gate keeping it from arriving early.
STATUS: one feature exists. internal/ext/shortcodes is the first, listed in cmd/khosra/wire.go
(ADR-0027) — one directory, called explicitly, correct and sufficient until the counters say otherwise. The
Extension struct below is still unbuilt; this document exists so the eventual shape is known, not so it
can be built now.
How a feature reaches the engine today: cmd builds the list, so nothing under internal/ knows which
features exist. A feature that must emit markup is handed render.Partial and renders through a theme
template, because deciding markup is not a feature's job (ADR-0036).
A feature that needs to know which bundle is rendering reads render.OriginFrom off the parse context —
the bundle's directory plus the rooted fs.FS, so a path in a call resolves against the bundle and cannot
leave the site root (ADR-0031). It is available while parsing, not while rendering, so anything a feature
must read from disk it reads then.
The gate
| Stage of growth | What a feature looks like | Trigger to advance |
|---|---|---|
| Now (0–2 features) | Its own directory under internal/ext/<name>/, called explicitly from wire.go |
— |
| Transform counter due | Extract the Stage pipeline: an ordered []Stage in one wire file |
state.md |
| Extension counter due | Extract the Extension struct below; move each into internal/ext/<name> |
state.md |
| After Arc 2 | Composition only; the core no longer grows | Arc 2 closes |
state.md holds the thresholds and is the only place they are written. Do not extract early. Do not
"prepare".
Target shape
Compile-time registry. No plugin.so, no init() side effects, no discovery, no config file listing
plugins. One slice, one file, source order — the order is the semantics.
Compile-time is not a preference: Go's plugin package forbids a static binary and demands an exact
toolchain match, which the container target (ADR-0010) rules out. Dynamic loading buys only
extension-without-recompiling, worth nothing to the single author (ADR-0006) holding commit access.
The registry costs a few hundred lines that render zero pages — hence real callers before a contract.
// internal/ext/ext.go — the whole contract, once earned.
type Extension struct {
Name string
Stages []Stage // ordered; Phase decides placement
Views map[string]View // named, referenced by frontmatter `view:`
Shortcodes map[string]Shortcode // trusted content only (ADR-0003)
Effects []Effect // derived artifacts and outbound calls, off the request path
Adapters []Adapter // Interaction sources, Arc 3
Routes []Route // additional URL cases, via the resolver
}
One field of that struct is real: Routes (ADR-0081). It is not the struct — a feature returns
map[string]http.Handler from cmd/khosra/wire.go's routes(), keyed by exact URL path, and web.Handler
mounts each one. Core learns that some paths belong to somebody else and nothing about who owns them. A path
core already answers is skipped with a warning rather than overridden, because http.ServeMux panics on a
duplicate pattern. internal/ext/passthrough/ is the first and only user.
The other six fields wait for their own triggers. Building them now would give five of six no implementor, and the earn-it rule exists to prevent exactly that. The shape above stays the target, not a promise about next week.
cmd/khosra/wire.go holds the only list of enabled extensions — two lists now, extenders() for the ones
goldmark composes and routes() for the ones owning a path. Enabling
or disabling one is a one-line diff and a rebuild. Removing one leaves no trace elsewhere — that property is
the test of whether the contract is right, and it is testable today: empty the list and the engine still
builds and serves, minus that feature.
Stage phases
An ordered list, not a dependency graph. Two stages needing a graph to be correct are one stage wearing a disguise.
| Phase | Operates on | Examples |
|---|---|---|
PhaseLoad |
raw bytes + frontmatter | translation fallback. Not includes: they turned out to be parse-phase, because splicing another file's parsed nodes into a page is invalid rather than merely awkward (ADR-0038) |
PhaseParse |
the parsed Markdown tree | shortcodes, transclusion, image derivatives |
PhaseMarkup |
rendered HTML fragments, code spans skipped | nothing, and possibly nothing ever. Everything expected here belonged somewhere else: smart quotes and dashes are a Markdown parser option, chrome localisation is a template function (ADR-0034), and widows turned out to be CSS (ADR-0045). A phase with no inhabitants is worth noticing before it is built |
PhasePage |
the assembled page object | OpenGraph, JSON-LD, related posts, series nav |
PhaseOutput |
the final byte stream | minification, dithering, gemtext conversion |
Every Stage runs on every bundle unless a cascade key disables it (ideas/deferred-decisions.md), and declares its trust
requirement. A Stage evaluating templates or shortcodes runs in trusted mode only, and the pipeline
refuses it otherwise — enforced in code, not by convention, and
tested with an untrusted-input case.
Rules for any extension
- Deletable without trauma. Removing the package leaves the engine building and serving.
- Reads only what already exists on the page; adds through the
Extrabag, never by widening the core struct for its own convenience. - Owns its output files under a namespaced path, or none.
- No new dependency without an ADR — extensions get no looser budget than the core, and their Go
lines count against
EXT_LOC_MAX. Presentation features (OpenGraph, galleries, series nav, related posts) belong in templates and frontmatter where they cost nothing; reach for Go only when there is real logic. - Failure degrades: a broken extension logs and is skipped, never takes a request down.
- Needing a permanent external service or a primitive change makes it a trunk — see
ideas/exploration.md. - One directory, no sibling imports, and a
doc.goin this shape (ADR-0027):
// Package feeds emits RSS and Atom for the primary feed and each section.
//
// Contributes: Effect (on change).
// Cascade keys: feeds.enabled, feeds.limit.
// Contract fields: none.
// Not doing: JSON Feed, WebSub — separate features if wanted.
package feeds
ContentAPI
A thin internal write path, introduced with comments in Arc 3 and not before. It exists so a second client (admin panel, Micropub endpoint) becomes possible without the engine growing a UI. Read paths keep going straight to the filesystem; git remains the source of truth.