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
+6 -2
View File
@@ -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}`,
);
}
+7 -1
View File
@@ -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.
@@ -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;
},
};
+15
View File
@@ -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[];
}
/**