/** * Spark table scanner — CLI-side markdown table detection and conversion. * * Parses markdown tables, detects spark tables (first column header is a dice * formula), and converts them to CSV for the directive scanner. * * Not imported at runtime — the frontend only needs CSV parsing + rolling. */ import Slugger from "github-slugger"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface MarkdownTable { headers: string[]; 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("-"); } /** * Scan all spark tables in a markdown file and return their metadata * (without rows — suitable for listing available tables). */ export function scanSparkTables( markdown: string, ): { notation: string; slug: string; dataHeaders: string[] }[] { const tables = parseMarkdownTables(markdown); return tables.filter(isSparkTable).map((table) => ({ notation: table.headers[0], slug: sparkTableSlug(table), dataHeaders: table.headers.slice(1), })); }