/** * 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; } // --------------------------------------------------------------------------- // 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, fileDir: string, ): DirectiveScanResult { const slugger = new Slugger(); const dice: DiceCompletion[] = []; const sparkTables: SparkTableCompletion[] = []; const newIndexEntries: Record = {}; // ------------------------------------------------------------------ // 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 }; }