Files
khosra/docs/extensions.md
T
bdeshiandClaude Opus 5 9349c54d2e let a feature own a route, and serve the site's own files at exact paths
Addresses like /.well-known/security.txt are fixed by somebody else's spec.
None is a bundle, none belongs under /static/, and core had no way to serve one.

This is the trigger the extension registry has been held for, in those words:
ADR-0042 called core's generic derived-file route "the seam to revisit when a
second feature wants output of its own", and state.md's counter note said to
build the registry "when a feature wants a route". Raw passthrough is that
feature, so the seam is built rather than worked around.

Only Routes, not the seven-field Extension struct extensions.md describes. Five
of the other six fields have no implementor and building them would be the
speculation rule 6 forbids. It also kept the change inside the core budget,
which had 65 lines left: the seam is ~30 core lines and the feature's own code
lands in internal/ext/, where there is room. Core is 2965/3000.

A feature returns map[string]http.Handler; core mounts each as an exact pattern
and learns nothing about who owns it. A path core already answers is skipped
with a warning, not overridden — http.ServeMux panics on a duplicate pattern, so
a site shipping root/robots.txt would otherwise take the server down at startup.
Verified: server alive, engine keeps /robots.txt, warning logged, zero panics.

Templating is opt-in by filename. A .tmpl suffix is stripped from the URL and
the file is rendered with text/template — never html/template, which would turn
an ampersand in a contact address into & and a JSON quote into ". Opt-in
by name rather than by sniffing the type, because a key or a signature may
contain anything and a pass choosing for itself which files to rewrite would
eventually eat one. The data is the site's own declarations and nothing more,
which is the point: a security.txt naming its canonical URL should not repeat
what site.yaml already says.

Headers come from root/_headers.yaml, exact paths only. Globs are a second-use
feature and the concrete need is a handful of .well-known names. The manifest is
not served, by the leading-underscore rule that already means "not addressable"
everywhere else — no special case was added for it. A manifest that will not
parse is logged and ignored; the files still serve.

Found while counting: the Extensions row read 4 while five packages existed.
notation landed in ADR-0061/0062 and was never counted, though the prose beside
the number already named all five. Corrected to 6. That is the latent item about
counters having no mechanical check, demonstrating itself.

Not done, and logged as latent: khosra check cannot report a root/ file
shadowing an engine path, because verify.sh fails a feature that imports a
sibling and the reserved paths live in passthrough. The startup warning fires on
every boot, which is louder than a check finding.

Evidence against the demo with a fresh binary: /pubkey answers with its declared
text/plain despite having no extension; /.well-known/security.txt answers with
Canonical filled from site.yaml's base, plus the declared CORS header;
/humans.txt gets a derived type; /_headers.yaml is 404; / and a bundle page are
untouched. Eight unit tests cover layout, absence, interpolation, non-escaping,
declared and derived headers, a broken template, and a broken manifest.

24 files, +514/-46. Extensions 4 (miscounted) → 6. Routing cases unmoved: exact
paths are mux entries, not resolver cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:48:59 +06:00

6.7 KiB
Raw Blame History

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 (02 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

  1. Deletable without trauma. Removing the package leaves the engine building and serving.
  2. Reads only what already exists on the page; adds through the Extra bag, never by widening the core struct for its own convenience.
  3. Owns its output files under a namespaced path, or none.
  4. 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.
  5. Failure degrades: a broken extension logs and is skipped, never takes a request down.
  6. Needing a permanent external service or a primitive change makes it a trunk — see ideas/exploration.md.
  7. One directory, no sibling imports, and a doc.go in 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.