/** * 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; } /** * Find a spark table by its combined slug (`pageName-columnSlug`). * Iterates every spark table in the content, constructs the combined slug, * and returns the first match. Avoids the need to guess where the page * name ends and the column slug begins (page names may contain `-`). */ export function findSparkTableByCombinedSlug( markdown: string, combinedSlug: string, pageName: string, ): SparkTableMeta | null { const tables = parseMarkdownTables(markdown); for (const table of tables) { if (!isSparkTable(table)) continue; const colSlug = sparkTableSlug(table); const candidate = `${pageName}-${colSlug}`; if (candidate === combinedSlug) { return { notation: table.headers[0], slug: colSlug, 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 — compare as integers, // falling back to string comparison. let value = `(no row for ${rolledValue})`; for (const row of meta.rows) { const diceCell = row[0] ?? ""; const cellNum = parseInt(diceCell.trim(), 10); const match = !isNaN(cellNum) && rolledValue === cellNum ? true : String(rolledValue) === diceCell.trim(); if (match) { value = row[colIdx + 1] ?? ""; break; } } columns.push({ header, slug, value }); } return { notation: meta.notation, columns, source: "", }; }