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
This commit is contained in:
@@ -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;
|
||||||
+18
-14
@@ -373,26 +373,30 @@ label,name,description
|
|||||||
:md-table[./quests.csv]{roll=true remix=true}
|
:md-table[./quests.csv]{roll=true remix=true}
|
||||||
```
|
```
|
||||||
|
|
||||||
**自动表格转换:**
|
**内联表格(显式声明):**
|
||||||
|
|
||||||
标准 Markdown 表格会自动转换为 `md-table` 组件,当表头包含 `label` 或 `md-table-label` 列时:
|
Markdown 表格不会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`):
|
||||||
|
|
||||||
```markdown
|
````markdown
|
||||||
| label | name | description |
|
```markdown role=spark-table
|
||||||
|-------|------|-------------|
|
| d6 | 结果 |
|
||||||
| 1 | 战士 | 近战专家 |
|
|----|------|
|
||||||
| 2 | 法师 | 奥术施法者 |
|
| 1 | 遭遇强盗 |
|
||||||
|
| 2 | 平安无事 |
|
||||||
```
|
```
|
||||||
|
````
|
||||||
|
|
||||||
自动转换为 `:md-table` 组件。
|
扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
|
||||||
|
|
||||||
**特殊表头标识:**
|
````markdown
|
||||||
|
```csv role=spark-table
|
||||||
|
d6,结果
|
||||||
|
1,遭遇强盗
|
||||||
|
2,平安无事
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
| 表头 | 效果 |
|
普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
|
||||||
|------|------|
|
|
||||||
| `label` 或 `md-table-label` | 转换为 md-table |
|
|
||||||
| `md-roll-label` 或骰子格式(如 `1d6`) | 添加 `roll=true` |
|
|
||||||
| `md-remix-label` | 添加 `roll=true remix=true` |
|
|
||||||
|
|
||||||
### 🃏 卡牌组件 (md-deck)
|
### 🃏 卡牌组件 (md-deck)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export default {
|
|||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
// Resolve .js imports to .ts source files (ESM convention in TS source)
|
// Resolve .js imports to .ts source files (ESM convention in TS source)
|
||||||
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
|
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
|
||||||
|
// github-slugger v2 is ESM-only; jest's CJS runtime cannot require it.
|
||||||
|
'^github-slugger$': '<rootDir>/__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: {
|
transform: {
|
||||||
'^.+\\.tsx?$': [
|
'^.+\\.tsx?$': [
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* extensions can share the same syntax.
|
* extensions can share the same syntax.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs";
|
export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Regex
|
// Regex
|
||||||
|
|||||||
@@ -12,11 +12,8 @@
|
|||||||
// Mocks
|
// Mocks
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
jest.mock("csv-parse/browser/esm/sync", () => {
|
// csv-parse/browser/esm/sync and github-slugger are mapped to CJS builds
|
||||||
// Redirect browser-specific import to Node-compatible sync parser
|
// globally in jest.config.js moduleNameMapper.
|
||||||
const actual = jest.requireActual("csv-parse/sync");
|
|
||||||
return { parse: actual.parse };
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock("github-slugger", () => {
|
jest.mock("github-slugger", () => {
|
||||||
// Simple slugger mock for testing
|
// Simple slugger mock for testing
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+45
-95
@@ -114,8 +114,8 @@ export interface DocScanResult {
|
|||||||
/**
|
/**
|
||||||
* Process a single markdown doc:
|
* Process a single markdown doc:
|
||||||
* - strips/replaces attributed fenced code blocks based on `as`
|
* - strips/replaces attributed fenced code blocks based on `as`
|
||||||
* - coerces spark-shaped markdown tables to `:md-table` directives
|
* - converts `markdown`-lang `role=spark-table` bodies (pipe tables) to CSV
|
||||||
* - collects inline content (role=file, md-* bodies, spark tables, declare)
|
* - collects inline content (role=file, md-* bodies, declare)
|
||||||
* into the doc's content store
|
* into the doc's content store
|
||||||
*
|
*
|
||||||
* Does NOT touch the path index — the caller assembles the registry.
|
* 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-")) {
|
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] = {
|
contentStore[id] = {
|
||||||
id,
|
id,
|
||||||
kind: "csv",
|
kind: "csv",
|
||||||
body,
|
body: blockBody,
|
||||||
role: attrs.role,
|
role: attrs.role,
|
||||||
as: effectiveAs,
|
as: effectiveAs,
|
||||||
};
|
};
|
||||||
@@ -189,10 +193,7 @@ export function scanDoc(content: string, docPath: string): DocScanResult {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Pass 2: coerce spark-shaped markdown tables to :md-table ----
|
return { stripped, content: contentStore };
|
||||||
const rewritten = coerceSparkTables(stripped, contentStore);
|
|
||||||
|
|
||||||
return { stripped: rewritten, content: contentStore };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,8 +242,8 @@ export function buildRegistryFromIndex(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a table header cell is a dice formula (a "spark table" first
|
* 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
|
* column). Used to validate `role=spark-table` blocks (CSV or markdown
|
||||||
* `markedTable` renderer so both agree on what counts as a spark table.
|
* pipe-table bodies).
|
||||||
*/
|
*/
|
||||||
export function isSparkTableHeader(header: string): boolean {
|
export function isSparkTableHeader(header: string): boolean {
|
||||||
return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim());
|
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)
|
* Languages whose fenced-block bodies are markdown pipe tables. Used with
|
||||||
* into `:md-table` directives, storing the CSV in the doc's content store and
|
* `role=spark-table` to convert the table to CSV at scan time — explicitly
|
||||||
* injecting `data-spark` for the reveal feature.
|
* authorized by the role, never by content shape.
|
||||||
*/
|
*/
|
||||||
function coerceSparkTables(
|
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
|
||||||
content: string,
|
|
||||||
contentStore: Record<string, DocContent>,
|
|
||||||
): string {
|
|
||||||
const mdTableRegex = /^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
|
|
||||||
|
|
||||||
interface TableMatch {
|
function isMarkdownTableLang(lang: string): boolean {
|
||||||
fullMatch: string;
|
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
|
||||||
headerRow: string;
|
}
|
||||||
separatorRow: string;
|
|
||||||
bodyRowsText: string;
|
/**
|
||||||
index: number;
|
* 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;
|
||||||
}
|
}
|
||||||
const tableMatches: TableMatch[] = [];
|
|
||||||
|
|
||||||
let mdMatch: RegExpExecArray | null;
|
const [headerRow, separatorRow, ...rows] = lines;
|
||||||
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
|
|
||||||
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
|
|
||||||
const headers = splitTableRow(headerRow);
|
const headers = splitTableRow(headerRow);
|
||||||
if (!isSparkTableHeader(headers[0])) continue;
|
if (!isSparkTableHeader(headers[0] || "")) {
|
||||||
|
console.warn(
|
||||||
const bodyRows = bodyRowsText
|
`[content-registry] ${docPath}: spark table first column "${headers[0]}" is not a dice formula`,
|
||||||
.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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let rewritten = content;
|
return markdownTableToCsv(headerRow, separatorRow, rows) ?? body;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rewritten;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -422,10 +392,12 @@ export function injectSparkDirectives(
|
|||||||
/**
|
/**
|
||||||
* Resolve a content reference from within a doc.
|
* Resolve a content reference from within a doc.
|
||||||
*
|
*
|
||||||
* - `ref` is inline CSV → returned as-is.
|
|
||||||
* - `ref` is an absolute path → path index.
|
* - `ref` is an absolute path → path index.
|
||||||
* - `ref` is a relative path → resolved against the doc directory; checks
|
* - `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.
|
* Returns `null` when nothing matches.
|
||||||
*/
|
*/
|
||||||
@@ -437,9 +409,6 @@ export function resolveContent(
|
|||||||
const trimmed = ref.trim();
|
const trimmed = ref.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
// Inline CSV body.
|
|
||||||
if (looksLikeCsv(trimmed)) return trimmed;
|
|
||||||
|
|
||||||
if (trimmed.startsWith("/")) {
|
if (trimmed.startsWith("/")) {
|
||||||
return registry.pathIndex[trimmed] ?? null;
|
return registry.pathIndex[trimmed] ?? null;
|
||||||
}
|
}
|
||||||
@@ -477,25 +446,6 @@ export function resolveInlineByPath(
|
|||||||
return null;
|
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
|
// Derived completions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store";
|
|||||||
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
|
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
|
||||||
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
|
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
|
||||||
import {resolvePath} from "../../utils/path";
|
import {resolvePath} from "../../utils/path";
|
||||||
import {loadCSV} from "../../utils/csv-loader";
|
import {loadCSVFromPath} from "../../utils/csv-loader";
|
||||||
|
|
||||||
const defaultCommands: MdCommanderCommandMap = {
|
const defaultCommands: MdCommanderCommandMap = {
|
||||||
help: setupHelpCommand({}),
|
help: setupHelpCommand({}),
|
||||||
@@ -111,7 +111,7 @@ export async function loadCommandTemplatesFromCSV(
|
|||||||
setCommandsError(undefined);
|
setCommandsError(undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csv = await loadCSV<CommandTemplateRow>(resolvePath(articlePath, path));
|
const csv = await loadCSVFromPath<CommandTemplateRow>(resolvePath(articlePath, path));
|
||||||
|
|
||||||
// 按命令分组模板
|
// 按命令分组模板
|
||||||
const templatesByCommand = new Map<string, CommandTemplateRow[]>();
|
const templatesByCommand = new Map<string, CommandTemplateRow[]>();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
import { calculateDimensions } from "./dimensions";
|
import { calculateDimensions } from "./dimensions";
|
||||||
import { loadCSV, CSV } from "../../utils/csv-loader";
|
import { loadCSVFromPath, CSV } from "../../utils/csv-loader";
|
||||||
import { formatLayers } from "./layer-parser";
|
import { formatLayers } from "./layer-parser";
|
||||||
import * as layerCrud from "./layer-crud";
|
import * as layerCrud from "./layer-crud";
|
||||||
import type {
|
import type {
|
||||||
@@ -461,7 +461,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
|
setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await loadCSV(path);
|
const data = await loadCSVFromPath(path);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
setState({
|
setState({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { parseMarkdown } from "../markdown";
|
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 { resolveContentRef } from "./utils/resolve-content";
|
||||||
import {
|
import {
|
||||||
areAllLabelsNumeric,
|
areAllLabelsNumeric,
|
||||||
@@ -59,7 +59,7 @@ customElement(
|
|||||||
if (content === null) {
|
if (content === null) {
|
||||||
throw new Error(`Failed to resolve table content: "${ref}"`);
|
throw new Error(`Failed to resolve table content: "${ref}"`);
|
||||||
}
|
}
|
||||||
return loadCSV(content);
|
return parseCSVString(content);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 字符串
|
* 解析 CSV 字符串
|
||||||
* @template T 返回数据的类型,默认为 Record<string, string>
|
* @template T 返回数据的类型,默认为 Record<string, string>
|
||||||
@@ -102,18 +66,12 @@ export function parseCSVString<T = Record<string, string>>(csvString: string, so
|
|||||||
/**
|
/**
|
||||||
* 加载 CSV 文件
|
* 加载 CSV 文件
|
||||||
* @template T 返回数据的类型,默认为 Record<string, string>
|
* @template T 返回数据的类型,默认为 Record<string, string>
|
||||||
* @param pathOrContent 文件路径或 inline CSV 字符串
|
* @param path 文件路径(通过 file-index 获取内容)
|
||||||
* @returns 解析后的 CSV 数据
|
* @returns 解析后的 CSV 数据
|
||||||
*/
|
*/
|
||||||
export async function loadCSV<T = Record<string, string>>(pathOrContent: string): Promise<CSV<T>> {
|
export async function loadCSVFromPath<T = Record<string, string>>(path: string): Promise<CSV<T>> {
|
||||||
// 检测是否是 inline CSV 数据
|
const content = await getIndexedData(path);
|
||||||
if (isCSV(pathOrContent)) {
|
return parseCSVString<T>(content, path);
|
||||||
return parseCSVString<T>(pathOrContent, 'inline');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从索引获取文件内容
|
|
||||||
const content = await getIndexedData(pathOrContent);
|
|
||||||
return parseCSVString<T>(content, pathOrContent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type JSONData = JSONArray | JSONObject | string | number | boolean | null;
|
type JSONData = JSONArray | JSONObject | string | number | boolean | null;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Marked, type MarkedExtension } from "marked";
|
|||||||
import { createDirectives, presetDirectiveConfigs } from "marked-directive";
|
import { createDirectives, presetDirectiveConfigs } from "marked-directive";
|
||||||
import markedAlert from "marked-alert";
|
import markedAlert from "marked-alert";
|
||||||
import markedMermaid from "./mermaid";
|
import markedMermaid from "./mermaid";
|
||||||
import markedTable from "./table";
|
|
||||||
import { gfmHeadingId } from "marked-gfm-heading-id";
|
import { gfmHeadingId } from "marked-gfm-heading-id";
|
||||||
import markedColumns from "./columns";
|
import markedColumns from "./columns";
|
||||||
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
|
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
|
||||||
@@ -14,7 +13,6 @@ const marked = new Marked()
|
|||||||
.use(gfmHeadingId())
|
.use(gfmHeadingId())
|
||||||
.use(markedAlert())
|
.use(markedAlert())
|
||||||
.use(markedMermaid())
|
.use(markedMermaid())
|
||||||
.use(markedTable())
|
|
||||||
.use(markedCodeBlockYamlTag())
|
.use(markedCodeBlockYamlTag())
|
||||||
.use(
|
.use(
|
||||||
createDirectives([
|
createDirectives([
|
||||||
|
|||||||
@@ -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 `<md-table ${roll}${remix}>${csvData}</md-table>\n`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user