diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 47dd126..b4c9bd1 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -227,13 +227,17 @@ export function createContentServer( host: string = "0.0.0.0", ): ContentServer { let contentIndex: ContentIndex = {}; - let completionsIndex: CompletionsPayload = { dice: [], links: [] }; + let completionsIndex: CompletionsPayload = { + dice: [], + links: [], + sparkTables: [], + }; /** Re-scan completions from current content index (cached) */ function recomputeCompletions(): void { completionsIndex = scanCompletions(contentIndex); console.log( - `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length}`, + `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length}`, ); } diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts index 807b4a7..dc5e5c3 100644 --- a/src/cli/completions/index.ts +++ b/src/cli/completions/index.ts @@ -4,16 +4,22 @@ import { diceSource } from "./sources/dice.js"; import { linksSource } from "./sources/links.js"; +import { sparkTablesSource } from "./sources/spark-tables.js"; import type { CompletionSource, CompletionsPayload } from "./types.js"; export type { CompletionsPayload, DiceCompletion, LinkCompletion, + SparkTableCompletion, } from "./types.js"; /** Registered sources — open for extension */ -const sources: CompletionSource[] = [diceSource, linksSource]; +const sources: CompletionSource[] = [ + diceSource, + linksSource, + sparkTablesSource, +]; /** * Scan the full content index and return structured completion data. diff --git a/src/cli/completions/sources/spark-tables.ts b/src/cli/completions/sources/spark-tables.ts new file mode 100644 index 0000000..a47bf0b --- /dev/null +++ b/src/cli/completions/sources/spark-tables.ts @@ -0,0 +1,79 @@ +/** + * Spark table completion source — extracts spark tables (markdown tables + * whose first column header is a dice formula like d6, d20, etc.) from all + * .md files. + */ + +import Slugger from "github-slugger"; +import type { CompletionSource, SparkTableCompletion } from "../types.js"; + +/** Regex: matches a pipe-delimited markdown table row */ +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()); +} + +const SEP_RE = /^:?-{3,}:?$/; +const DICE_RE = /^d\d+$/i; + +export const sparkTablesSource: CompletionSource = { + key: "sparkTables", + + scan(index) { + const items: SparkTableCompletion[] = []; + const slugger = new Slugger(); + + for (const [filePath, content] of Object.entries(index)) { + if (!filePath.endsWith(".md")) continue; + + const lines = content.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const headerCells = splitTableRow(lines[i]); + if (!headerCells || headerCells.length < 2) continue; + if (!DICE_RE.test(headerCells[0])) continue; + + // Check separator row + if (i + 1 >= lines.length) continue; + const sepCells = splitTableRow(lines[i + 1]); + if (!sepCells || !sepCells.every((c) => SEP_RE.test(c))) continue; + + // Collect body rows + let j = i + 2; + while (j < lines.length) { + const rowCells = splitTableRow(lines[j]); + if (!rowCells) break; + j++; + } + if (j <= i + 2) continue; // No body rows + + // Build slug from data columns + const dataHeaders = headerCells.slice(1); + const slug = dataHeaders + .map((h: string) => slugger.slug(h.toLowerCase())) + .join("-"); + + const basePath = filePath.replace(/\.md$/, ""); + const fileName = basePath.split("/").filter(Boolean).pop() || basePath; + + // Combined key: pageName-columnSlug (what user types after /spark) + const combinedSlug = `${fileName}-${slug}`; + + items.push({ + label: `${fileName} § ${slug}`, + notation: headerCells[0], + slug: combinedSlug, + filePath: basePath, + headers: dataHeaders, + }); + + i = j - 1; + } + } + + return items; + }, +}; diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts index 4050094..ee43b16 100644 --- a/src/cli/completions/types.ts +++ b/src/cli/completions/types.ts @@ -22,10 +22,25 @@ export interface LinkCompletion { section: string | null; } +/** A spark table found in a markdown file */ +export interface SparkTableCompletion { + /** Display label: "file § slug" */ + label: string; + /** Dice notation (e.g. "d6", "d20") parsed from the first column header */ + notation: string; + /** Concatenated slug of data column headers */ + slug: string; + /** File path of the containing .md file */ + filePath: string; + /** Data column headers for display */ + headers: string[]; +} + /** Top-level payload served at /__COMPLETIONS.json */ export interface CompletionsPayload { dice: DiceCompletion[]; links: LinkCompletion[]; + sparkTables: SparkTableCompletion[]; } /** diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index 2864a10..a9d56b4 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -24,6 +24,7 @@ import { sendMessage, useJournalStream } from "../stores/journalStream"; import { linkPrefill, setLinkPrefill } from "../stores/reveal"; import { useJournalCompletions, ensureCompletions } from "./completions"; import { resolveRollPayload } from "./types/roll"; +import { resolveSparkPayload } from "./types/spark"; // ---- Helpers ---- @@ -34,7 +35,7 @@ interface CompletionItem { } interface ParsedInput { - type: "chat" | "roll" | "link"; + type: "chat" | "roll" | "spark" | "link"; payload: Record; error?: string; } @@ -47,6 +48,13 @@ function parseInput(raw: string): ParsedInput { return { type: "roll", payload: { notation, label: notation } }; } + if (raw.startsWith("/spark ")) { + const key = raw.slice("/spark ".length).trim(); + if (!key) + return { type: "spark", payload: {}, error: "Spark table key required" }; + return { type: "spark", payload: { key } }; + } + if (raw.startsWith("/link ")) { const arg = raw.slice("/link ".length).trim(); if (!arg) return { type: "link", payload: {}, error: "Path required" }; @@ -57,8 +65,8 @@ function parseInput(raw: string): ParsedInput { return { type: "link", payload: { path, section } }; } - // /roll or /link with no space — need to complete, don't send - if (raw === "/roll" || raw === "/link") { + // /roll, /spark, or /link with no space — need to complete, don't send + if (raw === "/roll" || raw === "/spark" || raw === "/link") { return { type: "chat", payload: {}, error: "Complete the command" }; } @@ -101,7 +109,7 @@ export const JournalInput: Component = () => { // ---- Send ---- - function handleSend() { + async function handleSend() { const raw = text().trim(); if (!raw) return; @@ -142,6 +150,30 @@ export const JournalInput: Component = () => { return; } + // GM spark: resolve the spark table roll locally + if (parsed.type === "spark") { + try { + const key = (parsed.payload as { key: string }).key; + // Look up filePath from completions data + const match = comp.data.sparkTables.find((s) => s.slug === key); + const filePath = match?.filePath ?? ""; + const p = await resolveSparkPayload({ key, filePath }); + const result = sendMessage("spark", p); + if (!result.success) { + setError(result.error); + } else { + setText(""); + } + setSending(false); + textareaRef?.focus(); + return; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to roll spark table"); + setSending(false); + return; + } + } + const result = sendMessage(parsed.type, parsed.payload); if (!result.success) { setError(result.error); @@ -172,6 +204,7 @@ export const JournalInput: Component = () => { const data = comp.data; const commands = [ { label: "/roll", kind: "command" as const, insertText: "/roll " }, + { label: "/spark", kind: "command" as const, insertText: "/spark " }, { label: "/link", kind: "command" as const, insertText: "/link " }, ]; @@ -206,6 +239,32 @@ export const JournalInput: Component = () => { })); } + // After /spark — show spark table suggestions + if (raw.startsWith("/spark ")) { + const prefix = raw.slice("/spark ".length).toLowerCase(); + const matches = data.sparkTables + .filter( + (s) => + s.slug.toLowerCase().includes(prefix) || + s.label.toLowerCase().includes(prefix), + ) + .slice(0, 8); + if (matches.length === 0) { + return [ + { + label: "No spark tables found", + kind: "no-results", + insertText: "", + }, + ]; + } + return matches.map((s) => ({ + label: `${s.filePath} § ${s.slug} (${s.notation})`, + kind: "value" as const, + insertText: `/spark ${s.slug}`, + })); + } + // After /link — show article and heading suggestions if (raw.startsWith("/link ")) { const prefix = raw.slice("/link ".length).toLowerCase(); diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index f98749c..c719a7f 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -9,6 +9,7 @@ */ import { createSignal } from "solid-js"; +import Slugger from "github-slugger"; import { extractHeadings } from "../../data-loader/toc"; import { getPathsByExtension, @@ -29,9 +30,18 @@ export interface LinkCompletion { 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[]; } export type CompletionsState = @@ -56,6 +66,7 @@ async function tryServer(): Promise { return { dice: Array.isArray(data.dice) ? data.dice : [], links: Array.isArray(data.links) ? data.links : [], + sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [], }; } catch { return null; @@ -68,7 +79,9 @@ async function scanClientSide(): Promise { const paths = await getPathsByExtension("md"); const dice: DiceCompletion[] = []; const links: LinkCompletion[] = []; + const sparkTables: SparkTableCompletion[] = []; const tagRegex = /]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; + const slugger = new Slugger(); for (const filePath of paths) { const content = await getIndexedData(filePath); @@ -95,9 +108,52 @@ async function scanClientSide(): Promise { 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; + } } - return { dice, links }; + return { dice, links, sparkTables }; +} + +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) ------------------- @@ -157,5 +213,5 @@ export function useJournalCompletions(): { if (s.status === "loaded") { return { state: s, data: s.data }; } - return { state: s, data: { dice: [], links: [] } }; + return { state: s, data: { dice: [], links: [], sparkTables: [] } }; } diff --git a/src/components/journal/types/index.ts b/src/components/journal/types/index.ts index 7efbae3..5c05f06 100644 --- a/src/components/journal/types/index.ts +++ b/src/components/journal/types/index.ts @@ -7,5 +7,6 @@ import "./chat"; import "./roll"; +import "./spark"; import "./link"; import "./intent"; diff --git a/src/components/journal/types/spark.tsx b/src/components/journal/types/spark.tsx new file mode 100644 index 0000000..c51fc41 --- /dev/null +++ b/src/components/journal/types/spark.tsx @@ -0,0 +1,153 @@ +/** + * Built-in message type: spark + * + * Emitters: gm + * Command: /spark pageName-columnSlug + * + * "spark tables" are markdown tables whose first column header is a dice + * formula (d6, d20, d100, etc.). The command rolls the dice once per data + * column, looks up the row matching each roll, and publishes the results. + * + * The completion slug is "pageName-columnSlug" where columnSlug is the + * concatenated slugs of every data-column header. + */ + +import { z } from "zod"; +import { Show, For } from "solid-js"; +import { registerMessageType } from "../registry"; +import { rollFormula } from "../../md-commander/hooks"; +import { + findSparkTable, + rollSparkTable, + parseMarkdownTables, + isSparkTable, + sparkTableSlug, +} from "../../utils/spark-table"; +import { getIndexedData } from "../../../data-loader/file-index"; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +const sparkColumnSchema = z.object({ + header: z.string(), + slug: z.string(), + value: z.string(), +}); + +const sparkResultSchema = z.object({ + notation: z.string(), + columns: z.array(sparkColumnSchema), + source: z.string(), +}); + +const schema = z.object({ + /** User-visible label, e.g. "adventure body-label" */ + label: z.string(), + /** The dice notation from the spark table header */ + notation: z.string().min(1), + /** Resolved spark table columns */ + sparkTable: sparkResultSchema, + /** First column's raw dice roll — informational only */ + rollResult: z.object({ + total: z.number(), + detail: z.string(), + plainDetail: z.string(), + pools: z.array( + z.object({ + rolls: z.array(z.number()), + subtotal: z.number(), + }), + ), + }), +}); + +export type SparkPayload = z.infer; + +// --------------------------------------------------------------------------- +// Resolve +// --------------------------------------------------------------------------- + +/** + * Resolve a spark table roll. + * + * `key` is the combined slug (pageName-columnSlug). + * `filePath` is the .md file path (without .md extension) from completions. + */ +export async function resolveSparkPayload(raw: { + key: string; + filePath: string; +}): Promise { + // key is "pageName-columnSlug"; columnSlug is the part after the first "-" + const dashIdx = raw.key.indexOf("-"); + const columnSlug = dashIdx === -1 ? raw.key : raw.key.slice(dashIdx + 1); + const filePath = `/${raw.filePath.replace(/^\//, "")}`; + + let content: string; + try { + content = await getIndexedData(filePath); + } catch { + throw new Error(`Failed to load file: "${filePath}"`); + } + + const meta = findSparkTable(content, columnSlug); + if (!meta) { + throw new Error(`Spark table "${columnSlug}" not found in "${filePath}"`); + } + + const sparkResult = rollSparkTable(meta); + const firstRoll = rollFormula(meta.notation); + + return { + label: raw.key, + notation: meta.notation, + sparkTable: sparkResult, + rollResult: firstRoll.result, + }; +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +registerMessageType({ + type: "spark", + label: "Spark Table", + emitters: ["gm"], + schema, + defaultPayload: () => ({ + label: "", + notation: "d6", + sparkTable: { notation: "d6", columns: [], source: "" }, + rollResult: { total: 0, detail: "", plainDetail: "", pools: [] }, + }), + render: (p) => { + const st = p.sparkTable; + return ( +
+
+ + + {st.notation} · spark + +
+
+ + + + {(col) => ( + + + + + )} + + +
+ {col.header} + {col.value}
+
+
+ ); + }, +}); diff --git a/src/components/md-dice.tsx b/src/components/md-dice.tsx index 8ee138e..6423c7f 100644 --- a/src/components/md-dice.tsx +++ b/src/components/md-dice.tsx @@ -62,7 +62,7 @@ customElement("md-dice", { key: "" }, (props, { element }) => { setRollDetail(rollResult.result.plainDetail); setIsRolled(true); if (effectiveKey()) { - setDiceResultToUrl(effectiveKey(), rollResult.total); + setDiceResultToUrl(effectiveKey(), rollResult.result.total); } }; diff --git a/src/components/utils/spark-table.ts b/src/components/utils/spark-table.ts new file mode 100644 index 0000000..2205ef6 --- /dev/null +++ b/src/components/utils/spark-table.ts @@ -0,0 +1,204 @@ +/** + * Spark Table — markdown table where the first column header is a dice + * formula (d6, d20, d100, etc.). Rolling a spark table means: + * + * 1. Roll the dice formula once for each data column (non-dice columns) + * 2. Look up the row whose dice-column value matches each roll + * 3. Return the rolled values keyed by column slug + */ + +import Slugger from "github-slugger"; +import { rollFormula } from "../md-commander/hooks"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface MarkdownTable { + headers: string[]; + rows: string[][]; +} + +export interface SparkTableColumn { + header: string; + slug: string; + value: string; +} + +export interface SparkTableResult { + /** The dice notation (e.g. "d6", "d20") */ + notation: string; + /** The roll results keyed by column slug */ + columns: SparkTableColumn[]; + /** Source file path */ + source: string; +} + +export interface SparkTableMeta { + /** Dice notation from the first column header */ + notation: string; + /** Concatenated slug of data columns */ + slug: string; + /** Data column headers (excluding dice column) */ + dataHeaders: string[]; + /** Full list of rows */ + rows: string[][]; +} + +// --------------------------------------------------------------------------- +// Markdown table parser +// --------------------------------------------------------------------------- + +/** + * Parse all markdown tables from a markdown string. + * Handles both leading/trailing `|` styles and bare styles. + */ +export function parseMarkdownTables(markdown: string): MarkdownTable[] { + const tables: MarkdownTable[] = []; + const lines = markdown.split(/\r?\n/); + + for (let i = 0; i < lines.length; i++) { + const headerCells = splitTableRow(lines[i]); + if (!headerCells || headerCells.length < 2) continue; + + // Peek at the next line — must be a separator row + if (i + 1 >= lines.length) continue; + const sepCells = splitTableRow(lines[i + 1]); + if (!sepCells || sepCells.length < headerCells.length) continue; + if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue; + + // Valid table header + separator — collect body rows + const rows: string[][] = []; + let j = i + 2; + while (j < lines.length) { + const rowCells = splitTableRow(lines[j]); + if (!rowCells) break; + // Allow rows with fewer cells (unfilled trailing columns) + rows.push(rowCells); + j++; + } + + // Only include tables with at least one data row + if (rows.length > 0) { + tables.push({ headers: headerCells, rows }); + } + i = j - 1; + } + + return tables; +} + +/** Split a pipe-delimited table row, stripping optional leading/trailing `|` */ +function splitTableRow(line: string): string[] | null { + const trimmed = line.trim(); + if (!trimmed.includes("|")) return null; + + // Strip optional leading and trailing `|` + 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()); +} + +// --------------------------------------------------------------------------- +// Spark table detection & metadata +// --------------------------------------------------------------------------- + +const DICE_HEADER_RE = /^d\d+$/i; + +/** Check whether a table is a spark table (first header is a dice formula) */ +export function isSparkTable(table: MarkdownTable): boolean { + if (table.headers.length < 2) return false; + return DICE_HEADER_RE.test(table.headers[0]); +} + +/** + * Generate the spark table slug by concatenating slugs of all data column + * headers (excluding the dice column). + */ +export function sparkTableSlug(table: MarkdownTable): string { + const slugger = new Slugger(); + return table.headers + .slice(1) + .map((h) => slugger.slug(h.toLowerCase())) + .join("-"); +} + +/** + * Find a spark table in the given markdown content matching `slug`. + * Returns null if not found. + */ +export function findSparkTable( + markdown: string, + slug: string, +): SparkTableMeta | null { + const tables = parseMarkdownTables(markdown); + for (const table of tables) { + if (!isSparkTable(table)) continue; + if (sparkTableSlug(table) === slug) { + return { + notation: table.headers[0], + slug, + dataHeaders: table.headers.slice(1), + rows: table.rows, + }; + } + } + return null; +} + +/** Scan all spark tables in a markdown file and return their metadata */ +export function scanSparkTables( + markdown: string, +): Omit[] { + const tables = parseMarkdownTables(markdown); + return tables + .filter(isSparkTable) + .map((table) => ({ + notation: table.headers[0], + slug: sparkTableSlug(table), + dataHeaders: table.headers.slice(1), + })); +} + +// --------------------------------------------------------------------------- +// Rolling +// --------------------------------------------------------------------------- + +/** + * Roll a spark table: for each data column, roll the dice formula and look + * up the corresponding row value. + */ +export function rollSparkTable( + meta: SparkTableMeta, +): SparkTableResult { + const slugger = new Slugger(); + const columns: SparkTableColumn[] = []; + + for (let colIdx = 0; colIdx < meta.dataHeaders.length; colIdx++) { + const header = meta.dataHeaders[colIdx]; + const slug = slugger.slug(header.toLowerCase()); + + const roll = rollFormula(meta.notation); + const rolledValue = roll.result.total; + + // Find the row matching the rolled value (1-based dice result) + let value = `(no row for ${rolledValue})`; + for (const row of meta.rows) { + const diceCell = row[0] ?? ""; + if (String(rolledValue) === diceCell.trim()) { + value = row[colIdx + 1] ?? ""; + break; + } + } + + columns.push({ header, slug, value }); + } + + return { + notation: meta.notation, + columns, + source: "", + }; +}