321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
/**
|
|
* Unified directive scanner — shared between CLI and browser.
|
|
*
|
|
* One pass over stripped markdown content that:
|
|
* 1. Detects markdown tables that look like spark tables → coerces to
|
|
* :md-table[./_inline_{hash}.csv] directives
|
|
* 2. Scans :md-dice[...] directives → collects DiceCompletion
|
|
* 3. Scans :md-table[...] directives → resolves CSV, checks if spark table
|
|
* → collects SparkTableCompletion
|
|
* 4. Scans :md-card[...] directives → same as md-table
|
|
*
|
|
* Safe for both Node and browser. No Node-specific imports.
|
|
*/
|
|
|
|
import Slugger from "github-slugger";
|
|
import type { DiceCompletion, SparkTableCompletion } from "./types.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface DirectiveScanResult {
|
|
/** Rewritten content with markdown tables coerced to directives */
|
|
rewritten: string;
|
|
/** Dice completions discovered */
|
|
dice: DiceCompletion[];
|
|
/** Spark table completions discovered */
|
|
sparkTables: SparkTableCompletion[];
|
|
/** New index entries for inline CSV bodies (key → CSV content) */
|
|
newIndexEntries: Record<string, string>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DICE_HEADER_RE = /^\d*d\d+$/i;
|
|
|
|
function contentHash(body: string): string {
|
|
// Simple hash suitable for both Node and browser
|
|
let hash = 0;
|
|
for (let i = 0; i < body.length; i++) {
|
|
const ch = body.charCodeAt(i);
|
|
hash = ((hash << 5) - hash + ch) | 0;
|
|
}
|
|
return Math.abs(hash).toString(16).slice(0, 8);
|
|
}
|
|
|
|
function looksLikeDice(raw: string): boolean {
|
|
if (raw.length > 80) return false;
|
|
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Markdown table → CSV conversion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Split a markdown table row into cells.
|
|
* Handles leading/trailing pipes and trims whitespace.
|
|
*/
|
|
function splitTableRow(row: string): string[] {
|
|
return row
|
|
.replace(/^\|/, "")
|
|
.replace(/\|$/, "")
|
|
.split("|")
|
|
.map((c) => c.trim());
|
|
}
|
|
|
|
/**
|
|
* Escape a cell value for CSV output.
|
|
*/
|
|
function escapeCsvCell(cell: string): string {
|
|
if (
|
|
cell.includes(",") ||
|
|
cell.includes("\n") ||
|
|
cell.includes('"') ||
|
|
cell.includes("#")
|
|
) {
|
|
return `"${cell.replace(/"/g, '""')}"`;
|
|
}
|
|
return cell;
|
|
}
|
|
|
|
/**
|
|
* Convert a markdown table (header + separator + rows) to a CSV string.
|
|
*/
|
|
function markdownTableToCsv(
|
|
headerRow: string,
|
|
separatorRow: string,
|
|
bodyRows: string[],
|
|
): string | null {
|
|
const headers = splitTableRow(headerRow);
|
|
if (headers.length === 0) return null;
|
|
|
|
// Validate separator row (must contain dashes)
|
|
const sepCells = splitTableRow(separatorRow);
|
|
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null;
|
|
if (sepCells.length !== headers.length) return null;
|
|
|
|
const csvHeader = headers.map(escapeCsvCell).join(",");
|
|
|
|
const csvRows = bodyRows.map((row) => {
|
|
const cells = splitTableRow(row);
|
|
// Pad to match header length
|
|
while (cells.length < headers.length) cells.push("");
|
|
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
|
|
});
|
|
|
|
return [csvHeader, ...csvRows].join("\n");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spark table CSV inspection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Check if a CSV body represents a spark table.
|
|
* Returns the data column headers (excluding the dice column) if so, or null.
|
|
*/
|
|
export function inspectSparkTableCsv(csv: string): string[] | null {
|
|
const lines = csv.trim().split(/\r?\n/);
|
|
if (lines.length < 2) return null;
|
|
|
|
const headers = lines[0].split(",").map((h) => h.trim());
|
|
if (headers.length < 2) return null;
|
|
if (!DICE_HEADER_RE.test(headers[0])) return null;
|
|
|
|
return headers.slice(1);
|
|
}
|
|
|
|
/**
|
|
* Build a SparkTableCompletion from CSV data and file path.
|
|
*/
|
|
export function buildSparkTableCompletion(
|
|
csv: string,
|
|
filePath: string,
|
|
slugger: Slugger,
|
|
): SparkTableCompletion | null {
|
|
const dataHeaders = inspectSparkTableCsv(csv);
|
|
if (!dataHeaders) return null;
|
|
|
|
const lines = csv.trim().split(/\r?\n/);
|
|
const headers = lines[0].split(",").map((h) => h.trim());
|
|
|
|
const slug = dataHeaders
|
|
.map((h) => slugger.slug(h.toLowerCase()))
|
|
.join("-");
|
|
|
|
const basePath = filePath.replace(/\.md$/, "");
|
|
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
|
const combinedSlug = `${fileName}-${slug}`;
|
|
|
|
return {
|
|
label: `${fileName} § ${slug}`,
|
|
notation: headers[0],
|
|
slug: combinedSlug,
|
|
filePath: basePath,
|
|
headers: dataHeaders,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main scanner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Scan a single markdown file's stripped content for directives and
|
|
* spark-shaped markdown tables.
|
|
*
|
|
* @param content - Stripped markdown content (after block processing)
|
|
* @param filePath - The file's path (e.g. "/rules/combat.md")
|
|
* @param index - The content index for resolving CSV paths
|
|
* @param fileDir - Directory of the file (for resolving relative paths)
|
|
*/
|
|
export function scanDirectives(
|
|
content: string,
|
|
filePath: string,
|
|
index: Record<string, string>,
|
|
fileDir: string,
|
|
): DirectiveScanResult {
|
|
const slugger = new Slugger();
|
|
const dice: DiceCompletion[] = [];
|
|
const sparkTables: SparkTableCompletion[] = [];
|
|
const newIndexEntries: Record<string, string> = {};
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pass 1: Coerce spark-shaped markdown tables to :md-table directives
|
|
// ------------------------------------------------------------------
|
|
|
|
const mdTableRegex =
|
|
/^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
|
|
|
|
let rewritten = content;
|
|
let mdMatch: RegExpExecArray | null;
|
|
|
|
// Collect matches first (rewriting while iterating is tricky with regex)
|
|
interface TableMatch {
|
|
fullMatch: string;
|
|
headerRow: string;
|
|
separatorRow: string;
|
|
bodyRowsText: string;
|
|
index: number;
|
|
}
|
|
const tableMatches: TableMatch[] = [];
|
|
|
|
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
|
|
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
|
|
const headers = splitTableRow(headerRow);
|
|
|
|
// Check if this looks like a spark table: first column is a dice formula
|
|
const isSpark = DICE_HEADER_RE.test(headers[0]);
|
|
|
|
if (!isSpark) continue;
|
|
|
|
const bodyRows = bodyRowsText
|
|
.trim()
|
|
.split(/\n/)
|
|
.filter((r) => r.trim().startsWith("|"));
|
|
|
|
const csv = markdownTableToCsv(headerRow, separatorRow, bodyRows);
|
|
if (!csv) continue;
|
|
|
|
tableMatches.push({
|
|
fullMatch: mdMatch[0],
|
|
headerRow,
|
|
separatorRow,
|
|
bodyRowsText,
|
|
index: mdMatch.index,
|
|
});
|
|
}
|
|
|
|
// Replace matches from end to start to preserve indices
|
|
for (let i = tableMatches.length - 1; i >= 0; i--) {
|
|
const m = tableMatches[i];
|
|
const bodyRows = m.bodyRowsText
|
|
.trim()
|
|
.split(/\n/)
|
|
.filter((r) => r.trim().startsWith("|"));
|
|
const csv = markdownTableToCsv(m.headerRow, m.separatorRow, bodyRows)!;
|
|
|
|
const hash = contentHash(csv);
|
|
const filename = `_spark_md_${hash}.csv`;
|
|
const resolvedPath = `${fileDir}/${filename}`;
|
|
|
|
newIndexEntries[resolvedPath] = csv;
|
|
|
|
// Collect spark table completion
|
|
const st = buildSparkTableCompletion(csv, filePath, slugger);
|
|
if (st) {
|
|
sparkTables.push(st);
|
|
}
|
|
|
|
// Replace markdown table with :md-table directive
|
|
const directive = `:md-table[./${filename}]{data-spark="${st?.slug ?? ""}"}`;
|
|
rewritten =
|
|
rewritten.slice(0, m.index) +
|
|
directive +
|
|
rewritten.slice(m.index + m.fullMatch.length);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pass 2: Scan :md-dice[...] directives
|
|
// ------------------------------------------------------------------
|
|
|
|
const diceRegex = /:md-dice\[([^[\]]+)\]/gi;
|
|
let diceMatch: RegExpExecArray | null;
|
|
while ((diceMatch = diceRegex.exec(rewritten)) !== null) {
|
|
const raw = diceMatch[1].trim();
|
|
if (!raw || !looksLikeDice(raw)) continue;
|
|
dice.push({ label: raw, notation: raw, source: filePath });
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pass 3: Scan :md-table[...] and :md-card[...] directives
|
|
// ------------------------------------------------------------------
|
|
|
|
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
|
|
let tableMatch: RegExpExecArray | null;
|
|
while ((tableMatch = tableDirectiveRegex.exec(rewritten)) !== null) {
|
|
const [, /* type */ , path, extraStr] = tableMatch;
|
|
|
|
// Resolve the CSV path
|
|
const csvPath = path.startsWith("./")
|
|
? `${fileDir}/${path.slice(2)}`
|
|
: path;
|
|
|
|
let csv = index[csvPath] ?? newIndexEntries[csvPath];
|
|
if (!csv) continue;
|
|
|
|
const st = buildSparkTableCompletion(csv, filePath, slugger);
|
|
if (!st) continue;
|
|
|
|
// Check if data-spark is already set in extra attrs
|
|
if (!extraStr || !extraStr.includes("data-spark=")) {
|
|
// Inject data-spark attribute into the directive
|
|
const fullMatch = tableMatch[0];
|
|
const insertPos = fullMatch.indexOf("]") + 1;
|
|
const before = fullMatch.slice(0, insertPos);
|
|
const after = fullMatch.slice(insertPos);
|
|
|
|
const sparkAttr = `{data-spark="${st.slug}"}`;
|
|
let replacement: string;
|
|
if (after.startsWith("{")) {
|
|
// Merge into existing attrs
|
|
replacement = before + after.replace(/^\{/, `{data-spark="${st.slug}" `);
|
|
} else {
|
|
replacement = before + sparkAttr + after;
|
|
}
|
|
|
|
rewritten =
|
|
rewritten.slice(0, tableMatch.index) +
|
|
replacement +
|
|
rewritten.slice(tableMatch.index + fullMatch.length);
|
|
}
|
|
|
|
sparkTables.push(st);
|
|
}
|
|
|
|
return { rewritten, dice, sparkTables, newIndexEntries };
|
|
} |