Compare commits
8
Commits
f1ddc8fb8e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b83123b6e | ||
|
|
b7a804f1cf | ||
|
|
256c685f6c | ||
|
|
5061c4d19d | ||
|
|
638e8f6526 | ||
|
|
80e2b5b209 | ||
|
|
5fc2bc5262 | ||
|
|
d75df5280f |
@@ -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;
|
||||||
+24
-4
@@ -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
|
||||||
@@ -15,3 +14,24 @@ bleed: 1
|
|||||||
padding: 2
|
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
|
||||||
|
tag: md-deck
|
||||||
|
body: ./names.csv
|
||||||
|
data-config:
|
||||||
|
size: 54x86
|
||||||
|
grid: 5x8
|
||||||
|
layers:
|
||||||
|
- prop: name1
|
||||||
|
pos: 1,1-2,2
|
||||||
|
font: 12
|
||||||
|
- prop: num2
|
||||||
|
pos: 4,5-5,6
|
||||||
|
font: 12
|
||||||
|
orientation: s
|
||||||
|
- template: |
|
||||||
|
**{{name1}}** / {{num1}}
|
||||||
|
pos: 1,3-5,4
|
||||||
|
font: 6
|
||||||
|
align: l
|
||||||
|
```
|
||||||
|
|||||||
+33
-20
@@ -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)
|
||||||
|
|
||||||
@@ -423,12 +427,12 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
|
|||||||
|
|
||||||
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
|
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
|
||||||
|
|
||||||
**结构化配置(yaml/tag 代码块):**
|
**结构化配置(yaml 代码块):**
|
||||||
|
|
||||||
可以使用 ```yaml/tag 代码块,通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
|
推荐使用 ```yaml role=tag 代码块(`yaml` 语言可被语法高亮),通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```yaml/tag
|
```yaml role=tag
|
||||||
tag: md-deck
|
tag: md-deck
|
||||||
body: ./cards.csv
|
body: ./cards.csv
|
||||||
data-config:
|
data-config:
|
||||||
@@ -447,7 +451,7 @@ data-config:
|
|||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`。`back_layers` 用于背面图层。
|
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
|
||||||
|
|
||||||
### 🧶 叙事线组件 (md-yarn-spinner)
|
### 🧶 叙事线组件 (md-yarn-spinner)
|
||||||
|
|
||||||
@@ -489,10 +493,10 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
|
|||||||
|
|
||||||
## YAML 标签
|
## YAML 标签
|
||||||
|
|
||||||
使用 ```yaml/tag 代码块创建自定义标签:
|
使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```yaml/tag
|
```yaml role=tag
|
||||||
tag: tag-name
|
tag: tag-name
|
||||||
class: custom-class
|
class: custom-class
|
||||||
id: my-id
|
id: my-id
|
||||||
@@ -500,6 +504,15 @@ body: 标签内容
|
|||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
|
`tag`、`id` 等简单属性也可以直接写在 fence 行上(fence 行属性优先于 YAML 内容):
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```yaml role=tag tag=tag-name id=my-id
|
||||||
|
class: custom-class
|
||||||
|
body: 标签内容
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
渲染为:
|
渲染为:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
|
|||||||
@@ -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?$': [
|
||||||
|
|||||||
@@ -2,66 +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)
|
|
||||||
* - no role, no as → "codeblock" (keep as visible code block)
|
|
||||||
*/
|
|
||||||
export function resolveBlockAs(role: string | undefined, as: string | undefined): string {
|
|
||||||
if (as) return as;
|
|
||||||
// spark-table blocks render as md-table by default
|
|
||||||
if (role === "spark-table") return "md-table";
|
|
||||||
if (role) return "none";
|
|
||||||
return "codeblock";
|
|
||||||
}
|
|
||||||
@@ -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,24 +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 when no role and no as", () => {
|
|
||||||
expect(resolveBlockAs(undefined, undefined)).toBe("codeblock");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// variable-expression
|
// variable-expression
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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[]>();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createMemo, For, Show } from "solid-js";
|
import { createMemo, For, Show, type JSX } from "solid-js";
|
||||||
import { parseMarkdown } from "../../markdown";
|
import { parseMarkdown } from "../../markdown";
|
||||||
import { getLayerStyle } from "./hooks/dimensions";
|
import { getLayerStyle } from "./hooks/dimensions";
|
||||||
import type { CardData, CardSide, LayerConfig } from "./types";
|
import type { Align, CardData, CardSide, LayerConfig } from "./types";
|
||||||
import { DeckStore } from "./hooks/deckStore";
|
import { DeckStore } from "./hooks/deckStore";
|
||||||
import { processVariables } from "../utils/csv-loader";
|
import { processVariables } from "../utils/csv-loader";
|
||||||
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
|
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
|
||||||
@@ -31,10 +31,21 @@ export function CardLayer(props: CardLayerProps) {
|
|||||||
) as string;
|
) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAlignStyle = (align?: "l" | "c" | "r") => {
|
const getAlignStyle = (align?: Align) => {
|
||||||
if (align === "l") return "left";
|
const horizontal = align?.includes("l")
|
||||||
if (align === "r") return "right";
|
? "left"
|
||||||
return "center";
|
: align?.includes("r")
|
||||||
|
? "right"
|
||||||
|
: "center";
|
||||||
|
const vertical = align?.includes("t")
|
||||||
|
? "flex-start"
|
||||||
|
: align?.includes("b")
|
||||||
|
? "flex-end"
|
||||||
|
: "center";
|
||||||
|
return {
|
||||||
|
"text-align": horizontal,
|
||||||
|
"justify-content": vertical,
|
||||||
|
} as JSX.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLayerSelected = (layerIndex: number) =>
|
const isLayerSelected = (layerIndex: number) =>
|
||||||
@@ -87,7 +98,7 @@ export function CardLayer(props: CardLayerProps) {
|
|||||||
style={{
|
style={{
|
||||||
...getLayerStyle(layer, dimensions()),
|
...getLayerStyle(layer, dimensions()),
|
||||||
"font-size": `${layer.fontSize || 3}mm`,
|
"font-size": `${layer.fontSize || 3}mm`,
|
||||||
"text-align": getAlignStyle(layer.align),
|
...getAlignStyle(layer.align),
|
||||||
}}
|
}}
|
||||||
innerHTML={renderLayerContent(
|
innerHTML={renderLayerContent(
|
||||||
layer.template ?? props.cardData[layer.prop ?? ""],
|
layer.template ?? props.cardData[layer.prop ?? ""],
|
||||||
|
|||||||
@@ -50,6 +50,25 @@ describe("layersToConfigs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parses combined vertical+horizontal align", () => {
|
||||||
|
const layers = layersToConfigs([
|
||||||
|
{ prop: "a", pos: "1,1-2,2", align: "tl" },
|
||||||
|
{ prop: "b", pos: "1,1-2,2", align: "br" },
|
||||||
|
{ prop: "c", pos: "1,1-2,2", align: "tc" },
|
||||||
|
{ prop: "d", pos: "1,1-2,2", align: "bc" },
|
||||||
|
]);
|
||||||
|
expect(layers[0].align).toBe("tl");
|
||||||
|
expect(layers[1].align).toBe("br");
|
||||||
|
expect(layers[2].align).toBe("tc");
|
||||||
|
expect(layers[3].align).toBe("bc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses vertical+horizontal align in compact layers string", () => {
|
||||||
|
const layers = layersToConfigs("title:1,1-4,1tl body:1,2-5,8bc");
|
||||||
|
expect(layers[0].align).toBe("tl");
|
||||||
|
expect(layers[1].align).toBe("bc");
|
||||||
|
});
|
||||||
|
|
||||||
test("defaults placement when pos is missing or invalid", () => {
|
test("defaults placement when pos is missing or invalid", () => {
|
||||||
const layers = layersToConfigs([{ prop: "x" }, { prop: "y", pos: "bogus" }]);
|
const layers = layersToConfigs([{ prop: "x" }, { prop: "y", pos: "bogus" }]);
|
||||||
expect(layers[0]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
|
expect(layers[0]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CardShape, LayerConfig } from "./types";
|
import type { Align, CardShape, LayerConfig } from "./types";
|
||||||
import { parseLayers } from "./hooks/layer-parser";
|
import { parseLayers } from "./hooks/layer-parser";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +27,7 @@ export interface DeckLayerYaml {
|
|||||||
font?: number;
|
font?: number;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
orientation?: "n" | "s" | "e" | "w";
|
orientation?: "n" | "s" | "e" | "w";
|
||||||
align?: "l" | "c" | "r";
|
align?: Align;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { For, createSignal, onCleanup, onMount } from "solid-js";
|
import { For, createSignal, onCleanup, onMount } from "solid-js";
|
||||||
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
|
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
|
||||||
import type { DeckStore } from "../hooks/deckStore";
|
import type { DeckStore } from "../hooks/deckStore";
|
||||||
import type { LayerConfig } from "../types";
|
import type { Align, LayerConfig } from "../types";
|
||||||
import alignLeftIcon from "./icons/align-left.png";
|
import alignLeftIcon from "./icons/align-left.png";
|
||||||
import alignCenterIcon from "./icons/align-center.png";
|
import alignCenterIcon from "./icons/align-center.png";
|
||||||
import alignRightIcon from "./icons/align-right.png";
|
import alignRightIcon from "./icons/align-right.png";
|
||||||
@@ -17,7 +17,7 @@ export interface LayerRowProps {
|
|||||||
setOpenDropdown: (val: string | null) => void;
|
setOpenDropdown: (val: string | null) => void;
|
||||||
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
|
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
|
||||||
onUpdateFontSize: (fs?: number) => void;
|
onUpdateFontSize: (fs?: number) => void;
|
||||||
onUpdateAlign: (a?: "l" | "c" | "r") => void;
|
onUpdateAlign: (a?: Align) => void;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}
|
}
|
||||||
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
|
|||||||
{ value: "w" as const, label: "← 西" },
|
{ value: "w" as const, label: "← 西" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALIGNS = [
|
const ALIGNS: { value: Align | ""; icon: string }[] = [
|
||||||
{ value: "" as const, icon: alignCenterIcon },
|
{ value: "", icon: alignCenterIcon },
|
||||||
{ value: "l" as const, icon: alignLeftIcon },
|
{ value: "l", icon: alignLeftIcon },
|
||||||
{ value: "c" as const, icon: alignCenterIcon },
|
{ value: "c", icon: alignCenterIcon },
|
||||||
{ value: "r" as const, icon: alignRightIcon },
|
{ value: "r", icon: alignRightIcon },
|
||||||
|
{ value: "tl", icon: "↖" },
|
||||||
|
{ value: "tc", icon: "↑" },
|
||||||
|
{ value: "tr", icon: "↗" },
|
||||||
|
{ value: "bl", icon: "↙" },
|
||||||
|
{ value: "bc", icon: "↓" },
|
||||||
|
{ value: "br", icon: "↘" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FONT_PRESETS = [3, 5, 8, 12] as const;
|
const FONT_PRESETS = [3, 5, 8, 12] as const;
|
||||||
@@ -53,14 +59,28 @@ function orientChar(v: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function alignSrc(v: string) {
|
function alignIcon(v: string): import("solid-js").JSX.Element {
|
||||||
switch (v) {
|
switch (v) {
|
||||||
|
case "tl":
|
||||||
|
return "↖";
|
||||||
|
case "tc":
|
||||||
|
return "↑";
|
||||||
|
case "tr":
|
||||||
|
return "↗";
|
||||||
|
case "bl":
|
||||||
|
return "↙";
|
||||||
|
case "bc":
|
||||||
|
return "↓";
|
||||||
|
case "br":
|
||||||
|
return "↘";
|
||||||
case "l":
|
case "l":
|
||||||
return alignLeftIcon;
|
return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
|
||||||
case "r":
|
case "r":
|
||||||
return alignRightIcon;
|
return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
|
||||||
default:
|
default:
|
||||||
return alignCenterIcon;
|
return (
|
||||||
|
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
</DropdownButton>
|
</DropdownButton>
|
||||||
|
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
icon={
|
icon={alignIcon(props.layer.align || "")}
|
||||||
<img
|
|
||||||
src={alignSrc(props.layer.align || "")}
|
|
||||||
alt="align"
|
|
||||||
class="w-5 h-5 not-prose"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
visible={props.layer.visible}
|
visible={props.layer.visible}
|
||||||
open={props.openDropdown === `align-${props.index}`}
|
open={props.openDropdown === `align-${props.index}`}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
onClick={() => props.onUpdateAlign(o.value || undefined)}
|
onClick={() => props.onUpdateAlign(o.value || undefined)}
|
||||||
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
|
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
|
{o.icon.endsWith(".png") ? (
|
||||||
|
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
|
||||||
|
) : (
|
||||||
|
<span class="w-4 h-4 flex items-center justify-center not-prose">
|
||||||
|
{o.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -521,7 +521,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
return parts.join("");
|
return parts.join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Serialize the deck back to a ```yaml/tag codeblock (round-trips templates). */
|
/** Serialize the deck back to a yaml codeblock (round-trips templates). */
|
||||||
const generateYamlCode = () => {
|
const generateYamlCode = () => {
|
||||||
const toYamlLayer = (l: LayerConfig) => {
|
const toYamlLayer = (l: LayerConfig) => {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
@@ -560,8 +560,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
lineWidth: -1,
|
lineWidth: -1,
|
||||||
noRefs: true,
|
noRefs: true,
|
||||||
});
|
});
|
||||||
const fence = "```";
|
const fence = "```yaml role=tag";
|
||||||
return `${fence}yaml/tag\n${yamlStr}${fence}`;
|
const backtick = "`";
|
||||||
|
return `${fence}\n${yamlStr}${backtick.repeat(3)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyCode = async (fallback?: (code: string) => void) => {
|
const copyCode = async (fallback?: (code: string) => void) => {
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
|
|||||||
/**
|
/**
|
||||||
* 解析 layers 字符串
|
* 解析 layers 字符串
|
||||||
* 格式:body:1,7-5,8 title:1,1-4,1f6.6sl
|
* 格式:body:1,7-5,8 title:1,1-4,1f6.6sl
|
||||||
* f[fontSize] 表示字体大小(可选),方向字母(可选),对齐字母 l/c/r(可选)
|
* f[fontSize] 表示字体大小(可选),方向字母(可选),
|
||||||
|
* 对齐字母(可选):水平 l/c/r,可加垂直前缀 t/b 组成 tl/tr/bl/br
|
||||||
*/
|
*/
|
||||||
export function parseLayers(layersStr: string): Layer[] {
|
export function parseLayers(layersStr: string): Layer[] {
|
||||||
if (!layersStr) return [];
|
if (!layersStr) return [];
|
||||||
|
|
||||||
const layers: Layer[] = [];
|
const layers: Layer[] = [];
|
||||||
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align]
|
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
|
||||||
const regex =
|
const regex =
|
||||||
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g;
|
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
while ((match = regex.exec(layersStr)) !== null) {
|
while ((match = regex.exec(layersStr)) !== null) {
|
||||||
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
|
|||||||
y2: parseInt(match[5]),
|
y2: parseInt(match[5]),
|
||||||
fontSize: match[6] ? parseFloat(match[6]) : undefined,
|
fontSize: match[6] ? parseFloat(match[6]) : undefined,
|
||||||
orientation: match[7] as "n" | "s" | "e" | "w" | undefined,
|
orientation: match[7] as "n" | "s" | "e" | "w" | undefined,
|
||||||
align: match[8] as "l" | "c" | "r" | undefined,
|
align: [match[8], match[9]].filter(Boolean).join("") as Layer["align"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-config(yaml/tag 代码块方式):结构化配置优先
|
// 读取 data-config(yaml 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);
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ export interface CardData {
|
|||||||
|
|
||||||
export type CardSide = "front" | "back";
|
export type CardSide = "front" | "back";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer alignment: horizontal (l/c/r) optionally combined with vertical
|
||||||
|
* (t/b). "tl" = top-left, "br" = bottom-right, "tc" = top-center,
|
||||||
|
* "bc" = bottom-center, etc. Plain "l"/"c"/"r" means vertically centered.
|
||||||
|
*/
|
||||||
|
export type Align =
|
||||||
|
| "l"
|
||||||
|
| "c"
|
||||||
|
| "r"
|
||||||
|
| "tl"
|
||||||
|
| "tc"
|
||||||
|
| "tr"
|
||||||
|
| "bl"
|
||||||
|
| "bc"
|
||||||
|
| "br";
|
||||||
|
|
||||||
export type { CardShape } from "../../plotcutter/contour";
|
export type { CardShape } from "../../plotcutter/contour";
|
||||||
|
|
||||||
export interface Layer {
|
export interface Layer {
|
||||||
@@ -17,7 +33,7 @@ export interface Layer {
|
|||||||
y2: number;
|
y2: number;
|
||||||
orientation?: "n" | "s" | "e" | "w";
|
orientation?: "n" | "s" | "e" | "w";
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
align?: "l" | "c" | "r";
|
align?: Align;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LayerConfig {
|
export interface LayerConfig {
|
||||||
@@ -32,7 +48,7 @@ export interface LayerConfig {
|
|||||||
y2: number;
|
y2: number;
|
||||||
orientation?: "n" | "s" | "e" | "w";
|
orientation?: "n" | "s" | "e" | "w";
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
align?: "l" | "c" | "r";
|
align?: Align;
|
||||||
_key?: number;
|
_key?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
// 将加载的数据赋值给 rows,CSV 类型已经包含 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ title: 卡牌组件
|
|||||||
|
|
||||||
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
|
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
|
||||||
|
|
||||||
**语法:** `:md-deck[./cards.csv]{选项}` 或 ```yaml/tag 代码块
|
**语法:** `:md-deck[./cards.csv]{选项}` 或 yaml 代码块
|
||||||
|
|
||||||
**基础卡牌:**
|
**基础卡牌:**
|
||||||
:md-deck[./spells.csv]{grid="3x3"}
|
:md-deck[./spells.csv]{grid="3x3"}
|
||||||
@@ -16,12 +16,12 @@ title: 卡牌组件
|
|||||||
|
|
||||||
CSV 包含数据字段列,通过图层定义控制各字段的位置、大小和对齐。
|
CSV 包含数据字段列,通过图层定义控制各字段的位置、大小和对齐。
|
||||||
|
|
||||||
## 结构化配置 (yaml/tag)
|
## 结构化配置 (yaml 代码块)
|
||||||
|
|
||||||
```yaml/tag 代码块可以用 `body` 指定 CSV 路径,`data-config` 提供结构化配置,图层可以是字段(`prop`)或 markdown 模板(`template`,支持 `{{变量}}` 替换):
|
推荐 ```yaml role=tag 代码块(`yaml` 语言可被语法高亮),用 `body` 指定 CSV 路径,`data-config` 提供结构化配置,图层可以是字段(`prop`)或 markdown 模板(`template`,支持 `{{变量}}` 替换):
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```yaml/tag
|
```yaml role=tag
|
||||||
tag: md-deck
|
tag: md-deck
|
||||||
body: ./cards.csv
|
body: ./cards.csv
|
||||||
data-config:
|
data-config:
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,17 +21,14 @@ 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:",
|
||||||
" size: 63x88",
|
" size: 63x88",
|
||||||
" layers:",
|
" layers:",
|
||||||
" - prop: title",
|
" - prop: title",
|
||||||
" x1: 1",
|
" pos: 1,1-5,1",
|
||||||
" y1: 1",
|
|
||||||
" x2: 5",
|
|
||||||
" y2: 1",
|
|
||||||
" font: 12",
|
" font: 12",
|
||||||
"```",
|
"```",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
@@ -47,14 +44,39 @@ describe("code-block-yaml-tag", () => {
|
|||||||
expect(config.layers).toHaveLength(1);
|
expect(config.layers).toHaveLength(1);
|
||||||
expect(config.layers[0]).toMatchObject({
|
expect(config.layers[0]).toMatchObject({
|
||||||
prop: "title",
|
prop: "title",
|
||||||
x1: 1,
|
pos: "1,1-5,1",
|
||||||
y1: 1,
|
|
||||||
font: 12,
|
font: 12,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("supports tag= and id= on the info string", () => {
|
||||||
|
const html = render(
|
||||||
|
"```yaml role=tag tag=md-deck id=my-deck\nbody: ./cards.csv\n```",
|
||||||
|
);
|
||||||
|
expect(html).toContain("<md-deck id=\"my-deck\">./cards.csv</md-deck>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fence attributes override YAML body values", () => {
|
||||||
|
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", () => {
|
||||||
|
const token = ext.tokenizer("```yaml\nsize: 54x86\n```");
|
||||||
|
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>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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,22 +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 src.match(/^```yaml\/tag\s*\n/m)?.index;
|
return src.match(/^```yaml\s+role=tag(?:\s|\n)/m)?.index;
|
||||||
},
|
},
|
||||||
tokenizer(src: string) {
|
tokenizer(src: string) {
|
||||||
const rule = /^```yaml\/tag\s*\n([\s\S]*?)\n```/;
|
const rule = /^```yaml\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;
|
||||||
|
|
||||||
@@ -33,21 +55,13 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
|
|||||||
delete (rest as Record<string, unknown>).body;
|
delete (rest as Record<string, unknown>).body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A structured `data-config` object is serialized to JSON and
|
// `data-*` props may hold structured YAML values, so they are
|
||||||
// passed as a single attribute (e.g. md-deck layers/templates).
|
// serialized to JSON strings (e.g. md-deck layers/templates).
|
||||||
let configAttr = "";
|
|
||||||
if ("data-config" in rest) {
|
|
||||||
const rawConfig = rest["data-config"];
|
|
||||||
delete (rest as Record<string, unknown>)["data-config"];
|
|
||||||
if (rawConfig !== undefined && rawConfig !== null) {
|
|
||||||
const json = JSON.stringify(rawConfig).replace(/"/g, """);
|
|
||||||
configAttr = ` data-config="${json}"`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const propsStr = Object.entries(rest)
|
const propsStr = Object.entries(rest)
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
const strValue = String(value);
|
const strValue = key.startsWith("data-")
|
||||||
|
? JSON.stringify(value)
|
||||||
|
: String(value);
|
||||||
if (strValue.includes(" ") || strValue.includes('"')) {
|
if (strValue.includes(" ") || strValue.includes('"')) {
|
||||||
return `${key}="${strValue.replace(/"/g, """)}"`;
|
return `${key}="${strValue.replace(/"/g, """)}"`;
|
||||||
}
|
}
|
||||||
@@ -60,17 +74,15 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
|
|||||||
raw: match[0],
|
raw: match[0],
|
||||||
tagName,
|
tagName,
|
||||||
props: propsStr,
|
props: propsStr,
|
||||||
config: configAttr,
|
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer(token: any) {
|
renderer(token: any) {
|
||||||
const propsAttr = token.props ? ` ${token.props}` : "";
|
const propsAttr = token.props ? ` ${token.props}` : "";
|
||||||
const configAttr = token.config || "";
|
return `<${token.tagName}${propsAttr}>${token.content || ""}</${token.tagName}>\n`;
|
||||||
return `<${token.tagName}${propsAttr}${configAttr}>${token.content || ""}</${token.tagName}>\n`;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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