Thing/web/eve-dsl.js
2026-06-05 22:07:43 +01:00

544 lines
13 KiB
JavaScript

export function compileEveDsl(source) {
const mesh = createCultMeshStore();
const root = {
id: "root",
kind: "surface",
props: {},
children: [],
};
const state = {
providerId: "eve.dsl.fixture",
providerKind: "eve.composition",
title: "Eve DSL Surface",
version: 1,
surface: {
root,
styles: {
tokens: {
colorBackground: "#04070b",
colorPanel: "#0b1720",
colorPanelAlt: "#0e2230",
colorAccent: "#ffcc66",
colorText: "#eef7ff",
colorMuted: "#8ba7b8",
colorLink: "#8efcff",
},
controlSkins: {},
},
},
commands: [],
mesh,
};
const stack = [{ indent: -1, node: root }];
let activeSkin;
for (const entry of readDslLines(source)) {
while (entry.indent <= stack[stack.length - 1].indent) stack.pop();
const parent = stack[stack.length - 1].node;
const [head, ...rest] = entry.tokens;
if (applyStateCommand(state, mesh, head, rest)) {
activeSkin = undefined;
continue;
}
if (head === "skin") {
activeSkin = {
target: readKeyword(rest, "for") || rest[2] || "control",
children: [],
};
state.surface.styles.controlSkins[rest[0]] = activeSkin;
stack.push({ indent: entry.indent, node: activeSkin });
continue;
}
if (isPropertyLine(head, parent)) {
applyProperty(parent.props || (parent.props = {}), head, rest);
continue;
}
const node = createComponent(entry, state);
if (!node) continue;
if (head === "fieldRow") {
parent.children.push(node.row);
stack.push({ indent: entry.indent, node: node.field });
continue;
}
if (activeSkin && parent === activeSkin) {
activeSkin.children.push(node);
stack.push({ indent: entry.indent, node });
continue;
}
parent.children.push(node);
stack.push({ indent: entry.indent, node });
}
return state;
}
export function createCultMeshStore() {
const records = new Map();
const ensure = (path, initialValue) => {
if (!records.has(path)) {
records.set(path, {
value: initialValue,
subscribers: new Set(),
});
}
return records.get(path);
};
const notify = (path) => {
const record = ensure(path, undefined);
for (const subscriber of record.subscribers) subscriber(record.value);
};
return {
var(path) {
return {
get: () => ensure(path, undefined).value,
set: (value) => {
ensure(path, undefined).value = value;
notify(path);
},
subscribe: (subscriber) => {
const record = ensure(path, undefined);
record.subscribers.add(subscriber);
subscriber(record.value);
return () => record.subscribers.delete(subscriber);
},
};
},
collection(path) {
const record = ensure(path, []);
if (!Array.isArray(record.value)) record.value = [];
return {
get: () => record.value.slice(),
append: (value) => {
record.value = [...record.value, value];
notify(path);
},
subscribe: (subscriber) => {
record.subscribers.add(subscriber);
subscriber(record.value.slice());
return () => record.subscribers.delete(subscriber);
},
};
},
snapshot() {
return Object.fromEntries([...records.entries()].map(([path, record]) => [path, record.value]));
},
derive(target, operator, sourcePath) {
const source = this.var(sourcePath);
const targetVar = this.var(target);
source.subscribe((value) => {
if (operator === "count") {
targetVar.set(Array.isArray(value) ? value.length : 0);
} else if (operator === "latest") {
targetVar.set(Array.isArray(value) ? value[value.length - 1] : value);
}
});
},
applyAction(action) {
if (!action) return;
if (action.type === "append") {
this.collection(action.target).append(renderTemplate(action.value));
} else if (action.type === "set") {
this.var(action.target).set(parseDslValue(renderTemplate(action.value)));
}
},
};
}
function readDslLines(source) {
const lines = [];
let indentMode;
let spaceUnit;
for (const rawLine of source.split(/\r?\n/)) {
const withoutComment = stripComment(rawLine);
if (!withoutComment.trim()) continue;
const leading = withoutComment.match(/^[\t ]*/)?.[0] || "";
if (leading.includes("\t") && leading.includes(" ")) {
throw new Error("CultUI indentation may use tabs or spaces, not both on one line.");
}
if (leading) {
const mode = leading.includes("\t") ? "tabs" : "spaces";
indentMode ||= mode;
if (indentMode !== mode) throw new Error("CultUI indentation must be consistent within a file.");
if (mode === "spaces") spaceUnit ||= leading.length;
}
const indent = leading.includes("\t")
? leading.length
: leading.length ? leading.length / (spaceUnit || leading.length) : 0;
const line = withoutComment.trim();
lines.push({ indent, tokens: tokenize(line), line });
}
return lines;
}
function applyStateCommand(state, mesh, head, rest) {
if (head === "surface") {
state.providerId = rest[0] || state.providerId;
state.surface.root.id = rest[0] || state.surface.root.id;
state.title = rest[1] || state.title;
return true;
}
if (head === "version") {
state.version = Number(rest[0]) || state.version;
return true;
}
if (head === "var") {
mesh.var(rest[0]).set(parseDslValue(rest.slice(1).join(" ")));
return true;
}
if (head === "collection") {
mesh.collection(rest[0]);
return true;
}
if (head === "item") {
mesh.collection(rest[0]).append(parseDslValue(rest.slice(1).join(" ")));
return true;
}
if (head === "derive") {
const [target, operator, sourcePath] = rest;
mesh.derive(target, operator, sourcePath);
return true;
}
return false;
}
function createComponent(entry, state) {
const [head, ...rest] = entry.tokens;
if (head === "fieldRow") {
const label = rest[0] || "Field";
const id = slug(label);
const field = {
id: `${id}.field`,
kind: "partition",
props: { size: "1fr", role: "inspector.field" },
children: [],
};
return {
field,
row: {
id: `${id}.row`,
kind: "partition",
props: { split: "x", gap: 8, padding: 4, align: "center", role: "inspector.row" },
children: [
{
id: `${id}.label`,
kind: "partition",
props: { size: "12rem", role: "inspector.label" },
children: [{ id: `${id}.label.text`, kind: "label", props: { text: label }, children: [] }],
},
field,
],
},
};
}
if (head === "partition") {
return {
id: rest[0] || `partition.${entry.line.length}`,
kind: "partition",
props: parsePairs(rest.slice(1)),
children: [],
};
}
if (head === "pane") {
return {
id: rest[0] || `pane.${entry.line.length}`,
kind: "pane",
props: parsePairs(rest.slice(1), { title: rest[0] }),
children: [],
};
}
if (head === "card") {
return {
id: rest[0] || `card.${entry.line.length}`,
kind: "card",
props: { title: rest[1] || rest[0] || "Card", ...parsePairs(rest.slice(2)) },
children: [],
};
}
if (head === "title") {
return {
id: scopedId("title", entry),
kind: "text.title",
props: parseTextProps(rest),
children: [],
};
}
if (head === "text") {
return {
id: scopedId("text", entry),
kind: "text",
props: parseTextProps(rest),
children: [],
};
}
if (head === "label") {
return {
id: scopedId("label", entry),
kind: "label",
props: parseTextProps(rest),
children: [],
};
}
if (head === "metric") {
return {
id: scopedId("metric", entry),
kind: "metric",
props: {
label: rest[0] || "Metric",
bind: readKeyword(rest, "bind"),
format: rest.includes("percent") ? "percent" : "value",
},
children: [],
};
}
if (head === "list") {
return {
id: scopedId("list", entry),
kind: "list",
props: {
title: rest[0] || "List",
bind: readKeyword(rest, "bind"),
},
children: [],
};
}
if (head === "button") {
return {
id: scopedId("button", entry),
kind: "control.button",
props: {
label: rest[0] || "Action",
action: parseButtonAction(rest),
},
children: [],
};
}
if (head === "slider") {
return {
id: scopedId("slider", entry),
kind: "control.slider",
props: parsePairs(rest),
children: [],
};
}
if (head === "box") {
return {
id: scopedId("box", entry),
kind: "control.box",
props: parsePairs(rest),
children: [],
};
}
if (head === "part") {
return {
id: scopedId(`part.${rest[0] || "unnamed"}`, entry),
kind: "control.part",
props: { name: rest[0] || "part", ...parsePairs(rest.slice(1)) },
children: [],
};
}
if (head === "hitArea") {
return {
id: scopedId("hitArea", entry),
kind: "control.hitArea",
props: parsePairs(rest),
children: [],
};
}
if (head === "skin") return undefined;
return {
id: scopedId(head, entry),
kind: head,
props: parsePairs(rest),
children: [],
};
}
function parseTextProps(tokens) {
const bind = readKeyword(tokens, "bind");
return {
text: bind ? "" : tokens.join(" "),
bind,
prefix: readKeyword(tokens, "prefix") || "",
suffix: readKeyword(tokens, "suffix") || "",
};
}
function parseButtonAction(tokens) {
const appendIndex = tokens.indexOf("append");
if (appendIndex >= 0) {
return {
type: "append",
target: tokens[appendIndex + 1],
value: tokens.slice(appendIndex + 2).join(" "),
};
}
const setIndex = tokens.indexOf("set");
if (setIndex >= 0) {
return {
type: "set",
target: tokens[setIndex + 1],
value: tokens.slice(setIndex + 2).join(" "),
};
}
return undefined;
}
function parsePairs(tokens, initial = {}) {
const props = { ...initial };
for (let index = 0; index < tokens.length; index += 1) {
const key = tokens[index];
if (key === "bind") {
props.bind = tokens[index + 1];
index += 1;
} else if (key === "split") {
props.split = tokens[index + 1];
index += 1;
} else if (key === "skin") {
props.skin = tokens[index + 1];
index += 1;
} else if (PROPERTY_NAMES.has(key)) {
const value = readPropertyValue(tokens, index + 1);
props[key] = value.value;
index = value.nextIndex - 1;
} else if (index + 1 < tokens.length && !PROPERTY_NAMES.has(tokens[index + 1])) {
props[key] = parseDslValue(tokens[index + 1]);
index += 1;
} else {
props[key] = true;
}
}
return props;
}
function isPropertyLine(head, parent) {
return Boolean(parent?.props && PROPERTY_NAMES.has(head));
}
function applyProperty(props, head, rest) {
props[head] = normalizePropertyValues(rest.map(parseDslValue));
}
function readPropertyValue(tokens, start) {
let nextIndex = start;
while (nextIndex < tokens.length && !PROPERTY_NAMES.has(tokens[nextIndex])) {
nextIndex += 1;
}
return {
value: normalizePropertyValues(tokens.slice(start, nextIndex).map(parseDslValue)),
nextIndex,
};
}
function normalizePropertyValues(values) {
return values.length <= 1 ? values[0] : values;
}
function readKeyword(tokens, keyword) {
const index = tokens.indexOf(keyword);
return index >= 0 ? tokens[index + 1] : undefined;
}
function parseDslValue(value) {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if (trimmed === "true") return true;
if (trimmed === "false") return false;
return trimmed;
}
function renderTemplate(value) {
return value.replace(/\{\{now\}\}/g, new Date().toLocaleTimeString());
}
function stripComment(line) {
let quote;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if ((char === "\"" || char === "'") && line[index - 1] !== "\\") {
quote = quote === char ? undefined : quote || char;
}
if (char === "#" && !quote) return line.slice(0, index);
}
return line;
}
function tokenize(line) {
const tokens = [];
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
let match;
while ((match = pattern.exec(line))) {
tokens.push(match[1] ?? match[2] ?? match[3]);
}
return tokens;
}
function slug(value) {
return String(value || "field")
.toLowerCase()
.replace(/[^a-z0-9]+/g, ".")
.replace(/^\.+|\.+$/g, "") || "field";
}
function scopedId(prefix, entry) {
return `${prefix}.${slug(entry.line).slice(0, 48)}`;
}
const PROPERTY_NAMES = new Set([
"align",
"anchor",
"bleed",
"clip",
"fill",
"gap",
"height",
"max",
"min",
"overflow",
"padding",
"radius",
"scroll",
"shadow",
"size",
"step",
"width",
]);