diff --git a/src/cli/completions/directive-scanner.ts b/src/cli/completions/directive-scanner.ts index 36841d2..1291511 100644 --- a/src/cli/completions/directive-scanner.ts +++ b/src/cli/completions/directive-scanner.ts @@ -51,6 +51,18 @@ function looksLikeDice(raw: string): boolean { return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw); } +/** Parse key=value pairs from directive extra attrs string */ +function parseDirectiveAttrs(extraStr: string | undefined): Record { + if (!extraStr) return {}; + const attrs: Record = {}; + const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(extraStr)) !== null) { + attrs[m[1]] = m[2].replace(/^"|"$/g, ""); + } + return attrs; +} + // --------------------------------------------------------------------------- // Markdown table → CSV conversion // --------------------------------------------------------------------------- @@ -136,6 +148,7 @@ export function buildSparkTableCompletion( csv: string, filePath: string, csvPath: string, + remix: boolean, slugger: Slugger, ): SparkTableCompletion | null { const dataHeaders = inspectSparkTableCsv(csv); @@ -159,6 +172,7 @@ export function buildSparkTableCompletion( filePath: basePath, csvPath, headers: dataHeaders, + remix, }; } @@ -248,7 +262,7 @@ export function scanDirectives( newIndexEntries[resolvedPath] = csv; // Collect spark table completion - const st = buildSparkTableCompletion(csv, filePath, resolvedPath, slugger); + const st = buildSparkTableCompletion(csv, filePath, resolvedPath, false, slugger); if (st) { sparkTables.push(st); } @@ -290,7 +304,11 @@ export function scanDirectives( let csv = index[csvPath] ?? newIndexEntries[csvPath]; if (!csv) continue; - const st = buildSparkTableCompletion(csv, filePath, csvPath, slugger); + // Parse extra attrs for remix flag + const attrs = parseDirectiveAttrs(extraStr); + const isRemix = attrs["remix"] === "true"; + + const st = buildSparkTableCompletion(csv, filePath, csvPath, isRemix, slugger); if (!st) continue; // Check if data-spark is already set in extra attrs diff --git a/src/cli/completions/spark-scanner.ts b/src/cli/completions/spark-scanner.ts new file mode 100644 index 0000000..44c2c50 --- /dev/null +++ b/src/cli/completions/spark-scanner.ts @@ -0,0 +1,114 @@ +/** + * 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), + })); +} diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts index 15d273f..ba1c110 100644 --- a/src/cli/completions/types.ts +++ b/src/cli/completions/types.ts @@ -52,6 +52,8 @@ export interface SparkTableCompletion { csvPath: string; /** Data column headers for display */ headers: string[]; + /** Whether to roll each column independently (remix mode) */ + remix: boolean; } export interface CompletionsPayload { diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index 9eb99de..9659c50 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -128,7 +128,8 @@ export const JournalInput: Component = () => { const key = (parsed.payload as { key: string }).key; const match = comp.data.sparkTables.find((s) => s.slug === key); const csvPath = match?.csvPath ?? ""; - const p = await resolveSparkPayload({ key, csvPath }); + const remix = match?.remix ?? false; + const p = await resolveSparkPayload({ key, csvPath, remix }); const result = sendMessage("spark", p); const r = unwrap(result); finish(r.ok, r.err); diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index c8417e1..56277b8 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -54,6 +54,7 @@ export interface SparkTableCompletion { filePath: string; csvPath: string; headers: string[]; + remix: boolean; } export interface JournalCompletions { diff --git a/src/components/journal/types/spark.tsx b/src/components/journal/types/spark.tsx index 80c4d81..f28c662 100644 --- a/src/components/journal/types/spark.tsx +++ b/src/components/journal/types/spark.tsx @@ -70,10 +70,12 @@ export type SparkPayload = z.infer; * * `key` is the combined slug (pageName-columnSlug). * `csvPath` is the resolved .csv file path from completions. + * `remix` controls whether each column gets an independent roll. */ export async function resolveSparkPayload(raw: { key: string; csvPath: string; + remix: boolean; }): Promise { let csv: string; try { @@ -89,7 +91,7 @@ export async function resolveSparkPayload(raw: { ); } - const sparkResult = rollSparkTable(meta); + const sparkResult = rollSparkTable(meta, { remix: raw.remix }); const firstRoll = rollFormula(meta.notation); return { diff --git a/src/components/utils/spark-table.ts b/src/components/utils/spark-table.ts index 77c1e40..9059d88 100644 --- a/src/components/utils/spark-table.ts +++ b/src/components/utils/spark-table.ts @@ -1,13 +1,19 @@ /** - * Spark Table — markdown table where the first column header is a dice - * formula (d6, d20, d100, etc.). Rolling a spark table means: + * Spark Table — runtime rolling and CSV parsing. * - * 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 + * A spark table is a CSV whose first column header is a dice formula + * (d6, d20, d100, etc.). Rolling a spark table means: + * + * 1. Roll the dice formula once + * 2. Look up the row whose dice-column value matches the roll + * 3. Return all column values from that row + * + * When `remix` is true, each data column gets its own independent roll + * and may come from different rows. */ import Slugger from "github-slugger"; +import { parse } from "csv-parse/browser/esm/sync"; import { parseCSVString } from "../utils/csv-loader"; import { rollFormula } from "../md-commander/hooks"; @@ -15,11 +21,6 @@ import { rollFormula } from "../md-commander/hooks"; // Types // --------------------------------------------------------------------------- -export interface MarkdownTable { - headers: string[]; - rows: string[][]; -} - export interface SparkTableColumn { header: string; slug: string; @@ -40,6 +41,8 @@ export interface SparkTableMeta { notation: string; /** Concatenated slug of data columns */ slug: string; + /** The header name of the dice column (first column) */ + diceHeader: string; /** Data column headers (excluding dice column) */ dataHeaders: string[]; /** Full list of rows as objects keyed by header */ @@ -47,185 +50,52 @@ export interface SparkTableMeta { } // --------------------------------------------------------------------------- -// 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 +// CSV parsing // --------------------------------------------------------------------------- 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: rowsToObjects(table), - }; - } - } - 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: rowsToObjects(table), - }; - } - } - return null; -} - -/** Convert a MarkdownTable's string[][] rows to Record[] */ -function rowsToObjects(table: MarkdownTable): Record[] { - return table.rows.map((row) => { - const obj: Record = {}; - for (let i = 0; i < table.headers.length; i++) { - obj[table.headers[i]] = row[i] ?? ""; - } - return obj; - }); -} - /** * Parse a CSV string into a SparkTableMeta. * The CSV must have a dice formula as its first column header. * Returns null if the CSV is not a valid spark table. */ export function parseSparkTableCsv(csv: string): SparkTableMeta | null { - const parsed = parseCSVString(csv); - const headers = Object.keys(parsed[0] ?? {}); + // Parse raw headers first — before parseCSVString injects frontmatter keys + // into rows. We use csv-parse directly to get the header order reliably. + const rawParsed = parse(csv, { + columns: false, + comment: "#", + trim: true, + skipEmptyLines: true, + bom: true, + }) as string[][]; + if (rawParsed.length < 2) return null; + + const headers = rawParsed[0]; if (headers.length < 2) return null; if (!DICE_HEADER_RE.test(headers[0])) return null; + // Now parse with csv-loader for full frontmatter + quoting support + const parsed = parseCSVString(csv); + const slugger = new Slugger(); + const diceHeader = headers[0]; const dataHeaders = headers.slice(1); const slug = dataHeaders .map((h) => slugger.slug(h.toLowerCase())) .join("-"); return { - notation: headers[0], + notation: diceHeader, slug, + diceHeader, dataHeaders, rows: parsed as Record[], }; } -/** 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), - })); -} - // --------------------------------------------------------------------------- // Range parsing // --------------------------------------------------------------------------- @@ -267,33 +137,62 @@ function matchesCell(diceCell: string, rolledValue: number): boolean { // Rolling // --------------------------------------------------------------------------- +export interface RollSparkTableOptions { + /** When true, each data column gets its own independent roll. Default false. */ + remix?: boolean; +} + /** - * Roll a spark table: for each data column, roll the dice formula and look - * up the corresponding row value. + * Roll a spark table. + * + * By default, rolls the dice once and reads all columns from the matched row. + * When `remix` is true, each data column gets its own independent roll and + * may come from different rows. */ -export function rollSparkTable(meta: SparkTableMeta): SparkTableResult { +export function rollSparkTable( + meta: SparkTableMeta, + options: RollSparkTableOptions = {}, +): SparkTableResult { const slugger = new Slugger(); const columns: SparkTableColumn[] = []; - const diceHeader = Object.keys(meta.rows[0] ?? {})[0] ?? ""; + if (options.remix) { + // Independent roll per column + for (const header of meta.dataHeaders) { + const slug = slugger.slug(header.toLowerCase()); + const roll = rollFormula(meta.notation); + const rolledValue = roll.result.total; - for (const header of meta.dataHeaders) { - const slug = slugger.slug(header.toLowerCase()); + let value = `(no row for ${rolledValue})`; + for (const row of meta.rows) { + if (matchesCell(row[meta.diceHeader] ?? "", rolledValue)) { + value = row[header] ?? ""; + break; + } + } + columns.push({ header, slug, value }); + } + } else { + // Single roll — all columns from the same row const roll = rollFormula(meta.notation); const rolledValue = roll.result.total; - // Find the row matching the rolled value - let value = `(no row for ${rolledValue})`; + let matchedRow: Record | null = null; for (const row of meta.rows) { - const diceCell = row[diceHeader] ?? ""; - if (matchesCell(diceCell, rolledValue)) { - value = row[header] ?? ""; + if (matchesCell(row[meta.diceHeader] ?? "", rolledValue)) { + matchedRow = row; break; } } - columns.push({ header, slug, value }); + for (const header of meta.dataHeaders) { + const slug = slugger.slug(header.toLowerCase()); + const value = matchedRow + ? (matchedRow[header] ?? "") + : `(no row for ${rolledValue})`; + columns.push({ header, slug, value }); + } } return {