diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index ff95952..fbedb1a 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -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(); } } diff --git a/src/cli/completions/block-processor.ts b/src/cli/completions/block-processor.ts index 9c83383..78ed48a 100644 --- a/src/cli/completions/block-processor.ts +++ b/src/cli/completions/block-processor.ts @@ -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, ): 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"}` diff --git a/src/cli/completions/block-scanner.ts b/src/cli/completions/block-scanner.ts index e0001e8..7dae76b 100644 --- a/src/cli/completions/block-scanner.ts +++ b/src/cli/completions/block-scanner.ts @@ -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, - }; -} +} \ No newline at end of file diff --git a/src/cli/completions/directive-scanner.ts b/src/cli/completions/directive-scanner.ts new file mode 100644 index 0000000..5b4f895 --- /dev/null +++ b/src/cli/completions/directive-scanner.ts @@ -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; +} + +// --------------------------------------------------------------------------- +// 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 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 }; +} \ No newline at end of file diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts index 22c3137..b585b96 100644 --- a/src/cli/completions/index.ts +++ b/src/cli/completions/index.ts @@ -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, 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, diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index 1f9969f..021a07d 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -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 { 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 = {}; + + // 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 { }); } - // ---- 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 { 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: [] }; diff --git a/src/markdown/table.ts b/src/markdown/table.ts index 10e7580..ecf0c8c 100644 --- a/src/markdown/table.ts +++ b/src/markdown/table.ts @@ -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 `${csvData}\n`; + // data-spark attribute is injected by the CLI directive scanner, + // not computed here. + return `${csvData}\n`; }, }, };