ttrpg-tools/src/components/journal/completions.ts

243 lines
7.0 KiB
TypeScript
Raw Normal View History

/**
* 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 and headings.
*
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
* from any Solid component to reactively read the state.
*/
import { createSignal } from "solid-js";
import Slugger from "github-slugger";
import { extractHeadings } from "../../data-loader/toc";
import {
getPathsByExtension,
getIndexedData,
} from "../../data-loader/file-index";
import {
parseStatYaml,
parseStatCsv,
parseTemplateCsv,
parseStatModifiers,
} from "../../cli/completions/stat-parser";
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
parseSparkTableCsv,
} from "../../cli/completions/block-scanner";
export type { StatDef, StatTemplate };
// ------------------- Types (mirrors CLI) -------------------
export interface DiceCompletion {
label: string;
notation: string;
source: string;
}
export interface LinkCompletion {
path: string;
label: string;
section: string | null;
}
export interface SparkTableCompletion {
label: string;
notation: string;
slug: string;
filePath: string;
headers: string[];
}
export interface JournalCompletions {
dice: DiceCompletion[];
links: LinkCompletion[];
sparkTables: SparkTableCompletion[];
stats: StatDef[];
statTemplates: StatTemplate[];
}
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",
});
// ------------------- 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 : [],
stats: Array.isArray(data.stats) ? data.stats : [],
statTemplates: Array.isArray(data.statTemplates)
? data.statTemplates
: [],
};
} catch {
return null;
}
}
// ------------------- Client-side fallback scan -------------------
async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md");
const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const stats: StatDef[] = [];
const statTemplates: StatTemplate[] = [];
const tagRegex = /:md-dice\[([^[]+)\]/gi;
for (const filePath of paths) {
const content = await getIndexedData(filePath);
if (!content) continue;
// Fresh slugger per file
const slugger = new Slugger();
// ---- Dice directives ----
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
while ((match = tagRegex.exec(content)) !== null) {
const raw = match[1].trim();
if (!raw || raw.length > 80) continue;
if (!/^\d*d\d+/i.test(raw) && !/^[+-]/.test(raw)) continue;
dice.push({ label: raw, notation: raw, source: filePath });
}
// ---- Links (headings) ----
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,
});
}
// ---- Unified block scanning ----
FENCED_BLOCK_RE.lastIndex = 0;
let blockMatch: RegExpExecArray | null;
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
const [, lang, infoString, body] = blockMatch;
const attrs = parseBlockAttrs(infoString);
attrs.lang = attrs.lang || lang;
if (attrs.role === "stat") {
if (attrs.lang === "yaml" || attrs.lang === "yml") {
stats.push(...parseStatYaml(body, filePath));
} else if (attrs.lang === "csv") {
stats.push(...parseStatCsv(body, filePath));
}
}
if (attrs.role === "stat-template") {
const name = attrs.id || `_tpl_${filePath}_${stats.length}`;
statTemplates.push(parseTemplateCsv(body, filePath, name));
}
if (attrs.role === "stat-modifiers") {
const id = attrs.id || `_mod_${filePath}_${stats.length}`;
const result = parseStatModifiers(body, filePath, id);
stats.push(result.statDef);
stats.push(...result.modifierDefs);
statTemplates.push(result.template);
}
if (attrs.role === "spark-table") {
const parsed = parseSparkTableCsv(body, filePath, slugger);
if (parsed) sparkTables.push(parsed);
}
}
}
return { dice, links, sparkTables, stats, statTemplates };
}
// ------------------- 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 });
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 });
} 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;
}
/** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
let _invalidated = false;
export function invalidateCompletions(): void {
_invalidated = true;
// On next import (page reload), the module will re-init.
// For a runtime invalidation, you could call init again.
}
/**
* 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: [],
stats: [],
statTemplates: [],
},
};
}