/** * 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 Slugger from "github-slugger"; import { extractHeadings } from "../../data-loader/toc"; import { getPathsByExtension, getIndexedData, } from "../../data-loader/file-index"; import { FENCED_BLOCK_RE, parseBlockAttrs, } from "../../cli/completions/block-scanner"; import { scanDirectives, } from "../../cli/completions/directive-scanner"; import { parseDeclareCsv } from "./declare-parser"; import type { VarDeclaration, TagModifier } from "./declare-parser"; import { initReactivity, computeInitialValues } from "./var-reactivity"; import { sendMessage, journalStreamState } from "../stores/journalStream"; export type { VarDeclaration, TagModifier }; // ------------------- 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[]; declarations: VarDeclaration[]; tagModifiers: TagModifier[]; } 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 : [], declarations: Array.isArray(data.declarations) ? data.declarations : [], tagModifiers: Array.isArray(data.tagModifiers) ? data.tagModifiers : [], }; } 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 declarations: VarDeclaration[] = []; const tagModifiers: TagModifier[] = []; // 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 (declare) ---- 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 === "declare") { try { const result = parseDeclareCsv(body, filePath); declarations.push(...result.variables); tagModifiers.push(...result.tagModifiers); } catch (e) { console.warn(`[completions] ${filePath}: ${e}`); } } } // ---- 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, declarations, tagModifiers }; } // ------------------- 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 }); 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 { 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; } /** * 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 }); } }