Files
khosra/docs/extensions.md
T
Claude Opus 5andbdeshi c16bf4bd9d add shortcodes as the first internal/ext feature
A call is `{{< name key="value" >}}` alone on a line, parsed by a goldmark block
parser into an AST node and rendered by executing a theme template of that name
(ADR-0036). `figure` ships; `include` and `gallery` need the including bundle's
directory, which the parser does not carry yet, so they wait.

The layering did the design work here. internal/render may not import
internal/ext, so render.New takes a callback that receives a Partial and returns
Markdown extensions, and cmd/khosra/wire.go holds the only list of enabled
features. Empty that list and the engine still builds and serves — which is the
property extensions.md says the contract should have.

Raw HTML stays disabled. An author's text reaches a page only as arguments that
html/template escapes in context, which the real binary shows: a hostile alt
becomes &lt;script&gt; and src="javascript:…" becomes #ZgotmplZ. Getting
contextual escaping from the standard library rather than writing it is the whole
reason a fragment renders this instead of the feature.

parseSet became variadic so the fragment set reuses it rather than growing a
second copy of the overlay logic; `Partial` takes map[string]string after the
advisory correctly flagged `any` as generality nothing had asked for.
2026-07-30 10:29:00 +06:00

5.3 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).

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 includes, translation fallback
PhaseParse the parsed Markdown tree shortcodes, transclusion, image derivatives
PhaseMarkup rendered HTML fragments, code spans skipped widows. Smart quotes and dashes turned out to be a Markdown parser option, and chrome localisation a template function (ADR-0034) — neither needed a phase
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.