Compare commits

...
5 Commits
Author SHA1 Message Date
hypercross 9b83123b6e fix: heading scan skips code fences; unify content resolution
- deriveLinks strips fenced code blocks so markdown examples don't
  produce phantom link completions
- Add resolveContentEntry returning body + path + inline flag;
  scanDocDirectives uses it instead of re-deriving resolution, and
  parseDirectiveAttrs is replaced by the shared parseBlockAttrs
- Fix relative file refs with ./ prefix never resolving through
  resolveContent (pathIndex lookup joined the ./ verbatim)
2026-09-08 22:30:14 +08:00
hypercross b7a804f1cf refactor: remove as= override and render data-spark at runtime
- Drop resolveBlockAs and the as= attribute; scanDoc now switches
  directly on role and warns on unknown roles instead of silently
  stripping
- md-table derives data-spark from its loaded CSV via
  parseSparkTableCsv; delete injectSparkDirectives and the second
  registry pass so pathIndex is exactly scanDoc output
- Align spark-table dice header regex with isSparkTableHeader
2026-09-08 22:18:12 +08:00
hypercross 256c685f6c 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
2026-09-08 21:33:12 +08:00
hypercross 5061c4d19d docs: remove remaining yaml/tag legacy references 2026-09-08 20:59:30 +08:00
hypercross 638e8f6526 refactor(markdown): unify yaml tag block syntax on role=tag
Drop legacy yaml/tag info string; tag blocks now require
```yaml role=tag. Add fence-line tag=/id= attributes that
override YAML body values, and move parseBlockAttrs to
src/markdown/block-attrs.ts so scanner and render extensions
share one parser.
2026-09-08 20:56:14 +08:00
21 changed files with 547 additions and 577 deletions
+26
View File
@@ -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;
+3 -6
View File
@@ -1,13 +1,12 @@
# yaml/tag 代码块格式测试 # yaml role=tag 代码块格式测试
:md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"} :md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" } :md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
## 使用 yaml/tag 语法创建 md-deck ## 使用 yaml role=tag 语法创建 md-deck(fence 行内联 tag,简洁写法)
```yaml/tag ```yaml role=tag tag=md-deck
tag: md-deck
body: ./names.csv body: ./names.csv
size: 54x86 size: 54x86
grid: 5x8 grid: 5x8
@@ -16,8 +15,6 @@ padding: 2
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
``` ```
## 使用 yaml role=tag 语法创建 md-deck(推荐,可语法高亮)
```yaml role=tag ```yaml role=tag
tag: md-deck tag: md-deck
body: ./names.csv body: ./names.csv
+22 -18
View File
@@ -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)
@@ -489,7 +493,7 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
## YAML 标签 ## YAML 标签
使用 ```yaml role=tag 代码块创建自定义标签(推荐,`yaml` 语言可被语法高亮): 使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
````markdown ````markdown
```yaml role=tag ```yaml role=tag
@@ -500,11 +504,11 @@ body: 标签内容
``` ```
```` ````
也支持旧写法 ```yaml/tag(等价,但无语法高亮): `tag`、`id` 等简单属性也可以直接写在 fence 行上(fence 行属性优先于 YAML 内容):
````markdown ````markdown
```yaml/tag ```yaml role=tag tag=tag-name id=my-id
tag: tag-name class: custom-class
body: 标签内容 body: 标签内容
``` ```
```` ````
+4
View File
@@ -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?$': [
+13 -58
View File
@@ -2,70 +2,25 @@
* Shared block scanning utilities — safe for both CLI and browser. * Shared block scanning utilities — safe for both CLI and browser.
* *
* Parses fenced code blocks with attributes: * Parses fenced code blocks with attributes:
* ```lang id=xxx role=xxx as=xxx * ```lang id=xxx role=xxx key=value
* *
* - `role` dispatches to content scanners (declare, spark-table) * The `role` fully determines block behavior (see `scanDoc` in
* - `as` controls rendering (none, codeblock, md-table, md-card, md-dice) * `content-registry.ts`):
* - `id` is used for cross-references (file paths) * - spark-table → stored as CSV, rendered as an `:md-table` directive
* - declare / file → stored, block stripped
* - tag → block kept intact for the yaml-tag render extension
* - unknown role → warned about and stripped
* - no role → kept as a visible code block
*
* Attribute parsing itself lives in `src/markdown/block-attrs.ts` so render
* extensions can share the same syntax.
*/ */
// --------------------------------------------------------------------------- export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js";
// Types
// ---------------------------------------------------------------------------
export interface BlockAttrs {
lang: string;
id?: string;
role?: string;
as?: string;
/** Any other attributes not in the standard set */
extra: Record<string, string>;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Regex // Regex
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Matches fenced code blocks with an info string (at least one attr). */ /** Matches fenced code blocks with an info string (at least one attr). */
export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm; export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm;
// ---------------------------------------------------------------------------
// Attribute parsing
// ---------------------------------------------------------------------------
/** Parse key="value" and key=value pairs from an attribute string. */
export function parseBlockAttrs(info: string): BlockAttrs {
const attrs: Record<string, string> = {};
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(info)) !== null) {
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
}
const { lang, id, role, as, ...extra } = attrs;
return { lang: lang || "", id, role, as, extra };
}
// ---------------------------------------------------------------------------
// as resolution
// ---------------------------------------------------------------------------
/**
* Determine the effective `as` value.
*
* Defaults:
* - role is set, no explicit as → "none" (strip — it's metadata)
* - role=spark-table → "md-table" (render as md-table directive)
* - role=tag → "codeblock" (kept for the marked yaml-tag extension)
* - 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";
// yaml-defined tag blocks must survive stripping so the
// code-block-yaml-tag marked extension can render them
if (role === "tag") return "codeblock";
if (role) return "none";
return "codeblock";
}
+6 -31
View File
@@ -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
@@ -31,7 +28,7 @@ jest.mock("github-slugger", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { parseDeclareCsv } from "./declare-parser"; import { parseDeclareCsv } from "./declare-parser";
import { parseBlockAttrs, resolveBlockAs } from "./block-scanner"; import { parseBlockAttrs } from "./block-scanner";
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression"; import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
import { import {
initReactivity, initReactivity,
@@ -223,10 +220,9 @@ describe("parseBlockAttrs", () => {
// parseBlockAttrs receives the info string AFTER the lang. // parseBlockAttrs receives the info string AFTER the lang.
// The lang is extracted from the fenced block regex capture group // The lang is extracted from the fenced block regex capture group
// and applied separately in content-registry. // and applied separately in content-registry.
const attrs = parseBlockAttrs("id=stats role=declare as=none"); const attrs = parseBlockAttrs("id=stats role=declare");
expect(attrs.id).toBe("stats"); expect(attrs.id).toBe("stats");
expect(attrs.role).toBe("declare"); expect(attrs.role).toBe("declare");
expect(attrs.as).toBe("none");
}); });
test("parses quoted values", () => { test("parses quoted values", () => {
@@ -245,7 +241,6 @@ describe("parseBlockAttrs", () => {
expect(attrs.lang).toBe(""); expect(attrs.lang).toBe("");
expect(attrs.id).toBeUndefined(); expect(attrs.id).toBeUndefined();
expect(attrs.role).toBeUndefined(); expect(attrs.role).toBeUndefined();
expect(attrs.as).toBeUndefined();
}); });
test("handles empty info string (lang extracted separately)", () => { test("handles empty info string (lang extracted separately)", () => {
@@ -255,28 +250,8 @@ describe("parseBlockAttrs", () => {
}); });
}); });
describe("resolveBlockAs", () => { // Role behavior is covered by the scanDoc tests in
test("returns explicit as value", () => { // src/cli/content-registry.test.ts.
expect(resolveBlockAs("declare", "codeblock")).toBe("codeblock");
});
test("defaults to none when role is set", () => {
expect(resolveBlockAs("declare", undefined)).toBe("none");
expect(resolveBlockAs("file", undefined)).toBe("none");
});
test("defaults to md-table for spark-table role", () => {
expect(resolveBlockAs("spark-table", undefined)).toBe("md-table");
});
test("defaults to codeblock for tag role", () => {
expect(resolveBlockAs("tag", undefined)).toBe("codeblock");
});
test("defaults to codeblock when no role and no as", () => {
expect(resolveBlockAs(undefined, undefined)).toBe("codeblock");
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// variable-expression // variable-expression
+199
View File
@@ -0,0 +1,199 @@
import {
scanDoc,
resolveContent,
resolveContentEntry,
buildRegistryFromIndex,
deriveCompletions,
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);
});
test("tag blocks are kept intact", () => {
const md = "```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\n```";
const { stripped, content } = scanDoc(md, "test.md");
expect(stripped).toBe(md);
expect(Object.keys(content)).toHaveLength(0);
});
test("blocks without role are kept as code blocks", () => {
const md = "```csv\nlabel,body\n1,text\n```";
const { stripped, content } = scanDoc(md, "test.md");
expect(stripped).toBe(md);
expect(Object.keys(content)).toHaveLength(0);
});
test("unknown roles warn and strip", () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
const md = "```csv role=frobnicate\nlabel,body\n```";
const { stripped, content } = scanDoc(md, "test.md");
expect(stripped).toBe("");
expect(Object.keys(content)).toHaveLength(0);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('unknown role "frobnicate"'),
);
warn.mockRestore();
});
test("legacy as= attribute is ignored", () => {
const md = [
"```csv role=spark-table as=codeblock",
"d6,Name",
"1,Alice",
"```",
].join("\n");
const { stripped } = scanDoc(md, "test.md");
// Role wins — the block still becomes a directive.
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
});
});
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",
},
};
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();
});
test("resolveContentEntry reports inline vs file resolution", () => {
const registry = emptyRegistry();
registry.pathIndex["/test.md"] = "# Doc";
registry.pathIndex["/data/table.csv"] = "d6,Name\n1,Alice";
registry.docContent["/test.md"] = {
csv_abc: {
id: "csv_abc",
kind: "csv",
body: "d6,Name\n1,Bob",
role: "spark-table",
},
};
const inline = resolveContentEntry(registry, "/test.md", "./csv_abc");
expect(inline).toMatchObject({ path: "csv_abc", inline: true });
const file = resolveContentEntry(registry, "/test.md", "./data/table.csv");
expect(file).toMatchObject({ path: "/data/table.csv", inline: false });
});
});
describe("deriveCompletions", () => {
test("headings inside fenced code blocks do not become link completions", () => {
const md = [
"# Real Heading",
"",
"```markdown",
"# Fake Heading In Code",
"```",
].join("\n");
const registry = buildRegistryFromIndex({ "test.md": md });
const { links } = deriveCompletions(registry);
const testLinks = links.filter((l) => l.path === "/test");
expect(testLinks.map((l) => l.label)).toEqual([
"test",
"test § Real Heading",
]);
expect(testLinks.some((l) => l.label.includes("Fake"))).toBe(false);
});
});
+160 -278
View File
@@ -24,7 +24,6 @@ import {
import { import {
FENCED_BLOCK_RE, FENCED_BLOCK_RE,
parseBlockAttrs, parseBlockAttrs,
resolveBlockAs,
} from "./completions/block-scanner.js"; } from "./completions/block-scanner.js";
import type { import type {
CompletionsPayload, CompletionsPayload,
@@ -47,8 +46,6 @@ export interface DocContent {
body: string; body: string;
/** Origin role that produced this content, for debugging. */ /** Origin role that produced this content, for debugging. */
role?: string; role?: string;
/** Origin `as` value, for debugging. */
as?: string;
} }
export interface ContentRegistry { export interface ContentRegistry {
@@ -113,86 +110,77 @@ export interface DocScanResult {
/** /**
* Process a single markdown doc: * Process a single markdown doc:
* - strips/replaces attributed fenced code blocks based on `as` * - dispatches attributed fenced code blocks by `role`
* - 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 (spark-table, declare, file) into the doc's
* into the doc's content store * content store
* *
* Does NOT touch the path index — the caller assembles the registry. * Does NOT touch the path index — the caller assembles the registry.
*/ */
export function scanDoc(content: string, docPath: string): DocScanResult { export function scanDoc(content: string, docPath: string): DocScanResult {
const contentStore: Record<string, DocContent> = {}; const contentStore: Record<string, DocContent> = {};
// ---- Pass 1: attributed fenced code blocks ----
const stripped = content.replace( const stripped = content.replace(
FENCED_BLOCK_RE, FENCED_BLOCK_RE,
( (
_match: string, match: string,
lang: string, lang: string,
infoString: string, infoString: string,
body: string, body: string,
): string => { ): string => {
const attrs = parseBlockAttrs(infoString); const attrs = parseBlockAttrs(infoString);
attrs.lang = attrs.lang || lang; attrs.lang = attrs.lang || lang;
const effectiveAs = resolveBlockAs(attrs.role, attrs.as);
if (attrs.role === "declare") { switch (attrs.role) {
const id = deriveContentId("declare", body, attrs.id); case "declare": {
contentStore[id] = { const id = deriveContentId("declare", body, attrs.id);
id, contentStore[id] = { id, kind: "declare", body, role: attrs.role };
kind: "declare", return "";
body, }
role: attrs.role,
as: effectiveAs, case "file": {
}; const id = deriveContentId("text", body, attrs.id);
contentStore[id] = { id, kind: "text", body, role: attrs.role };
return "";
}
case "spark-table": {
let blockBody = body;
if (isMarkdownTableLang(attrs.lang)) {
blockBody = markdownTableBodyToCsv(body, docPath);
}
const id = deriveContentId("csv", blockBody, attrs.id);
contentStore[id] = {
id,
kind: "csv",
body: blockBody,
role: attrs.role,
};
const extraStr = Object.entries(attrs.extra)
.map(([k, v]) => `${k}=${v}`)
.join(" ");
return `:md-table[./${id}]${extraStr ? `{${extraStr}}` : ""}`;
}
case "tag":
// Kept intact so the code-block-yaml-tag render extension sees it.
return match;
case undefined:
// No role declared — plain visible code block.
return match;
default:
console.warn(
`[content-registry] ${docPath}: unknown role "${attrs.role}" — stripping block`,
);
return "";
} }
if (attrs.role === "file") {
const id = deriveContentId("text", body, attrs.id);
contentStore[id] = {
id,
kind: "text",
body,
role: attrs.role,
as: effectiveAs,
};
}
if (effectiveAs === "codeblock") {
return _match;
}
if (effectiveAs === "none") {
return "";
}
if (effectiveAs.startsWith("md-")) {
const id = deriveContentId("csv", body, attrs.id);
contentStore[id] = {
id,
kind: "csv",
body,
role: attrs.role,
as: effectiveAs,
};
const extra = { ...attrs.extra };
const extraStr = Object.keys(extra).length
? `{${Object.entries(extra)
.map(([k, v]) => `${k}=${v}`)
.join(" ")}}`
: "";
return `:${effectiveAs}[./${id}]${extraStr}`;
}
return "";
}, },
); );
// ---- Pass 2: coerce spark-shaped markdown tables to :md-table ---- return { stripped, content: contentStore };
const rewritten = coerceSparkTables(stripped, contentStore);
return { stripped: rewritten, content: contentStore };
} }
/** /**
@@ -201,10 +189,12 @@ export function scanDoc(content: string, docPath: string): DocScanResult {
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser * Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so * folder scan (`scanClientSide` in `components/journal/completions.ts`) so
* both modes produce identical `pathIndex`/`docContent` — including the * both modes produce identical `pathIndex`/`docContent` — including the
* `data-spark` injection pass that only the CLI used to run. * Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
* both modes produce identical `pathIndex`/`docContent`.
* *
* Keys are normalized via `normalizePathKey`; `.md` files are run through * Keys are normalized via `normalizePathKey`; `.md` files are run through
* `scanDoc` and the spark-injection pass, other extensions are stored raw. * `scanDoc`, other extensions are stored raw.
*/ */
export function buildRegistryFromIndex( export function buildRegistryFromIndex(
index: Record<string, string>, index: Record<string, string>,
@@ -222,16 +212,6 @@ export function buildRegistryFromIndex(
} }
} }
// Inject data-spark into real-file spark table directives (idempotent).
for (const [relPath, content] of Object.entries(registry.pathIndex)) {
if (!relPath.endsWith(".md")) continue;
registry.pathIndex[relPath] = injectSparkDirectives(
content,
relPath,
registry,
);
}
return registry; return registry;
} }
@@ -241,8 +221,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,167 +274,104 @@ 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;
}
const tableMatches: TableMatch[] = [];
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,
});
}
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);
}
return rewritten;
} }
// ---------------------------------------------------------------------------
// Directive spark injection (real-file `:md-table` / `:md-card` references)
// ---------------------------------------------------------------------------
/** /**
* Scan a doc's stripped content for `:md-table[...]` / `:md-card[...]` * Convert a fenced markdown pipe-table body to CSV for `role=spark-table`
* directives that resolve to a spark table, and inject `data-spark` so the * blocks. Validates the dice-formula first column (warning only — the role
* reveal feature can match them. Idempotent — only injects when missing. * already declared intent) and falls back to storing the body as-is when it
* * is not a recognizable pipe table.
* Returns the possibly-rewritten content.
*/ */
export function injectSparkDirectives( function markdownTableBodyToCsv(body: string, docPath: string): string {
content: string, const lines = body
docPath: string, .trim()
registry: ContentRegistry, .split(/\r?\n/)
): string { .filter((l) => l.trim().startsWith("|"));
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi; if (lines.length < 2) {
let rewritten = content; console.warn(
let m: RegExpExecArray | null; `[content-registry] ${docPath}: role=spark-table markdown body is not a pipe table; storing as-is`,
);
while ((m = tableDirectiveRegex.exec(content)) !== null) { return body;
const [, , ref, extraStr] = m;
if (extraStr && extraStr.includes("data-spark=")) continue;
const csv = resolveContent(registry, docPath, ref);
if (!csv) continue;
const slug = sparkSlug(csv);
if (!slug) continue;
const fullMatch = m[0];
const insertPos = fullMatch.indexOf("]") + 1;
const before = fullMatch.slice(0, insertPos);
const after = fullMatch.slice(insertPos);
let replacement: string;
if (after.startsWith("{")) {
replacement = before + after.replace(/^\{/, `{data-spark="${slug}" `);
} else {
replacement = before + `{data-spark="${slug}"}` + after;
}
rewritten =
rewritten.slice(0, m.index) +
replacement +
rewritten.slice(m.index + fullMatch.length);
} }
return rewritten; 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 markdownTableToCsv(headerRow, separatorRow, rows) ?? body;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Resolution // Resolution
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* A resolved content reference: the body plus how it was addressed.
*/
export interface ResolvedContent {
body: string;
/** Content id for inline content, path-index key for real files. */
path: string;
/** True when resolved from the doc's inline content store. */
inline: boolean;
}
/** /**
* 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.
*/ */
export function resolveContentEntry(
registry: ContentRegistry,
docPath: string,
ref: string,
): ResolvedContent | null {
const trimmed = ref.trim();
if (!trimmed) return null;
if (trimmed.startsWith("/")) {
const body = registry.pathIndex[trimmed];
return body == null ? null : { body, path: trimmed, inline: false };
}
// Inline content in the same doc (e.g. ./{id}).
const docStore = registry.docContent[docPath];
const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
const entry = docStore?.[id];
if (entry) return { body: entry.body, path: id, inline: true };
// Relative path resolved against the doc directory (`./` already stripped).
const resolved = posixJoin(posixDir(docPath), id);
const body = registry.pathIndex[resolved];
return body != null ? { body, path: resolved, inline: false } : null;
}
/** Resolve a content reference to its body only. */
export function resolveContent( export function resolveContent(
registry: ContentRegistry, registry: ContentRegistry,
docPath: string, docPath: string,
ref: string, ref: string,
): string | null { ): string | null {
const trimmed = ref.trim(); return resolveContentEntry(registry, docPath, ref)?.body ?? null;
if (!trimmed) return null;
// Inline CSV body.
if (looksLikeCsv(trimmed)) return trimmed;
if (trimmed.startsWith("/")) {
return registry.pathIndex[trimmed] ?? null;
}
// Inline content in the same doc (e.g. ./{id}).
const docStore = registry.docContent[docPath];
if (docStore) {
const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
const entry = docStore[id];
if (entry) return entry.body;
}
// Relative path resolved against the doc directory.
const resolved = posixJoin(posixDir(docPath), trimmed);
return registry.pathIndex[resolved] ?? null;
} }
/** /**
@@ -477,25 +394,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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -544,26 +442,18 @@ function scanDocDirectives(
const dice = scanDice(content, docPath); const dice = scanDice(content, docPath);
const sparkTables: SparkTableCompletion[] = []; const sparkTables: SparkTableCompletion[] = [];
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi; const tableDirectiveRegex = /:md-(table|card)\[([^\[\]]+)\](?:\{([^}]*)\})?/gi;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = tableDirectiveRegex.exec(content)) !== null) { while ((m = tableDirectiveRegex.exec(content)) !== null) {
const [, , ref, extraStr] = m; const [, , ref, extraStr] = m;
const csv = resolveContent(registry, docPath, ref); const resolved = resolveContentEntry(registry, docPath, ref);
if (!csv) continue; if (!resolved) continue;
// csvPath: content id for inline content, resolved path for real files. const attrs = parseBlockAttrs(extraStr || "").extra;
const docStore = registry.docContent[docPath];
const id = ref.startsWith("./") ? ref.slice(2) : ref;
const csvPath =
docStore && docStore[id]
? id
: resolveContentPath(registry, docPath, ref);
const attrs = parseDirectiveAttrs(extraStr);
const st = buildSparkTableCompletion( const st = buildSparkTableCompletion(
csv, resolved.body,
docPath, docPath,
csvPath, resolved.path,
attrs["remix"] === "true", attrs["remix"] === "true",
); );
if (st) sparkTables.push(st); if (st) sparkTables.push(st);
@@ -572,31 +462,6 @@ function scanDocDirectives(
return { dice, sparkTables }; return { dice, sparkTables };
} }
/** Resolve a directive ref to a path-index key (for real files). */
function resolveContentPath(
registry: ContentRegistry,
docPath: string,
ref: string,
): string {
const trimmed = ref.trim();
if (trimmed.startsWith("/")) return trimmed;
return posixJoin(posixDir(docPath), trimmed.replace(/^\.\//, ""));
}
/** Parse key=value pairs from a directive extra-attrs string. */
function parseDirectiveAttrs(
extraStr: string | undefined,
): Record<string, string> {
if (!extraStr) return {};
const attrs: Record<string, string> = {};
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(extraStr)) !== null) {
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
}
return attrs;
}
/** Derive variable declarations + tag modifiers from the registry. */ /** Derive variable declarations + tag modifiers from the registry. */
export function deriveBlocks(registry: ContentRegistry): { export function deriveBlocks(registry: ContentRegistry): {
declarations: VarDeclaration[]; declarations: VarDeclaration[];
@@ -625,12 +490,37 @@ export function deriveBlocks(registry: ContentRegistry): {
// Derivation helpers // Derivation helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Remove fenced code blocks (backtick or tilde) from content, so text
* scanners (headings, dice directives) don't match example code.
*/
function stripFencedBlocks(content: string): string {
const out: string[] = [];
let fence: string | null = null;
for (const line of content.split(/\r?\n/)) {
const fenceMatch = /^(`{3,}|~{3,})/.exec(line);
if (fence) {
if (line.startsWith(fence)) fence = null;
continue;
}
if (fenceMatch) {
fence = fenceMatch[1];
continue;
}
out.push(line);
}
return out.join("\n");
}
/** Extract headings from all `.md` files as link completions. */ /** Extract headings from all `.md` files as link completions. */
function deriveLinks(pathIndex: Record<string, string>): LinkCompletion[] { function deriveLinks(pathIndex: Record<string, string>): LinkCompletion[] {
const items: LinkCompletion[] = []; const items: LinkCompletion[] = [];
for (const [filePath, content] of Object.entries(pathIndex)) { for (const [filePath, rawContent] of Object.entries(pathIndex)) {
if (!filePath.endsWith(".md")) continue; if (!filePath.endsWith(".md")) continue;
// Headings inside fenced code blocks (e.g. markdown examples) are not
// real headings — exclude them from link completions.
const content = stripFencedBlocks(rawContent);
const basePath = filePath.replace(/\.md$/, ""); const basePath = filePath.replace(/\.md$/, "");
const fileName = fileNameFromPath(basePath); const fileName = fileNameFromPath(basePath);
const slugger = new Slugger(); const slugger = new Slugger();
@@ -686,14 +576,6 @@ export function inspectSparkTableCsv(csv: string): string[] | null {
return headers.slice(1); return headers.slice(1);
} }
/** Compute the spark slug for a CSV body, or null if it's not a spark table. */
function sparkSlug(csv: string): string | null {
const dataHeaders = inspectSparkTableCsv(csv);
if (!dataHeaders) return null;
const slugger = new Slugger();
return dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-");
}
/** Build a SparkTableCompletion from a CSV body. */ /** Build a SparkTableCompletion from a CSV body. */
export function buildSparkTableCompletion( export function buildSparkTableCompletion(
csv: string, csv: string,
+3 -3
View File
@@ -110,7 +110,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md"); const paths = await getPathsByExtension("md");
// Load all .md content into a raw index, then build the registry through // Load all .md content into a raw index, then build the registry through
// the same shared pipeline as the CLI (scanDoc + spark injection). // the same shared pipeline as the CLI (scanDoc).
const index: Record<string, string> = {}; const index: Record<string, string> = {};
for (const filePath of paths) { for (const filePath of paths) {
const content = await getIndexedData(filePath); const content = await getIndexedData(filePath);
@@ -122,8 +122,8 @@ async function scanClientSide(): Promise<JournalCompletions> {
activeRegistry = registry; activeRegistry = registry;
// Write the processed (stripped) content back into the file index so // Write the processed (stripped) content back into the file index so
// Article/md-embed render the same content as CLI mode (spark tables // Article/md-embed render the same content as CLI mode (attributed blocks
// coerced, attributed blocks processed, data-spark injected). // processed by role).
for (const [path, content] of Object.entries(registry.pathIndex)) { for (const [path, content] of Object.entries(registry.pathIndex)) {
setIndexedData(path, content); setIndexedData(path, content);
} }
@@ -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[]>();
+3 -3
View File
@@ -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 {
@@ -42,7 +42,7 @@ export interface DeckState {
cornerRadius: number; cornerRadius: number;
shape: CardShape; shape: CardShape;
fixed: boolean; fixed: boolean;
/** True when the deck was configured via a yaml/tag codeblock (data-config). */ /** True when the deck was configured via a yaml role=tag codeblock (data-config). */
isYamlBlock: boolean; isYamlBlock: boolean;
src: string; src: string;
rawSrc: string; rawSrc: string;
@@ -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({
+2 -2
View File
@@ -69,7 +69,7 @@ customElement<DeckProps>(
const deckId = `deck-${uuidv4()}`; const deckId = `deck-${uuidv4()}`;
registerDeck(deckId, store, resolvedSrc, csvPath); registerDeck(deckId, store, resolvedSrc, csvPath);
// 读取 data-configyaml/tag 代码块方式):结构化配置优先 // 读取 data-configyaml role=tag 代码块方式):结构化配置优先
let config: let config:
| ReturnType<typeof normalizeDeckConfig> | ReturnType<typeof normalizeDeckConfig>
| undefined; | undefined;
@@ -77,7 +77,7 @@ customElement<DeckProps>(
if (dataConfigAttr) { if (dataConfigAttr) {
try { try {
config = normalizeDeckConfig(JSON.parse(dataConfigAttr)); config = normalizeDeckConfig(JSON.parse(dataConfigAttr));
// 记录来源,复制代码时输出 yaml/tag 代码块 // 记录来源,复制代码时输出 yaml role=tag 代码块
store.actions.setIsYamlBlock(true); store.actions.setIsYamlBlock(true);
} catch (e) { } catch (e) {
console.error("Invalid data-config on md-deck:", e); console.error("Invalid data-config on md-deck:", e);
+16 -8
View File
@@ -8,12 +8,13 @@ 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 { parseSparkTableCsv } from "./utils/spark-table";
import { import {
areAllLabelsNumeric, areAllLabelsNumeric,
weightedRandomIndex, weightedRandomIndex,
} from "./utils/weighted-random"; } from "./utils/weighted-random";;
export interface TableProps { export interface TableProps {
roll?: boolean; roll?: boolean;
@@ -59,16 +60,23 @@ 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 content;
}, },
); );
// 当数据加载完成后更新 rows // 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager
// 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。
createEffect(() => { createEffect(() => {
const data = csvData(); const content = csvData();
if (data) { if (!content) return;
// 将加载的数据赋值给 rowsCSV 类型已经包含 sourcePath 等属性 setRows(parseCSVString(content) as unknown as CSV<TableRow>);
setRows(data as unknown as CSV<TableRow>); if (element) {
const meta = parseSparkTableCsv(content);
if (meta) {
element.setAttribute("data-spark", meta.slug);
} else {
element.removeAttribute("data-spark");
}
} }
}); });
+4 -46
View File
@@ -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;
+3 -1
View File
@@ -53,7 +53,9 @@ export interface SparkTableMeta {
// CSV parsing // CSV parsing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const DICE_HEADER_RE = /^d\d+$/i; // Matches the scanner's `isSparkTableHeader` (content-registry): plain dice,
// counts, and modifiers all count as spark-table first columns.
const DICE_HEADER_RE = /^\d*d\d+(?:[+-]\d+)?$/i;
/** /**
* Parse a CSV string into a SparkTableMeta. * Parse a CSV string into a SparkTableMeta.
+1 -1
View File
@@ -40,7 +40,7 @@ data-config:
``` ```
```` ````
`pos` 为网格位置 `x1,y1-x2,y2`1-based,与紧凑 layers 字符串同格式)。也支持旧写法 ```yaml/tag。 `pos` 为网格位置 `x1,y1-x2,y2`1-based,与紧凑 layers 字符串同格式)。
## 图层格式 ## 图层格式
+30
View File
@@ -0,0 +1,30 @@
/**
* Fenced code block attribute parsing — shared by the content scanner
* (CLI + browser registry) and the markdown render extensions.
*
* Parses info strings like:
* ```lang id=xxx role=xxx as=xxx key=value
*/
export interface BlockAttrs {
lang: string;
id?: string;
role?: string;
/** Any other attributes not in the standard set */
extra: Record<string, string>;
}
/** Parse key="value" and key=value pairs from an attribute string. */
export function parseBlockAttrs(info: string): BlockAttrs {
const attrs: Record<string, string> = {};
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(info)) !== null) {
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
}
// `as` was a render-target override; roles now fully determine behavior,
// so it is parsed out and ignored (kept out of `extra` for directives).
const { lang, id, role, as: _legacyAs, ...extra } = attrs;
return { lang: lang || "", id, role, extra };
}
+21 -22
View File
@@ -13,7 +13,7 @@ function render(src: string): string {
describe("code-block-yaml-tag", () => { describe("code-block-yaml-tag", () => {
test("renders body and scalar props as attributes", () => { test("renders body and scalar props as attributes", () => {
const html = render( const html = render(
"```yaml/tag\ntag: md-deck\nbody: ./cards.csv\nsize: 54x86\n```", "```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\nsize: 54x86\n```",
); );
expect(html).toContain("<md-deck size=\"54x86\">./cards.csv</md-deck>"); expect(html).toContain("<md-deck size=\"54x86\">./cards.csv</md-deck>");
}); });
@@ -21,7 +21,7 @@ describe("code-block-yaml-tag", () => {
test("serializes data-config to a JSON attribute", () => { test("serializes data-config to a JSON attribute", () => {
const html = render( const html = render(
[ [
"```yaml/tag", "```yaml role=tag",
"tag: md-deck", "tag: md-deck",
"body: ./cards.csv", "body: ./cards.csv",
"data-config:", "data-config:",
@@ -49,26 +49,20 @@ describe("code-block-yaml-tag", () => {
}); });
}); });
test("supports yaml role=tag info string (highlight-friendly)", () => { test("supports tag= and id= on the info string", () => {
const html = render( const html = render(
[ "```yaml role=tag tag=md-deck id=my-deck\nbody: ./cards.csv\n```",
"```yaml role=tag",
"tag: md-deck",
"body: ./cards.csv",
"data-config:",
" grid: 5x5",
" layers:",
" - template: |",
" **{{name}}**",
" pos: 1,1-5,8",
"```",
].join("\n"),
); );
expect(html).toContain("<md-deck data-config="); expect(html).toContain("<md-deck id=\"my-deck\">./cards.csv</md-deck>");
expect(html).toContain("./cards.csv</md-deck>"); });
const m = html.match(/data-config="([^"]*)"/);
const config = JSON.parse((m![1] || "").replace(/&quot;/g, '"')); test("fence attributes override YAML body values", () => {
expect(config.layers[0].template).toBe("**{{name}}**\n"); const html = render(
"```yaml role=tag tag=md-deck size=54x86\ntag: md-other\nsize: 63x88\n```",
);
expect(html).toContain("<md-deck size=\"54x86\"></md-deck>");
expect(html).not.toContain("md-other");
expect(html).not.toContain("63x88");
}); });
test("does not swallow plain yaml code blocks", () => { test("does not swallow plain yaml code blocks", () => {
@@ -76,8 +70,13 @@ describe("code-block-yaml-tag", () => {
expect(token).toBeUndefined(); expect(token).toBeUndefined();
}); });
test("does not swallow yaml blocks without role=tag", () => {
const token = ext.tokenizer("```yaml tag=md-deck\nsize: 54x86\n```");
expect(token).toBeUndefined();
});
test("handles missing tag and body", () => { test("handles missing tag and body", () => {
const html = render("```yaml/tag\nclass: foo\n```"); const html = render("```yaml role=tag\nclass: foo\n```");
expect(html).toContain("<tag-unknown class=\"foo\"></tag-unknown>"); expect(html).toContain("<tag-unknown class=\"foo\"></tag-unknown>");
}); });
}); });
+29 -12
View File
@@ -1,6 +1,20 @@
import type { MarkedExtension } from "marked"; import type { MarkedExtension } from "marked";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { parseBlockAttrs } from "./block-attrs";
/**
* YAML-defined tag blocks:
*
* ```yaml role=tag tag=md-deck id=my-deck
* body: ./cards.csv
* data-config: ...
* ```
*
* `role=tag` on the info string marks the block (and lets the content
* scanner keep it intact). `tag=` / `id=` / any other `key=value` fence
* attributes override the same keys in the YAML body, so simple cases
* stay on one line.
*/
export default function markedCodeBlockYamlTag(): MarkedExtension { export default function markedCodeBlockYamlTag(): MarkedExtension {
return { return {
extensions: [ extensions: [
@@ -8,27 +22,30 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
name: "code-block-yaml-tag", name: "code-block-yaml-tag",
level: "block", level: "block",
start(src: string) { start(src: string) {
return ( return src.match(/^```yaml\s+role=tag(?:\s|\n)/m)?.index;
src.match(/^```yaml\/tag\s*\n/m)?.index ??
src.match(/^```yaml\s+role=tag(?:\s|\n)/m)?.index
);
}, },
tokenizer(src: string) { tokenizer(src: string) {
// Both `yaml/tag` (legacy) and `yaml role=tag` (highlight-friendly) const rule = /^```yaml\s+role=tag([^\n]*)\n([\s\S]*?)\n```/;
// info strings identify a yaml-defined tag block.
const rule = /^```yaml(?:\/tag|\s+role=tag[^\n]*)\n([\s\S]*?)\n```/;
const match = rule.exec(src); const match = rule.exec(src);
if (match) { if (match) {
const yamlContent = match[1]?.trim() || ""; const yamlContent = match[2]?.trim() || "";
let props: Record<string, unknown> = {}; let yamlProps: Record<string, unknown> = {};
try { try {
props = yamlProps =
(yaml.load(yamlContent) as Record<string, unknown>) || {}; (yaml.load(yamlContent) as Record<string, unknown>) || {};
} catch (e) { } catch (e) {
console.error("YAML Parse Error in code-block-yaml-tag:", e); console.error("YAML Parse Error in code-block-yaml-tag:", e);
props = { error: "Invalid YAML content" }; yamlProps = { error: "Invalid YAML content" };
} }
// Fence attributes override YAML body values.
const attrs = parseBlockAttrs(match[1] || "");
const fenceProps: Record<string, unknown> = {
...(attrs.id ? { id: attrs.id } : {}),
...attrs.extra,
};
const props: Record<string, unknown> = { ...yamlProps, ...fenceProps };
const tagName = (props.tag as string) || "tag-unknown"; const tagName = (props.tag as string) || "tag-unknown";
const { tag, ...rest } = props; const { tag, ...rest } = props;
@@ -68,4 +85,4 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
}, },
], ],
}; };
} }
-2
View File
@@ -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([
-84
View File
@@ -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`;
},
},
};
}