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:
@@ -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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
/** Data column headers for display */
|
||||
headers: string[];
|
||||
/** Whether to roll each column independently (remix mode) */
|
||||
remix: boolean;
|
||||
}
|
||||
|
||||
export interface CompletionsPayload {
|
||||
|
||||
Reference in New Issue
Block a user