feat: add remix mode for spark table rolling
Introduces a `remix` option that allows each data column in a spark table to be rolled independently. This is enabled via a `remix=true` attribute in the directive's extra attributes. Also refactors spark table scanning by moving markdown parsing logic from the frontend to a new CLI-side `spark-scanner.ts` utility, improving separation of concerns.
This commit is contained in:
parent
719bb6ad8a
commit
2068ecad10
|
|
@ -51,6 +51,18 @@ function looksLikeDice(raw: string): boolean {
|
||||||
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse key=value pairs from directive extra attrs string */
|
||||||
|
function parseDirectiveAttrs(extraStr: string | undefined): Record<string, string> {
|
||||||
|
if (!extraStr) return {};
|
||||||
|
const attrs: Record<string, string> = {};
|
||||||
|
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
|
// Markdown table → CSV conversion
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -136,6 +148,7 @@ export function buildSparkTableCompletion(
|
||||||
csv: string,
|
csv: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
csvPath: string,
|
csvPath: string,
|
||||||
|
remix: boolean,
|
||||||
slugger: Slugger,
|
slugger: Slugger,
|
||||||
): SparkTableCompletion | null {
|
): SparkTableCompletion | null {
|
||||||
const dataHeaders = inspectSparkTableCsv(csv);
|
const dataHeaders = inspectSparkTableCsv(csv);
|
||||||
|
|
@ -159,6 +172,7 @@ export function buildSparkTableCompletion(
|
||||||
filePath: basePath,
|
filePath: basePath,
|
||||||
csvPath,
|
csvPath,
|
||||||
headers: dataHeaders,
|
headers: dataHeaders,
|
||||||
|
remix,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,7 +262,7 @@ export function scanDirectives(
|
||||||
newIndexEntries[resolvedPath] = csv;
|
newIndexEntries[resolvedPath] = csv;
|
||||||
|
|
||||||
// Collect spark table completion
|
// Collect spark table completion
|
||||||
const st = buildSparkTableCompletion(csv, filePath, resolvedPath, slugger);
|
const st = buildSparkTableCompletion(csv, filePath, resolvedPath, false, slugger);
|
||||||
if (st) {
|
if (st) {
|
||||||
sparkTables.push(st);
|
sparkTables.push(st);
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +304,11 @@ export function scanDirectives(
|
||||||
let csv = index[csvPath] ?? newIndexEntries[csvPath];
|
let csv = index[csvPath] ?? newIndexEntries[csvPath];
|
||||||
if (!csv) continue;
|
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;
|
if (!st) continue;
|
||||||
|
|
||||||
// Check if data-spark is already set in extra attrs
|
// Check if data-spark is already set in extra attrs
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,8 @@ export interface SparkTableCompletion {
|
||||||
csvPath: string;
|
csvPath: string;
|
||||||
/** Data column headers for display */
|
/** Data column headers for display */
|
||||||
headers: string[];
|
headers: string[];
|
||||||
|
/** Whether to roll each column independently (remix mode) */
|
||||||
|
remix: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompletionsPayload {
|
export interface CompletionsPayload {
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,8 @@ export const JournalInput: Component = () => {
|
||||||
const key = (parsed.payload as { key: string }).key;
|
const key = (parsed.payload as { key: string }).key;
|
||||||
const match = comp.data.sparkTables.find((s) => s.slug === key);
|
const match = comp.data.sparkTables.find((s) => s.slug === key);
|
||||||
const csvPath = match?.csvPath ?? "";
|
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 result = sendMessage("spark", p);
|
||||||
const r = unwrap(result);
|
const r = unwrap(result);
|
||||||
finish(r.ok, r.err);
|
finish(r.ok, r.err);
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export interface SparkTableCompletion {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
csvPath: string;
|
csvPath: string;
|
||||||
headers: string[];
|
headers: string[];
|
||||||
|
remix: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JournalCompletions {
|
export interface JournalCompletions {
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,12 @@ export type SparkPayload = z.infer<typeof schema>;
|
||||||
*
|
*
|
||||||
* `key` is the combined slug (pageName-columnSlug).
|
* `key` is the combined slug (pageName-columnSlug).
|
||||||
* `csvPath` is the resolved .csv file path from completions.
|
* `csvPath` is the resolved .csv file path from completions.
|
||||||
|
* `remix` controls whether each column gets an independent roll.
|
||||||
*/
|
*/
|
||||||
export async function resolveSparkPayload(raw: {
|
export async function resolveSparkPayload(raw: {
|
||||||
key: string;
|
key: string;
|
||||||
csvPath: string;
|
csvPath: string;
|
||||||
|
remix: boolean;
|
||||||
}): Promise<SparkPayload> {
|
}): Promise<SparkPayload> {
|
||||||
let csv: string;
|
let csv: string;
|
||||||
try {
|
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);
|
const firstRoll = rollFormula(meta.notation);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* Spark Table — markdown table where the first column header is a dice
|
* Spark Table — runtime rolling and CSV parsing.
|
||||||
* formula (d6, d20, d100, etc.). Rolling a spark table means:
|
|
||||||
*
|
*
|
||||||
* 1. Roll the dice formula once for each data column (non-dice columns)
|
* A spark table is a CSV whose first column header is a dice formula
|
||||||
* 2. Look up the row whose dice-column value matches each roll
|
* (d6, d20, d100, etc.). Rolling a spark table means:
|
||||||
* 3. Return the rolled values keyed by column slug
|
*
|
||||||
|
* 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 Slugger from "github-slugger";
|
||||||
|
import { parse } from "csv-parse/browser/esm/sync";
|
||||||
import { parseCSVString } from "../utils/csv-loader";
|
import { parseCSVString } from "../utils/csv-loader";
|
||||||
import { rollFormula } from "../md-commander/hooks";
|
import { rollFormula } from "../md-commander/hooks";
|
||||||
|
|
||||||
|
|
@ -15,11 +21,6 @@ import { rollFormula } from "../md-commander/hooks";
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface MarkdownTable {
|
|
||||||
headers: string[];
|
|
||||||
rows: string[][];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SparkTableColumn {
|
export interface SparkTableColumn {
|
||||||
header: string;
|
header: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -40,6 +41,8 @@ export interface SparkTableMeta {
|
||||||
notation: string;
|
notation: string;
|
||||||
/** Concatenated slug of data columns */
|
/** Concatenated slug of data columns */
|
||||||
slug: string;
|
slug: string;
|
||||||
|
/** The header name of the dice column (first column) */
|
||||||
|
diceHeader: string;
|
||||||
/** Data column headers (excluding dice column) */
|
/** Data column headers (excluding dice column) */
|
||||||
dataHeaders: string[];
|
dataHeaders: string[];
|
||||||
/** Full list of rows as objects keyed by header */
|
/** Full list of rows as objects keyed by header */
|
||||||
|
|
@ -47,185 +50,52 @@ export interface SparkTableMeta {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Markdown table parser
|
// CSV parsing
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
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<string, string>[] */
|
|
||||||
function rowsToObjects(table: MarkdownTable): Record<string, string>[] {
|
|
||||||
return table.rows.map((row) => {
|
|
||||||
const obj: Record<string, string> = {};
|
|
||||||
for (let i = 0; i < table.headers.length; i++) {
|
|
||||||
obj[table.headers[i]] = row[i] ?? "";
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a CSV string into a SparkTableMeta.
|
* Parse a CSV string into a SparkTableMeta.
|
||||||
* The CSV must have a dice formula as its first column header.
|
* The CSV must have a dice formula as its first column header.
|
||||||
* Returns null if the CSV is not a valid spark table.
|
* Returns null if the CSV is not a valid spark table.
|
||||||
*/
|
*/
|
||||||
export function parseSparkTableCsv(csv: string): SparkTableMeta | null {
|
export function parseSparkTableCsv(csv: string): SparkTableMeta | null {
|
||||||
const parsed = parseCSVString(csv);
|
// Parse raw headers first — before parseCSVString injects frontmatter keys
|
||||||
const headers = Object.keys(parsed[0] ?? {});
|
// 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 (headers.length < 2) return null;
|
||||||
if (!DICE_HEADER_RE.test(headers[0])) 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 slugger = new Slugger();
|
||||||
|
const diceHeader = headers[0];
|
||||||
const dataHeaders = headers.slice(1);
|
const dataHeaders = headers.slice(1);
|
||||||
const slug = dataHeaders
|
const slug = dataHeaders
|
||||||
.map((h) => slugger.slug(h.toLowerCase()))
|
.map((h) => slugger.slug(h.toLowerCase()))
|
||||||
.join("-");
|
.join("-");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notation: headers[0],
|
notation: diceHeader,
|
||||||
slug,
|
slug,
|
||||||
|
diceHeader,
|
||||||
dataHeaders,
|
dataHeaders,
|
||||||
rows: parsed as Record<string, string>[],
|
rows: parsed as Record<string, string>[],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Range parsing
|
// Range parsing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -267,33 +137,62 @@ function matchesCell(diceCell: string, rolledValue: number): boolean {
|
||||||
// Rolling
|
// 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
|
* Roll a spark table.
|
||||||
* up the corresponding row value.
|
*
|
||||||
|
* 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 slugger = new Slugger();
|
||||||
const columns: SparkTableColumn[] = [];
|
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) {
|
let value = `(no row for ${rolledValue})`;
|
||||||
const slug = slugger.slug(header.toLowerCase());
|
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 roll = rollFormula(meta.notation);
|
||||||
const rolledValue = roll.result.total;
|
const rolledValue = roll.result.total;
|
||||||
|
|
||||||
// Find the row matching the rolled value
|
let matchedRow: Record<string, string> | null = null;
|
||||||
let value = `(no row for ${rolledValue})`;
|
|
||||||
for (const row of meta.rows) {
|
for (const row of meta.rows) {
|
||||||
const diceCell = row[diceHeader] ?? "";
|
if (matchesCell(row[meta.diceHeader] ?? "", rolledValue)) {
|
||||||
if (matchesCell(diceCell, rolledValue)) {
|
matchedRow = row;
|
||||||
value = row[header] ?? "";
|
|
||||||
break;
|
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 {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue