From 256c685f6c99eb3472bea981f2d781111caee027 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 8 Sep 2026 21:33:12 +0800 Subject: [PATCH] refactor: remove implicit content sniffing and table conversion - Split loadCSV into parseCSVString (content) and loadCSVFromPath (path); drop isCSV/looksLikeCsv heuristics - Delete coerceSparkTables and markedTable label-header magic; plain markdown tables now render as plain tables - Add explicit markdown role=spark-table fence syntax that converts pipe tables to CSV at scan time, with dice-header validation - Map ESM-only github-slugger and csv-parse browser build to CJS in jest config; add content-registry tests --- __mocks__/github-slugger.js | 26 ++++ docs/markdown.md | 32 ++-- jest.config.js | 4 + src/cli/completions/block-scanner.ts | 2 +- src/cli/completions/variable-system.test.ts | 7 +- src/cli/content-registry.test.ts | 110 ++++++++++++++ src/cli/content-registry.ts | 140 ++++++------------ .../md-commander/stores/commandsStore.ts | 4 +- src/components/md-deck/hooks/deckStore.ts | 4 +- src/components/md-table.tsx | 4 +- src/components/utils/csv-loader.ts | 50 +------ src/markdown/index.ts | 2 - src/markdown/table.ts | 84 ----------- 13 files changed, 216 insertions(+), 253 deletions(-) create mode 100644 __mocks__/github-slugger.js create mode 100644 src/cli/content-registry.test.ts delete mode 100644 src/markdown/table.ts diff --git a/__mocks__/github-slugger.js b/__mocks__/github-slugger.js new file mode 100644 index 0000000..b3f1f8a --- /dev/null +++ b/__mocks__/github-slugger.js @@ -0,0 +1,26 @@ +/** + * CJS mock of `github-slugger` (v2 is ESM-only, which jest's CJS runtime + * cannot require). Auto-applied to all test files via the root `__mocks__` + * directory — no `jest.mock()` call needed. + */ +class Slugger { + constructor() { + this.seen = new Map(); + } + + slug(value, maintainCase) { + let slug = String(value).trim(); + if (!maintainCase) slug = slug.toLowerCase(); + slug = slug + .replace(/[^\p{L}\p{N}\s_-]/gu, "") + .replace(/\s/g, "-"); + + // github-slugger dedups repeated slugs with a -1, -2, ... suffix + const count = this.seen.get(slug) || 0; + this.seen.set(slug, count + 1); + if (count > 0) return `${slug}-${count}`; + return slug; + } +} + +module.exports = Slugger; \ No newline at end of file diff --git a/docs/markdown.md b/docs/markdown.md index cac4640..50948c0 100644 --- a/docs/markdown.md +++ b/docs/markdown.md @@ -373,26 +373,30 @@ label,name,description :md-table[./quests.csv]{roll=true remix=true} ``` -**自动表格转换:** +**内联表格(显式声明):** -标准 Markdown 表格会自动转换为 `md-table` 组件,当表头包含 `label` 或 `md-table-label` 列时: +Markdown 表格不会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`): -```markdown -| label | name | description | -|-------|------|-------------| -| 1 | 战士 | 近战专家 | -| 2 | 法师 | 奥术施法者 | +````markdown +```markdown role=spark-table +| d6 | 结果 | +|----|------| +| 1 | 遭遇强盗 | +| 2 | 平安无事 | ``` +```` -自动转换为 `:md-table` 组件。 +扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言: -**特殊表头标识:** +````markdown +```csv role=spark-table +d6,结果 +1,遭遇强盗 +2,平安无事 +``` +```` -| 表头 | 效果 | -|------|------| -| `label` 或 `md-table-label` | 转换为 md-table | -| `md-roll-label` 或骰子格式(如 `1d6`) | 添加 `roll=true` | -| `md-remix-label` | 添加 `roll=true remix=true` | +普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。 ### 🃏 卡牌组件 (md-deck) diff --git a/jest.config.js b/jest.config.js index 33d964a..b9be301 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,10 @@ export default { moduleNameMapper: { // Resolve .js imports to .ts source files (ESM convention in TS source) '^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'], + // github-slugger v2 is ESM-only; jest's CJS runtime cannot require it. + '^github-slugger$': '/__mocks__/github-slugger.js', + // Same for the browser ESM build of csv-parse — map to the CJS build. + '^csv-parse/browser/esm/sync$': 'csv-parse/sync', }, transform: { '^.+\\.tsx?$': [ diff --git a/src/cli/completions/block-scanner.ts b/src/cli/completions/block-scanner.ts index 09c7c51..39d5292 100644 --- a/src/cli/completions/block-scanner.ts +++ b/src/cli/completions/block-scanner.ts @@ -12,7 +12,7 @@ * extensions can share the same syntax. */ -export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs"; +export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js"; // --------------------------------------------------------------------------- // Regex diff --git a/src/cli/completions/variable-system.test.ts b/src/cli/completions/variable-system.test.ts index f807ed3..de000d8 100644 --- a/src/cli/completions/variable-system.test.ts +++ b/src/cli/completions/variable-system.test.ts @@ -12,11 +12,8 @@ // Mocks // --------------------------------------------------------------------------- -jest.mock("csv-parse/browser/esm/sync", () => { - // Redirect browser-specific import to Node-compatible sync parser - const actual = jest.requireActual("csv-parse/sync"); - return { parse: actual.parse }; -}); +// csv-parse/browser/esm/sync and github-slugger are mapped to CJS builds +// globally in jest.config.js moduleNameMapper. jest.mock("github-slugger", () => { // Simple slugger mock for testing diff --git a/src/cli/content-registry.test.ts b/src/cli/content-registry.test.ts new file mode 100644 index 0000000..2f97360 --- /dev/null +++ b/src/cli/content-registry.test.ts @@ -0,0 +1,110 @@ +import { scanDoc, resolveContent, type ContentRegistry } from "./content-registry"; + +function emptyRegistry(): ContentRegistry { + return { pathIndex: {}, docContent: {} }; +} + +describe("scanDoc", () => { + test("csv role=spark-table block becomes an :md-table directive", () => { + const md = [ + "```csv role=spark-table", + "d6,Name", + "1,Alice", + "2,Bob", + "```", + ].join("\n"); + const { stripped, content } = scanDoc(md, "test.md"); + + expect(stripped).toMatch(/^:md-table\[\.\/csv_/); + const [entry] = Object.values(content); + expect(entry.kind).toBe("csv"); + expect(entry.body).toContain("d6,Name"); + expect(entry.role).toBe("spark-table"); + }); + + test("markdown role=spark-table body is converted to CSV", () => { + const md = [ + "```markdown role=spark-table", + "| d6 | Name |", + "|----|------|", + "| 1 | Alice |", + "| 2 | Bob |", + "```", + ].join("\n"); + const { stripped, content } = scanDoc(md, "test.md"); + + expect(stripped).toMatch(/^:md-table\[\.\/csv_/); + const [entry] = Object.values(content); + expect(entry.body).toBe("d6,Name\n1,Alice\n2,Bob"); + }); + + test("markdown role=spark-table with non-dice first column warns but still stores", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const md = [ + "```markdown role=spark-table", + "| Name | Value |", + "|------|-------|", + "| Alice | 10 |", + "```", + ].join("\n"); + const { content } = scanDoc(md, "test.md"); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("is not a dice formula"), + ); + const [entry] = Object.values(content); + expect(entry.body).toBe("Name,Value\nAlice,10"); + warn.mockRestore(); + }); + + test("plain markdown tables are left untouched", () => { + const md = [ + "| d6 | Name |", + "|----|------|", + "| 1 | Alice |", + ].join("\n"); + const { stripped, content } = scanDoc(md, "test.md"); + + expect(stripped).toBe(md); + expect(Object.keys(content)).toHaveLength(0); + }); + + test("plain markdown tables with label headers are left untouched", () => { + const md = [ + "| md-table-label | body |", + "|----------------|------|", + "| 1 | text |", + ].join("\n"); + const { stripped, content } = scanDoc(md, "test.md"); + + expect(stripped).toBe(md); + expect(Object.keys(content)).toHaveLength(0); + }); +}); + +describe("resolveContent", () => { + test("resolves inline content ids", () => { + const registry = emptyRegistry(); + registry.docContent["test.md"] = { + csv_abc: { + id: "csv_abc", + kind: "csv", + body: "d6,Name\n1,Alice", + role: "spark-table", + as: "md-table", + }, + }; + + expect(resolveContent(registry, "test.md", "./csv_abc")).toBe( + "d6,Name\n1,Alice", + ); + }); + + test("does not sniff refs as inline CSV", () => { + const registry = emptyRegistry(); + // A CSV-looking ref is treated as a relative path, not content. + expect( + resolveContent(registry, "test.md", "d6,Name\n1,Alice"), + ).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/cli/content-registry.ts b/src/cli/content-registry.ts index 683a4b8..4105a4d 100644 --- a/src/cli/content-registry.ts +++ b/src/cli/content-registry.ts @@ -114,8 +114,8 @@ export interface DocScanResult { /** * Process a single markdown doc: * - strips/replaces attributed fenced code blocks based on `as` - * - coerces spark-shaped markdown tables to `:md-table` directives - * - collects inline content (role=file, md-* bodies, spark tables, declare) + * - converts `markdown`-lang `role=spark-table` bodies (pipe tables) to CSV + * - collects inline content (role=file, md-* bodies, declare) * into the doc's content store * * Does NOT touch the path index — the caller assembles the registry. @@ -167,11 +167,15 @@ export function scanDoc(content: string, docPath: string): DocScanResult { } if (effectiveAs.startsWith("md-")) { - const id = deriveContentId("csv", body, attrs.id); + let blockBody = body; + if (attrs.role === "spark-table" && isMarkdownTableLang(attrs.lang)) { + blockBody = markdownTableBodyToCsv(body, docPath); + } + const id = deriveContentId("csv", blockBody, attrs.id); contentStore[id] = { id, kind: "csv", - body, + body: blockBody, role: attrs.role, as: effectiveAs, }; @@ -189,10 +193,7 @@ export function scanDoc(content: string, docPath: string): DocScanResult { }, ); - // ---- Pass 2: coerce spark-shaped markdown tables to :md-table ---- - const rewritten = coerceSparkTables(stripped, contentStore); - - return { stripped: rewritten, content: contentStore }; + return { stripped, content: contentStore }; } /** @@ -241,8 +242,8 @@ export function buildRegistryFromIndex( /** * Whether a table header cell is a dice formula (a "spark table" first - * column). Single source of truth shared by the CLI scanner and the frontend - * `markedTable` renderer so both agree on what counts as a spark table. + * column). Used to validate `role=spark-table` blocks (CSV or markdown + * pipe-table bodies). */ export function isSparkTableHeader(header: string): boolean { return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim()); @@ -294,74 +295,43 @@ function markdownTableToCsv( } /** - * Coerce spark-shaped markdown tables (first column header is a dice formula) - * into `:md-table` directives, storing the CSV in the doc's content store and - * injecting `data-spark` for the reveal feature. + * Languages whose fenced-block bodies are markdown pipe tables. Used with + * `role=spark-table` to convert the table to CSV at scan time — explicitly + * authorized by the role, never by content shape. */ -function coerceSparkTables( - content: string, - contentStore: Record, -): string { - const mdTableRegex = /^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm; +const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]); - interface TableMatch { - fullMatch: string; - headerRow: string; - separatorRow: string; - bodyRowsText: string; - index: number; - } - const tableMatches: TableMatch[] = []; +function isMarkdownTableLang(lang: string): boolean { + return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase()); +} - let mdMatch: RegExpExecArray | null; - while ((mdMatch = mdTableRegex.exec(content)) !== null) { - const [, headerRow, separatorRow, bodyRowsText] = mdMatch; - const headers = splitTableRow(headerRow); - if (!isSparkTableHeader(headers[0])) 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, - }); +/** + * Convert a fenced markdown pipe-table body to CSV for `role=spark-table` + * blocks. Validates the dice-formula first column (warning only — the role + * already declared intent) and falls back to storing the body as-is when it + * is not a recognizable pipe table. + */ +function markdownTableBodyToCsv(body: string, docPath: string): string { + const lines = body + .trim() + .split(/\r?\n/) + .filter((l) => l.trim().startsWith("|")); + if (lines.length < 2) { + console.warn( + `[content-registry] ${docPath}: role=spark-table markdown body is not a pipe table; storing as-is`, + ); + return body; } - let rewritten = content; - 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 id = deriveContentId("csv", csv); - contentStore[id] = { - id, - kind: "csv", - body: csv, - role: "spark-table", - as: "md-table", - }; - - const slug = sparkSlug(csv); - const directive = `:md-table[./${id}]{data-spark="${slug}"}`; - rewritten = - rewritten.slice(0, m.index) + - directive + - rewritten.slice(m.index + m.fullMatch.length); + const [headerRow, separatorRow, ...rows] = lines; + const headers = splitTableRow(headerRow); + if (!isSparkTableHeader(headers[0] || "")) { + console.warn( + `[content-registry] ${docPath}: spark table first column "${headers[0]}" is not a dice formula`, + ); } - return rewritten; + return markdownTableToCsv(headerRow, separatorRow, rows) ?? body; } // --------------------------------------------------------------------------- @@ -422,10 +392,12 @@ export function injectSparkDirectives( /** * Resolve a content reference from within a doc. * - * - `ref` is inline CSV → returned as-is. * - `ref` is an absolute path → path index. * - `ref` is a relative path → resolved against the doc directory; checks - * the path index first, then the doc's inline content store. + * the doc's inline content store first, then the path index. + * + * Refs are always ids or paths — inline content must be defined in a fenced + * block and referenced by its `./{id}`. * * Returns `null` when nothing matches. */ @@ -437,9 +409,6 @@ export function resolveContent( const trimmed = ref.trim(); if (!trimmed) return null; - // Inline CSV body. - if (looksLikeCsv(trimmed)) return trimmed; - if (trimmed.startsWith("/")) { return registry.pathIndex[trimmed] ?? null; } @@ -477,25 +446,6 @@ export function resolveInlineByPath( return null; } -/** Naive CSV sniff — matches the frontend `isCSV` heuristic. */ -function looksLikeCsv(str: string): boolean { - const trimmed = str.trim(); - if (trimmed.startsWith("---\n") || trimmed.startsWith("---\r\n")) return true; - - const lines = trimmed.split(/\r?\n/).filter((line) => line.trim() !== ""); - if (lines.length < 2) return false; - - const separators = [",", "\t", ";", "|"]; - const firstLine = lines[0]; - for (const sep of separators) { - if (firstLine.includes(sep)) { - const hasInOthers = lines.slice(1).some((line) => line.includes(sep)); - if (hasInOthers) return true; - } - } - return false; -} - // --------------------------------------------------------------------------- // Derived completions // --------------------------------------------------------------------------- diff --git a/src/components/md-commander/stores/commandsStore.ts b/src/components/md-commander/stores/commandsStore.ts index 45de973..01dc134 100644 --- a/src/components/md-commander/stores/commandsStore.ts +++ b/src/components/md-commander/stores/commandsStore.ts @@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"; import type { MdCommanderCommand, MdCommanderCommandMap } from "../types"; import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands"; import {resolvePath} from "../../utils/path"; -import {loadCSV} from "../../utils/csv-loader"; +import {loadCSVFromPath} from "../../utils/csv-loader"; const defaultCommands: MdCommanderCommandMap = { help: setupHelpCommand({}), @@ -111,7 +111,7 @@ export async function loadCommandTemplatesFromCSV( setCommandsError(undefined); try { - const csv = await loadCSV(resolvePath(articlePath, path)); + const csv = await loadCSVFromPath(resolvePath(articlePath, path)); // 按命令分组模板 const templatesByCommand = new Map(); diff --git a/src/components/md-deck/hooks/deckStore.ts b/src/components/md-deck/hooks/deckStore.ts index cd37559..9a0ea0d 100644 --- a/src/components/md-deck/hooks/deckStore.ts +++ b/src/components/md-deck/hooks/deckStore.ts @@ -1,7 +1,7 @@ import { createStore } from "solid-js/store"; import yaml from "js-yaml"; import { calculateDimensions } from "./dimensions"; -import { loadCSV, CSV } from "../../utils/csv-loader"; +import { loadCSVFromPath, CSV } from "../../utils/csv-loader"; import { formatLayers } from "./layer-parser"; import * as layerCrud from "./layer-crud"; import type { @@ -461,7 +461,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore { setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc }); try { - const data = await loadCSV(path); + const data = await loadCSVFromPath(path); if (data.length === 0) { setState({ diff --git a/src/components/md-table.tsx b/src/components/md-table.tsx index 58e7022..4d2fdcc 100644 --- a/src/components/md-table.tsx +++ b/src/components/md-table.tsx @@ -8,7 +8,7 @@ import { createResource, } from "solid-js"; import { parseMarkdown } from "../markdown"; -import { loadCSV, CSV, processVariables } from "./utils/csv-loader"; +import { parseCSVString, CSV, processVariables } from "./utils/csv-loader"; import { resolveContentRef } from "./utils/resolve-content"; import { areAllLabelsNumeric, @@ -59,7 +59,7 @@ customElement( if (content === null) { throw new Error(`Failed to resolve table content: "${ref}"`); } - return loadCSV(content); + return parseCSVString(content); }, ); diff --git a/src/components/utils/csv-loader.ts b/src/components/utils/csv-loader.ts index c1b829c..f5bbd16 100644 --- a/src/components/utils/csv-loader.ts +++ b/src/components/utils/csv-loader.ts @@ -31,42 +31,6 @@ function parseFrontMatter(content: string): { frontmatter?: JSONObject; remainin } } -/** - * 检测字符串是否是 CSV 格式 - * @param str 待检测的字符串 - * @returns 如果是 CSV 格式返回 true - */ -export function isCSV(str: string): boolean { - const trimmed = str.trim(); - - // 检查是否以 YAML front matter 开头 - if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) { - return true; - } - - // 检查是否包含 CSV 特征:多行且有分隔符 - const lines = trimmed.split(/\r?\n/).filter(line => line.trim() !== ''); - if (lines.length < 2) { - return false; - } - - // 检测常见 CSV 分隔符 - const separators = [',', '\t', ';', '|']; - const firstLine = lines[0]; - - for (const sep of separators) { - if (firstLine.includes(sep)) { - // 检查其他行是否也有相同的分隔符 - const hasSeparatorInOtherLines = lines.slice(1).some(line => line.includes(sep)); - if (hasSeparatorInOtherLines) { - return true; - } - } - } - - return false; -} - /** * 解析 CSV 字符串 * @template T 返回数据的类型,默认为 Record @@ -102,18 +66,12 @@ export function parseCSVString>(csvString: string, so /** * 加载 CSV 文件 * @template T 返回数据的类型,默认为 Record - * @param pathOrContent 文件路径或 inline CSV 字符串 + * @param path 文件路径(通过 file-index 获取内容) * @returns 解析后的 CSV 数据 */ -export async function loadCSV>(pathOrContent: string): Promise> { - // 检测是否是 inline CSV 数据 - if (isCSV(pathOrContent)) { - return parseCSVString(pathOrContent, 'inline'); - } - - // 从索引获取文件内容 - const content = await getIndexedData(pathOrContent); - return parseCSVString(content, pathOrContent); +export async function loadCSVFromPath>(path: string): Promise> { + const content = await getIndexedData(path); + return parseCSVString(content, path); } type JSONData = JSONArray | JSONObject | string | number | boolean | null; diff --git a/src/markdown/index.ts b/src/markdown/index.ts index ff38207..fa7d619 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -2,7 +2,6 @@ import { Marked, type MarkedExtension } from "marked"; import { createDirectives, presetDirectiveConfigs } from "marked-directive"; import markedAlert from "marked-alert"; import markedMermaid from "./mermaid"; -import markedTable from "./table"; import { gfmHeadingId } from "marked-gfm-heading-id"; import markedColumns from "./columns"; import markedCodeBlockYamlTag from "./code-block-yaml-tag"; @@ -14,7 +13,6 @@ const marked = new Marked() .use(gfmHeadingId()) .use(markedAlert()) .use(markedMermaid()) - .use(markedTable()) .use(markedCodeBlockYamlTag()) .use( createDirectives([ diff --git a/src/markdown/table.ts b/src/markdown/table.ts deleted file mode 100644 index 1b2fd56..0000000 --- a/src/markdown/table.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { MarkedExtension, Tokens } from "marked"; - -/** - * 将表格数据转换为 CSV 格式字符串 - * @param headers 表头数组 - * @param rows 表格数据行 - * @returns CSV 格式字符串 - */ -function tableToCSV(headers: string[], rows: string[][]): string { - const escapeCell = (cell: string) => { - // 如果单元格包含逗号、换行或引号,需要转义 - if ( - cell.includes(",") || - cell.includes("\n") || - cell.includes('"') || - cell.includes("#") - ) { - return `"${cell.replace(/"/g, '""')}"`; - } - return cell; - }; - - const headerLine = headers.map(escapeCell).join(","); - const dataLines = rows.map((row) => row.map(escapeCell).join(",")); - - return [headerLine, ...dataLines].join("\n"); -} - -export default function markedTable(): MarkedExtension { - return { - renderer: { - table(token: Tokens.Table) { - const header = token.header; - let roll = ""; - let remix = ""; - - // Spark tables (dice-formula first column) are handled upstream by - // `coerceSparkTables` in the content registry — they're rewritten to - // `:md-table` directives before rendering. This renderer only handles - // label-based tables (md-table-label / md-roll-label / md-remix-label). - const labelIndex = header.findIndex((cell) => { - if (cell.text === "md-roll-label") { - roll = " roll=true"; - return true; - } else if (cell.text === "md-remix-label") { - roll = " roll=true remix=true"; - return true; - } - return cell.text === "md-table-label" || cell.text === "label"; - }); - - // 默认表格渲染 - 使用 marked 默认行为 - if (labelIndex === -1) return false; - - const headers = token.header.map((cell) => cell.text); - headers[labelIndex] = "label"; - const rows = token.rows.map((row) => row.map((cell) => cell.text)); - - if (header.findIndex((header) => header.text === "body") < 0) { - // 收集所有非 label 列的表头 - const bodyColumns = headers.filter((cell) => cell !== "label"); - - // 构建 body 列的模板:**列名**:{{列名}}\n\n - const bodyTemplate = bodyColumns - .map((col) => `**${col}**:{{${col}}}`) - .join("\n\n"); - - headers.push("body"); - rows.forEach((row) => { - row.push(bodyTemplate); - }); - } - - // 生成 CSV 数据 - const csvData = tableToCSV(headers, rows); - - // 渲染为 md-table 组件,内联 CSV 数据 - // data-spark attribute is injected by the CLI directive scanner, - // not computed here. - return `${csvData}\n`; - }, - }, - }; -}