The last two words of a paragraph or heading are joined by a non-breaking space, so one word never falls alone onto its own line. Deferred from entry 11 for the right reason: over rendered HTML this cannot tell prose from an escaped code span, so it had to wait for a tree transform. The interesting failure is worth keeping: written against goldmark alone it passed six tests, and did nothing in the real engine. The typographer splits a text run wherever it looks for a substitution, so a paragraph ending "hand." arrives as two text nodes and the last of them holds no space at all. A version that inspects only the last child therefore finds nothing to join. It now takes the whole trailing run of text nodes, stopping at a line break or any markup, and there is a regression test that builds both extensions together — the only configuration that would have caught it. The joined text becomes a String node, which carries its own bytes: a segment is an offset into bytes every node shares, so editing the source in place is not possible. That path still escapes, and a test says so, since otherwise this transform would be an injection route. PhaseMarkup is now empty in extensions.md, and honestly so: everything expected there turned out to belong either earlier or later.
107 lines
5.9 KiB
Markdown
107 lines
5.9 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 yet. Everything expected here turned out to belong earlier or later: smart quotes and dashes are a Markdown parser option, chrome localisation is a template function (ADR-0034), and widows are a tree transform — over rendered HTML none of them could tell prose from an escaped code span |
|
||
| `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
|
||
`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.
|