# 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//`, 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/` | `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. ```go // 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 | 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): ```go // 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.