2026-07-06 16:57:36 +08:00
|
|
|
/**
|
|
|
|
|
* Journal completions — client-side loader for /__COMPLETIONS.json
|
|
|
|
|
*
|
2026-07-06 17:13:11 +08:00
|
|
|
* 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.
|
2026-07-06 16:57:36 +08:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { createSignal } from "solid-js";
|
2026-07-07 19:02:17 +08:00
|
|
|
import Slugger from "github-slugger";
|
2026-07-06 17:13:11 +08:00
|
|
|
import { extractHeadings } from "../../data-loader/toc";
|
|
|
|
|
import {
|
|
|
|
|
getPathsByExtension,
|
|
|
|
|
getIndexedData,
|
|
|
|
|
} from "../../data-loader/file-index";
|
2026-07-09 10:26:54 +08:00
|
|
|
import {
|
|
|
|
|
parseStatYaml,
|
|
|
|
|
parseStatCsv,
|
|
|
|
|
parseTemplateCsv,
|
2026-07-09 11:16:39 +08:00
|
|
|
parseStatModifiers,
|
2026-07-09 10:26:54 +08:00
|
|
|
} from "../../cli/completions/stat-parser";
|
|
|
|
|
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
|
2026-07-09 12:04:25 +08:00
|
|
|
import type { StatSheet } from "../../cli/completions/types";
|
2026-07-09 10:56:54 +08:00
|
|
|
import {
|
|
|
|
|
FENCED_BLOCK_RE,
|
|
|
|
|
parseBlockAttrs,
|
|
|
|
|
parseSparkTableCsv,
|
|
|
|
|
} from "../../cli/completions/block-scanner";
|
2026-07-09 10:00:17 +08:00
|
|
|
|
2026-07-09 12:04:25 +08:00
|
|
|
export type { StatDef, StatTemplate, StatSheet };
|
2026-07-06 16:57:36 +08:00
|
|
|
|
|
|
|
|
// ------------------- Types (mirrors CLI) -------------------
|
|
|
|
|
|
|
|
|
|
export interface DiceCompletion {
|
|
|
|
|
label: string;
|
|
|
|
|
notation: string;
|
|
|
|
|
source: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface LinkCompletion {
|
|
|
|
|
path: string;
|
|
|
|
|
label: string;
|
|
|
|
|
section: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 19:02:17 +08:00
|
|
|
export interface SparkTableCompletion {
|
|
|
|
|
label: string;
|
|
|
|
|
notation: string;
|
|
|
|
|
slug: string;
|
|
|
|
|
filePath: string;
|
|
|
|
|
headers: string[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 16:57:36 +08:00
|
|
|
export interface JournalCompletions {
|
|
|
|
|
dice: DiceCompletion[];
|
|
|
|
|
links: LinkCompletion[];
|
2026-07-07 19:02:17 +08:00
|
|
|
sparkTables: SparkTableCompletion[];
|
2026-07-09 09:22:14 +08:00
|
|
|
stats: StatDef[];
|
2026-07-09 10:26:54 +08:00
|
|
|
statTemplates: StatTemplate[];
|
2026-07-09 12:04:25 +08:00
|
|
|
statSheets: StatSheet[];
|
2026-07-06 16:57:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
export type CompletionsState =
|
|
|
|
|
| { status: "loading" }
|
|
|
|
|
| { status: "loaded"; data: JournalCompletions }
|
|
|
|
|
| { status: "empty" }
|
|
|
|
|
| { status: "error"; message: string };
|
2026-07-06 16:57:36 +08:00
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// ------------------- Reactive state (module-level signal) -------------------
|
2026-07-06 16:57:36 +08:00
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
|
|
|
|
status: "loading",
|
|
|
|
|
});
|
2026-07-06 16:57:36 +08:00
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// ------------------- Fetch (CLI mode) -------------------
|
2026-07-06 16:57:36 +08:00
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
async function tryServer(): Promise<JournalCompletions | null> {
|
2026-07-06 16:57:36 +08:00
|
|
|
try {
|
|
|
|
|
const resp = await fetch("/__COMPLETIONS.json");
|
2026-07-06 17:13:11 +08:00
|
|
|
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 : [],
|
2026-07-07 19:02:17 +08:00
|
|
|
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
2026-07-09 09:22:14 +08:00
|
|
|
stats: Array.isArray(data.stats) ? data.stats : [],
|
2026-07-09 10:26:54 +08:00
|
|
|
statTemplates: Array.isArray(data.statTemplates)
|
|
|
|
|
? data.statTemplates
|
|
|
|
|
: [],
|
2026-07-09 12:04:25 +08:00
|
|
|
statSheets: Array.isArray(data.statSheets) ? data.statSheets : [],
|
2026-07-06 17:13:11 +08:00
|
|
|
};
|
2026-07-06 16:57:36 +08:00
|
|
|
} catch {
|
2026-07-06 17:13:11 +08:00
|
|
|
return null;
|
2026-07-06 16:57:36 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// ------------------- Client-side fallback scan -------------------
|
|
|
|
|
|
|
|
|
|
async function scanClientSide(): Promise<JournalCompletions> {
|
|
|
|
|
const paths = await getPathsByExtension("md");
|
|
|
|
|
const dice: DiceCompletion[] = [];
|
|
|
|
|
const links: LinkCompletion[] = [];
|
2026-07-07 19:02:17 +08:00
|
|
|
const sparkTables: SparkTableCompletion[] = [];
|
2026-07-09 09:22:14 +08:00
|
|
|
const stats: StatDef[] = [];
|
2026-07-09 10:56:54 +08:00
|
|
|
const statTemplates: StatTemplate[] = [];
|
|
|
|
|
const tagRegex = /:md-dice\[([^[]+)\]/gi;
|
2026-07-06 17:13:11 +08:00
|
|
|
|
|
|
|
|
for (const filePath of paths) {
|
|
|
|
|
const content = await getIndexedData(filePath);
|
|
|
|
|
if (!content) continue;
|
|
|
|
|
|
2026-07-09 10:56:54 +08:00
|
|
|
// Fresh slugger per file
|
2026-07-07 19:07:37 +08:00
|
|
|
const slugger = new Slugger();
|
|
|
|
|
|
2026-07-09 10:56:54 +08:00
|
|
|
// ---- Dice directives ----
|
2026-07-06 17:13:11 +08:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 10:56:54 +08:00
|
|
|
// ---- Links (headings) ----
|
2026-07-06 17:13:11 +08:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-07-07 19:02:17 +08:00
|
|
|
|
2026-07-09 10:56:54 +08:00
|
|
|
// ---- 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:16:39 +08:00
|
|
|
if (attrs.role === "stat-modifiers") {
|
|
|
|
|
const id = attrs.id || `_mod_${filePath}_${stats.length}`;
|
2026-07-09 11:28:17 +08:00
|
|
|
const result = parseStatModifiers(body, filePath, id, "player", attrs.extra["label"]);
|
2026-07-09 11:16:39 +08:00
|
|
|
stats.push(result.statDef);
|
|
|
|
|
stats.push(...result.modifierDefs);
|
|
|
|
|
statTemplates.push(result.template);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 10:56:54 +08:00
|
|
|
if (attrs.role === "spark-table") {
|
|
|
|
|
const parsed = parseSparkTableCsv(body, filePath, slugger);
|
|
|
|
|
if (parsed) sparkTables.push(parsed);
|
|
|
|
|
}
|
2026-07-09 10:26:54 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 12:04:25 +08:00
|
|
|
return { dice, links, sparkTables, stats, statTemplates, statSheets: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ------------------- Stat sheet helpers -------------------
|
|
|
|
|
|
|
|
|
|
function extractSvgTitle(svg: string): string | null {
|
|
|
|
|
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
|
|
|
|
|
return m ? m[1].trim() : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function labelFromId(id: string): string {
|
|
|
|
|
return id
|
|
|
|
|
.replace(/[-_]/g, " ")
|
|
|
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function scanStatSheets(): Promise<StatSheet[]> {
|
|
|
|
|
const paths = await getPathsByExtension("svg");
|
|
|
|
|
const sheets: StatSheet[] = [];
|
|
|
|
|
|
|
|
|
|
for (const filePath of paths) {
|
|
|
|
|
if (!/\.sheet\.svg$/i.test(filePath)) continue;
|
|
|
|
|
try {
|
|
|
|
|
const content = await getIndexedData(filePath);
|
|
|
|
|
if (!content) continue;
|
|
|
|
|
const fileName = filePath.split("/").filter(Boolean).pop() || filePath;
|
|
|
|
|
const id = fileName.replace(/\.sheet\.svg$/i, "");
|
|
|
|
|
const title = extractSvgTitle(content);
|
|
|
|
|
sheets.push({
|
|
|
|
|
id,
|
|
|
|
|
label: title || labelFromId(id),
|
|
|
|
|
svg: content,
|
|
|
|
|
source: filePath,
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
// skip unreadable files
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sheets;
|
2026-07-07 19:02:17 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// ------------------- 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();
|
2026-07-09 12:04:25 +08:00
|
|
|
data.statSheets = await scanStatSheets();
|
|
|
|
|
if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) {
|
2026-07-06 17:13:11 +08:00
|
|
|
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.
|
|
|
|
|
*/
|
2026-07-06 16:57:36 +08:00
|
|
|
export function ensureCompletions(): Promise<void> {
|
2026-07-06 17:13:11 +08:00
|
|
|
return _initPromise;
|
2026-07-06 16:57:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
/** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
|
|
|
|
|
let _invalidated = false;
|
2026-07-06 16:57:36 +08:00
|
|
|
export function invalidateCompletions(): void {
|
2026-07-06 17:13:11 +08:00
|
|
|
_invalidated = true;
|
|
|
|
|
// On next import (page reload), the module will re-init.
|
|
|
|
|
// For a runtime invalidation, you could call init again.
|
2026-07-06 16:57:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-06 17:13:11 +08:00
|
|
|
* Reactive hooks for the journal input.
|
|
|
|
|
* Returns the current completions state + a convenience `data` extractor.
|
2026-07-06 16:57:36 +08:00
|
|
|
*/
|
2026-07-06 17:13:11 +08:00
|
|
|
export function useJournalCompletions(): {
|
|
|
|
|
state: CompletionsState;
|
|
|
|
|
data: JournalCompletions;
|
|
|
|
|
} {
|
|
|
|
|
const s = completionsState();
|
|
|
|
|
if (s.status === "loaded") {
|
|
|
|
|
return { state: s, data: s.data };
|
2026-07-06 16:57:36 +08:00
|
|
|
}
|
2026-07-09 09:22:14 +08:00
|
|
|
return {
|
|
|
|
|
state: s,
|
2026-07-09 10:26:54 +08:00
|
|
|
data: {
|
|
|
|
|
dice: [],
|
|
|
|
|
links: [],
|
|
|
|
|
sparkTables: [],
|
|
|
|
|
stats: [],
|
|
|
|
|
statTemplates: [],
|
2026-07-09 12:04:25 +08:00
|
|
|
statSheets: [],
|
2026-07-09 10:26:54 +08:00
|
|
|
},
|
2026-07-09 09:22:14 +08:00
|
|
|
};
|
2026-07-06 16:57:36 +08:00
|
|
|
}
|