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:
@@ -16,7 +16,8 @@ import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
|
||||
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
||||
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
const TAGMAP_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/;
|
||||
const TAGMAP_PATTERN =
|
||||
/^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/;
|
||||
|
||||
function isTagMapExpr(expr: string): boolean {
|
||||
const t = expr.trim();
|
||||
@@ -33,17 +34,16 @@ function normalizeTagMap(expr: string): string {
|
||||
// Result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DispatchResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string };
|
||||
export type DispatchResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
|
||||
* Components that show errors (JournalInput) read from here; callers that
|
||||
* want errors surfaced (CommandLinkManager) write to it.
|
||||
*/
|
||||
export const [dispatchError, setDispatchError] =
|
||||
createSignal<string | null>(null);
|
||||
export const [dispatchError, setDispatchError] = createSignal<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main dispatch
|
||||
@@ -57,7 +57,12 @@ export interface DispatchContext {
|
||||
/** The raw text to dispatch (with or without leading `/`) */
|
||||
command: string;
|
||||
/** Spark table lookup data (from completions) */
|
||||
sparkTables: { slug: string; csvPath?: string; remix?: boolean }[];
|
||||
sparkTables: {
|
||||
slug: string;
|
||||
csvPath?: string;
|
||||
docPath?: string;
|
||||
remix?: boolean;
|
||||
}[];
|
||||
/** Current runtime variable values */
|
||||
variables: Record<string, string>;
|
||||
/** Variable declarations (from role=declare blocks) */
|
||||
@@ -94,7 +99,9 @@ export async function dispatchCommand(
|
||||
}
|
||||
|
||||
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
|
||||
return finish(
|
||||
dispatchSet(parsed.payload as Record<string, unknown>, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
|
||||
@@ -109,8 +116,14 @@ export async function dispatchCommand(
|
||||
if (match) {
|
||||
try {
|
||||
const csvPath = match.csvPath ?? "";
|
||||
const docPath = match.docPath;
|
||||
const remix = match.remix ?? false;
|
||||
const p = await resolveSparkPayload({ key: arg, csvPath, remix });
|
||||
const p = await resolveSparkPayload({
|
||||
key: arg,
|
||||
csvPath,
|
||||
docPath,
|
||||
remix,
|
||||
});
|
||||
const result = sendMessage("spark", p);
|
||||
return finish(unwrap(result));
|
||||
} catch (e) {
|
||||
@@ -226,7 +239,11 @@ function dispatchSet(
|
||||
try {
|
||||
const cascade = computeCascade(key, oldValue, workingVars);
|
||||
for (const change of cascade) {
|
||||
sendMessage("var", { action: "set", key: change.key, value: change.value });
|
||||
sendMessage("var", {
|
||||
action: "set",
|
||||
key: change.key,
|
||||
value: change.value,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Cascade errors are non-fatal — the direct set already succeeded
|
||||
@@ -253,4 +270,4 @@ function unwrap<R>(
|
||||
r: { success: true; msg: R } | { success: false; error: string },
|
||||
): DispatchResult {
|
||||
return r.success ? { ok: true } : { ok: false, error: r.error };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,17 @@
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { extractHeadings } from "../../data-loader/toc";
|
||||
import {
|
||||
getPathsByExtension,
|
||||
getIndexedData,
|
||||
setInlineResolver,
|
||||
} from "../../data-loader/file-index";
|
||||
import {
|
||||
scanDirectives,
|
||||
} from "../../cli/completions/directive-scanner";
|
||||
import { scanDeclareBlocks } from "../../cli/completions/declare-parser";
|
||||
scanDoc,
|
||||
deriveCompletions,
|
||||
resolveInlineByPath,
|
||||
type ContentRegistry,
|
||||
} from "../../cli/content-registry";
|
||||
import type {
|
||||
CompletionsPayload,
|
||||
DiceCompletion,
|
||||
@@ -50,6 +52,23 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
||||
status: "loading",
|
||||
});
|
||||
|
||||
// The registry backing the completions. Populated in both CLI and client
|
||||
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
|
||||
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||
|
||||
/**
|
||||
* The registry backing the current completions.
|
||||
* In CLI mode this is fetched from the server; in browser mode it is built
|
||||
* client-side. Used to resolve inline content ids (e.g. spark table CSVs).
|
||||
*/
|
||||
export function getRegistry(): ContentRegistry {
|
||||
return activeRegistry;
|
||||
}
|
||||
|
||||
// Register the inline-content resolver so `getIndexedData` can resolve
|
||||
// directive refs (e.g. `./csv_abc123`) that aren't real files.
|
||||
setInlineResolver((path) => resolveInlineByPath(activeRegistry, path));
|
||||
|
||||
// ------------------- Fetch (CLI mode) -------------------
|
||||
|
||||
async function tryServer(): Promise<JournalCompletions | null> {
|
||||
@@ -61,66 +80,49 @@ async function tryServer(): Promise<JournalCompletions | null> {
|
||||
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||
links: Array.isArray(data.links) ? data.links : [],
|
||||
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
||||
declarations: Array.isArray(data.declarations)
|
||||
? data.declarations
|
||||
: [],
|
||||
tagModifiers: Array.isArray(data.tagModifiers)
|
||||
? data.tagModifiers
|
||||
: [],
|
||||
declarations: Array.isArray(data.declarations) ? data.declarations : [],
|
||||
tagModifiers: Array.isArray(data.tagModifiers) ? data.tagModifiers : [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Load the content registry from the server (CLI mode). */
|
||||
async function tryServerRegistry(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/__CONTENT_REGISTRY.json");
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
activeRegistry = {
|
||||
pathIndex: data.pathIndex ?? {},
|
||||
docContent: data.docContent ?? {},
|
||||
};
|
||||
} catch {
|
||||
// Registry unavailable — leave empty; client scan will populate it.
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------- Client-side fallback scan -------------------
|
||||
|
||||
async function scanClientSide(): Promise<JournalCompletions> {
|
||||
const paths = await getPathsByExtension("md");
|
||||
const dice: DiceCompletion[] = [];
|
||||
const links: LinkCompletion[] = [];
|
||||
const sparkTables: SparkTableCompletion[] = [];
|
||||
const declarations: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
|
||||
// Build a temporary index for resolving CSV paths
|
||||
const tempIndex: Record<string, string> = {};
|
||||
// Build a registry from the in-memory file index, then derive completions
|
||||
// through the same shared pipeline as the CLI.
|
||||
const registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||
|
||||
// First pass: load all .md content into temp index
|
||||
// First pass: load all .md content into the registry.
|
||||
for (const filePath of paths) {
|
||||
const content = await getIndexedData(filePath);
|
||||
if (content) tempIndex[filePath] = content;
|
||||
}
|
||||
|
||||
for (const filePath of paths) {
|
||||
const content = tempIndex[filePath];
|
||||
if (!content) continue;
|
||||
|
||||
// ---- Links (headings) - from original content ----
|
||||
const basePath = filePath.replace(/\.md$/, "");
|
||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||
links.push({ path: basePath, label: fileName, section: null });
|
||||
for (const heading of extractHeadings(content)) {
|
||||
links.push({
|
||||
path: basePath,
|
||||
label: `${fileName} § ${heading.title}`,
|
||||
section: heading.id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Declare block scanning (shared with CLI) ----
|
||||
const declareResult = scanDeclareBlocks(content, filePath);
|
||||
declarations.push(...declareResult.variables);
|
||||
tagModifiers.push(...declareResult.tagModifiers);
|
||||
|
||||
// ---- Directive scanning (dice + spark tables) ----
|
||||
const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
|
||||
const directiveResult = scanDirectives(content, filePath, tempIndex, fileDir);
|
||||
dice.push(...directiveResult.dice);
|
||||
sparkTables.push(...directiveResult.sparkTables);
|
||||
const result = scanDoc(content, filePath);
|
||||
registry.pathIndex[filePath] = result.stripped;
|
||||
registry.docContent[filePath] = result.content;
|
||||
}
|
||||
|
||||
return { dice, links, sparkTables, declarations, tagModifiers };
|
||||
activeRegistry = registry;
|
||||
return deriveCompletions(registry);
|
||||
}
|
||||
|
||||
// ------------------- Init (runs eagerly at import time) -------------------
|
||||
@@ -131,10 +133,16 @@ const _initPromise: Promise<void> = (async () => {
|
||||
const serverData = await tryServer();
|
||||
if (serverData) {
|
||||
setCompletionsState({ status: "loaded", data: serverData });
|
||||
await tryServerRegistry();
|
||||
try {
|
||||
initReactivity({ declarations: serverData.declarations, tagModifiers: serverData.tagModifiers });
|
||||
initReactivity({
|
||||
declarations: serverData.declarations,
|
||||
tagModifiers: serverData.tagModifiers,
|
||||
});
|
||||
seedDeclaredVariables();
|
||||
} catch (e) { console.warn("[completions] reactivity init error:", e); }
|
||||
} catch (e) {
|
||||
console.warn("[completions] reactivity init error:", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,9 +152,14 @@ const _initPromise: Promise<void> = (async () => {
|
||||
if (data.dice.length > 0 || data.links.length > 0) {
|
||||
setCompletionsState({ status: "loaded", data });
|
||||
try {
|
||||
initReactivity({ declarations: data.declarations, tagModifiers: data.tagModifiers });
|
||||
initReactivity({
|
||||
declarations: data.declarations,
|
||||
tagModifiers: data.tagModifiers,
|
||||
});
|
||||
seedDeclaredVariables();
|
||||
} catch (e) { console.warn("[completions] reactivity init error:", e); }
|
||||
} catch (e) {
|
||||
console.warn("[completions] reactivity init error:", e);
|
||||
}
|
||||
} else {
|
||||
setCompletionsState({ status: "empty" });
|
||||
}
|
||||
@@ -201,4 +214,4 @@ function seedDeclaredVariables(): void {
|
||||
for (const { key, value } of initial) {
|
||||
sendMessage("var", { action: "set", key, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,9 @@ import { z } from "zod";
|
||||
import { For } from "solid-js";
|
||||
import { registerMessageType } from "../registry";
|
||||
import { rollFormula } from "../../md-commander/hooks";
|
||||
import {
|
||||
parseSparkTableCsv,
|
||||
rollSparkTable,
|
||||
} from "../../utils/spark-table";
|
||||
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
|
||||
import { getIndexedData } from "../../../data-loader/file-index";
|
||||
import { getRegistry } from "../../journal/completions";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
@@ -75,12 +73,27 @@ export type SparkPayload = z.infer<typeof schema>;
|
||||
export async function resolveSparkPayload(raw: {
|
||||
key: string;
|
||||
csvPath: string;
|
||||
docPath?: string;
|
||||
remix: boolean;
|
||||
}): Promise<SparkPayload> {
|
||||
let csv: string;
|
||||
try {
|
||||
csv = await getIndexedData(raw.csvPath);
|
||||
} catch {
|
||||
let csv: string | null;
|
||||
|
||||
// Inline content ids resolve through the registry (docPath + content id);
|
||||
// real file paths fall back to the file index.
|
||||
const registry = getRegistry();
|
||||
const docStore = raw.docPath ? registry.docContent[raw.docPath] : undefined;
|
||||
const inline = docStore?.[raw.csvPath];
|
||||
if (inline) {
|
||||
csv = inline.body;
|
||||
} else {
|
||||
try {
|
||||
csv = await getIndexedData(raw.csvPath);
|
||||
} catch {
|
||||
csv = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (csv === null) {
|
||||
throw new Error(`Failed to load CSV: "${raw.csvPath}"`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user