/** * 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 } from "../../cli/completions/stat-parser"; import type { StatDef } from "../../cli/completions/stat-parser"; export type { StatDef }; // ------------------- 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[]; } 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 : [], }; } 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 tagRegex = /]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; const statBlockRegex = /```yaml\s+role=stat\s*\n([\s\S]*?)```/gi; const statCsvRegex = /```csv\s+role=stat\s*\n([\s\S]*?)```/gi; for (const filePath of paths) { const content = await getIndexedData(filePath); if (!content) continue; // Fresh slugger per file so duplicate headers across files don't // pollute each other. const slugger = new Slugger(); // Dice scan 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 }); } // Link scan (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, }); } // Spark table scan const sparkLines = content.split(/\r?\n/); for (let i = 0; i < sparkLines.length; i++) { const headerCells = splitTableRow(sparkLines[i]); if (!headerCells || headerCells.length < 2) continue; if (!/^d\d+$/i.test(headerCells[0])) continue; if (i + 1 >= sparkLines.length) continue; const sepCells = splitTableRow(sparkLines[i + 1]); if (!sepCells || !sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue; let j = i + 2; while (j < sparkLines.length && splitTableRow(sparkLines[j])) j++; if (j <= i + 2) continue; const dataHeaders = headerCells.slice(1); const stSlug = dataHeaders .map((h) => slugger.slug(h.toLowerCase())) .join("-"); // Combined key: pageName-columnSlug const combinedSlug = `${fileName}-${stSlug}`; sparkTables.push({ label: `${fileName} § ${stSlug}`, notation: headerCells[0], slug: combinedSlug, filePath: basePath, headers: dataHeaders, }); i = j - 1; } // Stat block scan (YAML) statBlockRegex.lastIndex = 0; let statMatch: RegExpExecArray | null; while ((statMatch = statBlockRegex.exec(content)) !== null) { const yaml = statMatch[1]; const parsed = parseStatYaml(yaml, filePath); stats.push(...parsed); } // Stat block scan (CSV) statCsvRegex.lastIndex = 0; while ((statMatch = statCsvRegex.exec(content)) !== null) { const csv = statMatch[1]; const parsed = parseStatCsv(csv, filePath); stats.push(...parsed); } } return { dice, links, sparkTables, stats }; } function splitTableRow(line: string): string[] | null { const trimmed = line.trim(); if (!trimmed.includes("|")) return null; let inner = trimmed; if (inner.startsWith("|")) inner = inner.slice(1); if (inner.endsWith("|")) inner = inner.slice(0, -1); return inner.split("|").map((c) => c.trim()); } // ------------------- 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: [] }, }; }