ttrpg-tools/src/cli/completions/directive-scanner.ts

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 };
}