A call is `{{< name key="value" >}}` alone on a line, parsed by a goldmark block
parser into an AST node and rendered by executing a theme template of that name
(ADR-0036). `figure` ships; `include` and `gallery` need the including bundle's
directory, which the parser does not carry yet, so they wait.
The layering did the design work here. internal/render may not import
internal/ext, so render.New takes a callback that receives a Partial and returns
Markdown extensions, and cmd/khosra/wire.go holds the only list of enabled
features. Empty that list and the engine still builds and serves — which is the
property extensions.md says the contract should have.
Raw HTML stays disabled. An author's text reaches a page only as arguments that
html/template escapes in context, which the real binary shows: a hostile alt
becomes <script> and src="javascript:…" becomes #ZgotmplZ. Getting
contextual escaping from the standard library rather than writing it is the whole
reason a fragment renders this instead of the feature.
parseSet became variadic so the fragment set reuses it rather than growing a
second copy of the overlay logic; `Partial` takes map[string]string after the
advisory correctly flagged `any` as generality nothing had asked for.
5.3 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).
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
}
cmd/khosra/wire.go holds the only list of enabled extensions — it exists now, holding one line. 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 | includes, translation fallback |
PhaseParse |
the parsed Markdown tree | shortcodes, transclusion, image derivatives |
PhaseMarkup |
rendered HTML fragments, code spans skipped | widows. Smart quotes and dashes turned out to be a Markdown parser option, and chrome localisation a template function (ADR-0034) — neither needed a phase |
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
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.