feat: add spark table completion and rolling support

Implement "spark tables" functionality, which allows users to roll
dice against markdown tables to retrieve specific values.

- Add `sparkTablesSource` to scan markdown files for tables starting
  with a dice notation (e.g., d6, d20).
- Implement `/spark` command in the journal to resolve and roll
  spark tables.
- Add a new message type `spark` with a dedicated UI component to
  render the results.
- Update completions API to include spark table metadata.
This commit is contained in:
hypercross 2026-07-07 19:02:17 +08:00
parent 3690d13407
commit 2f29f8774d
10 changed files with 587 additions and 10 deletions

View File

@ -227,13 +227,17 @@ export function createContentServer(
host: string = "0.0.0.0", host: string = "0.0.0.0",
): ContentServer { ): ContentServer {
let contentIndex: ContentIndex = {}; let contentIndex: ContentIndex = {};
let completionsIndex: CompletionsPayload = { dice: [], links: [] }; let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
sparkTables: [],
};
/** Re-scan completions from current content index (cached) */ /** Re-scan completions from current content index (cached) */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex); completionsIndex = scanCompletions(contentIndex);
console.log( console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length}`, `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length}`,
); );
} }

View File

@ -4,16 +4,22 @@
import { diceSource } from "./sources/dice.js"; import { diceSource } from "./sources/dice.js";
import { linksSource } from "./sources/links.js"; import { linksSource } from "./sources/links.js";
import { sparkTablesSource } from "./sources/spark-tables.js";
import type { CompletionSource, CompletionsPayload } from "./types.js"; import type { CompletionSource, CompletionsPayload } from "./types.js";
export type { export type {
CompletionsPayload, CompletionsPayload,
DiceCompletion, DiceCompletion,
LinkCompletion, LinkCompletion,
SparkTableCompletion,
} from "./types.js"; } from "./types.js";
/** Registered sources — open for extension */ /** 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. * Scan the full content index and return structured completion data.

View File

@ -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;
},
};

View File

@ -22,10 +22,25 @@ export interface LinkCompletion {
section: string | null; 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 */ /** Top-level payload served at /__COMPLETIONS.json */
export interface CompletionsPayload { export interface CompletionsPayload {
dice: DiceCompletion[]; dice: DiceCompletion[];
links: LinkCompletion[]; links: LinkCompletion[];
sparkTables: SparkTableCompletion[];
} }
/** /**

View File

@ -24,6 +24,7 @@ import { sendMessage, useJournalStream } from "../stores/journalStream";
import { linkPrefill, setLinkPrefill } from "../stores/reveal"; import { linkPrefill, setLinkPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions"; import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll"; import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
// ---- Helpers ---- // ---- Helpers ----
@ -34,7 +35,7 @@ interface CompletionItem {
} }
interface ParsedInput { interface ParsedInput {
type: "chat" | "roll" | "link"; type: "chat" | "roll" | "spark" | "link";
payload: Record<string, unknown>; payload: Record<string, unknown>;
error?: string; error?: string;
} }
@ -47,6 +48,13 @@ function parseInput(raw: string): ParsedInput {
return { type: "roll", payload: { notation, label: notation } }; 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 ")) { if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim(); const arg = raw.slice("/link ".length).trim();
if (!arg) return { type: "link", payload: {}, error: "Path required" }; if (!arg) return { type: "link", payload: {}, error: "Path required" };
@ -57,8 +65,8 @@ function parseInput(raw: string): ParsedInput {
return { type: "link", payload: { path, section } }; return { type: "link", payload: { path, section } };
} }
// /roll or /link with no space — need to complete, don't send // /roll, /spark, or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/link") { if (raw === "/roll" || raw === "/spark" || raw === "/link") {
return { type: "chat", payload: {}, error: "Complete the command" }; return { type: "chat", payload: {}, error: "Complete the command" };
} }
@ -101,7 +109,7 @@ export const JournalInput: Component = () => {
// ---- Send ---- // ---- Send ----
function handleSend() { async function handleSend() {
const raw = text().trim(); const raw = text().trim();
if (!raw) return; if (!raw) return;
@ -142,6 +150,30 @@ export const JournalInput: Component = () => {
return; 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); const result = sendMessage(parsed.type, parsed.payload);
if (!result.success) { if (!result.success) {
setError(result.error); setError(result.error);
@ -172,6 +204,7 @@ export const JournalInput: Component = () => {
const data = comp.data; const data = comp.data;
const commands = [ const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " }, { label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
{ label: "/link", kind: "command" as const, insertText: "/link " }, { 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 // After /link — show article and heading suggestions
if (raw.startsWith("/link ")) { if (raw.startsWith("/link ")) {
const prefix = raw.slice("/link ".length).toLowerCase(); const prefix = raw.slice("/link ".length).toLowerCase();

View File

@ -9,6 +9,7 @@
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import Slugger from "github-slugger";
import { extractHeadings } from "../../data-loader/toc"; import { extractHeadings } from "../../data-loader/toc";
import { import {
getPathsByExtension, getPathsByExtension,
@ -29,9 +30,18 @@ export interface LinkCompletion {
section: string | null; section: string | null;
} }
export interface SparkTableCompletion {
label: string;
notation: string;
slug: string;
filePath: string;
headers: string[];
}
export interface JournalCompletions { export interface JournalCompletions {
dice: DiceCompletion[]; dice: DiceCompletion[];
links: LinkCompletion[]; links: LinkCompletion[];
sparkTables: SparkTableCompletion[];
} }
export type CompletionsState = export type CompletionsState =
@ -56,6 +66,7 @@ async function tryServer(): Promise<JournalCompletions | null> {
return { return {
dice: Array.isArray(data.dice) ? data.dice : [], dice: Array.isArray(data.dice) ? data.dice : [],
links: Array.isArray(data.links) ? data.links : [], links: Array.isArray(data.links) ? data.links : [],
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
}; };
} catch { } catch {
return null; return null;
@ -68,7 +79,9 @@ async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md"); const paths = await getPathsByExtension("md");
const dice: DiceCompletion[] = []; const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = []; const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
const slugger = new Slugger();
for (const filePath of paths) { for (const filePath of paths) {
const content = await getIndexedData(filePath); const content = await getIndexedData(filePath);
@ -95,9 +108,52 @@ async function scanClientSide(): Promise<JournalCompletions> {
section: heading.id ?? null, 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) ------------------- // ------------------- Init (runs eagerly at import time) -------------------
@ -157,5 +213,5 @@ export function useJournalCompletions(): {
if (s.status === "loaded") { if (s.status === "loaded") {
return { state: s, data: s.data }; return { state: s, data: s.data };
} }
return { state: s, data: { dice: [], links: [] } }; return { state: s, data: { dice: [], links: [], sparkTables: [] } };
} }

View File

@ -7,5 +7,6 @@
import "./chat"; import "./chat";
import "./roll"; import "./roll";
import "./spark";
import "./link"; import "./link";
import "./intent"; import "./intent";

View File

@ -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<typeof schema>;
// ---------------------------------------------------------------------------
// 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<SparkPayload> {
// 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<SparkPayload>({
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 (
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<span class="text-lg"></span>
<span class="font-mono text-xs text-purple-600">
{st.notation} · spark
</span>
</div>
<div class="bg-purple-50 rounded border border-purple-200 overflow-hidden">
<table class="w-full text-xs">
<tbody>
<For each={st.columns}>
{(col) => (
<tr class="border-t border-purple-100 first:border-t-0">
<td class="px-2 py-1 text-purple-600 font-medium w-1/3">
{col.header}
</td>
<td class="px-2 py-1 text-gray-800">{col.value}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
);
},
});

View File

@ -62,7 +62,7 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
setRollDetail(rollResult.result.plainDetail); setRollDetail(rollResult.result.plainDetail);
setIsRolled(true); setIsRolled(true);
if (effectiveKey()) { if (effectiveKey()) {
setDiceResultToUrl(effectiveKey(), rollResult.total); setDiceResultToUrl(effectiveKey(), rollResult.result.total);
} }
}; };

View File

@ -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<SparkTableMeta, "rows">[] {
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: "",
};
}