{{< include file="notes.md" >}} renders a file from the bundle as Markdown in
place. The included file is converted by the same goldmark instance that is
rendering the page — handed to the transformer in Extend — so its configuration
can never drift from the page's.
Three properties, each tested:
A name containing ".." is refused. os.Root would stop a path leaving the site
root, but path.Join collapses ".." long before the filesystem sees it, so without
this an include could read a template or a stray dotfile from the site root and
publish it. Verified the test fails without the guard: it took three levels of
".." from content/pages/d to reach the root, and the first version of the test
used two, so it passed either way and proved nothing.
An include inside an included file renders nothing and logs. The nested parse is
marked, so one level is all there is and a file including itself is a log line
rather than a stack overflow (ADR-0038, ADR-0029).
A gallery inside an included file still resolves, because the nested parse carries
the same Origin.
The node gained `content` for output a feature produced itself, plus `isContent`
so a failed include renders nothing instead of falling through to a fragment
lookup and complaining about a template that was never meant to exist.
5.8 KiB
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.
// 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 | 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
- Deletable without trauma. Removing the package leaves the engine building and serving.
- Reads only what already exists on the page; adds through the
Extrabag, never by widening the core struct for its own convenience. - Owns its output files under a namespaced path, or none.
- 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. - Failure degrades: a broken extension logs and is skipped, never takes a request down.
- Needing a permanent external service or a primitive change makes it a trunk — see
exploration.md. - One directory, no sibling imports, and a
doc.goin 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.