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:
2026-07-07 19:02:17 +08:00
parent 3690d13407
commit 2f29f8774d
10 changed files with 587 additions and 10 deletions
+58 -2
View File
@@ -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<JournalCompletions | null> {
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<JournalCompletions> {
const paths = await getPathsByExtension("md");
const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const tagRegex = /<md-dice[^>]*>\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<JournalCompletions> {
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: [] } };
}