Two structural changes, both about what an agent may pull into context. exploration.md catalogues engine features nobody has asked for. That is storage, not working material, so it moves to ideas/ where nothing sweeps it and it is opened only when named — the same rule the other parked material already follows. Six references repointed; the ideas gates then demanded an index line and a status, and both were supplied rather than exempted. build-queue.md was 516 lines, nearly all of it entries 0-23 finished months of work ago, with the plan buried at the top. It becomes .scratch/continue.md at 49: where the code is, what is planned, and the findings worth carrying that no doc owns — chiefly that silent damage to prose is this engine's recurring failure mode, and that three defects this arc were invisible to curl. Docs and HARNESS point at the new names. No rule, gate or threshold changed.
107 lines
6.0 KiB
Markdown
107 lines
6.0 KiB
Markdown
# 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.
|
||
|
||
```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.
|