Files
ttrpg-tools/src/components/journal/completions.ts
T
hyper c03528a293 feat: Unify spark table detection and content resolution
Export isSparkTableHeader from content-registry and use it in both CLI
and frontend table parsing. Add resolveContentRef to resolve content
references consistently. Write processed registry content back to the
file index so browser mode renders identically to CLI mode.
2026-08-07 13:15:51 +08:00

224 lines
6.9 KiB
TypeScript

/**
* Journal completions — client-side loader for /__COMPLETIONS.json
*
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
* back to scanning the in-memory file index for dice expressions, headings,
* and declare blocks.
*
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
* from any Solid component to reactively read the state.
*/
import { createSignal } from "solid-js";
import {
getPathsByExtension,
getIndexedData,
setIndexedData,
setInlineResolver,
} from "../../data-loader/file-index";
import {
buildRegistryFromIndex,
deriveCompletions,
resolveInlineByPath,
type ContentRegistry,
} from "../../cli/content-registry";
import type {
CompletionsPayload,
DiceCompletion,
LinkCompletion,
SparkTableCompletion,
VarDeclaration,
TagModifier,
} from "../../cli/completions/types";
import { initReactivity, computeInitialValues } from "./var-reactivity";
import { sendMessage, journalStreamState } from "../stores/journalStream";
export type { VarDeclaration, TagModifier };
// Re-export CLI types so consumers don't need to know about the CLI path
export type { DiceCompletion, LinkCompletion, SparkTableCompletion };
/** Convenience alias — same shape as the CLI's CompletionsPayload. */
export type JournalCompletions = CompletionsPayload;
export type CompletionsState =
| { status: "loading" }
| { status: "loaded"; data: JournalCompletions }
| { status: "empty" }
| { status: "error"; message: string };
// ------------------- Reactive state (module-level signal) -------------------
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> {
try {
const resp = await fetch("/__COMPLETIONS.json");
if (!resp.ok) return null;
const data = await resp.json();
return {
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 : [],
};
} 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");
// Load all .md content into a raw index, then build the registry through
// the same shared pipeline as the CLI (scanDoc + spark injection).
const index: Record<string, string> = {};
for (const filePath of paths) {
const content = await getIndexedData(filePath);
if (!content) continue;
index[filePath] = content;
}
const registry = buildRegistryFromIndex(index);
activeRegistry = registry;
// Write the processed (stripped) content back into the file index so
// Article/md-embed render the same content as CLI mode (spark tables
// coerced, attributed blocks processed, data-spark injected).
for (const [path, content] of Object.entries(registry.pathIndex)) {
setIndexedData(path, content);
}
return deriveCompletions(registry);
}
// ------------------- Init (runs eagerly at import time) -------------------
// Using a top-level IIFE so the promise starts immediately
const _initPromise: Promise<void> = (async () => {
// 1. Try server first (CLI mode)
const serverData = await tryServer();
if (serverData) {
setCompletionsState({ status: "loaded", data: serverData });
await tryServerRegistry();
try {
initReactivity({
declarations: serverData.declarations,
tagModifiers: serverData.tagModifiers,
});
seedDeclaredVariables();
} catch (e) {
console.warn("[completions] reactivity init error:", e);
}
return;
}
// 2. Fall back to client-side scan (dev/browser mode)
try {
const data = await scanClientSide();
if (data.dice.length > 0 || data.links.length > 0) {
setCompletionsState({ status: "loaded", data });
try {
initReactivity({
declarations: data.declarations,
tagModifiers: data.tagModifiers,
});
seedDeclaredVariables();
} catch (e) {
console.warn("[completions] reactivity init error:", e);
}
} else {
setCompletionsState({ status: "empty" });
}
} catch (e) {
setCompletionsState({
status: "error",
message: e instanceof Error ? e.message : "Failed to scan",
});
}
})();
// ------------------- Public API -------------------
/**
* Returns a Promise that resolves once completions are loaded (or failed).
* Useful for waiting before showing the completions dropdown.
*/
export function ensureCompletions(): Promise<void> {
return _initPromise;
}
/**
* Reactive hooks for the journal input.
* Returns the current completions state + a convenience `data` extractor.
*/
export function useJournalCompletions(): {
state: CompletionsState;
data: JournalCompletions;
} {
const s = completionsState();
if (s.status === "loaded") {
return { state: s, data: s.data };
}
return {
state: s,
data: {
dice: [],
links: [],
sparkTables: [],
declarations: [],
tagModifiers: [],
},
};
}
// ---------------------------------------------------------------------------
// Seed declared variables into the store on load
// ---------------------------------------------------------------------------
function seedDeclaredVariables(): void {
const initial = computeInitialValues(journalStreamState.variables);
for (const { key, value } of initial) {
sendMessage("var", { action: "set", key, value });
}
}