/** * 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, } from "../../cli/completions/block-scanner"; import { scanDirectives, } from "../../cli/completions/directive-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; csvPath: string; headers: string[]; remix: boolean; } 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({ status: "loading", }); // ------------------- Fetch (CLI mode) ------------------- async function tryServer(): Promise { 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 { const paths = await getPathsByExtension("md"); const dice: DiceCompletion[] = []; const links: LinkCompletion[] = []; const sparkTables: SparkTableCompletion[] = []; const stats: StatDef[] = []; const statTemplates: StatTemplate[] = []; // Build a temporary index for resolving CSV paths const tempIndex: Record = {}; // First pass: load all .md content into temp index 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; // Fresh slugger per file const slugger = new Slugger(); // ---- 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, }); } // ---- Unified block scanning (stats) ---- 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, "player", attrs.extra["label"]); stats.push(result.statDef); stats.push(...result.modifierDefs); statTemplates.push(result.template); } } // ---- 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); } 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 = (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 { 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: [], }, }; }