diff --git a/README.md b/README.md index 75854a7..bb52a8a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,33 @@ npm test # unit tests, no live Verse required npm run check ``` +## Static lowering + +The live lowering binds to a provider and updates, which is right for an +operator dashboard and structurally unavailable to anyone else: an anonymous +visitor to a public page is not a Verse participant and never will be. Rendering +a surface ahead of time removes the mesh from the delivery path, so the page +becomes bytes any web server can hand out — no mesh, no runtime, no daemon. + +```bash +npm run lower -- surface.json --out page.html +cat surface.json | node src/lower-surface.cjs > page.html +``` + +It renders through the same `renderEveSurface` the browser uses, under jsdom, +rather than through a second emitter walking the same graph. A separate emitter +would drift, and the drift would stay invisible until someone compared two pages +side by side. + +**Controls that would publish a command are rendered disabled and labelled.** A +static page has no back-channel; a button that looks live is lying about one. +They are marked rather than deleted, because deleting them would misrepresent +the surface — the capability exists, it just is not reachable from a snapshot. + +jsdom is needed only for this, and only at build time. It resolves from +Hermodr's own install if present, otherwise from the Eve checkout, which already +carries it for the renderer's own tests. + ## Known shape Provider state is read by polling, wrapped in lifecycle semantics that emit diff --git a/package.json b/package.json index ddcaf55..1560ef3 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,17 @@ "private": true, "description": "Eve browser lowering: resolves providers through Odin, reads their state, renders surfaces, and relays operator intent as typed CultMesh command documents.", "type": "commonjs", + "bin": { + "hermodr-lower": "src/lower-surface.cjs" + }, "scripts": { "start": "node src/hermodr-daemon.cjs", + "lower": "node src/lower-surface.cjs", "test": "node --test test/*.test.cjs", - "check": "node --check src/hermodr-daemon.cjs && node --check src/hermodr-provider-subscription.cjs && node --check src/hermodr-state-stream.cjs && node --check src/idunn-health.cjs && node --check src/args.cjs" + "check": "node --check src/hermodr-daemon.cjs && node --check src/hermodr-provider-subscription.cjs && node --check src/hermodr-state-stream.cjs && node --check src/idunn-health.cjs && node --check src/args.cjs && node --check src/static-lowering.cjs && node --check src/lower-surface.cjs" + }, + "devDependencies": { + "jsdom": "^30.0.1" }, "license": "MIT" } diff --git a/src/lower-surface.cjs b/src/lower-surface.cjs new file mode 100644 index 0000000..9c838d1 --- /dev/null +++ b/src/lower-surface.cjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +"use strict"; + +// Static lowering, from the command line. +// +// Reads a gamecult.eve.surface.v1 document and writes standalone HTML. It does +// not talk to Hermodr, Odin, or any provider: a published surface that needs a +// daemon running to be published is not static. + +const fs = require("fs"); +const path = require("path"); + +const { parseArgs } = require("./args.cjs"); +const { lowerSurfaceToHtml } = require("./static-lowering.cjs"); + +async function main() { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed.help || parsed.h) { + printUsage(); + return; + } + + const positional = process.argv.slice(2).filter((arg) => !arg.startsWith("--")); + const inputPath = stringOption(parsed.in || parsed.input) || positional[0]; + + const raw = inputPath ? fs.readFileSync(path.resolve(inputPath), "utf8") : await readStdin(); + if (!raw.trim()) { + throw new Error("No surface document given. Pass a path, or pipe one on stdin."); + } + + let surface; + try { + surface = JSON.parse(raw); + } catch (error) { + throw new Error(`Surface document is not valid JSON: ${error.message}`); + } + + const result = await lowerSurfaceToHtml(surface, { + eveRepoRoot: stringOption(parsed.eveRepoRoot || parsed["eve-repo-root"]), + title: stringOption(parsed.title), + lang: stringOption(parsed.lang), + source: stringOption(parsed.source) || (inputPath ? path.basename(inputPath) : "stdin"), + }); + + const outPath = stringOption(parsed.out || parsed.output); + if (outPath) { + const resolved = path.resolve(outPath); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + fs.writeFileSync(resolved, result.html); + process.stderr.write( + `Lowered "${result.title}" to ${resolved} (${result.inertCount} control${result.inertCount === 1 ? "" : "s"} marked inert)\n`, + ); + } else { + process.stdout.write(result.html); + } +} + +function stringOption(value) { + return typeof value === "string" && value.trim() ? value.trim() : ""; +} + +async function readStdin() { + if (process.stdin.isTTY) return ""; + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +function printUsage() { + console.log(`Usage: + node src/lower-surface.cjs [--out page.html] + cat surface.json | node src/lower-surface.cjs --out page.html + +Renders an Eve surface document to standalone HTML through the same renderer +the browser uses. Writes to stdout when --out is omitted. + + --out, --output write here instead of stdout + --eve-repo-root Eve checkout holding the browser lowering + (or HERMODR_EVE_REPO_ROOT) + --title override the page title + --lang document language, default en + --source provenance label passed to the renderer + +Controls that would publish a command are rendered disabled and labelled. A +static page has no back-channel, and a button that looks live is a lie about +one. +`); +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`${error?.message || error}\n`); + process.exitCode = 1; + }); +} + +module.exports = { main }; diff --git a/src/static-lowering.cjs b/src/static-lowering.cjs new file mode 100644 index 0000000..3fb98d6 --- /dev/null +++ b/src/static-lowering.cjs @@ -0,0 +1,223 @@ +"use strict"; + +// Static lowering: an Eve surface graph rendered ahead of time to standalone +// HTML. +// +// The live lowering binds to a provider over CultMesh and updates. That is +// exactly right for an operator dashboard and structurally unavailable to +// anyone else: an anonymous visitor to a public page is not a Verse participant +// and never will be. Rendering at publish time removes the mesh from the +// delivery path, so the page becomes bytes any web server can hand out. +// +// It renders through the same renderEveSurface the browser uses, under a DOM +// shim, rather than through a second emitter that walks the same graph. A +// separate emitter would drift, and the drift would be invisible until someone +// compared two pages side by side. +// +// This is a build step. It deliberately does not need the daemon: the whole +// point is that a published surface stops depending on Hermodr running, and a +// lowering you can only reach by starting a server has given that back. + +const fs = require("fs"); +const path = require("path"); +const { createRequire } = require("module"); + +const repoRoot = path.resolve(__dirname, ".."); +const projectsRoot = path.resolve(repoRoot, ".."); + +// Command controls carry these classes. Anything operable in the live surface +// must be visibly inert here; see neutraliseCommandControls. +const COMMAND_CONTROL_SELECTOR = "button, .eve-control-button, .eve-control-popup, [data-eve-command]"; + +function resolveEveRepoRoot(options = {}) { + return path.resolve( + options.eveRepoRoot || process.env.HERMODR_EVE_REPO_ROOT || path.resolve(projectsRoot, "Eve"), + ); +} + +function resolveLoweringPackage(eveRepoRoot) { + const entry = path.resolve(eveRepoRoot, "packages", "eve-browser-lowering", "dist", "index.js"); + if (!fs.existsSync(entry)) { + throw new Error( + `Eve browser lowering not found at ${entry}. Set --eve-repo-root or HERMODR_EVE_REPO_ROOT to an Eve checkout.`, + ); + } + return entry; +} + +// jsdom is a build-time dependency only. Prefer Hermodr's own install, fall +// back to Eve's, which already carries it for the renderer's own tests. +function requireJsdom(eveRepoRoot) { + const candidates = [ + path.resolve(repoRoot, "package.json"), + path.resolve(eveRepoRoot, "packages", "eve-browser-lowering", "package.json"), + path.resolve(eveRepoRoot, "package.json"), + ]; + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) continue; + try { + return createRequire(candidate)("jsdom"); + } catch { + // Try the next install root. + } + } + throw new Error( + "jsdom is required for static lowering and was not found. Run npm install here, or point --eve-repo-root at an Eve checkout that has it.", + ); +} + +const DOM_GLOBALS = [ + "document", + "window", + "Element", + "HTMLElement", + "HTMLButtonElement", + "HTMLInputElement", + "HTMLSelectElement", + "HTMLOptionElement", + "HTMLTextAreaElement", + "HTMLImageElement", + "Node", + "SVGElement", +]; + +function installDom(JSDOM) { + const dom = new JSDOM("
", { + url: "https://static.invalid/", + pretendToBeVisual: true, + }); + const previous = new Map(DOM_GLOBALS.map((name) => [name, globalThis[name]])); + for (const name of DOM_GLOBALS) { + if (dom.window[name] !== undefined) globalThis[name] = dom.window[name]; + } + return { + dom, + restore() { + for (const [name, value] of previous) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; + } + dom.window.close(); + }, + }; +} + +/** + * Every control that would have published a command is disabled and labelled. + * + * A live-looking button in a static page is a lie about a back-channel that + * does not exist: the click goes nowhere and the visitor has no way to know + * that before trying. Marking them is the honest degradation, and it is what + * the extraction plan's negative check looks for. + */ +function neutraliseCommandControls(root, note) { + const controls = root.querySelectorAll(COMMAND_CONTROL_SELECTOR); + let count = 0; + for (const control of controls) { + control.setAttribute("disabled", "disabled"); + control.setAttribute("aria-disabled", "true"); + control.setAttribute("data-eve-static-inert", "true"); + control.setAttribute("title", note); + control.classList.add("eve-static-inert"); + count += 1; + } + return count; +} + +const INERT_STYLES = ` +.eve-static-inert { + opacity: 0.55; + cursor: not-allowed; + filter: grayscale(1); +} +.eve-static-inert::after { + content: " (unavailable)"; + font-size: 0.85em; + opacity: 0.8; +} +`; + +/** + * Renders a gamecult.eve.surface.v1 document to a standalone HTML string. + * + * No command sink is supplied, so nothing here can publish intent even if a + * control survived neutralisation. + */ +async function lowerSurfaceToHtml(surface, options = {}) { + if (!surface || typeof surface !== "object") { + throw new Error("Static lowering requires a surface document."); + } + + const eveRepoRoot = resolveEveRepoRoot(options); + const { JSDOM } = requireJsdom(eveRepoRoot); + const loweringEntry = resolveLoweringPackage(eveRepoRoot); + const { renderEveSurface } = await import(`file://${loweringEntry.replace(/\\/g, "/")}`); + + const installed = installDom(JSDOM); + try { + const { document } = installed.dom.window; + const host = document.getElementById("eve-surface"); + + renderEveSurface(surface, host, { + body: document.body, + clientId: "hermodr.static", + source: options.source || "static", + // No commandSink, no documentResolver, no stateBindingResolver: a static + // artifact has no back-channel and no live state to bind to. + }); + + const inertCount = neutraliseCommandControls( + host, + options.inertNote || "This page is a static snapshot. Controls are not connected.", + ); + + const title = options.title + || surface.title + || surface.surface?.title + || surface.providerId + || "Eve surface"; + + const styles = [readPackageStyles(eveRepoRoot), INERT_STYLES, options.extraCss || ""] + .filter(Boolean) + .join("\n"); + + const html = [ + "", + ``, + "", + '', + '', + `${escapeText(title)}`, + ``, + "", + ``, + host.outerHTML, + "", + "", + "", + ].join("\n"); + + return { html, inertCount, title }; + } finally { + installed.restore(); + } +} + +function readPackageStyles(eveRepoRoot) { + const stylePath = path.resolve(eveRepoRoot, "packages", "eve-browser-lowering", "styles.css"); + return fs.existsSync(stylePath) ? fs.readFileSync(stylePath, "utf8") : ""; +} + +function escapeText(value) { + return String(value).replace(/[&<>]/g, (char) => ({ "&": "&", "<": "<", ">": ">" }[char])); +} + +function escapeAttribute(value) { + return escapeText(value).replace(/"/g, """); +} + +module.exports = { + COMMAND_CONTROL_SELECTOR, + lowerSurfaceToHtml, + neutraliseCommandControls, +}; diff --git a/test/static-lowering.test.cjs b/test/static-lowering.test.cjs new file mode 100644 index 0000000..95a3663 --- /dev/null +++ b/test/static-lowering.test.cjs @@ -0,0 +1,145 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { lowerSurfaceToHtml, neutraliseCommandControls } = require("../src/static-lowering.cjs"); + +function surfaceWith(children) { + return { + providerId: "erycina", + title: "Erycina Operator", + surface: { + schema: "gamecult.eve.surface.v1", + id: "erycina-operator-surface", + title: "Erycina Operator", + root: { id: "root", kind: "dashboard", props: { title: "Erycina" }, children }, + }, + }; +} + +const panel = { + id: "readiness", + kind: "panel", + props: { title: "Readiness" }, + // "metric" is the kind Erycina real surface uses for a labelled value. + // The renderer silently drops kinds it does not know, so a fixture that + // invents one tests nothing. + children: [{ id: "manifest", kind: "metric", props: { label: "Manifest", value: "present" } }], +}; + +const commandButton = { + id: "run-ingest", + kind: "control.button", + props: { label: "Run ingest", command: "erycina.ingest.run", target: "cultmesh://erycina/commands/ingest" }, +}; + +test("a surface lowers to a standalone document", async () => { + const { html, title } = await lowerSurfaceToHtml(surfaceWith([panel])); + + assert.equal(title, "Erycina Operator"); + assert.match(html, /^/); + assert.match(html, //); + assert.match(html, /<\/html>\s*$/); + // Content the graph declared actually reaches the page. + assert.match(html, /Readiness/); + assert.match(html, /Manifest/); +}); + +test("styles are inlined, so the page needs no second request", async () => { + const { html } = await lowerSurfaceToHtml(surfaceWith([panel])); + + assert.match(html, /