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:
@@ -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: "",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user