Compiling templates
Plume has two back-ends over one language and one front-end:
- The interpreting renderer powers static-site generation. It is dynamic and feature-complete (scoped styles, client scripts, the asset pipeline, host functions,
@stateand the full filter library). - The compiling back-end lowers a template to Swift source that compiles under Embedded Swift: a typed
renderfunction that writes HTML bytes. This is what a request-time view layer (for example, a Cloudflare Workers Wasm isolate) uses to render views with no interpreter in the loop.
The same .plume file is the source for both. The compiling back-end accepts a dynamically-renderable subset of the language (below) and rejects the rest with clear, source-located errors.
Compiling
$plumekit compile Views/ # print generated Swift to stdout$plumekit compile Views/ -o Generated/ # write one .swift per templateEach template is checked against the renderable subset first; any out-of-subset feature is reported as path:line:col: message and compilation stops with a non-zero exit. Valid templates emit Swift that imports PlumeRuntime.
Typed props
For the compiled target, component parameters name a Swift type. The interpreting renderer ignores the annotation, so a typed component still renders on the static-site path unchanged.
@component PostPage(post: Post, related: [Post] = [], currentUser: User?) {
<h1>{post.title}</h1>
@for item in related { ... }
}lowers to:
func postPage(post: Post, related: [Post] = [], currentUser: User?,
into out: inout HTML) { ... }- Defaults map to Swift default arguments; optionals stay optionals.
{post.title}→ escapingout.text(post.title)(auto-escaping is the default).{value | raw}→out.raw(value)(the explicit unescaped opt-out).@if/@for→ Swiftif/for;forloopis available inside loops.@Child(post: p)→ a typed callchild(post: p, into: &out).@slot/@slot(name)→ optional render-closure parameters; an unfilled slot renders its fallback.
Member-level type checking is deferred to swiftc. Because generated code carries #sourceLocation directives, a type error (for example, interpolating a value that is not a String) is reported against the .plume line, not generated Swift.
The dynamically-renderable subset
Generated render functions are pure and synchronous: already-materialised data in, bytes out. No await, no I/O, no concurrency. Supported:
- Text, escaped interpolation (
{x}) and the raw opt-out ({x | raw}). @if/@else if/@else,@for ... in ...(withforloop),@let,@if letoptional binding. Conditions must beBool(swiftcenforces this), so write@if posts.count > 0or@if flag, not a bare non-boolean value.- Components, component calls, default and named slots, and defaults.
- Embedded-safe filters:
plus,minus,times,dividedBy,modulo,abs,atLeast,atMost,round,ceil,floor,default, plusraw/escape. - Embedded-safe methods:
hasPrefix/startsWith,hasSuffix/endsWith,contains.
All text handling is byte-wise UTF-8. The compiled back-end never relies on String ==, case-folding or Unicode collation, because those fail to link under Embedded Swift (the Unicode tables are absent). String equality is compiled to a byte-wise comparison; human-text collation is not this layer's job.
Behaviour hooks and the asset bundle
@style, @script, @state and @navigation are split between build time and request time. Their heavy parts (scoped CSS, the client-script-language → JS compilation and the Plume client runtime) are compiled once into a content-hashed bundle (plumekit bundle -o DIR, or the PlumeAssetBundle API). The render function emits only the HTML-side hooks:
- a scoped
@styleadds adata-plume-scope-…attribute to the component's tags; the CSS itself goes to the bundle. The scope id is computed identically on both sides, so the bundled CSS always matches the markup. @statelowers to a<script type="application/json" data-plume-state>hook whose initial values are computed from props at render time (JSON-encoded byte-wise).@navigationlowers to a static<script data-plume-navigation>marker.@scriptcompiles into the bundle; the render function emits nothing inline.- Bundle tags are injected automatically. Any
@stylesite records that the response needs the stylesheet; any@script/@state/@navigationsite records that it needs the client script. At the response boundary (Response.view), the required<link>/<script>are spliced into the document's<head>, wherever in the page the directives appeared, markeddata-plume-trackso client-side navigation falls back to a full load when a deploy changes the bundle hashes. Fragments (no<head>) are left untouched; a page using none of these directives loads no bundle at all. The author never writes a manual<script src="app.js">or stylesheet link. asset("name")resolves at build time to the content-hashed bundle URL, baked into the render function as a string literal (asset("app.js")→/app.<hash>.js;asset("logo.png")→/logo.pngfor your ownPublic/files). Because the URL is a literal, no runtimeStringlookup links into the guest. In a compiled template the argument must be a string literal.
The bundle files are content-hashed: app.<hash>.css (extracted scoped @style CSS) and app.<hash>.js (@script client scripts plus the Plume client runtime).
Build-time-only features (rejected by the checker)
Using these in a compiled template is a checker error:
@imageand the responsive-image pipeline. (asset(...)is allowed; it is resolved to a baked URL literal at build time, as above.)- Host-provided functions.
- Foundation/Unicode-backed filters and methods: case-folding (
upcase/downcase/capitalize,uppercased/lowercased),slugify,date,json,urlEncode/urlDecode,split,replace/replaceFirst,strip/lstrip/rstrip,truncate,sort/sortNatural,map/where, and similar.
Continuous integration
support/embedded-gate.sh is the link-and-run gate: it runs plumekit compile on Fixtures/EmbeddedGate, then builds the generated code with the Embedded-Swift Wasm SDK, links an executable, runs it under Node's WASI and asserts the rendered bytes. Linking (not just building a library) is required because Embedded link-time failures are invisible to a library-only build.