Initialize first-party Eve plugin owners
This commit is contained in:
commit
6f23900898
14 changed files with 441 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
artifacts/
|
||||
|
||||
6
LICENSE
Normal file
6
LICENSE
Normal file
|
|
@ -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/.
|
||||
|
||||
15
README.md
Normal file
15
README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
|
||||
56
package-lock.json
generated
Normal file
56
package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
package.json
Normal file
11
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
28
plugins/eve-plugin-tex/advertisement.json
Normal file
28
plugins/eve-plugin-tex/advertisement.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
|
||||
8
plugins/eve-plugin-tex/fixtures/basic-source.json
Normal file
8
plugins/eve-plugin-tex/fixtures/basic-source.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"schema": "tex.math.source.v1",
|
||||
"documentId": "tex.basic",
|
||||
"source": "E = mc^2",
|
||||
"displayMode": true,
|
||||
"macros": {}
|
||||
}
|
||||
|
||||
14
plugins/eve-plugin-tex/package.json
Normal file
14
plugins/eve-plugin-tex/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
18
plugins/eve-plugin-tex/plugin-abi-fixture.json
Normal file
18
plugins/eve-plugin-tex/plugin-abi-fixture.json
Normal file
|
|
@ -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" } }
|
||||
]
|
||||
}
|
||||
40
plugins/eve-plugin-tex/plugin.json
Normal file
40
plugins/eve-plugin-tex/plugin.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
130
plugins/eve-plugin-tex/src/plugin.mjs
Normal file
130
plugins/eve-plugin-tex/src/plugin.mjs
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
||||
28
plugins/eve-plugin-tex/src/sidecar.mjs
Normal file
28
plugins/eve-plugin-tex/src/sidecar.mjs
Normal file
|
|
@ -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`);
|
||||
}
|
||||
|
||||
28
plugins/eve-plugin-tex/test/plugin.test.mjs
Normal file
28
plugins/eve-plugin-tex/test/plugin.test.mjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
||||
56
scripts/run-tex-witness.mjs
Normal file
56
scripts/run-tex-witness.mjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
Reference in a new issue