refactor(completions): centralize content scanning

Add a browser-safe ContentRegistry owning path and inline content
stores, then derive completions from it. Remove the old block-processor,
directive-scanner, and link-source modules in favor of scanDoc,
deriveCompletions, and injectSparkDirectives, and register an inline
resolver for the frontend file index.
This commit is contained in:
hyper
2026-08-07 12:17:35 +08:00
parent 9e081891df
commit e8aa7165cb
13 changed files with 985 additions and 848 deletions
+100 -43
View File
@@ -5,7 +5,7 @@
* - variable-expression (expression evaluation)
* - var-reactivity (dependency graph, cascade, tag activation)
* - command-parser (input parsing)
* - directive-scanner (spark table detection)
* - content-registry (spark table detection)
*/
// ---------------------------------------------------------------------------
@@ -47,8 +47,7 @@ import { parseInput } from "../../components/journal/command-parser";
import {
inspectSparkTableCsv,
buildSparkTableCompletion,
} from "./directive-scanner";
import Slugger from "github-slugger";
} from "../content-registry";
// ---------------------------------------------------------------------------
// Helpers
@@ -70,7 +69,10 @@ describe("parseDeclareCsv", () => {
,$ac,10+$dex`;
const result = parseDeclareCsv(csv, "test.md");
expect(result.variables).toHaveLength(2);
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5+$mod_hp" });
expect(result.variables[0]).toEqual({
key: "$hp",
expression: "$con*5+$mod_hp",
});
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
expect(result.tagModifiers).toHaveLength(0);
});
@@ -154,7 +156,7 @@ describe("parseDeclareCsv", () => {
const csv = `tag,key,expr
warrior,$mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'tag must start with #',
"tag must start with #",
);
});
@@ -162,7 +164,7 @@ warrior,$mod_hp,20`;
const csv = `tag,key,expr
,hp,$con*5`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'key must start with $',
"key must start with $",
);
});
@@ -170,7 +172,7 @@ warrior,$mod_hp,20`;
const csv = `tag,key,expr
#warrior,mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'key must start with $',
"key must start with $",
);
});
@@ -219,8 +221,8 @@ describe("parseBlockAttrs", () => {
test("parses standard attributes", () => {
// parseBlockAttrs receives the info string AFTER the lang.
// The lang is extracted from the fenced block regex capture group
// and applied separately in block-processor.
const attrs = parseBlockAttrs('id=stats role=declare as=none');
// and applied separately in content-registry.
const attrs = parseBlockAttrs("id=stats role=declare as=none");
expect(attrs.id).toBe("stats");
expect(attrs.role).toBe("declare");
expect(attrs.as).toBe("none");
@@ -233,7 +235,7 @@ describe("parseBlockAttrs", () => {
});
test("collects unknown attributes in extra", () => {
const attrs = parseBlockAttrs('csv role=declare foo=bar baz=42');
const attrs = parseBlockAttrs("csv role=declare foo=bar baz=42");
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
});
@@ -282,7 +284,9 @@ describe("evaluateExpression", () => {
});
test("evaluates with parentheses", () => {
const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined });
const result = evaluateExpression("(2 + 3) * 4", {
lookup: () => undefined,
});
expect(result.value).toBe(20);
});
@@ -309,11 +313,13 @@ describe("evaluateExpression", () => {
evaluateExpression("$class + 5", {
lookup: (name) => (name === "class" ? "#warrior" : undefined),
}),
).toThrow('$class is a tag');
).toThrow("$class is a tag");
});
test("evaluates floor function", () => {
const result = evaluateExpression("floor(3.7)", { lookup: () => undefined });
const result = evaluateExpression("floor(3.7)", {
lookup: () => undefined,
});
expect(result.value).toBe(3);
});
@@ -323,7 +329,9 @@ describe("evaluateExpression", () => {
});
test("evaluates round function", () => {
const result = evaluateExpression("round(3.5)", { lookup: () => undefined });
const result = evaluateExpression("round(3.5)", {
lookup: () => undefined,
});
expect(result.value).toBe(4);
});
@@ -424,9 +432,7 @@ describe("var-reactivity", () => {
test("detects self-referencing circular dependency", () => {
expect(() =>
initReactivity({
declarations: [
{ key: "$a", expression: "$a + 1" },
],
declarations: [{ key: "$a", expression: "$a + 1" }],
tagModifiers: [],
}),
).toThrow("Circular dependency");
@@ -527,14 +533,28 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{ tag: "#warrior", target: "$mod_str", expression: "5", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{
tag: "#warrior",
target: "$mod_str",
expression: "5",
threshold: 1,
},
],
});
// Set $class to #warrior:1 — should activate both modifiers
setBase("$class", "#warrior:1");
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
const cascade = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:1" }),
);
// Should produce combined values for both targets
const modHp = cascade.find((r) => r.key === "$mod_hp");
@@ -547,7 +567,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
],
});
@@ -572,7 +597,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 },
],
});
@@ -600,19 +630,32 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 2,
},
],
});
// Count 1 < threshold 2 — should NOT activate
setBase("$class", "#warrior:1");
const cascade1 = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
const cascade1 = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:1" }),
);
const modHp1 = cascade1.find((r) => r.key === "$mod_hp");
expect(modHp1).toBeUndefined();
// Increase to count 2 >= threshold 2 — should activate
setBase("$class", "#warrior:2");
const cascade2 = computeCascade("$class", "#warrior:1", store({ $class: "#warrior:2" }));
const cascade2 = computeCascade(
"$class",
"#warrior:1",
store({ $class: "#warrior:2" }),
);
const modHp2 = cascade2.find((r) => r.key === "$mod_hp");
expect(modHp2?.value).toBe("20");
});
@@ -621,7 +664,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 2,
},
],
});
@@ -645,13 +693,22 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#druid", target: "$mod_mp", expression: "15", threshold: 1 },
],
});
setBase("$class", "#warrior:2;#druid:1");
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:2;#druid:1" }));
const cascade = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:2;#druid:1" }),
);
const modHp = cascade.find((r) => r.key === "$mod_hp");
const modMp = cascade.find((r) => r.key === "$mod_mp");
@@ -663,9 +720,7 @@ describe("var-reactivity", () => {
describe("computeCascade — declaration re-evaluation", () => {
test("re-evaluates dependents when a dependency changes", () => {
initReactivity({
declarations: [
{ key: "$hp", expression: "$con * 5" },
],
declarations: [{ key: "$hp", expression: "$con * 5" }],
tagModifiers: [],
});
@@ -701,11 +756,14 @@ describe("var-reactivity", () => {
test("handles tagmap transition during re-evaluation", () => {
initReactivity({
declarations: [
{ key: "$class", expression: "0" },
],
declarations: [{ key: "$class", expression: "0" }],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 },
],
});
@@ -744,7 +802,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
],
});
@@ -861,7 +924,7 @@ describe("parseInput", () => {
});
// ---------------------------------------------------------------------------
// directive-scanner
// content-registry (spark tables)
// ---------------------------------------------------------------------------
describe("inspectSparkTableCsv", () => {
@@ -915,13 +978,11 @@ describe("buildSparkTableCompletion", () => {
test("builds completion from CSV data", () => {
const csv = `d6,Name,Description
1,Alice,The brave`;
const slugger = new Slugger();
const result = buildSparkTableCompletion(
csv,
"/rules/combat.md",
"/rules/combat/test.csv",
false,
slugger,
);
expect(result).not.toBeNull();
expect(result!.notation).toBe("d6");
@@ -933,13 +994,11 @@ describe("buildSparkTableCompletion", () => {
test("returns null for non-spark CSV", () => {
const csv = `Name,Value
Alice,10`;
const slugger = new Slugger();
const result = buildSparkTableCompletion(
csv,
"/test.md",
"/test.csv",
false,
slugger,
);
expect(result).toBeNull();
});
@@ -947,13 +1006,11 @@ Alice,10`;
test("sets remix flag", () => {
const csv = `d6,Result
1,Yes`;
const slugger = new Slugger();
const result = buildSparkTableCompletion(
csv,
"/test.md",
"/test.csv",
true,
slugger,
);
expect(result!.remix).toBe(true);
});