refactor: move spark table parsing to directive scanner

Relocate spark table logic from the block processor to a dedicated
directive scanner. This allows spark tables to be treated as
`:md-table` directives and ensures they are processed in a second pass
after the content index is fully populated.
This commit is contained in:
hypercross 2026-07-10 15:55:08 +08:00
parent 89b32ef15e
commit d4ebca5fd0
7 changed files with 419 additions and 100 deletions

View File

@ -15,6 +15,10 @@ import {
processBlocks,
type ProcessedBlocks,
} from "../completions/block-processor.js";
import {
scanDirectives,
type DirectiveScanResult,
} from "../completions/directive-scanner.js";
import type { StatSheet } from "../completions/types.js";
interface ContentIndex {
@ -106,14 +110,16 @@ function labelFromId(id: string): string {
export function scanDirectory(dir: string): {
index: ContentIndex;
blocks: ProcessedBlocks;
directiveResults: DirectiveScanResult[];
} {
const index: ContentIndex = {};
const blocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
sparkTables: [],
statSheets: [],
};
const directiveResults: DirectiveScanResult[] = [];
const mdFiles: { content: string; relPath: string }[] = [];
function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath);
@ -141,7 +147,7 @@ export function scanDirectory(dir: string): {
index[normalizedRelPath] = result.stripped;
blocks.stats.push(...result.blocks.stats);
blocks.statTemplates.push(...result.blocks.statTemplates);
blocks.sparkTables.push(...result.blocks.sparkTables);
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
} else if (entry.endsWith(".sheet.svg")) {
index[normalizedRelPath] = content;
const id = entry.replace(/\.sheet\.svg$/i, "");
@ -164,7 +170,32 @@ export function scanDirectory(dir: string): {
}
scan(dir, "");
return { index, blocks };
// ---- Directive scanning pass (after all blocks processed) ----
const posixDir = dir.split(sep).join("/");
for (const { content, relPath } of mdFiles) {
const fileDir = posixRelDir(relPath);
const result = scanDirectives(content, relPath, index, fileDir);
// Apply rewritten content back to index
index[relPath] = result.rewritten;
// Inject new index entries (inline CSV bodies)
for (const [key, value] of Object.entries(result.newIndexEntries)) {
index[key] = value;
}
directiveResults.push(result);
}
return { index, blocks, directiveResults };
}
/** Get the POSIX directory of a file path */
function posixRelDir(filePath: string): string {
const parts = filePath.split("/");
parts.pop();
return parts.join("/") || ".";
}
/**
@ -310,9 +341,9 @@ export function createContentServer(
let collectedBlocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
sparkTables: [],
statSheets: [],
};
let directiveResults: DirectiveScanResult[] = [];
let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
@ -324,7 +355,7 @@ export function createContentServer(
/** 从当前内容索引和已收集的块重新扫描补全数据 */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks);
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length} sheets=${completionsIndex.statSheets.length}`,
);
@ -335,6 +366,7 @@ export function createContentServer(
const scanResult = scanDirectory(contentDir);
contentIndex = scanResult.index;
collectedBlocks = scanResult.blocks;
directiveResults = scanResult.directiveResults;
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
recomputeCompletions();
@ -363,12 +395,14 @@ export function createContentServer(
// Re-scan to get fresh blocks (simpler than per-file merge)
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
}
@ -393,12 +427,14 @@ export function createContentServer(
contentIndex[relPath] = result.stripped;
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
}
@ -421,6 +457,7 @@ export function createContentServer(
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
}

View File

@ -7,7 +7,6 @@
import { posix } from "path";
import { createHash } from "crypto";
import Slugger from "github-slugger";
import {
parseStatYaml,
parseStatCsv,
@ -20,16 +19,14 @@ import {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
parseSparkTableCsv,
} from "./block-scanner.js";
import type { SparkTableCompletion, StatSheet } from "./types.js";
import type { StatSheet } from "./types.js";
// Re-export shared pieces for convenience
export {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
parseSparkTableCsv,
type BlockAttrs,
} from "./block-scanner.js";
@ -40,7 +37,6 @@ export {
export interface ProcessedBlocks {
stats: StatDef[];
statTemplates: StatTemplate[];
sparkTables: SparkTableCompletion[];
statSheets: StatSheet[];
}
@ -68,7 +64,9 @@ function contentHash(body: string): string {
*
* - Strips/replaces blocks based on `as`
* - Injects `role=file` bodies into the content index
* - Collects stat/template/spark-table blocks for completions
* - Collects stat/template blocks for completions
* - role=spark-table blocks are converted to :md-table directives
* (spark table completions are collected later by the directive scanner)
*/
export function processBlocks(
content: string,
@ -76,12 +74,10 @@ export function processBlocks(
index: Record<string, string>,
): BlockResult {
const fileDir = posix.dirname(fileRelativePath);
const slugger = new Slugger();
const blocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
sparkTables: [],
statSheets: [],
};
@ -122,11 +118,6 @@ export function processBlocks(
blocks.statTemplates.push(result.template);
}
if (attrs.role === "spark-table") {
const parsed = parseSparkTableCsv(body, fileRelativePath, slugger);
if (parsed) blocks.sparkTables.push(parsed);
}
if (attrs.role === "file") {
const filename = attrs.id
? `${attrs.id}.${attrs.lang || "txt"}`

View File

@ -9,9 +9,6 @@
* - `id` is used for cross-references (template names, file paths)
*/
import Slugger from "github-slugger";
import type { SparkTableCompletion } from "./types.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@ -58,46 +55,13 @@ export function parseBlockAttrs(info: string): BlockAttrs {
*
* Defaults:
* - role is set, no explicit as "none" (strip it's metadata)
* - role=spark-table "md-table" (render as md-table directive)
* - no role, no as "codeblock" (keep as visible code block)
*/
export function resolveBlockAs(role: string | undefined, as: string | undefined): string {
if (as) return as;
// spark-table blocks render as md-table by default
if (role === "spark-table") return "md-table";
if (role) return "none";
return "codeblock";
}
// ---------------------------------------------------------------------------
// Spark table parsing
// ---------------------------------------------------------------------------
const DICE_RE = /^d\d+$/i;
export function parseSparkTableCsv(
body: string,
filePath: string,
slugger: Slugger,
): SparkTableCompletion | null {
const lines = body.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_RE.test(headers[0])) return null;
const dataHeaders = headers.slice(1);
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,
};
}

View File

@ -0,0 +1,329 @@
/**
* 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 is a spark-shaped table: first column is a dice formula
// and one of the headers is a label marker
const isSpark =
DICE_HEADER_RE.test(headers[0]) &&
headers.some(
(h) =>
h === "label" ||
h === "md-table-label" ||
h === "md-roll-label",
);
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 };
}

View File

@ -2,10 +2,10 @@
* Completion index orchestrates all registered completion sources.
*/
import { diceSource } from "./sources/dice.js";
import { linksSource } from "./sources/links.js";
import type { ProcessedBlocks } from "./block-processor.js";
import type { CompletionsPayload } from "./types.js";
import type { DirectiveScanResult } from "./directive-scanner.js";
export type {
CompletionsPayload,
@ -18,20 +18,29 @@ export type {
} from "./types.js";
/**
* Build completions from the content index and pre-collected blocks.
* Build completions from the content index, pre-collected blocks,
* and directive scan results.
* Called at server startup and on any file change.
*/
export function scanCompletions(
index: Record<string, string>,
blocks: ProcessedBlocks,
directiveResults: DirectiveScanResult[],
): CompletionsPayload {
const dice = diceSource.scan(index) as CompletionsPayload["dice"];
const links = linksSource.scan(index) as CompletionsPayload["links"];
// Merge all directive scan results
const dice: CompletionsPayload["dice"] = [];
const sparkTables: CompletionsPayload["sparkTables"] = [];
for (const dr of directiveResults) {
dice.push(...dr.dice);
sparkTables.push(...dr.sparkTables);
}
return {
dice,
links,
sparkTables: blocks.sparkTables,
sparkTables,
stats: blocks.stats,
statTemplates: blocks.statTemplates,
statSheets: blocks.statSheets,

View File

@ -26,8 +26,12 @@ import type { StatSheet } from "../../cli/completions/types";
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
parseSparkTableCsv,
} from "../../cli/completions/block-scanner";
import {
scanDirectives,
buildSparkTableCompletion,
inspectSparkTableCsv,
} from "../../cli/completions/directive-scanner";
export type { StatDef, StatTemplate, StatSheet };
@ -105,26 +109,24 @@ async function scanClientSide(): Promise<JournalCompletions> {
const sparkTables: SparkTableCompletion[] = [];
const stats: StatDef[] = [];
const statTemplates: StatTemplate[] = [];
const tagRegex = /:md-dice\[([^[]+)\]/gi;
// Build a temporary index for resolving CSV paths
const tempIndex: Record<string, string> = {};
// First pass: load all .md content into temp index
for (const filePath of paths) {
const content = await getIndexedData(filePath);
if (content) tempIndex[filePath] = content;
}
for (const filePath of paths) {
const content = tempIndex[filePath];
if (!content) continue;
// Fresh slugger per file
const slugger = new Slugger();
// ---- Dice directives ----
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
while ((match = tagRegex.exec(content)) !== null) {
const raw = match[1].trim();
if (!raw || raw.length > 80) continue;
if (!/^\d*d\d+/i.test(raw) && !/^[+-]/.test(raw)) continue;
dice.push({ label: raw, notation: raw, source: filePath });
}
// ---- Links (headings) ----
// ---- Links (headings) - from original content ----
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
links.push({ path: basePath, label: fileName, section: null });
@ -136,7 +138,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
});
}
// ---- Unified block scanning ----
// ---- Unified block scanning (stats) ----
FENCED_BLOCK_RE.lastIndex = 0;
let blockMatch: RegExpExecArray | null;
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
@ -164,12 +166,13 @@ async function scanClientSide(): Promise<JournalCompletions> {
stats.push(...result.modifierDefs);
statTemplates.push(result.template);
}
if (attrs.role === "spark-table") {
const parsed = parseSparkTableCsv(body, filePath, slugger);
if (parsed) sparkTables.push(parsed);
}
}
// ---- Directive scanning (dice + spark tables) ----
const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
const directiveResult = scanDirectives(content, filePath, tempIndex, fileDir);
dice.push(...directiveResult.dice);
sparkTables.push(...directiveResult.sparkTables);
}
return { dice, links, sparkTables, stats, statTemplates, statSheets: [] };

View File

@ -1,5 +1,4 @@
import type { MarkedExtension, Tokens } from "marked";
import Slugger from "github-slugger";
/**
* CSV
@ -27,32 +26,17 @@ function tableToCSV(headers: string[], rows: string[][]): string {
return [headerLine, ...dataLines].join("\n");
}
/** Spark table: first column header is a pure dice formula (d6, d20, etc.) */
const SPARK_DICE_RE = /^\d*d\d+$/i;
export default function markedTable(): MarkedExtension {
return {
renderer: {
table(token: Tokens.Table) {
// 检查表头是否包含 md-table-label
const header = token.header;
let roll = "";
let spark = "";
let remix = "";
const labelIndex = header.findIndex((cell) => {
if (cell.text === "md-roll-label" || cell.text.match(/(\d+)?d\d+/)) {
roll = " roll=true";
// If this is a spark table (first column is a pure dice formula),
// compute the data-column slug for DOM injection
if (SPARK_DICE_RE.test(cell.text)) {
const slugger = new Slugger();
const dataHeaders = header
.filter((_, i) => i !== 0)
.map((h) => h.text);
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
spark = ` data-spark="${slug}"`;
}
return true;
} else if (cell.text === "md-remix-label") {
roll = " roll=true remix=true";
@ -87,7 +71,9 @@ export default function markedTable(): MarkedExtension {
const csvData = tableToCSV(headers, rows);
// 渲染为 md-table 组件,内联 CSV 数据
return `<md-table ${roll}${spark}>${csvData}</md-table>\n`;
// data-spark attribute is injected by the CLI directive scanner,
// not computed here.
return `<md-table ${roll}${remix}>${csvData}</md-table>\n`;
},
},
};