clanker hai 2 semanas
pai
achega
7134d9d913

+ 64 - 0
example/Actions.vala

@@ -0,0 +1,64 @@
+using Statum;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Example {
+
+    /**
+     * `POST /login` — creates a session-scoped `auth` slot from the submitted
+     * name and redirects the client home. The `is_admin` flag is kept in the
+     * slot's (encrypted, server-only) private data.
+     */
+    public class LoginAction : StatumAction {
+
+        public override async Lot<Model.DirectiveDto> handle() throws GLib.Error {
+            var name = "guest";
+            if (request.form != null) {
+                var submitted = request.form.get_field("name");
+                if (submitted != null && ((!)submitted).length > 0) {
+                    name = (!)submitted;
+                }
+            }
+            var is_admin = name == "admin";
+
+            var auth_public = new PropertyDictionary();
+            auth_public.set_native<string>("name", name);
+            auth_public.set_native<string>("role", is_admin ? "admin" : "member");
+
+            var auth_private = new PropertyDictionary();
+            auth_private.set_native<bool>("is_admin", is_admin);
+
+            var state = new State() {
+                type_name = "auth",
+                public_data = auth_public,
+                private_data = auth_private
+            };
+            var slot = state_service.new_slot(Scope.SESSION, state);
+            var frame = state_service.sign_slot(slot.id);
+
+            var directives = new Series<Model.DirectiveDto>();
+            directives.add(new Model.SetDirectiveDto.with_frame((!)frame));
+            directives.add(new Model.NavigateDirectiveDto.with_uri("/"));
+            return directives;
+        }
+    }
+
+    /**
+     * `POST /logout` — clears the held `auth` slot and redirects home.
+     */
+    public class LogoutAction : StatumAction {
+
+        public override async Lot<Model.DirectiveDto> handle() throws GLib.Error {
+            var directives = new Series<Model.DirectiveDto>();
+
+            HeldSlot auth;
+            if (request.held.try_get("auth", out auth)) {
+                yield state_service.clear_slot(auth.key);
+                directives.add(new Model.ClearDirectiveDto.with_key(auth.key));
+            }
+
+            directives.add(new Model.NavigateDirectiveDto.with_uri("/"));
+            return directives;
+        }
+    }
+}

+ 30 - 0
example/HomeEntrypoint.vala

@@ -0,0 +1,30 @@
+using Statum;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Example {
+
+    /**
+     * Entrypoint for the home page: hydrates a `counter` slot the template binds
+     * to with `stm-text="counter.value"`.
+     */
+    public class HomeEntrypoint : StatumEntrypoint {
+
+        public override async Lot<Model.DirectiveDto> handle() throws GLib.Error {
+            var directives = new Series<Model.DirectiveDto>();
+
+            var counter_public = new PropertyDictionary();
+            counter_public.set_native<int>("value", 42);
+            var counter_state = new State() {
+                type_name = "counter",
+                public_data = counter_public,
+                private_data = new PropertyDictionary()
+            };
+            var counter_slot = state_service.new_slot(Scope.PAGE, counter_state);
+            var counter_frame = state_service.sign_slot(counter_slot.id);
+            directives.add(new Model.SetDirectiveDto.with_frame((!)counter_frame));
+
+            return directives;
+        }
+    }
+}

+ 29 - 0
example/Main.vala

@@ -0,0 +1,29 @@
+using Astralis;
+using Inversion;
+using Statum;
+using Example;
+
+void main(string[] args) {
+    int port = args.length > 1 ? int.parse(args[1]) : 8080;
+
+    try {
+        var application = new WebApplication(port);
+        application.use_compression();
+        application.add_module<StatumModule>();
+
+        var statum = application.configure_with<StatumConfigurator>();
+        // HomePage is generated from home.html; HomeEntrypoint hydrates it.
+        // The Statum client scripts (statum.js / statum-worker.js) are embedded in
+        // the library and served by default, so only the page-specific CSS needs
+        // registering here.
+        statum.add_entrypoint_page<HomePage, HomeEntrypoint>(new EndpointRoute("/"));
+        statum.add_action<LoginAction>(new EndpointRoute("/login", Method.POST));
+        statum.add_action<LogoutAction>(new EndpointRoute("/logout", Method.POST));
+        statum.add_resource<StylesResource>();
+
+        application.run();
+    } catch (Error e) {
+        printerr("Error: %s\n", e.message);
+        Process.exit(1);
+    }
+}

+ 39 - 0
example/home.html

@@ -0,0 +1,39 @@
+<pstm-uri>/</pstm-uri>
+<pstm-template>main</pstm-template>
+
+<!--
+  Server-side variant (pstm-*).
+
+  The branch is chosen by statum-mkpstm at request time from the X-Statum-Slot
+  headers the client carries. A normal browser navigation sends no such headers,
+  so the default ("guest") branch is what is first painted; the client then takes
+  over the live UI below. To see the authenticated variant directly, replay the
+  request with the stored frame:
+
+      curl http://localhost:8080/ -H "X-Statum-Slot: <base64-frame>"
+-->
+<p pstm-if="auth != null">Server-rendered: signed in.</p>
+<p pstm-else>Server-rendered: browsing as a guest.</p>
+
+<hr>
+
+<p>Counter (set by the entrypoint): <span stm-text="counter.value">…</span></p>
+
+<!--
+  Client-side variant (stm-*).
+
+  Evaluated live against the held slots after the entrypoint hydrates the page.
+-->
+<div stm-if="auth != null">
+    <p>Welcome back, <strong stm-text="auth.name">user</strong>.</p>
+    <form stm-action="{uri: '/logout', method: 'POST'}">
+        <button type="submit">Log out</button>
+    </form>
+</div>
+<div stm-else>
+    <p>Sign in to personalise this page.</p>
+    <form stm-action="{uri: '/login', method: 'POST'}">
+        <label>Name <input name="name" placeholder='try "admin"'></label>
+        <button type="submit">Log in</button>
+    </form>
+</div>

+ 17 - 0
example/main.html

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <pstm-name>main</pstm-name>
+    <pstm-title>Statum Example</pstm-title>
+    <link rel="stylesheet" pstm-res-href="styles.css">
+    <script pstm-res-src="statum.js"></script>
+</head>
+<body>
+    <header><h1>Statum Example</h1></header>
+    <main>
+        <pstm-content></pstm-content>
+    </main>
+    <footer><p>Server-rendered and hydrated by the Statum client.</p></footer>
+</body>
+</html>

+ 25 - 0
example/meson.build

@@ -0,0 +1,25 @@
+# Statum end-to-end example app.
+#
+# A small but complete browser-driven Statum application: a server-rendered,
+# client-hydrated home page with login/logout actions. The Statum client
+# scripts are embedded in libstatum and served by default, so this only
+# generates the page (home.html + main.html template) and the page CSS.
+
+home_page = custom_target('home-page',
+    input: 'home.html',
+    output: 'HomePage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'HomePage', '--ns=Example', '@INPUT@'],
+    depend_files: files('main.html')
+)
+
+styles_resource = custom_target('styles-resource',
+    input: 'styles.css',
+    output: 'StylesResource.vala',
+    command: [statum_mkres, '-o', '@OUTPUT@', '-n', 'styles.css', '-c', 'text/css', '@INPUT@']
+)
+
+executable('statum-example',
+    ['Main.vala', 'HomeEntrypoint.vala', 'Actions.vala', home_page, styles_resource],
+    dependencies: [statum_dep],
+    install: false
+)

+ 3 - 0
example/styles.css

@@ -0,0 +1,3 @@
+body { font-family: sans-serif; margin: 2rem; }
+header h1 { color: #2a4d69; }
+footer { margin-top: 2rem; color: #888; font-size: 0.85rem; }

+ 3 - 1
js/statum.js

@@ -40,7 +40,9 @@
     entrypointUrl: '/_statum/entrypoint',
     slotsUrl: '/_statum/slots',
     channelUrl: '/_statum/channel',
-    workerUrl: '/_statum/worker.js'
+    // The realtime worker is embedded in the library as a default resource
+    // (served at /_statum/resource/statum-worker.js); override with stm-worker.
+    workerUrl: '/_statum/resource/statum-worker.js'
   };
 
   // ---------------------------------------------------------------------------

+ 4 - 0
meson.build

@@ -27,4 +27,8 @@ sodium_deps = declare_dependency(sources: sodium_vapi, dependencies: sodium_c_li
 # VAPI Directory
 add_project_arguments(['--vapidir', vapi_dir], language: 'vala')
 
+# Tools are built first (from source — never required to be installed) so the
+# library can use them at its own build time (e.g. to embed the client scripts).
+subdir('tools')
 subdir('src')
+subdir('example')

+ 123 - 0
src/ChannelEndpoint.vala

@@ -0,0 +1,123 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * The realtime channel endpoint and {@link ChannelService} implementation.
+     *
+     * Registered as a singleton (both as itself and as {@link ChannelService}).
+     * Each `GET /_statum/channel` opens a long-lived SSE stream whose first event
+     * is `event: channel` carrying a fresh channel id; subsequent `set`/`clear`
+     * events are delivered only for the slot keys the channel has subscribed to
+     * (via `PATCH /_statum/channel/{id}`).
+     *
+     * {@link StateService.update}/{@link StateService.clear_slot} call
+     * {@link push_set}/{@link push_clear}, which fan out to the subscribed
+     * streams — a no-op when nothing is subscribed, giving selective fan-out for
+     * free.
+     */
+    public class ChannelEndpoint : SseEndpoint, ChannelService {
+
+        public override uint retry_interval { get { return 3000; } }
+
+        private Series<ChannelConnection> connections = new Series<ChannelConnection>();
+
+        public override async void new_connection(HttpContext http_context, RouteContext route_context, SseStream stream) {
+            var connection = new ChannelConnection() {
+                stream = stream,
+                id = generate_channel_id()
+            };
+            connections.add(connection);
+
+            stream.disconnected.connect(() => connections.remove(connection));
+
+            try {
+                yield stream.send_event(new SseEvent.with_type("channel", connection.id));
+            } catch (GLib.Error e) {
+                stream.close();
+            }
+        }
+
+        /** Pushes a `set` event carrying `frame_json` to subscribed channels. */
+        public async void push_set(string key, string frame_json) {
+            foreach (var connection in open_subscribers(key)) {
+                try {
+                    yield connection.stream.send_event(new SseEvent.with_type("set", frame_json));
+                } catch (GLib.Error e) {
+                    connection.stream.close();
+                }
+            }
+        }
+
+        /** Pushes a `clear` event for `key` to subscribed channels. */
+        public async void push_clear(string key) {
+            foreach (var connection in open_subscribers(key)) {
+                try {
+                    yield connection.stream.send_event(new SseEvent.with_type("clear", key));
+                } catch (GLib.Error e) {
+                    connection.stream.close();
+                }
+            }
+        }
+
+        /** Whether a channel with `id` is currently open. */
+        public bool has_channel(string id) {
+            return connections.any(c => !c.stream.is_closed && c.id == id);
+        }
+
+        /** Adds `key` to channel `id`'s subscriptions. */
+        public void subscribe(string id, string key) {
+            var connection = find_connection(id);
+            if (connection != null) {
+                ((!)connection).subs.add(key);
+            }
+        }
+
+        /** Removes `key` from channel `id`'s subscriptions. */
+        public void unsubscribe(string id, string key) {
+            var connection = find_connection(id);
+            if (connection != null) {
+                ((!)connection).subs.remove(key);
+            }
+        }
+
+        private ChannelConnection? find_connection(string id) {
+            foreach (var connection in connections) {
+                if (!connection.stream.is_closed && connection.id == id) {
+                    return connection;
+                }
+            }
+            return null;
+        }
+
+        private Lot<ChannelConnection> open_subscribers(string key) {
+            return connections
+                .where(c => !c.stream.is_closed && c.subs.contains(key))
+                .to_immutable_buffer();
+        }
+
+        private static string generate_channel_id() {
+            var bytes = new uint8[16];
+            Sodium.Random.random_bytes(bytes);
+            var builder = new StringBuilder();
+            foreach (var b in bytes) {
+                builder.append_printf("%02x", b);
+            }
+            return builder.str;
+        }
+
+        private class ChannelConnection : Object {
+            public SseStream stream { get; set; }
+            public string id { get; set; }
+            public Set<string> subs { get; private set; }
+
+            public ChannelConnection() {
+                subs = new HashSet<string>();
+            }
+        }
+
+    }
+
+}

+ 33 - 0
src/ChannelService.vala

@@ -0,0 +1,33 @@
+namespace Statum {
+
+    /**
+     * One-way realtime push service for slot updates.
+     *
+     * {@link StateService} injects a {@link ChannelService} (the singleton
+     * {@link ChannelEndpoint}) so that every state mutation auto-pushes the
+     * resulting frame to any channels subscribed to that slot. The dependency is
+     * deliberately one-way: a {@link ChannelService} never injects
+     * {@link StateService}, so there is no DI cycle.
+     *
+     * The push is a no-op when no stream is subscribed to `key`, so selective
+     * fan-out emerges naturally from subscriptions. The low-level
+     * {@link ChannelEndpoint.push_set}/{@link ChannelEndpoint.push_clear} remain
+     * callable directly for advanced cases.
+     */
+    public interface ChannelService : Object {
+
+        /**
+         * Pushes a `set` event carrying `frame_json` to every channel subscribed
+         * to `key`. No-op when nothing is subscribed.
+         */
+        public abstract async void push_set(string key, string frame_json);
+
+        /**
+         * Pushes a `clear` event for `key` to every channel subscribed to it.
+         * No-op when nothing is subscribed.
+         */
+        public abstract async void push_clear(string key);
+
+    }
+
+}

+ 78 - 0
src/ChannelSubscriptionEndpoint.vala

@@ -0,0 +1,78 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillJson;
+using Inversion;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * `PATCH /_statum/channel/{id}` — adds or removes a channel's subscriptions.
+     *
+     * The request body is `{ "subscribe": [keys…], "unsubscribe": [keys…] }`.
+     * For each newly-subscribed key, if the {@link StateService} has the slot
+     * cached, its current frame is pushed immediately so a fresh client sees the
+     * latest state right away (mirroring `server.php`). Responds `204 No Content`.
+     */
+    public class ChannelSubscriptionEndpoint : Object, Endpoint {
+
+        private ChannelEndpoint channel_endpoint = inject<ChannelEndpoint>();
+        private StateService state_service = inject<StateService>();
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
+            var id = route_context.mapped_parameters.get_or_default("id");
+            if (id == null || !channel_endpoint.has_channel((!)id)) {
+                return new HttpStringResult("No such channel", StatusCode.NOT_FOUND);
+            }
+
+            var body = yield http_context.request.request_body.read_all();
+            var body_str = body.to_raw_string();
+
+            Enumerable<string> subscribe_keys = Iterate.nothing<string>();
+            Enumerable<string> unsubscribe_keys = Iterate.nothing<string>();
+
+            if (body_str.strip().length > 0) {
+                var root = new JsonElement.from_string(body_str).as<JsonObject>();
+                subscribe_keys = read_string_array(root, "subscribe");
+                unsubscribe_keys = read_string_array(root, "unsubscribe");
+            }
+
+            foreach (var key in unsubscribe_keys) {
+                channel_endpoint.unsubscribe((!)id, key);
+            }
+
+            foreach (var key in subscribe_keys) {
+                channel_endpoint.subscribe((!)id, key);
+
+                var slot = state_service.get_slot(key);
+                if (slot != null) {
+                    var frame = state_service.sign_slot(key);
+                    if (frame != null) {
+                        var frame_json = serialize_frame((!)frame);
+                        yield channel_endpoint.push_set(key, frame_json);
+                    }
+                }
+            }
+
+            return new HttpEmptyResult(StatusCode.NO_CONTENT);
+        }
+
+        private static string serialize_frame(Model.FrameDto frame) throws GLib.Error {
+            return new JsonElement.from_properties(Model.FrameDto.get_mapper().map_from(frame)).stringify();
+        }
+
+        private static Enumerable<string> read_string_array(JsonObject root, string key) throws GLib.Error {
+            var array = root.get_array(key);
+            if (array == null) {
+                return Iterate.nothing<string>();
+            }
+            var result = new Series<string>();
+            foreach (var element in (!)array) {
+                result.add(element.as<string>());
+            }
+            return result;
+        }
+
+    }
+
+}

+ 62 - 0
src/EntrypointEndpoint.vala

@@ -0,0 +1,62 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * `GET /_statum/entrypoint` — returns the initial directives for a page.
+     *
+     * The `uri` query parameter is matched against the {@link EntrypointRouteTable}
+     * (populated by {@link StatumConfigurator.add_page}). On a match the bound
+     * entrypoint handler is resolved, given a {@link StatumRequest} built from the
+     * held slots and query, and its {@link StatumEntrypoint.handle} directives are
+     * returned. Responds `404` when no entrypoint is bound to `uri`.
+     */
+    public class EntrypointEndpoint : Object, Endpoint {
+
+        private Inversion.Scope scope = inject<Inversion.Scope>();
+        private HeldSlotResolver resolver = inject<HeldSlotResolver>();
+        private EntrypointRouteTable route_table = inject<EntrypointRouteTable>();
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
+            var raw_uri = http_context.request.get_query("uri") ?? "/";
+
+            // The client passes the page's full href (e.g.
+            // "http://host/path?query") as `uri`; match against just the path.
+            var path = extract_path(raw_uri);
+
+            var match = route_table.match(path);
+            if (match == null) {
+                return new HttpStringResult(@"No entrypoint bound to \"$path\"", StatusCode.NOT_FOUND);
+            }
+
+            var held = resolver.resolve(http_context);
+            var request = new StatumRequest() {
+                uri = path,
+                route_params = ((!)match).route_params,
+                held = held,
+                query = http_context.request.query_params
+            };
+            scope.register_local_scoped<StatumRequest>(() => request);
+
+            var entrypoint = (StatumEntrypoint) scope.resolve_type(((!)match).entrypoint_type);
+            var directives = yield entrypoint.handle();
+            return StatumDirectives.to_result(directives);
+        }
+
+        private static string extract_path(string uri) {
+            try {
+                var parsed = Uri.parse(uri, UriFlags.NONE);
+                var path = parsed.get_path();
+                return path != null && path.length > 0 ? (!)path : "/";
+            } catch (GLib.Error e) {
+                // Not an absolute URI — assume it's already a path.
+                return uri;
+            }
+        }
+
+    }
+
+}

+ 89 - 0
src/EntrypointRouteTable.vala

@@ -0,0 +1,89 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * A successful match of a request URI against an entrypoint route.
+     */
+    public class EntrypointMatch : Object {
+
+        /** The entrypoint handler type bound to the matched route. */
+        public Type entrypoint_type { get; set; }
+
+        /** Route parameters captured from the matched route. */
+        public ReadOnlyAssociative<string, string> route_params { get; set; default = new Dictionary<string, string>(); }
+
+    }
+
+    /**
+     * Singleton table of `(route, entrypoint type)` pairs, populated by
+     * {@link StatumConfigurator.add_page}.
+     *
+     * The {@link EntrypointEndpoint} matches the `uri` query parameter against
+     * this table (using the same `{param}` segment matching as the Astralis
+     * router) so that a page and its entrypoint agree on routing.
+     */
+    public class EntrypointRouteTable : Object {
+
+        private Series<EntrypointRegistration> registrations = new Series<EntrypointRegistration>();
+
+        /** Registers an entrypoint type for `route`. */
+        public void register(EndpointRoute route, Type entrypoint_type) {
+            registrations.add(new EntrypointRegistration(route, entrypoint_type));
+        }
+
+        /**
+         * Matches `uri` against the registered routes, returning the bound
+         * entrypoint type and any captured route parameters, or `null`.
+         */
+        public EntrypointMatch? match(string uri) {
+            var path_components = Wrap.array<string>(uri.split("/"))
+                .where(c => c.length != 0)
+                .to_array();
+
+            foreach (var registration in registrations) {
+                var route_segments = registration.route.route_segments.to_array();
+                var matched = try_match(path_components, route_segments);
+                if (matched != null) {
+                    return new EntrypointMatch() {
+                        entrypoint_type = registration.entrypoint_type,
+                        route_params = (!)matched
+                    };
+                }
+            }
+            return null;
+        }
+
+        private static Dictionary<string, string>? try_match(string[] path, string[] route_segments) {
+            if (path.length != route_segments.length) {
+                return null;
+            }
+
+            var params = new Dictionary<string, string>();
+            for (var i = 0; i < path.length; i++) {
+                var pattern = route_segments[i];
+                var value = path[i];
+                if (pattern.has_prefix("{") && pattern.has_suffix("}")) {
+                    params.set(pattern.substring(1, pattern.length - 2), value);
+                } else if (pattern != value) {
+                    return null;
+                }
+            }
+            return params;
+        }
+
+        private class EntrypointRegistration : Object {
+            public EndpointRoute route { get; private set; }
+            public Type entrypoint_type { get; private set; }
+
+            public EntrypointRegistration(EndpointRoute route, Type entrypoint_type) {
+                this.route = route;
+                this.entrypoint_type = entrypoint_type;
+            }
+        }
+
+    }
+
+}

+ 52 - 0
src/HeldSlot.vala

@@ -0,0 +1,52 @@
+using Invercargill;
+
+namespace Statum {
+
+    /**
+     * A slot the client currently holds, as resolved from the request's
+     * `X-Statum-Slot` headers.
+     *
+     * Each held slot exposes its decrypted {@link public} AND {@link private}
+     * data so that server-side predicate evaluation (page guards/`pstm-if`) and
+     * entrypoint/action handlers can read both. The {@link private} data is
+     * server-only and never reaches the browser.
+     *
+     * A {@link HeldSlotResolver} builds these from either the {@link reference}
+     * header form (re-using the server cache) or the {@link frame} header form
+     * (re-verifying a base64-encoded frame the client carried).
+     */
+    public class HeldSlot : Object {
+
+        /** The slot key (carried on the wire in headers/directives). */
+        public string key { get; set; }
+
+        /** The slot's type name (used to index held slots by type). */
+        public string type_name { get; set; }
+
+        /** The slot's persistence scope. */
+        public Scope scope { get; set; }
+
+        /**
+         * The user-facing public data the JS/HTML APIs bind to. Always non-null
+         * (an empty {@link Properties} when the snapshot carried none).
+         */
+        public Properties @public { get; set; }
+
+        /**
+         * The decrypted private data, only readable by the server. Always
+         * non-null (an empty {@link Properties} when the snapshot carried none).
+         */
+        public Properties @private { get; set; }
+
+        /** ISO 8601 timestamp the held snapshot was authored. */
+        public DateTime as_at { get; set; }
+
+        /** When the server cache for this snapshot expires, or `null`. */
+        public DateTime? transmit_after { get; set; }
+
+        /** When the held snapshot becomes invalid, or `null`. */
+        public DateTime? invalid_after { get; set; }
+
+    }
+
+}

+ 167 - 0
src/HeldSlotResolver.vala

@@ -0,0 +1,167 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillJson;
+using Inversion;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * Resolves the slots the client currently holds from the request's
+     * `X-Statum-Slot` headers into a type-name → {@link HeldSlot} map.
+     *
+     * Two header forms are supported (see `model.md`):
+     *
+     *  - **Reference form** (`key=…; as_at=…`): the snapshot is still within its
+     *    `transmit_after` window, so the client sends only a reference. The
+     *    resolver re-uses the server-side cache via {@link StateService.get_slot}
+     *    and skips the slot if it is absent.
+     *
+     *  - **Base64 frame form**: the snapshot's `transmit_after == as_at`
+     *    (always-send), so the client sends the full frame. The resolver
+     *    base64-decodes it, verifies the signature, parses the snapshot and
+     *    decrypts the private payload.
+     *
+     * Only slots whose data the client actually sent are surfaced (scoped to the
+     * `X-Statum-Slot` headers), and each is indexed by its slot type name.
+     */
+    public class HeldSlotResolver : Object {
+
+        private StateService state_service = inject<StateService>();
+        private Cryptography.SigningProvider signing_provider = inject<Cryptography.SigningProvider>();
+        private Cryptography.EncryptionProvider encryption_provider = inject<Cryptography.EncryptionProvider>();
+
+        private const string SLOT_HEADER = "X-Statum-Slot";
+
+        /**
+         * Resolves every `X-Statum-Slot` header on `http_context` into a
+         * type-name → {@link HeldSlot} map.
+         */
+        public ReadOnlyAssociative<string, HeldSlot> resolve(HttpContext http_context) {
+            var held = new Dictionary<string, HeldSlot>();
+
+            foreach (var header_value in http_context.request.headers.get_or_empty(SLOT_HEADER)) {
+                HeldSlot? slot = null;
+                if (header_value.contains("key=")) {
+                    slot = resolve_reference(header_value);
+                } else {
+                    slot = resolve_frame(header_value);
+                }
+
+                if (slot == null || ((!)slot).type_name == null) {
+                    continue;
+                }
+
+                held.set(((!)slot).type_name, (!)slot);
+            }
+
+            return held;
+        }
+
+        private HeldSlot? resolve_reference(string header_value) {
+            string? key = null;
+            DateTime? as_at = null;
+            foreach (var part in header_value.split(";")) {
+                var trimmed = part.strip();
+                if (trimmed.has_prefix("key=")) {
+                    key = trimmed.substring(4).strip();
+                } else if (trimmed.has_prefix("as_at=")) {
+                    var raw = trimmed.substring(6).strip();
+                    as_at = new DateTime.from_iso8601(raw, new TimeZone.utc());
+                }
+            }
+
+            if (key == null) {
+                return null;
+            }
+
+            var slot = state_service.get_slot((!)key);
+            if (slot == null || slot.current_state == null) {
+                return null;
+            }
+
+            var state = slot.current_state;
+
+            DateTime as_at_value = as_at;
+            if (as_at_value == null) {
+                if (state.last_touched != null) {
+                    as_at_value = state.last_touched;
+                } else {
+                    as_at_value = new DateTime.now_utc();
+                }
+            }
+
+            return new HeldSlot() {
+                key = slot.id,
+                type_name = slot.type_name,
+                scope = slot.scope,
+                @public = state.public_data ?? new PropertyDictionary(),
+                @private = state.private_data ?? new PropertyDictionary(),
+                as_at = as_at_value
+            };
+        }
+
+        private HeldSlot? resolve_frame(string header_value) {
+            string frame_json;
+            try {
+                var bytes = Base64.decode(header_value.strip());
+                frame_json = to_null_terminated_string(bytes);
+            } catch (GLib.Error e) {
+                return null;
+            }
+
+            Model.FrameDto frame;
+            Model.SnapshotDto snapshot;
+            try {
+                var frame_element = new JsonElement.from_string(frame_json);
+                frame = Model.FrameDto.get_mapper().materialise(frame_element.as<JsonObject>());
+                signing_provider.verify_frame(frame);
+
+                var snapshot_element = new JsonElement.from_string(frame.content);
+                snapshot = Model.SnapshotDto.get_mapper().materialise(snapshot_element.as<JsonObject>());
+            } catch (GLib.Error e) {
+                return null;
+            }
+
+            // Drop snapshots that have passed their invalid_after time.
+            if (snapshot.invalid_after != null) {
+                var now = new DateTime.now_utc();
+                if (((!)snapshot.invalid_after).compare(now) < 0) {
+                    return null;
+                }
+            }
+
+            var private_data = decrypt_private(snapshot);
+
+            return new HeldSlot() {
+                key = snapshot.slot.key,
+                type_name = snapshot.slot.slot_type,
+                scope = snapshot.slot.scope,
+                @public = snapshot.@public ?? new PropertyDictionary(),
+                @private = private_data,
+                as_at = snapshot.as_at,
+                transmit_after = snapshot.transmit_after,
+                invalid_after = snapshot.invalid_after
+            };
+        }
+
+        private Properties decrypt_private(Model.SnapshotDto snapshot) {
+            if (snapshot.@private == null) {
+                return new PropertyDictionary();
+            }
+            try {
+                return encryption_provider.read_properties(snapshot.slot.key, (!)snapshot.@private);
+            } catch (GLib.Error e) {
+                return new PropertyDictionary();
+            }
+        }
+
+        private static string to_null_terminated_string(uint8[] bytes) {
+            var copy = new uint8[bytes.length + 1];
+            Memory.copy(copy, bytes, bytes.length);
+            return (string) copy;
+        }
+
+    }
+
+}

+ 5 - 5
src/Model/DirectiveDto.vala

@@ -1,5 +1,5 @@
-using Gee;
 using Invercargill;
+using Invercargill.DataStructures;
 using Invercargill.Mapping;
 using InvercargillJson;
 
@@ -75,10 +75,10 @@ namespace Statum.Model {
         }
 
         /**
-         * Serialises a list of directives to the JSON array form used by every
+         * Serialises a lot of directives to the JSON array form used by every
          * directive-returning endpoint.
          */
-        public static JsonElement serialize_array(Gee.List<DirectiveDto> directives) throws GLib.Error {
+        public static JsonElement serialize_array(Lot<DirectiveDto> directives) throws GLib.Error {
             var array = new JsonArray();
             foreach (var directive in directives) {
                 array.add(directive.to_json_object().as_element());
@@ -89,8 +89,8 @@ namespace Statum.Model {
         /**
          * Deserialises a JSON array of directives.
          */
-        public static Gee.List<DirectiveDto> deserialize_array(JsonElement element) throws GLib.Error {
-            var result = new ArrayList<DirectiveDto>();
+        public static Series<DirectiveDto> deserialize_array(JsonElement element) throws GLib.Error {
+            var result = new Series<DirectiveDto>();
             var array = element.as<JsonArray>();
             foreach (var item in array) {
                 result.add(from_json(item.as<JsonObject>()));

+ 48 - 0
src/ResourceEndpoint.vala

@@ -0,0 +1,48 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * `GET /_statum/resource/{name}` — serves precompressed {@link StatumResource}s.
+     *
+     * Mirrors Spry's {@link Spry.StaticResourceProvider}: all registered
+     * {@link StatumResource}s are collected at construction, the `{name}` segment
+     * selects one, the best encoding is chosen from `Accept-Encoding`, and
+     * `ETag`/`If-None-Match` is honoured.
+     */
+    public class ResourceEndpoint : Object, Endpoint {
+
+        private Dictionary<string, StatumResource> resource_map = inject_all<StatumResource>().to_dictionary<string>(r => r.name);
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
+            var name = route_context.mapped_parameters.get_or_default("name");
+            if (name == null) {
+                return new HttpStringResult("No such resource.", StatusCode.NOT_FOUND);
+            }
+
+            StatumResource resource;
+            if (!resource_map.try_get((!)name, out resource)) {
+                return new HttpStringResult(@"No such resource \"$((!)name)\".", StatusCode.NOT_FOUND);
+            }
+
+            var accepts = http_context.request.headers
+                .get_or_empty("Accept-Encoding")
+                .select_many<string>(h => Wrap.array<string>(h.split(",")))
+                .select<string>(s => s.strip())
+                .to_hash_set();
+
+            var best = resource.get_best_encoding(accepts);
+            var etag = resource.get_etag_for(best);
+            if (http_context.request.headers.get_or_empty("If-None-Match").contains(etag)) {
+                return new HttpEmptyResult(StatusCode.NOT_MODIFIED);
+            }
+
+            return resource.to_result(best);
+        }
+
+    }
+
+}

+ 58 - 0
src/SlotPostEndpoint.vala

@@ -0,0 +1,58 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillJson;
+using Inversion;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * `POST /_statum/slots` — refreshes snapshots that have passed their
+     * `transmit_after` window.
+     *
+     * The client POSTs a JSON array of Statum frames it currently holds whose
+     * cache has expired. For each frame this endpoint verifies the signature,
+     * {@link StateService.restore_slot}s it into the cache, re-signs it (issuing
+     * a fresh `as_at`/`transmit_after`), and returns a `set` directive per frame
+     * so the client refreshes its local snapshot (mirroring `server.php`).
+     */
+    public class SlotPostEndpoint : Object, Endpoint {
+
+        private StateService state_service = inject<StateService>();
+        private Cryptography.SigningProvider signing_provider = inject<Cryptography.SigningProvider>();
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
+            var body = yield http_context.request.request_body.read_all();
+            var body_str = body.to_raw_string();
+
+            var directives = new Series<Model.DirectiveDto>();
+
+            if (body_str.strip().length > 0) {
+                var array = new JsonElement.from_string(body_str).as<JsonArray>();
+                foreach (var item in array) {
+                    var frame = Model.FrameDto.get_mapper().materialise(item.as<JsonObject>());
+
+                    try {
+                        signing_provider.verify_frame(frame);
+                    } catch (GLib.Error e) {
+                        continue;
+                    }
+
+                    var content_object = new JsonElement.from_string(frame.content).as<JsonObject>();
+                    var snapshot = Model.SnapshotDto.get_mapper().materialise(content_object);
+
+                    state_service.restore_slot(frame);
+
+                    var refreshed = state_service.sign_slot(snapshot.slot.key);
+                    if (refreshed != null) {
+                        directives.add(new Model.SetDirectiveDto.with_frame((!)refreshed));
+                    }
+                }
+            }
+
+            return StatumDirectives.to_result(directives);
+        }
+
+    }
+
+}

+ 96 - 28
src/StateService.vala

@@ -1,9 +1,18 @@
+using Invercargill;
 using Invercargill.DataStructures;
 using InvercargillJson;
 using Inversion;
 
 namespace Statum {
 
+    /**
+     * Error domain for state service operations.
+     */
+    public errordomain StateError {
+        /** No slot is cached for the given key. */
+        SLOT_NOT_FOUND,
+    }
+
     public class StateService : Object {
 
         private const int RETRANSMIT_SECONDS = 60; // 1 minute
@@ -12,6 +21,14 @@ namespace Statum {
         private Cryptography.EncryptionProvider encryption_provider = inject<Cryptography.EncryptionProvider>();
         private Cryptography.SigningProvider signing_provider = inject<Cryptography.SigningProvider>();
 
+        /**
+         * The realtime channel service. Injected (singleton) so that every state
+         * mutation auto-pushes the resulting frame to subscribed channels. The
+         * dependency is one-way: {@link ChannelService} never injects this
+         * service, so there is no DI cycle.
+         */
+        private ChannelService channel_service = inject<ChannelService>();
+
         private Dictionary<string, Slot> slots = new Dictionary<string, Slot>();
 
         public Slot new_slot(Scope slot_scope, State initial_state) {
@@ -30,48 +47,66 @@ namespace Statum {
             return slot;
         }
 
+        /**
+         * Reads the cached slot for `id` without mutating it.
+         *
+         * Liveness is now driven by the `transmit_after`/`invalid_after` windows
+         * and explicit {@link update} calls, so reading a slot no longer touches
+         * its `last_touched`.
+         */
         public Slot? get_slot(string id) {
             Slot? slot = null;
-            if(slots.try_get(id, out slot)) {
-                slot.current_state.last_touched = new DateTime.now_utc();
-            }
+            slots.try_get(id, out slot);
             return slot;
         }
 
-        public Model.FrameDto? sign_slot(string id, bool unidirectional = false) throws Error {
+        public Model.FrameDto? sign_slot(string id, bool unidirectional = false) throws GLib.Error {
             var slot = get_slot(id);
-            if(slot == null) {
+            if (slot == null) {
                 return null;
             }
+            return sign_slot_for((!)slot, unidirectional);
+        }
 
-            var slot_dto = new Model.SlotDto() {
-                key = slot.id,
-                scope = slot.scope,
-                slot_type = slot.type_name
-            };
+        /**
+         * Mutates the slot cached under `key` to `state`, signs the resulting
+         * snapshot into a frame, auto-pushes that frame to any channel
+         * subscribed to `key` (a no-op when nothing is subscribed), and returns
+         * the signed frame so the caller can reuse it for its response `set`
+         * directive (one sign → push + response).
+         */
+        public async Model.FrameDto update(string key, State state) throws GLib.Error {
+            Slot? slot = null;
+            if (!slots.try_get(key, out slot) || slot == null) {
+                throw new StateError.SLOT_NOT_FOUND(@"No slot is cached for key \"$key\"");
+            }
 
-            var timestamp = new DateTime.now_utc();
-            var snapshot_dto = new Model.SnapshotDto() {
-                as_at = timestamp,
-                slot = slot_dto,
-                transmit_after = unidirectional ? null : timestamp.add_seconds(RETRANSMIT_SECONDS),
-                invalid_after = timestamp.add_seconds(EXPIRY_SECONDS),
-                @private = encryption_provider.author_properties(slot.id, slot.current_state.private_data),
-                @public = slot.current_state.public_data
-            };
+            state.last_touched = new DateTime.now_utc();
+            slot.current_state = state;
 
-            var json = new JsonElement.from_properties(Model.SnapshotDto.get_mapper().map_from(snapshot_dto)).stringify();
-            return signing_provider.author_frame(json);
+            var frame = sign_slot_for(slot, false);
+            var frame_json = serialize_frame(frame);
+            yield channel_service.push_set(key, frame_json);
+            return frame;
+        }
+
+        /**
+         * Removes the slot cached under `key` and auto-pushes a `clear` event to
+         * any channel subscribed to it (a no-op when nothing is subscribed).
+         */
+        public async void clear_slot(string key) {
+            slots.remove(key);
+            yield channel_service.push_clear(key);
         }
 
-        public bool restore_slot(Model.FrameDto frame) throws Error {
+        public bool restore_slot(Model.FrameDto frame) throws GLib.Error {
             signing_provider.verify_frame(frame);
             var json_object = new JsonElement.from_string(frame.content).as<JsonObject>();
             var snapshot = Model.SnapshotDto.get_mapper().materialise(json_object);
-            
+
             var slot = get_slot(snapshot.slot.key);
-            if(slot != null) {
-                // No-op slot not empty
+            if (slot != null) {
+                // No-op: slot not empty.
                 return false;
             }
 
@@ -90,13 +125,46 @@ namespace Statum {
             return true;
         }
 
+        /**
+         * Evicts slots whose `invalid_after` window has elapsed — i.e. slots that
+         * have not been touched for {@link EXPIRY_SECONDS}. The client resends a
+         * fresh frame via `/_statum/slots` when it next needs one of these.
+         */
         public void cleanup() {
-            var cutoff = new DateTime.now_utc().add_seconds(-RETRANSMIT_SECONDS).to_unix();
-            var to_remove = slots.values.where(s => s.current_state.last_touched.to_unix() > cutoff).to_series();
+            var cutoff = new DateTime.now_utc().add_seconds(-EXPIRY_SECONDS);
+            var to_remove = slots.values
+                .where(s => s.current_state.last_touched.compare(cutoff) < 0)
+                .to_series();
             foreach (var item in to_remove) {
                 slots.remove(item.id);
             }
         }
+
+        private Model.FrameDto sign_slot_for(Slot slot, bool unidirectional) throws GLib.Error {
+            var slot_dto = new Model.SlotDto() {
+                key = slot.id,
+                scope = slot.scope,
+                slot_type = slot.type_name
+            };
+
+            var timestamp = new DateTime.now_utc();
+            var snapshot_dto = new Model.SnapshotDto() {
+                as_at = timestamp,
+                slot = slot_dto,
+                transmit_after = unidirectional ? null : timestamp.add_seconds(RETRANSMIT_SECONDS),
+                invalid_after = timestamp.add_seconds(EXPIRY_SECONDS),
+                @private = encryption_provider.author_properties(slot.id, slot.current_state.private_data),
+                @public = slot.current_state.public_data
+            };
+
+            var json = new JsonElement.from_properties(Model.SnapshotDto.get_mapper().map_from(snapshot_dto)).stringify();
+            return signing_provider.author_frame(json);
+        }
+
+        private static string serialize_frame(Model.FrameDto frame) throws GLib.Error {
+            return new JsonElement.from_properties(Model.FrameDto.get_mapper().map_from(frame)).stringify();
+        }
+
     }
 
-}
+}

+ 109 - 3
src/Statum.vala

@@ -1,18 +1,124 @@
 using Inversion;
+using Invercargill.Expressions;
+using Astralis;
 
 namespace Statum {
 
     /**
-     * Inversion module that registers the core Statum framework services.
+     * Inversion module that registers the Statum runtime services and framework
+     * endpoints.
      *
-     * Register this module with the application container to wire up the
-     * signing provider used to author and verify Statum frames.
+     * Register this module with the application container (after which a
+     * {@link StatumConfigurator} binds pages/entrypoints/resources) to wire up
+     * the {@link StateService}, signing/encryption providers, realtime channel,
+     * entrypoint route table, held-slot resolver and the `/_statum/*` endpoints.
      */
     public class StatumModule : Object, Module {
 
         public void register_components(Container container) throws Error {
             container.register_singleton<Cryptography.SigningProvider>();
             container.register_singleton<Cryptography.EncryptionProvider>();
+
+            // Enable predicate member-access on Properties (slot public/private
+            // data) for the runtime `pstm-if`/`pstm-guard` evaluator.
+            Invercargill.Expressions.TypeAccessorRegistry.get_instance()
+                .register_property_accessor(new StatumPropertiesAccessorFactory());
+
+            container.register_singleton<StateService>();
+            container.register_singleton<HeldSlotResolver>();
+            container.register_singleton<EntrypointRouteTable>();
+
+            // The realtime channel endpoint is the singleton ChannelService.
+            container.register_singleton<ChannelEndpoint>()
+                .as<ChannelService>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/channel"));
+
+            container.register_scoped<ChannelSubscriptionEndpoint>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/channel/{id}", Method.PATCH));
+
+            container.register_scoped<EntrypointEndpoint>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/entrypoint"));
+
+            container.register_scoped<SlotPostEndpoint>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/slots", Method.POST));
+
+            container.register_scoped<ResourceEndpoint>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/resource/{name}"));
+
+            // Embed the Statum client scripts as default resources, served from
+            // /_statum/resource/statum.js and /_statum/resource/statum-worker.js
+            // (precompressed at build time). Apps get them for free and may add
+            // their own resources via StatumConfigurator.add_resource.
+            container.register_startup<ClientScript>().as<StatumResource>();
+            container.register_startup<ClientWorker>().as<StatumResource>();
+        }
+
+    }
+
+    /**
+     * Binds application pages, entrypoints and resources into the container.
+     *
+     * Usage mirrors Spry's {@link Spry.SpryConfigurator}:
+     *
+     * ```
+     * var statum = application.configure_with<StatumConfigurator>();
+     * statum.add_entrypoint_page<HomePage, HomeEntrypoint>(new EndpointRoute("/"));
+     * statum.add_page<AboutPage>(new EndpointRoute("/about"));
+     * statum.add_resource<LogoResource>();
+     * ```
+     *
+     * {@link add_entrypoint_page} registers the generated {@link StatumPage}
+     * (served at its URI) and adds the entrypoint to the {@link EntrypointRouteTable}
+     * so `/_statum/entrypoint?uri=…` dispatches to it. The page route is supplied
+     * explicitly (mirroring Spry) rather than read reflectively from the class.
+     */
+    public class StatumConfigurator : Object {
+
+        private Container container = inject<Container>();
+        private EntrypointRouteTable route_table = inject<EntrypointRouteTable>();
+
+        /**
+         * Registers a static page (no entrypoint) served at `route`.
+         */
+        public void add_page<TPage>(EndpointRoute route) {
+            container.register_scoped<TPage>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(route);
+        }
+
+        /**
+         * Registers a page served at `route` AND binds `TEntrypoint` to that route
+         * in the entrypoint table (so `/_statum/entrypoint?uri=route` dispatches to
+         * it).
+         *
+         * (Named distinctly from {@link add_page} because Vala does not permit
+         * methods that overload on generic arity.)
+         */
+        public void add_entrypoint_page<TPage, TEntrypoint>(EndpointRoute route) {
+            add_page<TPage>(route);
+            container.register_transient<TEntrypoint>();
+            route_table.register(route, typeof(TEntrypoint));
+        }
+
+        /** Registers a {@link StatumResource} for serving from `/_statum/resource/{name}`. */
+        public void add_resource<T>() {
+            container.register_startup<T>()
+                .as<StatumResource>();
+        }
+
+        /**
+         * Registers a {@link StatumAction} (a directive-returning POST/GET
+         * endpoint) at `route`.
+         */
+        public void add_action<TAction>(EndpointRoute route) {
+            container.register_scoped<TAction>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(route);
         }
 
     }

+ 30 - 0
src/StatumDirectives.vala

@@ -0,0 +1,30 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * Helpers for rendering Statum directive responses.
+     */
+    public class StatumDirectives : Object {
+
+        /** The content type that distinguishes a Statum directive response. */
+        public const string CONTENT_TYPE = "application/vnd.statum+json";
+
+        /**
+         * Renders a lot of directives as the JSON array response served by every
+         * directive-returning endpoint, with the Statum content type and
+         * `Cache-Control: no-store`.
+         */
+        public static HttpResult to_result(Lot<Model.DirectiveDto> directives) throws GLib.Error {
+            var json = Model.DirectiveDto.serialize_array(directives);
+            var result = new HttpStringResult(json.stringify());
+            result.set_header("Content-Type", CONTENT_TYPE);
+            result.set_header("Cache-Control", "no-store");
+            return result;
+        }
+
+    }
+
+}

+ 106 - 0
src/StatumHandlers.vala

@@ -0,0 +1,106 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * Base class for Statum entrypoint handlers.
+     *
+     * After a page is served its HTML boots the Statum JS, which GETs
+     * `/_statum/entrypoint?uri=…`. The framework resolves the entrypoint bound
+     * to that URI (see {@link StatumConfigurator.add_page}) and calls
+     * {@link handle}, whose returned directives hydrate the page.
+     *
+     * Subclasses are given the {@link request} (URI, route params, held slots,
+     * query), the {@link state_service} and the {@link channel_service}; they
+     * return the directives that set/subscribe/etc. the template's slots.
+     */
+    public abstract class StatumEntrypoint : Object {
+
+        /** The per-request Statum context (registered scoped by the framework). */
+        protected StatumRequest request = inject<StatumRequest>();
+
+        /** The singleton state service. */
+        protected StateService state_service = inject<StateService>();
+
+        /** The singleton realtime channel service. */
+        protected ChannelService channel_service = inject<ChannelService>();
+
+        /**
+         * Returns the directives that hydrate the page bound to this entrypoint.
+         */
+        public abstract async Lot<Model.DirectiveDto> handle() throws GLib.Error;
+
+    }
+
+    /**
+     * Base class for Statum action handlers.
+     *
+     * Actions are app-defined URIs. A {@link StatumAction} subclass implements
+     * the {@link Endpoint} interface directly: the framework routes a request to
+     * it, this base resolves the held slots, decrypts the `X-Statum-Private`
+     * header into {@link StatumRequest.action_private}, parses the form body into
+     * {@link StatumRequest.form}, builds the {@link request}, and then invokes
+     * {@link handle}. The returned directives are rendered as the response.
+     */
+    public abstract class StatumAction : Object, Endpoint {
+
+        /** Namespace mixed into action-private blobs (see `model.md`). */
+        public const string ACTION_PRIVATE_NAMESPACE = "action-private";
+
+        protected HeldSlotResolver resolver = inject<HeldSlotResolver>();
+        protected Cryptography.EncryptionProvider encryption_provider = inject<Cryptography.EncryptionProvider>();
+        protected StateService state_service = inject<StateService>();
+        protected ChannelService channel_service = inject<ChannelService>();
+        protected Inversion.Scope scope = inject<Inversion.Scope>();
+
+        /** The per-request Statum context (populated before {@link handle}). */
+        protected StatumRequest request;
+
+        /**
+         * Returns the directives produced by the action (e.g. a refreshed `set`
+         * for the slot it mutated, plus any `notify`/`navigate`).
+         */
+        public abstract async Lot<Model.DirectiveDto> handle() throws GLib.Error;
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
+            var held = resolver.resolve(http_context);
+            var action_private = decrypt_action_private(http_context);
+
+            FormData? form = null;
+            if (http_context.request.content_type != "" && (
+                    http_context.request.is_form_urlencoded() || http_context.request.is_multipart())) {
+                form = yield FormDataParser.parse(http_context.request.request_body, http_context.request.content_type);
+            }
+
+            request = new StatumRequest() {
+                uri = route_context.requested_path,
+                route_params = route_context.mapped_parameters,
+                held = held,
+                query = http_context.request.query_params,
+                action_private = action_private,
+                form = form
+            };
+            scope.register_local_scoped<StatumRequest>(() => request);
+
+            var directives = yield handle();
+            return StatumDirectives.to_result(directives);
+        }
+
+        private Properties decrypt_action_private(HttpContext http_context) {
+            var blob = http_context.request.get_header("X-Statum-Private");
+            if (blob == null || ((!)blob).length == 0) {
+                return new PropertyDictionary();
+            }
+            try {
+                return encryption_provider.read_properties(ACTION_PRIVATE_NAMESPACE, (!)blob);
+            } catch (GLib.Error e) {
+                return new PropertyDictionary();
+            }
+        }
+
+    }
+
+}

+ 160 - 0
src/StatumPage.vala

@@ -0,0 +1,160 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using Inversion;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * Outcome of {@link StatumPage.select_variant}: either the index of the
+     * pre-rendered variant to serve, or a guard failure (optional redirect or an
+     * error page at a status).
+     */
+    public class StatumVariantSelection : Object {
+
+        public bool guard_failed { get; private set; }
+        public string? redirect { get; private set; }
+        public StatusCode status { get; private set; default = StatusCode.FORBIDDEN; }
+        public int variant_index { get; private set; }
+
+        /** Selects the variant at `index`. */
+        public static StatumVariantSelection variant(int index) {
+            var selection = new StatumVariantSelection();
+            selection.variant_index = index;
+            return selection;
+        }
+
+        /** Signals a guard failure that should redirect to `redirect` (HTTP 302). */
+        public static StatumVariantSelection redirect_to(string redirect) {
+            var selection = new StatumVariantSelection();
+            selection.guard_failed = true;
+            selection.redirect = redirect;
+            return selection;
+        }
+
+        /** Signals a guard failure that should serve an error page at `status`. */
+        public static StatumVariantSelection failure(StatusCode status = StatusCode.FORBIDDEN) {
+            var selection = new StatumVariantSelection();
+            selection.guard_failed = true;
+            selection.status = status;
+            return selection;
+        }
+
+    }
+
+    /**
+     * Base class for pre-rendered Statum pages.
+     *
+     * A generated subclass (emitted by `statum-mkpstm`) extends this, providing
+     * the precompressed variant byte arrays and a compiled {@link select_variant}
+     * that evaluates `pstm-guard`s and the `pstm-if` matrix against the request's
+     * held slots. This base supplies header parsing → held slots, encoding
+     * negotiation, ETag handling and guard redirect/error-page serving.
+     */
+    public abstract class StatumPage : Object, Endpoint {
+
+        protected HeldSlotResolver resolver = inject<HeldSlotResolver>();
+
+        /** Content type of the served page (normally `text/html`). */
+        public abstract string content_type { get; }
+
+        /**
+         * Evaluates the page's guards and `pstm-if` matrix against the held
+         * slots, returning the variant to serve or a guard failure.
+         */
+        public abstract StatumVariantSelection select_variant(ReadOnlyAssociative<string, HeldSlot> held) throws GLib.Error;
+
+        /** Best supported encoding for the chosen variant. */
+        public abstract string get_best_encoding(int variant, Set<string> supported);
+
+        /** The bytes for `variant` under `encoding`. */
+        public abstract unowned uint8[] get_encoding(int variant, string encoding);
+
+        /** The ETag for `variant` under `encoding`. */
+        public abstract string get_etag_for(int variant, string encoding);
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
+            var held = resolver.resolve(http_context);
+
+            StatumVariantSelection selection;
+            try {
+                selection = select_variant(held);
+            } catch (GLib.Error e) {
+                return new HttpStringResult(@"Internal error: $(e.message)", StatusCode.INTERNAL_SERVER_ERROR);
+            }
+
+            if (selection.guard_failed) {
+                return serve_guard_failure(selection);
+            }
+
+            var accepts = parse_accept_encoding(http_context);
+            var best = get_best_encoding(selection.variant_index, accepts);
+            var etag = get_etag_for(selection.variant_index, best);
+
+            if (etag_matches(http_context, etag)) {
+                return new HttpEmptyResult(StatusCode.NOT_MODIFIED);
+            }
+
+            var bytes = get_encoding(selection.variant_index, best);
+            var result = new HttpDataResult(Wrap.byte_array(bytes));
+            result.set_header("Content-Type", content_type);
+            result.set_header("Content-Encoding", best);
+            result.set_header("ETag", etag);
+            return result;
+        }
+
+        private static HttpResult serve_guard_failure(StatumVariantSelection selection) {
+            if (selection.redirect != null) {
+                var result = new HttpEmptyResult((StatusCode) 302);
+                result.set_header("Location", (!)selection.redirect);
+                return result;
+            }
+            return new HttpStringResult("Forbidden", selection.status);
+        }
+
+        private static Set<string> parse_accept_encoding(HttpContext http_context) {
+            var supported = new HashSet<string>();
+            foreach (var header in http_context.request.headers.get_or_empty("Accept-Encoding")) {
+                foreach (var token in header.split(",")) {
+                    var encoding = token.strip();
+                    if (encoding.length > 0) {
+                        supported.add(encoding);
+                    }
+                }
+            }
+            return supported;
+        }
+
+        private static bool etag_matches(HttpContext http_context, string etag) {
+            foreach (var header in http_context.request.headers.get_or_empty("If-None-Match")) {
+                if (header.contains(etag)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+    }
+
+    /**
+     * Registers a {@link PropertiesPropertyAccessor} for any {@link Element}
+     * wrapping a {@link Properties} value, so that `pstm-if`/`pstm-guard`
+     * predicate evaluation (the runtime-evaluator fallback) can read members off
+     * a slot's public/private data — e.g. `auth.role` or `auth.private.score`.
+     *
+     * Registered once by {@link StatumModule}.
+     */
+    public class StatumPropertiesAccessorFactory : Object, PropertyAccessorFactory {
+
+        public PropertyAccessor? create(Element element) {
+            Properties properties;
+            if (element.try_get_as<Properties>(out properties)) {
+                return new PropertiesPropertyAccessor(properties);
+            }
+            return null;
+        }
+
+    }
+
+}

+ 42 - 0
src/StatumRequest.vala

@@ -0,0 +1,42 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * The per-request Statum context, shared by page, entrypoint and action
+     * handlers.
+     *
+     * Carries the matched {@link uri} and {@link route_params}, the slots the
+     * client currently {@link held} (indexed by type name, public AND private),
+     * and the request {@link query}. Action handlers additionally populate
+     * {@link action_private} (decrypted from the `X-Statum-Private` header) and
+     * the parsed {@link form}.
+     */
+    public class StatumRequest : Object {
+
+        /** The request URI (e.g. the page's `<pstm-uri>` or an action URI). */
+        public string uri { get; set; }
+
+        /** Route parameters captured from the matched {@link EndpointRoute}. */
+        public ReadOnlyAssociative<string, string> route_params { get; set; default = new Dictionary<string, string>(); }
+
+        /** Held slots, indexed by type name. */
+        public ReadOnlyAssociative<string, HeldSlot> held { get; set; default = new Dictionary<string, HeldSlot>(); }
+
+        /** The request's query parameters. */
+        public Catalogue<string, string> query { get; set; }
+
+        /**
+         * Action-private data decrypted from the `X-Statum-Private` header, or
+         * `null` for non-action requests.
+         */
+        public Properties? action_private { get; set; }
+
+        /** Parsed form body for POST/PUT/PATCH actions, or `null`. */
+        public FormData? form { get; set; }
+
+    }
+
+}

+ 64 - 0
src/StatumResource.vala

@@ -0,0 +1,64 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * A precompressed, named resource served from the Statum resource endpoint
+     * (`/_statum/resource/{name}`).
+     *
+     * Mirrors Spry's {@link Spry.StaticResource}: each concrete resource exposes
+     * the encodings it was precompressed with (identity/gzip/zstd/br), an ETag
+     * per encoding, and the ability to render itself as an {@link HttpResult}.
+     */
+    public interface StatumResource : Object {
+
+        /** The name the resource is served under (the `{name}` path segment). */
+        public abstract string name { get; }
+
+        /**
+         * Selects the best supported encoding for a request given the set of
+         * `Accept-Encoding` values the client advertised.
+         */
+        public abstract string get_best_encoding(Set<string> supported);
+
+        /** The ETag to send for a given encoding. */
+        public abstract string get_etag_for(string encoding);
+
+        /** Renders the resource for the given encoding as an {@link HttpResult}. */
+        public abstract HttpResult to_result(string encoding, StatusCode status = StatusCode.OK) throws GLib.Error;
+
+    }
+
+    /**
+     * A {@link StatumResource} whose bytes are baked in at compile time.
+     *
+     * Concrete subclasses (typically generated by `statum-mkres`) provide the
+     * precompressed byte arrays and the abstract metadata/encoding members; this
+     * base supplies the {@link get_etag_for}/{@link to_result} implementation.
+     */
+    public abstract class ConstantStatumResource : Object, StatumResource {
+
+        public abstract string name { get; }
+        public abstract string file_hash { get; }
+        public abstract string content_type { get; }
+
+        public abstract string get_best_encoding(Set<string> supported);
+        public abstract unowned uint8[] get_encoding(string encoding);
+
+        public string get_etag_for(string encoding) {
+            return @"\"$encoding-$file_hash\"";
+        }
+
+        public HttpResult to_result(string encoding, StatusCode status = StatusCode.OK) throws GLib.Error {
+            var result = new HttpDataResult(Wrap.byte_array(get_encoding(encoding)), status);
+            result.set_header("Content-Type", content_type);
+            result.set_header("Content-Encoding", encoding);
+            result.set_header("ETag", get_etag_for(encoding));
+            return result;
+        }
+
+    }
+
+}

+ 43 - 1
src/meson.build

@@ -1,5 +1,47 @@
+# Embed the Statum client scripts (js/statum.js and js/statum-worker.js) into the
+# library as precompressed ConstantStatumResource classes, served by default at
+# /_statum/resource/statum.js and /_statum/resource/statum-worker.js. Generated
+# at build time by the in-tree statum-mkres tool — no installed tool required.
+client_script = custom_target('statum-client-script',
+    input: files('../js/statum.js'),
+    output: 'ClientScript.vala',
+    command: [statum_mkres,
+              '-o', '@OUTPUT@',
+              '-n', 'statum.js',
+              '--class-name', 'ClientScript',
+              '--ns', 'Statum',
+              '-c', 'application/javascript',
+              '@INPUT@']
+)
+
+client_worker = custom_target('statum-client-worker',
+    input: files('../js/statum-worker.js'),
+    output: 'ClientWorker.vala',
+    command: [statum_mkres,
+              '-o', '@OUTPUT@',
+              '-n', 'statum-worker.js',
+              '--class-name', 'ClientWorker',
+              '--ns', 'Statum',
+              '-c', 'application/javascript',
+              '@INPUT@']
+)
+
 sources = files(
     'Statum.vala',
+    'StatumResource.vala',
+    'HeldSlot.vala',
+    'HeldSlotResolver.vala',
+    'ChannelService.vala',
+    'StatumRequest.vala',
+    'StatumDirectives.vala',
+    'StatumHandlers.vala',
+    'StatumPage.vala',
+    'EntrypointRouteTable.vala',
+    'ChannelEndpoint.vala',
+    'ChannelSubscriptionEndpoint.vala',
+    'SlotPostEndpoint.vala',
+    'EntrypointEndpoint.vala',
+    'ResourceEndpoint.vala',
     'Model/Scope.vala',
     'Model/SlotDto.vala',
     'Model/FrameDto.vala',
@@ -15,7 +57,7 @@ sources = files(
 
 library_version = meson.project_version()
 libstatum = shared_library('statum-@0@'.format(library_version),
-    sources,
+    sources, client_script, client_worker,
     dependencies: [
         glib_dep,
         gobject_dep,

+ 4 - 0
tools/meson.build

@@ -0,0 +1,4 @@
+# Statum code-generation tools
+
+subdir('statum-mkres')
+subdir('statum-mkpstm')

+ 9 - 0
tools/statum-mkpstm/meson.build

@@ -0,0 +1,9 @@
+statum_mkpstm_sources = files(
+    'statum-mkpstm.vala'
+)
+
+statum_mkpstm = executable('statum-mkpstm',
+    statum_mkpstm_sources,
+    dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, invercargill_json_dep, astralis_dep, inversion_dep, libxml_dep, json_glib_dep],
+    install: true
+)

+ 1235 - 0
tools/statum-mkpstm/statum-mkpstm.vala

@@ -0,0 +1,1235 @@
+using Xml;
+using Html;
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+
+namespace Statum.Tools {
+
+    /**
+     * `statum-mkpstm` — the build-time Statum pre-rendering pipeline.
+     *
+     * Turns an app HTML page (plus its templates/fragments) into a Vala
+     * {@link Statum.StatumPage} subclass carrying precompressed variant byte
+     * arrays and a compiled {@link Statum.StatumPage.select_variant} that
+     * evaluates `pstm-guard`s and the `pstm-if` matrix against the request's held
+     * slots using the Invercargill runtime expression evaluator.
+     *
+     * See `phase-1-plan.md` Subsystem B for the supported `pstm-*` directives.
+     */
+    public class Mkpstm : Object {
+
+        private static string? output_file = null;
+        private static string? namespace_name = null;
+        private static string? class_name_override = null;
+        private static string? route_override = null;
+        private static List<string> template_dirs = new List<string>();
+
+        private Dictionary<string, Xml.Doc*> templates_by_name = new Dictionary<string, Xml.Doc*>();
+        private Dictionary<string, Xml.Doc*> fragments_by_name = new Dictionary<string, Xml.Doc*>();
+
+        private const OptionEntry[] options = {
+            { "output", 'o', 0, OptionArg.FILENAME, ref output_file, "Output Vala file name", "FILE" },
+            { "ns", '\0', 0, OptionArg.STRING, ref namespace_name, "Namespace for the generated class", "NAMESPACE" },
+            { "name", 'n', 0, OptionArg.STRING, ref class_name_override, "Generated class name (default: <InputName>Page)", "NAME" },
+            { "route", 'r', 0, OptionArg.STRING, ref route_override, "Page route (default: from <pstm-uri>)", "ROUTE" },
+            { "template-dir", 't', 0, OptionArg.FILENAME_ARRAY, ref template_dirs, "Directory to search for templates/fragments (repeatable)", "DIR" },
+            { null }
+        };
+
+        public static int main(string[] args) {
+            try {
+                var opt = new OptionContext("PAGE_FILE - Generate a Statum StatumPage");
+                opt.add_main_entries(options, null);
+                opt.parse(ref args);
+            } catch (OptionError e) {
+                stderr.printf("Error: %s\n", e.message);
+                return 1;
+            }
+
+            string? page_file = null;
+            if (args.length > 1) {
+                page_file = args[1];
+            }
+            if (page_file == null) {
+                stderr.printf("Error: No page file specified.\n");
+                return 1;
+            }
+
+            try {
+                var tool = new Mkpstm();
+                var generated = tool.run((!)page_file);
+                var out_path = tool.output_file ?? (make_pascal_case((!)page_file) + "Page.vala");
+                write_file(out_path, generated);
+                stdout.printf("Generated: %s\n", out_path);
+                return 0;
+            } catch (GLib.Error e) {
+                stderr.printf("Error: %s\n", e.message);
+                return 1;
+            }
+        }
+
+        public string run(string page_file) throws GLib.Error {
+            if (template_dirs.length() == 0) {
+                var parent = File.new_for_path(page_file).get_parent();
+                template_dirs.append(parent != null ? ((!)parent).get_path() : ".");
+            }
+
+            load_templates_and_fragments(page_file);
+
+            var doc = parse_html_file(page_file);
+            // Capture the page's routes from the original document before
+            // composition (structural <pstm-*> directives are dropped while
+            // splicing templates, so they would no longer be present afterwards).
+            var routes = read_routes(doc);
+            var body = find_body(doc);
+            if (body == null) {
+                throw new IOError.FAILED("Page has no <body> element");
+            }
+
+            doc = compose_templates(doc, (!)body);
+            inline_fragments(doc);
+            rewrite_resources(doc);
+
+            var collected = collect(doc);
+            var class_name = class_name_override ?? (make_pascal_case(page_file) + "Page");
+            var route = route_override ?? (routes.length > 0 ? routes[0] : "/");
+
+            var variants = build_variants(doc, collected.groups);
+
+            return emit(class_name, route, collected, variants);
+        }
+
+        // ------------------------------------------------------------------
+        // Template/fragment loading
+        // ------------------------------------------------------------------
+
+        private void load_templates_and_fragments(string page_file) throws GLib.Error {
+            foreach (var dir in template_dirs) {
+                var dir_file = File.new_for_path(dir);
+                if (!dir_file.query_exists()) {
+                    continue;
+                }
+                var enumerator = dir_file.enumerate_children("standard::name", 0);
+                FileInfo info;
+                while ((info = enumerator.next_file()) != null) {
+                    var name = info.get_name();
+                    if (!name.has_suffix(".html") || name.has_prefix(".")) {
+                        continue;
+                    }
+                    var path = Path.build_filename(dir, name);
+                    if (path == page_file) {
+                        continue;
+                    }
+                    var tdoc = parse_html_file(path);
+                    var tname = read_pstm_name(tdoc);
+                    if (tname == null) {
+                        continue;
+                    }
+                    if (find_first_element(tdoc->get_root_element(), "pstm-content") != null) {
+                        templates_by_name.set((!)tname, tdoc);
+                    } else {
+                        fragments_by_name.set((!)tname, tdoc);
+                    }
+                }
+            }
+        }
+
+        // ------------------------------------------------------------------
+        // Composition
+        // ------------------------------------------------------------------
+
+        private Xml.Doc* compose_templates(Xml.Doc* doc, Xml.Node* body) throws GLib.Error {
+            Xml.Doc* current_doc = doc;
+            Xml.Node* current_body = body;
+            string merged_title = read_directive_in_doc(current_doc, "pstm-title") ?? "";
+
+            // Guard against cyclic template chains (A templates B templates A).
+            var visited = new HashSet<string>();
+
+            while (true) {
+                var parent_name = read_directive_in_doc(current_doc, "pstm-template");
+                if (parent_name == null || parent_name.length == 0) {
+                    break;
+                }
+                if (visited.contains((!)parent_name)) {
+                    break;
+                }
+                visited.add((!)parent_name);
+
+                Xml.Doc* parent_doc;
+                if (!templates_by_name.try_get((!)parent_name, out parent_doc)) {
+                    throw new IOError.FAILED(@"Unknown template \"$((!)parent_name)\"");
+                }
+
+                var parent_body = find_body(parent_doc);
+                if (parent_body == null) {
+                    throw new IOError.FAILED(@"Template \"$((!)parent_name)\" has no <body>");
+                }
+
+                // Splice this body's children into the parent's <pstm-content>
+                // outlet, but drop structural <pstm-*> directives (pstm-template,
+                // pstm-name, pstm-title, pstm-uri). These are page-level metadata
+                // and must NOT be carried into the parent — otherwise the
+                // spliced <pstm-template> would be re-discovered on the next pass
+                // and recompose forever.
+                var outlet = find_first_element(parent_body, "pstm-content");
+                for (var child = current_body->children; child != null; child = child->next) {
+                    if (is_structural_directive(child)) {
+                        continue;
+                    }
+                    var copied = child->doc_copy(parent_doc, 1);
+                    if (copied == null) {
+                        continue;
+                    }
+                    if (outlet != null) {
+                        outlet->add_prev_sibling(copied);
+                    } else {
+                        parent_body->add_child(copied);
+                    }
+                }
+                if (outlet != null) {
+                    outlet->unlink();
+                }
+
+                var parent_title = read_directive_in_doc(parent_doc, "pstm-title");
+                if (parent_title != null && parent_title.length > 0) {
+                    merged_title = merged_title + (!)parent_title;
+                }
+
+                current_doc = parent_doc;
+                current_body = parent_body;
+            }
+
+            apply_title(current_doc, merged_title);
+            return current_doc;
+        }
+
+        private static string? read_directive_in_doc(Xml.Doc* doc, string name) {
+            var root = doc->get_root_element();
+            if (root == null) {
+                return null;
+            }
+            return read_directive_text(root, name);
+        }
+
+        private static bool is_structural_directive(Xml.Node* node) {
+            if (!is_element(node)) {
+                return false;
+            }
+            var name = (string) node->name;
+            return name == "pstm-template" || name == "pstm-name" || name == "pstm-title" || name == "pstm-uri" || name == "pstm-content";
+        }
+
+        private void inline_fragments(Xml.Doc* doc) {
+            bool changed = true;
+            while (changed) {
+                changed = false;
+                var includes = find_all_elements(doc, "pstm-fragment");
+                foreach (var include in includes) {
+                    if (inline_one_fragment(doc, include)) {
+                        changed = true;
+                    }
+                }
+            }
+        }
+
+        private bool inline_one_fragment(Xml.Doc* doc, Xml.Node* include) {
+            var name = include->get_prop("name");
+            if (name == null) {
+                return false;
+            }
+            Xml.Doc* fragment_doc;
+            if (!fragments_by_name.try_get((!)name, out fragment_doc)) {
+                return false;
+            }
+
+            var fragment_body = find_body(fragment_doc);
+            if (fragment_body == null) {
+                return false;
+            }
+
+            var inputs = collect_input_names(fragment_body);
+            var materialise_as = include->get_prop("pstm-materialise-as");
+            bool wrap = (materialise_as != null) || inputs.length > 0;
+            string tag = materialise_as ?? "div";
+
+            if (wrap) {
+                var parent = include->parent;
+                if (parent == null) {
+                    return false;
+                }
+                var wrapper = parent->new_child(null, tag, null);
+                wrapper->unlink();
+                include->add_prev_sibling(wrapper);
+                for (var child = fragment_body->children; child != null; child = child->next) {
+                    var copied = child->doc_copy(doc, 1);
+                    if (copied != null) {
+                        wrapper->add_child(copied);
+                    }
+                }
+                foreach (var input_name in inputs) {
+                    var expr = include->get_prop(input_name);
+                    if (expr != null) {
+                        wrapper->set_prop(@"stm-def-$input_name-as", (!)expr);
+                    }
+                }
+            } else {
+                for (var child = fragment_body->children; child != null; child = child->next) {
+                    var copied = child->doc_copy(doc, 1);
+                    if (copied != null) {
+                        include->add_prev_sibling(copied);
+                    }
+                }
+            }
+
+            include->unlink();
+            return true;
+        }
+
+        private string[] collect_input_names(Xml.Node* fragment_body) {
+            var names = new string[0];
+            for (var n = fragment_body->children; n != null; n = n->next) {
+                if (is_element(n) && (string) n->name == "pstm-input") {
+                    var input_name = n->get_prop("name");
+                    if (input_name != null) {
+                        names += (!)input_name;
+                    }
+                }
+            }
+            return names;
+        }
+
+        private void rewrite_resources(Xml.Doc* doc) {
+            var root = doc->get_root_element();
+            if (root != null) {
+                rewrite_resources_in(root);
+            }
+        }
+
+        private void rewrite_resources_in(Xml.Node* node) {
+            if (!is_element(node)) {
+                return;
+            }
+
+            string[] to_add_attr = new string[0];
+            string[] to_add_val = new string[0];
+            string[] to_remove = new string[0];
+            for (var attr = node->properties; attr != null; attr = attr->next) {
+                var attr_name = (string) attr->name;
+                if (attr_name.has_prefix("pstm-res-")) {
+                    var target_attr = attr_name.substring("pstm-res-".length);
+                    var value = node->get_prop(attr_name);
+                    if (value != null) {
+                        to_add_attr += target_attr;
+                        to_add_val += @"/_statum/resource/$((!)value)";
+                    }
+                    to_remove += attr_name;
+                }
+            }
+            for (int i = 0; i < to_add_attr.length; i++) {
+                node->set_prop(to_add_attr[i], to_add_val[i]);
+            }
+            foreach (var attr_name in to_remove) {
+                node->unset_prop(attr_name);
+            }
+
+            for (var child = node->children; child != null; child = child->next) {
+                rewrite_resources_in(child);
+            }
+        }
+
+        // ------------------------------------------------------------------
+        // Group/guard collection
+        // ------------------------------------------------------------------
+
+        private Collected collect(Xml.Doc* doc) {
+            var result = new Collected();
+            var root = doc->get_root_element();
+            if (root != null) {
+                collect_guards(root, result);
+                collect_groups_from(root, result);
+            }
+            return result;
+        }
+
+        private void collect_guards(Xml.Node* node, Collected result) {
+            if (!is_element(node)) {
+                return;
+            }
+            if (node->get_prop("pstm-guard") != null) {
+                result.guards += new Guard() {
+                    predicate = node->get_prop("predicate") ?? "",
+                    require = node->get_prop("require"),
+                    redirect = node->get_prop("redirect"),
+                    status = node->get_prop("status")
+                };
+            }
+            for (var child = node->children; child != null; child = child->next) {
+                collect_guards(child, result);
+            }
+        }
+
+        private void collect_groups_from(Xml.Node* node, Collected result) {
+            var n = node->children;
+            while (n != null) {
+                if (!is_element(n)) {
+                    n = n->next;
+                    continue;
+                }
+                if (n->get_prop("pstm-if") != null) {
+                    var chain = gather_chain(n);
+                    result.groups += chain_to_group(chain);
+                    foreach (var branch in chain) {
+                        collect_groups_from(branch, result);
+                    }
+                    n = chain[chain.length - 1]->next;
+                    continue;
+                }
+                collect_groups_from(n, result);
+                n = n->next;
+            }
+        }
+
+        private Xml.Node*[] gather_chain(Xml.Node* if_node) {
+            Xml.Node*[] chain = new Xml.Node*[0];
+            chain += if_node;
+            var n = if_node->next;
+            while (n != null) {
+                if (!is_element(n)) {
+                    n = n->next;
+                    continue;
+                }
+                if (n->get_prop("pstm-else-if") != null || n->get_prop("pstm-else") != null) {
+                    chain += n;
+                    n = n->next;
+                } else {
+                    break;
+                }
+            }
+            return chain;
+        }
+
+        private IfGroup chain_to_group(Xml.Node*[] chain) {
+            var conditions = new string[0];
+            bool has_else = false;
+            foreach (var node in chain) {
+                var else_if = node->get_prop("pstm-else-if");
+                if (node->get_prop("pstm-if") != null) {
+                    conditions += node->get_prop("pstm-if") ?? "";
+                } else if (else_if != null) {
+                    conditions += (!)else_if;
+                } else if (node->get_prop("pstm-else") != null) {
+                    has_else = true;
+                }
+            }
+            return new IfGroup() { conditions = conditions, has_else = has_else };
+        }
+
+        // ------------------------------------------------------------------
+        // Variant rendering
+        // ------------------------------------------------------------------
+
+        private VariantData[] build_variants(Xml.Doc* doc, IfGroup[] groups) throws GLib.Error {
+            int total = 1;
+            foreach (var group in groups) {
+                total *= (group.conditions.length + 1);
+            }
+            if (total < 1) {
+                total = 1;
+            }
+
+            VariantData[] variants = new VariantData[0];
+            for (var index = 0; index < total; index++) {
+                int[] choices = new int[groups.length];
+                int stride = 1;
+                for (int gi = 0; gi < groups.length; gi++) {
+                    int outcomes = groups[gi].conditions.length + 1;
+                    choices[gi] = (index / stride) % outcomes;
+                    stride *= outcomes;
+                }
+
+                var copy = doc->copy(1);
+                apply_choices(copy, choices);
+                strip_pstm(copy);
+                var html = serialize(copy);
+                delete copy;
+
+                variants += compress_variant(html);
+            }
+            return variants;
+        }
+
+        private VariantData compress_variant(string html) throws GLib.Error {
+            var data = new VariantData();
+            data.identity = html.data;
+            var buffer = new ByteBuffer.from_byte_array(html.data);
+
+            var gzip = new GzipCompressor(9).compress_buffer(buffer, null).to_array();
+            var zstd = new ZstdCompressor(19).compress_buffer(buffer, null).to_array();
+            var br = new BrotliCompressor(11).compress_buffer(buffer, null).to_array();
+
+            data.has_gzip = gzip.length < html.length;
+            data.has_zstd = zstd.length < html.length;
+            data.has_br = br.length < html.length;
+            data.gzip = data.has_gzip ? gzip : null;
+            data.zstd = data.has_zstd ? zstd : null;
+            data.br = data.has_br ? br : null;
+            return data;
+        }
+
+        private void apply_choices(Xml.Doc* doc, int[] choices) {
+            var root = doc->get_root_element();
+            if (root == null) {
+                return;
+            }
+            int gi = 0;
+            apply_choices_from(root, choices, ref gi);
+        }
+
+        private void apply_choices_from(Xml.Node* node, int[] choices, ref int gi) {
+            var n = node->children;
+            while (n != null) {
+                if (!is_element(n)) {
+                    n = n->next;
+                    continue;
+                }
+                if (n->get_prop("pstm-if") != null) {
+                    var chain = gather_chain(n);
+                    var after = chain[chain.length - 1]->next;
+
+                    int conds = count_conditions(chain);
+                    int choice = (gi < choices.length) ? choices[gi] : conds;
+                    gi++;
+
+                    Xml.Node* keep = null;
+                    int idx = 0;
+                    foreach (var branch in chain) {
+                        bool is_conditional = branch->get_prop("pstm-if") != null || branch->get_prop("pstm-else-if") != null;
+                        if (is_conditional) {
+                            if (idx == choice) {
+                                keep = branch;
+                            }
+                            idx++;
+                        } else if (branch->get_prop("pstm-else") != null) {
+                            if (choice >= conds) {
+                                keep = branch;
+                            }
+                        }
+                    }
+
+                    foreach (var branch in chain) {
+                        if (branch == keep) {
+                            continue;
+                        }
+                        branch->unlink();
+                    }
+
+                    if (keep != null) {
+                        apply_choices_from(keep, choices, ref gi);
+                    }
+
+                    n = after;
+                    continue;
+                }
+                apply_choices_from(n, choices, ref gi);
+                n = n->next;
+            }
+        }
+
+        private int count_conditions(Xml.Node*[] chain) {
+            int count = 0;
+            foreach (var node in chain) {
+                if (node->get_prop("pstm-if") != null || node->get_prop("pstm-else-if") != null) {
+                    count++;
+                }
+            }
+            return count;
+        }
+
+        private void strip_pstm(Xml.Doc* doc) {
+            var root = doc->get_root_element();
+            if (root != null) {
+                strip_pstm_from(root);
+            }
+        }
+
+        private void strip_pstm_from(Xml.Node* node) {
+            // pstm-guard's parameters (predicate/require/redirect/status) are not
+            // pstm-prefixed, so remove them alongside the pstm-guard marker.
+            bool is_guard = node->get_prop("pstm-guard") != null;
+            string[] to_remove = new string[0];
+            for (var attr = node->properties; attr != null; attr = attr->next) {
+                var attr_name = (string) attr->name;
+                if (attr_name.has_prefix("pstm-")) {
+                    to_remove += attr_name;
+                } else if (is_guard && (attr_name == "predicate" || attr_name == "require" || attr_name == "redirect" || attr_name == "status")) {
+                    to_remove += attr_name;
+                }
+            }
+            foreach (var attr_name in to_remove) {
+                node->unset_prop(attr_name);
+            }
+
+            var child = node->children;
+            while (child != null) {
+                var next = child->next;
+                if (is_element(child) && ((string) child->name).has_prefix("pstm-")) {
+                    child->unlink();
+                } else if (is_element(child)) {
+                    strip_pstm_from(child);
+                }
+                child = next;
+            }
+        }
+
+        // ------------------------------------------------------------------
+        // Code generation
+        // ------------------------------------------------------------------
+
+        private string emit(string class_name, string route, Collected collected, VariantData[] variants) throws GLib.Error {
+            var page_hash = compute_hash(variants.length > 0 ? variants[0].identity : new uint8[0]);
+
+            var b = new StringBuilder();
+            string i1 = namespace_name != null ? "    " : "";
+            string i2 = namespace_name != null ? "        " : "    ";
+            string i3 = namespace_name != null ? "            " : "        ";
+            string i4 = namespace_name != null ? "                " : "            ";
+
+            b.append("using Statum;\n");
+            b.append("using Invercargill;\n");
+            b.append("using Invercargill.DataStructures;\n");
+            b.append("using Astralis;\n\n");
+
+            if (namespace_name != null) {
+                b.append_printf("namespace %s {\n\n", namespace_name);
+            }
+
+            b.append_printf("%s// Generated by statum-mkpstm\n", i1);
+            b.append_printf("%spublic class %s : StatumPage {\n\n", i1, class_name);
+
+            b.append_printf("%spublic const string ROUTE = \"%s\";\n", i2, escape(route));
+            b.append_printf("%spublic override string content_type { get { return \"text/html; charset=utf-8\"; } }\n\n", i2);
+
+            emit_select_variant(b, i2, i3, collected, variants.length);
+            emit_encoding_methods(b, i2, i3, i4, variants, page_hash);
+            emit_byte_arrays(b, i2, i3, variants);
+            emit_helpers(b, i2, i3);
+
+            b.append_printf("%s}\n", i1);
+            if (namespace_name != null) {
+                b.append("}\n");
+            }
+
+            return b.str;
+        }
+
+        private void emit_select_variant(StringBuilder b, string i2, string i3, Collected collected, int variant_count) {
+            b.append_printf("%spublic override StatumVariantSelection select_variant(ReadOnlyAssociative<string, HeldSlot> held) throws GLib.Error {\n", i2);
+
+            foreach (var guard in collected.guards) {
+                string predicate_check;
+                var compiled = @"el_truthy($(compile_predicate_string(guard.predicate)))";
+                if (guard.require != null) {
+                    predicate_check = @"held.has(\"$(escape((!)guard.require))\") && $(compiled)";
+                } else {
+                    predicate_check = compiled;
+                }
+                if (guard.redirect != null) {
+                    b.append_printf("%sif (!(%s)) {\n", i3, predicate_check);
+                    b.append_printf("%sreturn StatumVariantSelection.redirect_to(\"%s\");\n", i3, escape((!)guard.redirect));
+                    b.append_printf("%s}\n", i3);
+                } else {
+                    var status = guard.status ?? "FORBIDDEN";
+                    b.append_printf("%sif (!(%s)) {\n", i3, predicate_check);
+                    b.append_printf("%sreturn StatumVariantSelection.failure(StatusCode.%s);\n", i3, status);
+                    b.append_printf("%s}\n", i3);
+                }
+            }
+
+            // Compute the variant index as a mixed-radix number of per-group choices.
+            for (int gi = 0; gi < collected.groups.length; gi++) {
+                var group = collected.groups[gi];
+                b.append_printf("%sint g%d;\n", i3, gi);
+                b.append_printf("%s{\n", i3);
+                b.append_printf("%sint choice = %d;\n", i3, group.conditions.length);
+                for (int ci = 0; ci < group.conditions.length; ci++) {
+                    var compiled = @"el_truthy($(compile_predicate_string(group.conditions[ci])))";
+                    if (ci == 0) {
+                        b.append_printf("%sif (%s) { choice = 0; }\n", i3, compiled);
+                    } else {
+                        b.append_printf("%selse if (%s) { choice = %d; }\n", i3, compiled, ci);
+                    }
+                }
+                b.append_printf("%sg%d = choice;\n", i3, gi);
+                b.append_printf("%s}\n", i3);
+            }
+
+            if (collected.groups.length == 0) {
+                b.append_printf("%sreturn StatumVariantSelection.variant(0);\n", i3);
+            } else {
+                b.append_printf("%sint index = 0;\n", i3);
+                int stride = 1;
+                for (int gi = 0; gi < collected.groups.length; gi++) {
+                    b.append_printf("%sindex += g%d * %d;\n", i3, gi, stride);
+                    stride *= collected.groups[gi].conditions.length + 1;
+                }
+                b.append_printf("%sif (index < 0 || index >= %d) { index = 0; }\n", i3, variant_count);
+                b.append_printf("%sreturn StatumVariantSelection.variant(index);\n", i3);
+            }
+
+            b.append_printf("%s}\n\n", i2);
+        }
+
+        private void emit_encoding_methods(StringBuilder b, string i2, string i3, string i4, VariantData[] variants, string page_hash) {
+            // get_best_encoding: prefer the smallest supported, available encoding.
+            b.append_printf("%spublic override string get_best_encoding(int variant, Set<string> supported) {\n", i2);
+            b.append_printf("%sif (supported.has(\"br\") && has_encoding(variant, 3)) return \"br\";\n", i3);
+            b.append_printf("%sif (supported.has(\"zstd\") && has_encoding(variant, 2)) return \"zstd\";\n", i3);
+            b.append_printf("%sif (supported.has(\"gzip\") && has_encoding(variant, 1)) return \"gzip\";\n", i3);
+            b.append_printf("%sreturn \"identity\";\n", i3);
+            b.append_printf("%s}\n\n", i2);
+
+            // get_encoding: nested switch over (variant, encoding).
+            b.append_printf("%spublic override unowned uint8[] get_encoding(int variant, string encoding) {\n", i2);
+            b.append_printf("%sswitch (variant) {\n", i3);
+            for (int v = 0; v < variants.length; v++) {
+                b.append_printf("%scase %d:\n", i4, v);
+                b.append_printf("%sswitch (encoding) {\n", i4);
+                b.append_printf("%scase \"identity\": return V_%d_IDENTITY;\n", i4, v);
+                if (variants[v].has_gzip) {
+                    b.append_printf("%scase \"gzip\": return V_%d_GZIP;\n", i4, v);
+                }
+                if (variants[v].has_zstd) {
+                    b.append_printf("%scase \"zstd\": return V_%d_ZSTD;\n", i4, v);
+                }
+                if (variants[v].has_br) {
+                    b.append_printf("%scase \"br\": return V_%d_BR;\n", i4, v);
+                }
+                b.append_printf("%sdefault: return V_%d_IDENTITY;\n", i4, v);
+                b.append_printf("%s}\n", i4);
+            }
+            b.append_printf("%s}\n", i3);
+            b.append_printf("%sreturn V_0_IDENTITY;\n", i3);
+            b.append_printf("%s}\n\n", i2);
+
+            // get_etag_for
+            b.append_printf("%spublic override string get_etag_for(int variant, string encoding) {\n", i2);
+            b.append_printf("%sreturn \"\\\"%s-%%s-%%d\\\"\".printf(encoding, variant);\n", i3, page_hash);
+            b.append_printf("%s}\n\n", i2);
+
+            // has_encoding
+            b.append_printf("%sprivate static bool has_encoding(int variant, int encoding) {\n", i2);
+            b.append_printf("%sswitch (variant) {\n", i3);
+            for (int v = 0; v < variants.length; v++) {
+                b.append_printf("%scase %d: return encoding == 0%s%s%s;\n", i4, v,
+                    variants[v].has_gzip ? " || encoding == 1" : "",
+                    variants[v].has_zstd ? " || encoding == 2" : "",
+                    variants[v].has_br ? " || encoding == 3" : "");
+            }
+            b.append_printf("%s}\n", i3);
+            b.append_printf("%sreturn false;\n", i3);
+            b.append_printf("%s}\n\n", i2);
+        }
+
+        private void emit_byte_arrays(StringBuilder b, string i2, string i3, VariantData[] variants) {
+            for (int v = 0; v < variants.length; v++) {
+                emit_one_array(b, i2, i3, @"V_$(v)_IDENTITY", variants[v].identity);
+                if (variants[v].has_gzip && variants[v].gzip != null) {
+                    emit_one_array(b, i2, i3, @"V_$(v)_GZIP", (!)variants[v].gzip);
+                }
+                if (variants[v].has_zstd && variants[v].zstd != null) {
+                    emit_one_array(b, i2, i3, @"V_$(v)_ZSTD", (!)variants[v].zstd);
+                }
+                if (variants[v].has_br && variants[v].br != null) {
+                    emit_one_array(b, i2, i3, @"V_$(v)_BR", (!)variants[v].br);
+                }
+            }
+        }
+
+        private void emit_one_array(StringBuilder b, string i2, string i3, string name, uint8[] data) {
+            b.append_printf("%sprivate const uint8[] %s = {\n", i2, name);
+            for (int i = 0; i < data.length; i++) {
+                if (i == 0) {
+                    b.append(i3);
+                } else if (i % 16 == 0) {
+                    b.append(",\n").append(i3);
+                } else {
+                    b.append(", ");
+                }
+                b.append_printf("0x%02x", data[i]);
+            }
+            b.append_printf("\n%s};\n\n", i2);
+        }
+
+        private void emit_helpers(StringBuilder b, string i2, string i3) {
+            // Static helpers used by the compiled predicates in select_variant.
+            // Each predicate AST node is translated at build time into calls to
+            // these, so no expression is parsed/evaluated at request time.
+            b.append_printf("%sprivate static Element? held_pub(ReadOnlyAssociative<string, HeldSlot> held, string type_name) {\n", i2);
+            b.append_printf("%sHeldSlot slot;\n", i3);
+            b.append_printf("%sif (held.try_get(type_name, out slot)) { return new NativeElement<Properties>(slot.@public); }\n", i3);
+            b.append_printf("%sreturn null;\n", i3);
+            b.append_printf("%s}\n", i2);
+
+            b.append_printf("%sprivate static Element? held_priv(ReadOnlyAssociative<string, HeldSlot> held, string type_name) {\n", i2);
+            b.append_printf("%sHeldSlot slot;\n", i3);
+            b.append_printf("%sif (held.try_get(type_name, out slot)) { return new NativeElement<Properties>(slot.@private); }\n", i3);
+            b.append_printf("%sreturn null;\n", i3);
+            b.append_printf("%s}\n", i2);
+
+            b.append_printf("%sprivate static Element? el_get(Element? target, string name) {\n", i2);
+            b.append_printf("%sif (target == null) { return null; }\n", i3);
+            b.append_printf("%sProperties props;\n", i3);
+            b.append_printf("%sif (!((!)target).try_get_as<Properties>(out props)) { return null; }\n", i3);
+            b.append_printf("%sElement value;\n", i3);
+            b.append_printf("%sif (!props.try_get(name, out value)) { return null; }\n", i3);
+            b.append_printf("%sreturn value;\n", i3);
+            b.append_printf("%s}\n", i2);
+
+            b.append_printf("%sprivate static bool el_is_null(Element? e) {\n", i2);
+            b.append_printf("%sreturn e == null || ((!)e) is NullElement || ((!)e).is_null();\n", i3);
+            b.append_printf("%s}\n", i2);
+
+            b.append_printf("%sprivate static bool el_truthy(Element? e) {\n", i2);
+            b.append_printf("%sif (el_is_null(e)) { return false; }\n", i3);
+            b.append_printf("%sbool? b = ((!)e).as_bool_or_null();\n", i3);
+            b.append_printf("%sreturn b != null ? (!)b : true;\n", i3);
+            b.append_printf("%s}\n", i2);
+
+            b.append_printf("%sprivate static double? el_num_of(Element e) {\n", i2);
+            b.append_printf("%sint? i = e.as_int_or_null(); if (i != null) { return (double)(!)i; }\n", i3);
+            b.append_printf("%sint64? i64 = e.as_int64_or_null(); if (i64 != null) { return (double)(!)i64; }\n", i3);
+            b.append_printf("%sdouble? d = e.as_double_or_null(); if (d != null) { return d; }\n", i3);
+            b.append_printf("%sfloat? f = e.as_float_or_null(); if (f != null) { return (double)(!)f; }\n", i3);
+            b.append_printf("%slong? l = e.as_long_or_null(); if (l != null) { return (double)(!)l; }\n", i3);
+            b.append_printf("%sreturn null;\n", i3);
+            b.append_printf("%s}\n", i2);
+
+            b.append_printf("%sprivate static double? el_num_of_opt(Element? e) {\n", i2);
+            b.append_printf("%sif (el_is_null(e)) { return null; }\n", i3);
+            b.append_printf("%sreturn el_num_of((!)e);\n", i3);
+            b.append_printf("%s}\n", i2);
+
+            b.append_printf("%sprivate static int? el_cmp_values(Element a, Element b) {\n", i2);
+            b.append_printf("%sdouble? na = el_num_of(a), nb = el_num_of(b);\n", i3);
+            b.append_printf("%sif (na != null && nb != null) { return na < nb ? -1 : (na > nb ? 1 : 0); }\n", i3);
+            b.append_printf("%sstring? sa = a.as_string_or_null(), sb = b.as_string_or_null();\n", i3);
+            b.append_printf("%sif (sa != null && sb != null) { int c = strcmp((!)sa, (!)sb); return c < 0 ? -1 : (c > 0 ? 1 : 0); }\n", i3);
+            b.append_printf("%sreturn null;\n", i3);
+            b.append_printf("%s}\n", i2);
+
+            b.append_printf("%sprivate static bool el_values_equal(Element a, Element b) {\n", i2);
+            b.append_printf("%sint? c = el_cmp_values(a, b);\n", i3);
+            b.append_printf("%sif (c != null) { return (!)c == 0; }\n", i3);
+            b.append_printf("%sbool? ba = a.as_bool_or_null(), bb = b.as_bool_or_null();\n", i3);
+            b.append_printf("%sif (ba != null && bb != null) { return ba == bb; }\n", i3);
+            b.append_printf("%sreturn false;\n", i3);
+            b.append_printf("%s}\n", i2);
+
+            b.append_printf("%sprivate static Element? el_str(string s) { return new NativeElement<string>(s); }\n", i2);
+            b.append_printf("%sprivate static Element? el_num(double d) { return new NativeElement<double?>(d); }\n", i2);
+            b.append_printf("%sprivate static Element? el_bool(bool b) { return new NativeElement<bool>(b); }\n", i2);
+            b.append_printf("%sprivate static Element? el_null() { return new NullElement(); }\n", i2);
+
+            b.append_printf("%sprivate static Element? el_eq(Element? a, Element? b) {\n", i2);
+            b.append_printf("%sbool an = el_is_null(a), bn = el_is_null(b);\n", i3);
+            b.append_printf("%sif (an || bn) { return el_bool(an == bn); }\n", i3);
+            b.append_printf("%sreturn el_bool(el_values_equal((!)a, (!)b));\n", i3);
+            b.append_printf("%s}\n", i2);
+
+            b.append_printf("%sprivate static Element? el_neq(Element? a, Element? b) { return el_bool(!el_truthy(el_eq(a, b))); }\n", i2);
+
+            b.append_printf("%sprivate static Element? el_gt(Element? a, Element? b) {\n", i2);
+            b.append_printf("%sif (el_is_null(a) || el_is_null(b)) { return el_bool(false); }\n", i3);
+            b.append_printf("%sint? c = el_cmp_values((!)a, (!)b);\n", i3);
+            b.append_printf("%sreturn el_bool(c != null && (!)c > 0);\n", i3);
+            b.append_printf("%s}\n", i2);
+            b.append_printf("%sprivate static Element? el_ge(Element? a, Element? b) {\n", i2);
+            b.append_printf("%sif (el_is_null(a) || el_is_null(b)) { return el_bool(false); }\n", i3);
+            b.append_printf("%sint? c = el_cmp_values((!)a, (!)b);\n", i3);
+            b.append_printf("%sreturn el_bool(c != null && (!)c >= 0);\n", i3);
+            b.append_printf("%s}\n", i2);
+            b.append_printf("%sprivate static Element? el_lt(Element? a, Element? b) {\n", i2);
+            b.append_printf("%sif (el_is_null(a) || el_is_null(b)) { return el_bool(false); }\n", i3);
+            b.append_printf("%sint? c = el_cmp_values((!)a, (!)b);\n", i3);
+            b.append_printf("%sreturn el_bool(c != null && (!)c < 0);\n", i3);
+            b.append_printf("%s}\n", i2);
+            b.append_printf("%sprivate static Element? el_le(Element? a, Element? b) {\n", i2);
+            b.append_printf("%sif (el_is_null(a) || el_is_null(b)) { return el_bool(false); }\n", i3);
+            b.append_printf("%sint? c = el_cmp_values((!)a, (!)b);\n", i3);
+            b.append_printf("%sreturn el_bool(c != null && (!)c <= 0);\n", i3);
+            b.append_printf("%s}\n", i2);
+            b.append_printf("%sprivate static Element? el_and(Element? a, Element? b) { return el_bool(el_truthy(a) && el_truthy(b)); }\n", i2);
+            b.append_printf("%sprivate static Element? el_or(Element? a, Element? b) { return el_bool(el_truthy(a) || el_truthy(b)); }\n", i2);
+            b.append_printf("%sprivate static Element? el_not(Element? a) { return el_bool(!el_truthy(a)); }\n", i2);
+
+            b.append_printf("%sprivate static Element? el_arith(Element? a, Element? b, string op) {\n", i2);
+            b.append_printf("%sdouble? x = el_num_of_opt(a), y = el_num_of_opt(b);\n", i3);
+            b.append_printf("%sif (x == null || y == null) { return null; }\n", i3);
+            b.append_printf("%sdouble xv = (!)x, yv = (!)y;\n", i3);
+            b.append_printf("%sif (op == \"+\") { return el_num(xv + yv); }\n", i3);
+            b.append_printf("%sif (op == \"-\") { return el_num(xv - yv); }\n", i3);
+            b.append_printf("%sif (op == \"*\") { return el_num(xv * yv); }\n", i3);
+            b.append_printf("%sif (op == \"/\") { return yv == 0 ? null : el_num(xv / yv); }\n", i3);
+            b.append_printf("%sif (op == \"%%\") { return yv == 0 ? null : el_num((double)((int64)xv %% (int64)yv)); }\n", i3);
+            b.append_printf("%sreturn null;\n", i3);
+            b.append_printf("%s}\n", i2);
+            b.append_printf("%sprivate static Element? el_add(Element? a, Element? b) { return el_arith(a, b, \"+\"); }\n", i2);
+            b.append_printf("%sprivate static Element? el_sub(Element? a, Element? b) { return el_arith(a, b, \"-\"); }\n", i2);
+            b.append_printf("%sprivate static Element? el_mul(Element? a, Element? b) { return el_arith(a, b, \"*\"); }\n", i2);
+            b.append_printf("%sprivate static Element? el_div(Element? a, Element? b) { return el_arith(a, b, \"/\"); }\n", i2);
+            b.append_printf("%sprivate static Element? el_mod(Element? a, Element? b) { return el_arith(a, b, \"%%\"); }\n", i2);
+            b.append_printf("%sprivate static Element? el_neg(Element? a) { double? x = el_num_of_opt(a); return x == null ? null : el_num(-(!)x); }\n", i2);
+            b.append_printf("%sprivate static Element? el_ternary(Element? c, Element? a, Element? b) { return el_truthy(c) ? a : b; }\n", i2);
+
+            b.append("\n");
+        }
+
+        // ------------------------------------------------------------------
+        // Predicate compilation (build-time AST → Vala)
+        // ------------------------------------------------------------------
+
+        private string compile_predicate_string(string predicate) {
+            try {
+                return compile_predicate(ExpressionParser.parse(predicate));
+            } catch (ExpressionError e) {
+                // Unparseable predicate → always false.
+                return "el_bool(false)";
+            }
+        }
+
+        private string compile_predicate(Expression expr) {
+            if (expr is VariableExpression) {
+                var v = (VariableExpression) expr;
+                return @"held_pub(held, \"$(escape(v.variable_name))\")";
+            }
+            if (expr is PropertyExpression) {
+                var p = (PropertyExpression) expr;
+                if (p.property_name == "private" && p.target is VariableExpression) {
+                    var root = (VariableExpression) p.target;
+                    return @"held_priv(held, \"$(escape(root.variable_name))\")";
+                }
+                return @"el_get($(compile_predicate(p.target)), \"$(escape(p.property_name))\")";
+            }
+            if (expr is LiteralExpression) {
+                return compile_literal((LiteralExpression) expr);
+            }
+            if (expr is UnaryExpression) {
+                var u = (UnaryExpression) expr;
+                var operand = compile_predicate(u.operand);
+                if (u.operator == UnaryOperator.NOT) {
+                    return @"el_not($(operand))";
+                }
+                if (u.operator == UnaryOperator.NEGATE) {
+                    return @"el_neg($(operand))";
+                }
+                return "el_null()";
+            }
+            if (expr is BinaryExpression) {
+                return compile_binary((BinaryExpression) expr);
+            }
+            if (expr is BracketedExpression) {
+                return compile_predicate(((BracketedExpression) expr).inner);
+            }
+            if (expr is TernaryExpression) {
+                var t = (TernaryExpression) expr;
+                return @"el_ternary($(compile_predicate(t.condition)), $(compile_predicate(t.true_expression)), $(compile_predicate(t.false_expression)))";
+            }
+            // Unsupported node (function call, lambda, lot literal, parameter).
+            return "el_null()";
+        }
+
+        private string compile_binary(BinaryExpression b) {
+            var l = compile_predicate(b.left);
+            var r = compile_predicate(b.right);
+            switch (b.op) {
+                case BinaryOperator.EQUAL: return @"el_eq($(l), $(r))";
+                case BinaryOperator.NOT_EQUAL: return @"el_neq($(l), $(r))";
+                case BinaryOperator.GREATER_THAN: return @"el_gt($(l), $(r))";
+                case BinaryOperator.LESS_THAN: return @"el_lt($(l), $(r))";
+                case BinaryOperator.GREATER_EQUAL: return @"el_ge($(l), $(r))";
+                case BinaryOperator.LESS_EQUAL: return @"el_le($(l), $(r))";
+                case BinaryOperator.AND: return @"el_and($(l), $(r))";
+                case BinaryOperator.OR: return @"el_or($(l), $(r))";
+                case BinaryOperator.ADD: return @"el_add($(l), $(r))";
+                case BinaryOperator.SUBTRACT: return @"el_sub($(l), $(r))";
+                case BinaryOperator.MULTIPLY: return @"el_mul($(l), $(r))";
+                case BinaryOperator.DIVIDE: return @"el_div($(l), $(r))";
+                case BinaryOperator.MODULO: return @"el_mod($(l), $(r))";
+                default: return "el_null()";
+            }
+        }
+
+        private string compile_literal(LiteralExpression lit) {
+            var v = lit.value;
+            if (v == null || v is NullElement) {
+                return "el_null()";
+            }
+            string? s;
+            if (v.try_get_as<string>(out s)) {
+                return @"el_str(\"$(escape_vala_string((!)s))\")";
+            }
+            bool? bv;
+            if (v.try_get_as<bool>(out bv)) {
+                return (!)bv ? "el_bool(true)" : "el_bool(false)";
+            }
+            var d = numeric_value_of(v);
+            if (d != null) {
+                return @"el_num($((!)d))";
+            }
+            return "el_null()";
+        }
+
+        private static double? numeric_value_of(Invercargill.Element v) {
+            int64? i64;
+            if (v.try_get_as<int64?>(out i64)) {
+                return (double)(!)i64;
+            }
+            int? i;
+            if (v.try_get_as<int?>(out i)) {
+                return (double)(!)i;
+            }
+            double? d;
+            if (v.try_get_as<double?>(out d)) {
+                return d;
+            }
+            float? f;
+            if (v.try_get_as<float?>(out f)) {
+                return (double)(!)f;
+            }
+            long? l;
+            if (v.try_get_as<long?>(out l)) {
+                return (double)(!)l;
+            }
+            return null;
+        }
+
+        private static string escape_vala_string(string s) {
+            return s.replace("\\", "\\\\")
+                .replace("\"", "\\\"")
+                .replace("\n", "\\n")
+                .replace("\r", "\\r")
+                .replace("\t", "\\t");
+        }
+
+        // ------------------------------------------------------------------
+        // DOM helpers
+        // ------------------------------------------------------------------
+
+        private Xml.Doc* parse_html_file(string path) throws GLib.Error {
+            var file = File.new_for_path(path);
+            if (!file.query_exists()) {
+                throw new IOError.NOT_FOUND(@"Page file '$path' does not exist");
+            }
+
+            uint8[] contents;
+            string etag_out;
+            file.load_contents(null, out contents, out etag_out);
+
+            // Parse with libxml2's HTML parser (mirrors Astralis' MarkupDocument)
+            // so the tree has HTML semantics and serializes as HTML, not XML.
+            int opts = (int)(Html.ParserOption.RECOVER |
+                Html.ParserOption.NOERROR |
+                Html.ParserOption.NOWARNING |
+                Html.ParserOption.NOBLANKS |
+                Html.ParserOption.NONET);
+            char[] buffer = ((string) contents).to_utf8();
+            var doc = Html.Doc.read_memory(buffer, buffer.length, path, "utf-8", opts);
+            if (doc == null) {
+                throw new IOError.FAILED(@"Failed to parse HTML file '$path'");
+            }
+            return doc;
+        }
+
+        private static Xml.Node* find_body(Xml.Doc* doc) {
+            var root = doc->get_root_element();
+            if (root == null) {
+                return null;
+            }
+            return find_first_element(root, "body");
+        }
+
+        private static Xml.Node* find_first_element(Xml.Node* root, string name) {
+            if (!is_element(root)) {
+                return null;
+            }
+            if (((string) root->name).down() == name.down()) {
+                return root;
+            }
+            for (var child = root->children; child != null; child = child->next) {
+                var found = find_first_element(child, name);
+                if (found != null) {
+                    return found;
+                }
+            }
+            return null;
+        }
+
+        private static Xml.Node*[] find_all_elements(Xml.Doc* doc, string name) {
+            var collected = new Series<Xml.Node*>();
+            var root = doc->get_root_element();
+            if (root != null) {
+                find_all_from(root, name, collected);
+            }
+            return collected.to_array();
+        }
+
+        private static void find_all_from(Xml.Node* node, string name, Series<Xml.Node*> result) {
+            if (is_element(node) && ((string) node->name).down() == name.down()) {
+                result.add(node);
+            }
+            if (is_element(node)) {
+                for (var child = node->children; child != null; child = child->next) {
+                    find_all_from(child, name, result);
+                }
+            }
+        }
+
+        private static string? read_directive_text(Xml.Node* body, string directive) {
+            var node = find_first_element(body, directive);
+            if (node == null) {
+                return null;
+            }
+            var text = element_text(node);
+            return text.strip().length > 0 ? text.strip() : null;
+        }
+
+        private static string? read_pstm_name(Xml.Doc* doc) {
+            var node = find_first_element(doc->get_root_element(), "pstm-name");
+            if (node == null) {
+                return null;
+            }
+            return element_text(node).strip();
+        }
+
+        private static string[] read_routes(Xml.Doc* doc) {
+            string[] routes = new string[0];
+            var nodes = find_all_elements(doc, "pstm-uri");
+            foreach (var node in nodes) {
+                var text = element_text(node).strip();
+                if (text.length > 0) {
+                    routes += text;
+                }
+            }
+            return routes;
+        }
+
+        private static string element_text(Xml.Node* node) {
+            var b = new StringBuilder();
+            for (var child = node->children; child != null; child = child->next) {
+                if (child->type == ElementType.TEXT_NODE && child->content != null) {
+                    b.append((string) child->content);
+                } else if (is_element(child)) {
+                    b.append(element_text(child));
+                }
+            }
+            return b.str;
+        }
+
+        private static void apply_title(Xml.Doc* doc, string? title) {
+            if (title == null || title.length == 0) {
+                return;
+            }
+            var head = find_first_element(doc->get_root_element(), "head");
+            if (head == null) {
+                return;
+            }
+            var title_node = find_first_element(head, "title");
+            if (title_node == null) {
+                title_node = head->new_child(null, "title", null);
+            }
+            for (var child = title_node->children; child != null; child = child->next) {
+                child->unlink();
+            }
+            title_node->add_content(title);
+        }
+
+        private static string serialize(Xml.Doc* doc) {
+            // Serialize with libxml2's HTML serializer (htmlDocDumpMemory) so the
+            // output is valid HTML: no <?xml?> prolog, no self-closing
+            // `<script/>`, and correct void/non-void element handling. The doc was
+            // produced by the HTML parser, so it carries HTML structure.
+            string mem;
+            int len;
+            ((Html.Doc*) doc)->dump_memory(out mem, out len);
+            return len > 0 ? mem.substring(0, len) : mem;
+        }
+
+        private static bool is_element(Xml.Node* node) {
+            return node != null && node->type == ElementType.ELEMENT_NODE;
+        }
+
+        private static string compute_hash(uint8[] data) {
+            var checksum = new Checksum(ChecksumType.SHA512);
+            checksum.update(data, data.length);
+            return checksum.get_string();
+        }
+
+        private static string escape(string s) {
+            return s.replace("\\", "\\\\").replace("\"", "\\\"");
+        }
+
+        private static void write_file(string path, string contents) throws GLib.Error {
+            var file = File.new_for_path(path);
+            var stream = new DataOutputStream(file.replace(null, false, FileCreateFlags.NONE));
+            stream.put_string(contents);
+            stream.close();
+        }
+
+        private static string make_pascal_case(string input) {
+            var basename = Path.get_basename(input);
+            var dot = basename.last_index_of(".");
+            if (dot > 0) {
+                basename = basename.substring(0, dot);
+            }
+            var result = new StringBuilder();
+            var capitalize_next = true;
+            foreach (var c in basename.data) {
+                if (c == '-' || c == '_' || c == ' ' || c == '.') {
+                    capitalize_next = true;
+                } else {
+                    if (capitalize_next) {
+                        result.append_printf("%c", c >= 'a' && c <= 'z' ? c - 32 : c);
+                        capitalize_next = false;
+                    } else {
+                        result.append_printf("%c", c);
+                    }
+                }
+            }
+            return result.str;
+        }
+
+        // ------------------------------------------------------------------
+        // Model
+        // ------------------------------------------------------------------
+
+        private class Guard : Object {
+            public string predicate;
+            public string? require;
+            public string? redirect;
+            public string? status;
+        }
+
+        private class IfGroup : Object {
+            public string[] conditions = new string[0];
+            public bool has_else;
+        }
+
+        private class Collected : Object {
+            public Guard[] guards = new Guard[0];
+            public IfGroup[] groups = new IfGroup[0];
+        }
+
+        private class VariantData : Object {
+            public uint8[] identity;
+            public uint8[]? gzip;
+            public uint8[]? zstd;
+            public uint8[]? br;
+            public bool has_gzip;
+            public bool has_zstd;
+            public bool has_br;
+        }
+    }
+}

+ 9 - 0
tools/statum-mkres/meson.build

@@ -0,0 +1,9 @@
+statum_mkres_sources = files(
+    'statum-mkres.vala'
+)
+
+statum_mkres = executable('statum-mkres',
+    statum_mkres_sources,
+    dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, astralis_dep, inversion_dep, libxml_dep, json_glib_dep],
+    install: true
+)

+ 280 - 0
tools/statum-mkres/statum-mkres.vala

@@ -0,0 +1,280 @@
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Statum.Tools {
+
+    /**
+     * `statum-mkres` — generates a {@link Statum.ConstantStatumResource} Vala
+     * subclass from an input file, precompressing it with identity/gzip/zstd/br.
+     *
+     * Mirrors `spry-mkssr --vala`. Intended to be wired as a meson
+     * `custom_target` (see `tools/meson.build`).
+     *
+     *     statum-mkres -o LogoResource.vala -n logo.png logo.png
+     *     statum-mkres --ns MyApp.Static -c text/css -o Styles.vala styles.css
+     */
+    public class Mkres : Object {
+
+        private static string? output_file = null;
+        private static string? content_type_override = null;
+        private static string? resource_name_override = null;
+        private static string? class_name_override = null;
+        private static string? namespace_name = null;
+
+        private const OptionEntry[] options = {
+            { "output", 'o', 0, OptionArg.FILENAME, ref output_file, "Output Vala file name (default: <InputName>Resource.vala)", "FILE" },
+            { "content-type", 'c', 0, OptionArg.STRING, ref content_type_override, "Override content type (e.g. text/html)", "TYPE" },
+            { "name", 'n', 0, OptionArg.STRING, ref resource_name_override, "Resource name (default: input filename)", "NAME" },
+            { "class-name", '\0', 0, OptionArg.STRING, ref class_name_override, "Generated Vala class name (default: <Name>Resource)", "CLASS" },
+            { "ns", '\0', 0, OptionArg.STRING, ref namespace_name, "Namespace for the generated class", "NAMESPACE" },
+            { null }
+        };
+
+        public static int main(string[] args) {
+            try {
+                var opt_context = new OptionContext("INPUT_FILE - Generate a Statum ConstantStatumResource");
+                opt_context.add_main_entries(options, null);
+                opt_context.parse(ref args);
+            } catch (OptionError e) {
+                stderr.printf("Error: %s\n", e.message);
+                return 1;
+            }
+
+            string? input_file = null;
+            if (args.length > 1) {
+                input_file = args[1];
+            }
+            if (input_file == null) {
+                stderr.printf("Error: No input file specified.\n");
+                return 1;
+            }
+
+            var name = resource_name_override ?? Path.get_basename((!)input_file);
+            var class_name_base = resource_name_override ?? Path.get_basename((!)input_file);
+            var output_path = output_file ?? (make_pascal_case(class_name_base) + "Resource.vala");
+            var class_name = class_name_override ?? (make_pascal_case(class_name_base) + "Resource");
+
+            try {
+                var tool = new Mkres();
+                var generated = tool.process((!)input_file, name, class_name, content_type_override, namespace_name);
+                write_file(output_path, generated);
+                stdout.printf("Generated: %s\n", output_path);
+                return 0;
+            } catch (Error e) {
+                stderr.printf("Error: %s\n", e.message);
+                return 1;
+            }
+        }
+
+        public string process(string input_path, string name, string class_name, string? content_type_override, string? namespace_name) throws Error {
+            var input_data = read_all(input_path);
+
+            string content_type;
+            if (content_type_override != null) {
+                content_type = (!)content_type_override;
+            } else {
+                content_type = guess_content_type(name, input_data);
+            }
+
+            var hash_hex = compute_hash_hex(input_data);
+            var encodings = compress_all(input_data);
+
+            return emit_vala(class_name, name, content_type, hash_hex, encodings, namespace_name);
+        }
+
+        private EncodedData[] compress_all(uint8[] input_data) {
+            var encodings = new EncodedData[0];
+
+            var identity_data = new uint8[input_data.length];
+            Memory.copy(identity_data, input_data, input_data.length);
+            encodings += new EncodedData("identity", (owned)identity_data);
+
+            var input_buffer = new ByteBuffer.from_byte_array(input_data);
+
+            // GZip
+            var gzip = new GzipCompressor(9).compress_buffer(input_buffer, null).to_array();
+            if (gzip.length < input_data.length) {
+                encodings += new EncodedData("gzip", (owned)gzip);
+            }
+            // Zstandard
+            var zstd = new ZstdCompressor(19).compress_buffer(input_buffer, null).to_array();
+            if (zstd.length < input_data.length) {
+                encodings += new EncodedData("zstd", (owned)zstd);
+            }
+            // Brotli
+            var br = new BrotliCompressor(11).compress_buffer(input_buffer, null).to_array();
+            if (br.length < input_data.length) {
+                encodings += new EncodedData("br", (owned)br);
+            }
+
+            return encodings;
+        }
+
+        private string emit_vala(string class_name, string name, string content_type, string hash_hex, EncodedData[] encodings, string? namespace_name) {
+            var b = new StringBuilder();
+
+            string i1 = namespace_name != null ? "    " : "";
+            string i2 = namespace_name != null ? "        " : "    ";
+            string i3 = namespace_name != null ? "            " : "        ";
+            string i4 = namespace_name != null ? "                " : "            ";
+
+            b.append("using Statum;\n");
+            b.append("using Invercargill;\n");
+            b.append("using Invercargill.DataStructures;\n\n");
+
+            if (namespace_name != null) {
+                b.append_printf("namespace %s {\n\n", namespace_name);
+            }
+
+            b.append_printf("%s// Generated by statum-mkres\n", i1);
+            b.append_printf("%spublic class %s : ConstantStatumResource {\n\n", i1, class_name);
+
+            b.append_printf("%spublic override string name { get { return \"%s\"; } }\n", i2, escape(name));
+            b.append_printf("%spublic override string file_hash { get { return \"%s\"; } }\n", i2, hash_hex);
+            b.append_printf("%spublic override string content_type { get { return \"%s\"; } }\n\n", i2, escape(content_type));
+
+            // Order by size so the client gets the smallest supported encoding.
+            var ordered = sort_by_size(encodings);
+
+            b.append_printf("%spublic override string get_best_encoding(Set<string> supported) {\n", i2);
+            foreach (var encoding in ordered) {
+                if (encoding.type == "identity") {
+                    continue;
+                }
+                b.append_printf("%sif (supported.has(\"%s\")) return \"%s\";\n", i3, encoding.type, encoding.type);
+            }
+            b.append_printf("%sreturn \"identity\";\n", i3);
+            b.append_printf("%s}\n\n", i2);
+
+            b.append_printf("%spublic override unowned uint8[] get_encoding(string encoding) {\n", i2);
+            b.append_printf("%sswitch (encoding) {\n", i3);
+            foreach (var encoding in encodings) {
+                var var_name = encoding_const(encoding.type);
+                b.append_printf("%scase \"%s\": return %s;\n", i4, encoding.type, var_name);
+            }
+            b.append_printf("%sdefault: return IDENTITY_DATA;\n", i4);
+            b.append_printf("%s}\n", i3);
+            b.append_printf("%s}\n\n", i2);
+
+            foreach (var encoding in encodings) {
+                var var_name = encoding_const(encoding.type);
+                b.append_printf("%sprivate const uint8[] %s = {\n", i2, var_name);
+                append_byte_array(b, encoding.data, i3);
+                b.append_printf("\n%s};\n\n", i2);
+            }
+
+            b.append_printf("%s}\n", i1);
+            if (namespace_name != null) {
+                b.append("}\n");
+            }
+
+            return b.str;
+        }
+
+        private static EncodedData[] sort_by_size(EncodedData[] encodings) {
+            var copy = new EncodedData[encodings.length];
+            for (var i = 0; i < encodings.length; i++) {
+                copy[i] = encodings[i];
+            }
+            for (var i = 0; i < copy.length - 1; i++) {
+                for (var j = i + 1; j < copy.length; j++) {
+                    if (copy[i].data.length > copy[j].data.length) {
+                        var tmp = copy[i];
+                        copy[i] = copy[j];
+                        copy[j] = tmp;
+                    }
+                }
+            }
+            return copy;
+        }
+
+        private static string encoding_const(string encoding_type) {
+            return encoding_type.replace("-", "_").up() + "_DATA";
+        }
+
+        private static void append_byte_array(StringBuilder b, uint8[] data, string indent) {
+            for (var i = 0; i < data.length; i++) {
+                if (i == 0) {
+                    b.append(indent);
+                } else if (i % 16 == 0) {
+                    b.append(",\n").append(indent);
+                } else {
+                    b.append(", ");
+                }
+                b.append_printf("0x%02x", data[i]);
+            }
+        }
+
+        private static string escape(string s) {
+            return s.replace("\\", "\\\\").replace("\"", "\\\"");
+        }
+
+        private static string guess_content_type(string filename, uint8[] sample_data) {
+            bool uncertain;
+            var content_type = ContentType.guess(filename, sample_data, out uncertain);
+            var mime = ContentType.get_mime_type(content_type);
+            return mime ?? "application/octet-stream";
+        }
+
+        private static string compute_hash_hex(uint8[] data) {
+            var checksum = new Checksum(ChecksumType.SHA512);
+            checksum.update(data, data.length);
+            return checksum.get_string();
+        }
+
+        private static uint8[] read_all(string path) throws Error {
+            var file = File.new_for_path(path);
+            if (!file.query_exists()) {
+                throw new IOError.NOT_FOUND(@"Input file '$path' does not exist");
+            }
+            var size = (size_t) file.query_info("standard::size", 0).get_size();
+            var stream = new DataInputStream(file.read());
+            var bytes = stream.read_bytes(size);
+            stream.close();
+            var data = new uint8[bytes.get_size()];
+            Memory.copy(data, bytes.get_data(), bytes.get_size());
+            return data;
+        }
+
+        private static void write_file(string path, string contents) throws Error {
+            var file = File.new_for_path(path);
+            var stream = new DataOutputStream(file.replace(null, false, FileCreateFlags.NONE));
+            stream.put_string(contents);
+            stream.close();
+        }
+
+        private static string make_pascal_case(string input) {
+            var name = input;
+            var dot = name.last_index_of(".");
+            if (dot > 0) {
+                name = name.substring(0, dot);
+            }
+            var result = new StringBuilder();
+            var capitalize_next = true;
+            foreach (var c in name.data) {
+                if (c == '-' || c == '_' || c == ' ' || c == '.') {
+                    capitalize_next = true;
+                } else {
+                    if (capitalize_next) {
+                        result.append_printf("%c", c >= 'a' && c <= 'z' ? c - 32 : c);
+                        capitalize_next = false;
+                    } else {
+                        result.append_printf("%c", c);
+                    }
+                }
+            }
+            return result.str;
+        }
+
+        private class EncodedData {
+            public string type;
+            public uint8[] data;
+
+            public EncodedData(string type, owned uint8[] data) {
+                this.type = type;
+                this.data = (owned)data;
+            }
+        }
+    }
+}