Files
khosra/docs/extensions.md
T
bdeshi af79331dc8 prevent widows, as the second feature package
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.
2026-08-01 02:23:35 +06:00

5.9 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
}

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):
// 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.