commit 6f239008981d5e45e2875e262a532726cb4627fb Author: Meta Vens Date: Fri Jul 10 09:43:24 2026 +0100 Initialize first-party Eve plugin owners diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36a9264 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +artifacts/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a89d102 --- /dev/null +++ b/LICENSE @@ -0,0 +1,6 @@ +Mozilla Public License Version 2.0 + +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at https://mozilla.org/MPL/2.0/. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..954249d --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# EvePlugins + +First-party reusable semantic plugins for Eve. Each plugin owns its domain +semantics and runs through the runtime-independent Eve plugin ABI. Eve owns the +contract; this repository owns plugin behavior. + +The first owner plugin is `tex.math`, implemented under +`plugins/eve-plugin-tex`. It uses KaTeX for parsing and typesetting, emits +render-result projections, and never mutates provider state. + +```powershell +npm install +npm test +``` + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..99f9e86 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "@gamecult/eve-plugins", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@gamecult/eve-plugins", + "version": "0.1.0", + "license": "MPL-2.0", + "workspaces": [ + "plugins/*" + ] + }, + "node_modules/@gamecult/eve-plugin-tex": { + "resolved": "plugins/eve-plugin-tex", + "link": true + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "plugins/eve-plugin-tex": { + "name": "@gamecult/eve-plugin-tex", + "version": "0.1.0", + "license": "MPL-2.0", + "dependencies": { + "katex": "^0.16.22" + }, + "bin": { + "eve-plugin-tex": "src/sidecar.mjs" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cef38c9 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "@gamecult/eve-plugins", + "version": "0.1.0", + "private": true, + "license": "MPL-2.0", + "workspaces": ["plugins/*"], + "scripts": { + "test": "C:\\Progra~1\\nodejs\\node.exe --test plugins/*/test/*.test.mjs", + "witness:tex": "C:\\Progra~1\\nodejs\\node.exe scripts/run-tex-witness.mjs" + } +} diff --git a/plugins/eve-plugin-tex/advertisement.json b/plugins/eve-plugin-tex/advertisement.json new file mode 100644 index 0000000..38e4532 --- /dev/null +++ b/plugins/eve-plugin-tex/advertisement.json @@ -0,0 +1,28 @@ +{ + "schema": "gamecult.eve.plugin_advertisement.v1", + "pluginId": "tex.math", + "ownerService": "eveplugins", + "version": "0.1.0", + "manifestAddress": "file://plugins/eve-plugin-tex/plugin.json", + "runtime": { + "invocationModel": "executable-sidecar", + "contract": "gamecult.eve.plugin_abi.v1", + "transports": ["stdio"], + "authority": ["renderer-independent", "no-provider-state-mutation", "provider-accepts-or-denies-commands"], + "sidecar": { + "processKind": "long-running-daemon", + "protocol": "stdio-ndjson", + "requestSchema": "gamecult.eve.plugin_abi.request.v1", + "responseSchema": "gamecult.eve.plugin_abi.response.v1", + "operations": ["describe", "validate", "project", "lower", "measure", "apply"], + "commandEnvelope": "gamecult.eve.command_invocation.v1", + "receiptSchema": "gamecult.eve.plugin_receipt.v1", + "stateAuthority": "emits-render-results-only-provider-owns-source-state" + } + }, + "schemas": ["tex.math.source.v1", "tex.math.render_request.v1", "tex.math.render_result.v1"], + "componentKinds": ["embed.tex"], + "commands": [], + "fixtures": ["tex-math-basic"] +} + diff --git a/plugins/eve-plugin-tex/fixtures/basic-source.json b/plugins/eve-plugin-tex/fixtures/basic-source.json new file mode 100644 index 0000000..efbe216 --- /dev/null +++ b/plugins/eve-plugin-tex/fixtures/basic-source.json @@ -0,0 +1,8 @@ +{ + "schema": "tex.math.source.v1", + "documentId": "tex.basic", + "source": "E = mc^2", + "displayMode": true, + "macros": {} +} + diff --git a/plugins/eve-plugin-tex/package.json b/plugins/eve-plugin-tex/package.json new file mode 100644 index 0000000..cfd3dc6 --- /dev/null +++ b/plugins/eve-plugin-tex/package.json @@ -0,0 +1,14 @@ +{ + "name": "@gamecult/eve-plugin-tex", + "version": "0.1.0", + "description": "Runtime-independent TeX semantic sidecar for Eve surfaces.", + "license": "MPL-2.0", + "type": "module", + "bin": { + "eve-plugin-tex": "./src/sidecar.mjs" + }, + "dependencies": { + "katex": "^0.16.22" + } +} + diff --git a/plugins/eve-plugin-tex/plugin-abi-fixture.json b/plugins/eve-plugin-tex/plugin-abi-fixture.json new file mode 100644 index 0000000..0b0b5b7 --- /dev/null +++ b/plugins/eve-plugin-tex/plugin-abi-fixture.json @@ -0,0 +1,18 @@ +{ + "schema": "gamecult.eve.plugin_abi_fixture.v1", + "fixtureId": "tex-math-live-sidecar", + "pluginId": "tex.math", + "ownerRepo": "EvePlugins", + "contract": "gamecult.eve.plugin_abi.v1", + "requestSchema": "gamecult.eve.plugin_abi.request.v1", + "responseSchema": "gamecult.eve.plugin_abi.response.v1", + "authority": "EvePlugins owns TeX semantics; providers own source state and runtimes own projection.", + "operations": [ + { "operation": "describe", "input": {}, "expect": { "pluginId": "tex.math", "componentKinds": ["embed.tex"], "commands": [], "capabilities": ["embed.tex", "tex.inline", "tex.block"] } }, + { "operation": "validate", "input": { "componentKinds": ["embed.tex", "embed.norn"], "source": "E = mc^2" }, "expect": { "acceptedComponentKinds": ["embed.tex"], "rejectedComponentKinds": ["embed.norn"], "validSource": true, "requiredCapabilities": ["embed.tex"] } }, + { "operation": "project", "input": { "documentId": "tex.basic", "source": "E = mc^2", "displayMode": true }, "expect": { "projectionKind": "render-request", "ownedComponentKinds": ["embed.tex"], "outputSchemas": ["tex.math.render_request.v1", "tex.math.render_result.v1"] } }, + { "operation": "lower", "input": { "documentId": "tex.basic", "source": "E = mc^2", "displayMode": true }, "expect": { "loweringKind": "typeset-fragment", "preservedComponentKinds": ["embed.tex"], "requiredSchemas": ["tex.math.render_request.v1", "tex.math.render_result.v1"], "fallbackKind": "source-text" } }, + { "operation": "measure", "input": { "documentId": "tex.basic", "source": "E = mc^2" }, "expect": { "measurementKind": "typeset-baseline-metrics", "measurementOutputs": ["baseline", "advance", "inkBounds", "fallbackBounds"], "preservesProviderAuthority": true } }, + { "operation": "apply", "input": { "documentId": "tex.basic", "source": "E = mc^2", "displayMode": true }, "expect": { "stateEffects": ["cache-render-result", "emit-fallback-on-render-failure"], "receiptSchema": "gamecult.eve.plugin_receipt.v1" } } + ] +} diff --git a/plugins/eve-plugin-tex/plugin.json b/plugins/eve-plugin-tex/plugin.json new file mode 100644 index 0000000..9f82dc1 --- /dev/null +++ b/plugins/eve-plugin-tex/plugin.json @@ -0,0 +1,40 @@ +{ + "schema": "gamecult.eve.plugin.v1", + "pluginId": "tex.math", + "title": "TeX Math", + "owner": "EvePlugins", + "incubation": { + "ownerRepo": "EvePlugins", + "currentHostRepo": "EvePlugins", + "splitTarget": "EvePlugins/eve-plugin-tex", + "graduationTrigger": "Graduated: EvePlugins owns the executable TeX sidecar, fixtures, tests, and conformance witness." + }, + "componentKinds": ["embed.tex"], + "commands": [], + "runtime": { + "invocationModel": "executable-sidecar", + "contract": "gamecult.eve.plugin_abi.v1", + "transports": ["stdio"], + "authority": ["renderer-independent", "no-provider-state-mutation", "provider-accepts-or-denies-commands"], + "sidecar": { + "processKind": "long-running-daemon", + "protocol": "stdio-ndjson", + "requestSchema": "gamecult.eve.plugin_abi.request.v1", + "responseSchema": "gamecult.eve.plugin_abi.response.v1", + "operations": ["describe", "validate", "project", "lower", "measure", "apply"], + "commandEnvelope": "gamecult.eve.command_invocation.v1", + "receiptSchema": "gamecult.eve.plugin_receipt.v1", + "stateAuthority": "emits-render-results-only-provider-owns-source-state" + } + }, + "stateSchemas": ["tex.math.source.v1", "tex.math.render_request.v1", "tex.math.render_result.v1"], + "fixtures": [ + { "fixtureId": "tex-math-basic", "schema": "tex.math.source.v1", "path": "plugins/eve-plugin-tex/fixtures/basic-source.json" } + ], + "abiFixtures": ["plugins/eve-plugin-tex/plugin-abi-fixture.json"], + "capabilities": { + "requires": ["embed.tex"], + "optionalCapabilities": ["tex.inline", "tex.block", "tex.scene-placement"] + } +} + diff --git a/plugins/eve-plugin-tex/src/plugin.mjs b/plugins/eve-plugin-tex/src/plugin.mjs new file mode 100644 index 0000000..63a3fd1 --- /dev/null +++ b/plugins/eve-plugin-tex/src/plugin.mjs @@ -0,0 +1,130 @@ +import { createHash } from "node:crypto"; +import katex from "katex"; + +export const pluginId = "tex.math"; +export const operations = ["describe", "validate", "project", "lower", "measure", "apply"]; + +const renderCache = new Map(); + +export function dispatch(request) { + if (request?.schema !== "gamecult.eve.plugin_abi.request.v1") throw new Error("Unsupported request schema."); + if (request.pluginId !== pluginId) throw new Error(`Expected plugin ${pluginId}.`); + if (!operations.includes(request.operation)) throw new Error(`Unsupported operation ${request.operation}.`); + + switch (request.operation) { + case "describe": return describe(); + case "validate": return validate(request.input || {}); + case "project": return project(request.input || {}); + case "lower": return lower(request.input || {}); + case "measure": return measure(request.input || {}); + case "apply": return apply(request.input || {}); + } +} + +export function describe() { + return { + pluginId, + componentKinds: ["embed.tex"], + commands: [], + capabilities: ["embed.tex", "tex.inline", "tex.block"], + operations, + semanticEngine: "katex", + stateAuthority: "provider-owns-source-state", + }; +} + +export function validate(input) { + const requested = Array.isArray(input.componentKinds) ? input.componentKinds : []; + let validSource = true; + let diagnostic = null; + if (typeof input.source === "string") { + try { + render(input); + } catch (error) { + validSource = false; + diagnostic = error.message; + } + } + return { + acceptedComponentKinds: requested.filter((kind) => kind === "embed.tex"), + rejectedComponentKinds: requested.filter((kind) => kind !== "embed.tex"), + validSource, + ...(diagnostic ? { diagnostic } : {}), + requiredCapabilities: ["embed.tex"], + }; +} + +export function project(input) { + return { + schema: "tex.math.render_request.v1", + projectionKind: "render-request", + ownedComponentKinds: ["embed.tex"], + outputSchemas: ["tex.math.render_request.v1", "tex.math.render_result.v1"], + documentId: requiredString(input.documentId, "documentId"), + source: requiredString(input.source, "source"), + displayMode: Boolean(input.displayMode), + macros: normalizeMacros(input.macros), + }; +} + +export function lower(input) { + const rendered = render(input); + return { + schema: "tex.math.render_result.v1", + loweringKind: "typeset-fragment", + preservedComponentKinds: ["embed.tex"], + requiredSchemas: ["tex.math.render_request.v1", "tex.math.render_result.v1"], + fallbackKind: "source-text", + documentId: requiredString(input.documentId, "documentId"), + source: input.source, + html: rendered.html, + contentHash: rendered.contentHash, + engine: "katex", + }; +} + +export function measure(input) { + render(input); + return { + measurementKind: "typeset-baseline-metrics", + measurementOutputs: ["baseline", "advance", "inkBounds", "fallbackBounds"], + measurementAuthority: "runtime-layout-required", + preservesProviderAuthority: true, + }; +} + +export function apply(input) { + const rendered = render(input); + renderCache.set(rendered.contentHash, rendered); + return { + stateEffects: ["cache-render-result", "emit-fallback-on-render-failure"], + receiptSchema: "gamecult.eve.plugin_receipt.v1", + contentHash: rendered.contentHash, + cacheStatus: "stored", + providerStateMutated: false, + }; +} + +function render(input) { + const source = requiredString(input.source, "source"); + const macros = normalizeMacros(input.macros); + const contentHash = createHash("sha256").update(JSON.stringify({ source, macros, displayMode: Boolean(input.displayMode) })).digest("hex"); + const cached = renderCache.get(contentHash); + if (cached) return cached; + return { + contentHash, + html: katex.renderToString(source, { displayMode: Boolean(input.displayMode), macros, throwOnError: true, output: "htmlAndMathml" }), + }; +} + +function normalizeMacros(value) { + if (value == null) return {}; + if (typeof value !== "object" || Array.isArray(value)) throw new Error("macros must be an object."); + return Object.fromEntries(Object.entries(value).map(([key, expansion]) => [key, requiredString(expansion, `macro ${key}`)])); +} + +function requiredString(value, field) { + if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required.`); + return value; +} + diff --git a/plugins/eve-plugin-tex/src/sidecar.mjs b/plugins/eve-plugin-tex/src/sidecar.mjs new file mode 100644 index 0000000..540a8d8 --- /dev/null +++ b/plugins/eve-plugin-tex/src/sidecar.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import readline from "node:readline"; +import { dispatch, pluginId } from "./plugin.mjs"; + +readline.createInterface({ input: process.stdin }).on("line", (line) => { + if (!line.trim()) return; + let request; + try { + request = JSON.parse(line.replace(/^\uFEFF/, "")); + respond(request, dispatch(request)); + } catch (error) { + respond(request, {}, "rejected", error.message); + } +}); + +function respond(request, output, status = "accepted", message) { + process.stdout.write(`${JSON.stringify({ + schema: "gamecult.eve.plugin_abi.response.v1", + pluginId, + operation: request?.operation || "unknown", + requestId: request?.requestId || "unknown", + status, + output, + ...(message ? { diagnostics: [{ message }] } : {}), + })}\n`); +} + diff --git a/plugins/eve-plugin-tex/test/plugin.test.mjs b/plugins/eve-plugin-tex/test/plugin.test.mjs new file mode 100644 index 0000000..04a6a08 --- /dev/null +++ b/plugins/eve-plugin-tex/test/plugin.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { dispatch } from "../src/plugin.mjs"; + +function request(operation, input = {}) { + return dispatch({ schema: "gamecult.eve.plugin_abi.request.v1", pluginId: "tex.math", operation, requestId: operation, input }); +} + +test("typesets TeX through KaTeX without taking provider authority", () => { + const result = request("lower", { documentId: "basic", source: "E = mc^2", displayMode: true }); + assert.equal(result.schema, "tex.math.render_result.v1"); + assert.equal(result.engine, "katex"); + assert.match(result.html, /katex/); +}); + +test("validates owned components and rejects invalid TeX", () => { + assert.deepEqual(request("validate", { componentKinds: ["embed.tex", "embed.norn"], source: "x^2" }), { + acceptedComponentKinds: ["embed.tex"], rejectedComponentKinds: ["embed.norn"], validSource: true, requiredCapabilities: ["embed.tex"], + }); + assert.equal(request("validate", { componentKinds: ["embed.tex"], source: "\\notacommand{" }).validSource, false); +}); + +test("caches render results without mutating provider state", () => { + const result = request("apply", { documentId: "basic", source: "E = mc^2" }); + assert.equal(result.cacheStatus, "stored"); + assert.equal(result.providerStateMutated, false); +}); + diff --git a/scripts/run-tex-witness.mjs b/scripts/run-tex-witness.mjs new file mode 100644 index 0000000..86c10c4 --- /dev/null +++ b/scripts/run-tex-witness.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; + +const root = path.resolve(import.meta.dirname, ".."); +const fixture = JSON.parse(fs.readFileSync(path.join(root, "plugins/eve-plugin-tex/plugin-abi-fixture.json"), "utf8")); +const child = spawn(process.execPath, [path.join(root, "plugins/eve-plugin-tex/src/sidecar.mjs")], { cwd: root, stdio: ["pipe", "pipe", "inherit"] }); +const responses = readline.createInterface({ input: child.stdout })[Symbol.asyncIterator](); + +try { + for (let index = 0; index < fixture.operations.length; index += 1) { + const operation = fixture.operations[index]; + const requestId = `tex-witness-${index}`; + child.stdin.write(`${JSON.stringify({ schema: fixture.requestSchema, pluginId: fixture.pluginId, operation: operation.operation, requestId, input: operation.input })}\n`); + const next = await responses.next(); + assert.equal(next.done, false); + const response = JSON.parse(next.value); + assert.equal(response.schema, fixture.responseSchema); + assert.equal(response.pluginId, fixture.pluginId); + assert.equal(response.operation, operation.operation); + assert.equal(response.requestId, requestId); + assert.equal(response.status, "accepted"); + assertSubset(response.output, operation.expect, operation.operation); + } +} finally { + child.stdin.end(); + child.kill(); +} + +const outputDir = path.join(root, "artifacts", "tex-math"); +fs.mkdirSync(outputDir, { recursive: true }); +const witness = { + schema: "gamecult.eve.plugin_witness.v1", + witnessId: "tex.math.owner-sidecar", + pluginId: "tex.math", + ownerRepo: "EvePlugins", + transport: "stdio-ndjson", + operations: fixture.operations.map(({ operation }) => operation), + status: "passed", +}; +fs.writeFileSync(path.join(outputDir, "runtime-witness.json"), `${JSON.stringify(witness, null, 2)}\n`); +console.log(path.join(outputDir, "runtime-witness.json")); + +function assertSubset(actual, expected, label) { + if (Array.isArray(expected)) { + assert.deepEqual(actual, expected, label); + return; + } + if (expected && typeof expected === "object") { + for (const [key, value] of Object.entries(expected)) assertSubset(actual?.[key], value, `${label}.${key}`); + return; + } + assert.equal(actual, expected, label); +}