Compare commits

..
15 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
hypercross 80e2b5b209 fix(yaml/tag): prevent yaml role=tag from being stripped & generalize
data-config
2026-09-03 10:39:05 +08:00
hypercross 5fc2bc5262 feat(md-deck): add vertical alignment options to layers
Expand align from l/c/r to a 3x3 grid with vertical prefixes:
tl/tc/tr/bl/bc/br. Vertical position renders via flex
justify-content; compact layers strings and YAML both parse the
new values, and the layer editor dropdown gains corner/edge
options.
2026-09-03 10:17:44 +08:00
hypercross d75df5280f feat(markdown): support yaml role=tag info string
Accept yaml role=tag alongside legacy yaml/tag so the block
body gets yaml syntax highlighting, matching the CLI role=
attribute convention. Copy button now emits the role=tag form.
2026-09-03 10:10:06 +08:00
hypercross f1ddc8fb8e refactor(md-deck): pos string and yaml copy output
Replace per-axis x1/y1/x2/y2 layer fields with a single
pos string (x1,y1-x2,y2) matching the compact layers format.

When a deck is configured via a yaml/tag codeblock (data-config),
the copy button now emits a yaml/tag block serialized from current
store state so template layers round-trip.
2026-09-02 18:30:32 +08:00
hypercross 16dfc8a88c feat(md-deck): support structured data-config with template layers
Allow md-deck layers to render markdown templates via {{var}}
substitution instead of only CSV props. Add config.ts to normalize
a JSON data-config attribute (size, grid, shape, layers) emitted by
the yaml code-block tag, taking precedence over legacy string props.
2026-09-02 18:15:51 +08:00
hypercross 023d3a0cb9 style(md-embed): black text and prose-sm 2026-09-01 15:50:50 +08:00
hypercross 7c4865ba8c feat(journal): var stuff 2026-09-01 15:39:36 +08:00
hyper 49260ae4f6 fix(md-embed): proper font size/weight inheritence 2026-08-29 16:23:43 +08:00
hyper 750af4a50c refactor(markdown): Simplify table header detection logic
Remove unused `isSparkTableHeader` import and simplify condition
since spark tables are handled upstream by `coerceSparkTables`.
2026-08-07 13:21:11 +08:00
hyper c03528a293 feat: Unify spark table detection and content resolution
Export isSparkTableHeader from content-registry and use it in both CLI
and frontend table parsing. Add resolveContentRef to resolve content
references consistently. Write processed registry content back to the
file index so browser mode renders identically to CLI mode.
2026-08-07 13:15:51 +08:00
35 changed files with 1602 additions and 693 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;
+24 -4
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" 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
tag: md-deck
```yaml role=tag tag=md-deck
body: ./names.csv
size: 54x86
grid: 5x8
@@ -15,3 +14,24 @@ 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
```
```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
```
+55 -16
View File
@@ -373,26 +373,30 @@ label,name,description
:md-table[./quests.csv]{roll=true remix=true}
```
**自动表格转换**
**内联表格(显式声明)**
标准 Markdown 表格会自动转换`md-table` 组件,当表头包含 `label``md-table-label` 列时
Markdown 表格会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`
```markdown
| label | name | description |
|-------|------|-------------|
| 1 | 战士 | 近战专家 |
| 2 | 法师 | 奥术施法者 |
````markdown
```markdown role=spark-table
| d6 | 结果 |
|----|------|
| 1 | 遭遇强盗 |
| 2 | 平安无事 |
```
````
自动转换为 `:md-table` 组件。
扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
**特殊表头标识:**
````markdown
```csv role=spark-table
d6,结果
1,遭遇强盗
2,平安无事
```
````
| 表头 | 效果 |
|------|------|
| `label``md-table-label` | 转换为 md-table |
| `md-roll-label` 或骰子格式(如 `1d6` | 添加 `roll=true` |
| `md-remix-label` | 添加 `roll=true remix=true` |
普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
### 🃏 卡牌组件 (md-deck)
@@ -423,6 +427,32 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
**结构化配置(yaml 代码块):**
推荐使用 ```yaml role=tag 代码块(`yaml` 语言可被语法高亮),通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
````markdown
```yaml role=tag
tag: md-deck
body: ./cards.csv
data-config:
size: 63x88
grid: 5x5
layers:
- prop: title
pos: 1,1-5,1
font: 12
- template: |
**{{name}}** — {{type}}
{{description}}
pos: 1,3-5,8
font: 3
align: l
```
````
每个图层通过 `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)
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
@@ -463,10 +493,10 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
## YAML 标签
使用 ```yaml/tag 代码块创建自定义标签:
使用 ```yaml role=tag 代码块创建自定义标签`yaml` 语言可被语法高亮)
````markdown
```yaml/tag
```yaml role=tag
tag: tag-name
class: custom-class
id: my-id
@@ -474,6 +504,15 @@ body: 标签内容
```
````
`tag`、`id` 等简单属性也可以直接写在 fence 行上(fence 行属性优先于 YAML 内容):
````markdown
```yaml role=tag tag=tag-name id=my-id
class: custom-class
body: 标签内容
```
````
渲染为:
```html
+4
View File
@@ -7,6 +7,10 @@ export default {
moduleNameMapper: {
// Resolve .js imports to .ts source files (ESM convention in TS source)
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
// github-slugger v2 is ESM-only; jest's CJS runtime cannot require it.
'^github-slugger$': '<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: {
'^.+\\.tsx?$': [
+13 -54
View File
@@ -2,66 +2,25 @@
* Shared block scanning utilities — safe for both CLI and browser.
*
* 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)
* - `as` controls rendering (none, codeblock, md-table, md-card, md-dice)
* - `id` is used for cross-references (file paths)
* The `role` fully determines block behavior (see `scanDoc` in
* `content-registry.ts`):
* - 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.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BlockAttrs {
lang: string;
id?: string;
role?: string;
as?: string;
/** Any other attributes not in the standard set */
extra: Record<string, string>;
}
export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js";
// ---------------------------------------------------------------------------
// Regex
// ---------------------------------------------------------------------------
/** 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;
// ---------------------------------------------------------------------------
// 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";
}
export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm;
+121 -53
View File
@@ -12,11 +12,8 @@
// Mocks
// ---------------------------------------------------------------------------
jest.mock("csv-parse/browser/esm/sync", () => {
// Redirect browser-specific import to Node-compatible sync parser
const actual = jest.requireActual("csv-parse/sync");
return { parse: actual.parse };
});
// csv-parse/browser/esm/sync and github-slugger are mapped to CJS builds
// globally in jest.config.js moduleNameMapper.
jest.mock("github-slugger", () => {
// Simple slugger mock for testing
@@ -31,8 +28,8 @@ jest.mock("github-slugger", () => {
// ---------------------------------------------------------------------------
import { parseDeclareCsv } from "./declare-parser";
import { parseBlockAttrs, resolveBlockAs } from "./block-scanner";
import { evaluateExpression } from "../../components/journal/variable-expression";
import { parseBlockAttrs } from "./block-scanner";
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
import {
initReactivity,
computeCascade,
@@ -47,6 +44,7 @@ import { parseInput } from "../../components/journal/command-parser";
import {
inspectSparkTableCsv,
buildSparkTableCompletion,
isSparkTableHeader,
} from "../content-registry";
// ---------------------------------------------------------------------------
@@ -222,10 +220,9 @@ describe("parseBlockAttrs", () => {
// parseBlockAttrs receives the info string AFTER the lang.
// The lang is extracted from the fenced block regex capture group
// 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.role).toBe("declare");
expect(attrs.as).toBe("none");
});
test("parses quoted values", () => {
@@ -244,7 +241,6 @@ describe("parseBlockAttrs", () => {
expect(attrs.lang).toBe("");
expect(attrs.id).toBeUndefined();
expect(attrs.role).toBeUndefined();
expect(attrs.as).toBeUndefined();
});
test("handles empty info string (lang extracted separately)", () => {
@@ -254,24 +250,8 @@ describe("parseBlockAttrs", () => {
});
});
describe("resolveBlockAs", () => {
test("returns explicit as value", () => {
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");
});
});
// Role behavior is covered by the scanDoc tests in
// src/cli/content-registry.test.ts.
// ---------------------------------------------------------------------------
// variable-expression
@@ -280,14 +260,12 @@ describe("resolveBlockAs", () => {
describe("evaluateExpression", () => {
test("evaluates simple arithmetic", () => {
const result = evaluateExpression("2 + 3 * 4", { lookup: () => undefined });
expect(result.value).toBe(14);
expect(result.value).toEqual({ kind: "number", value: 14 });
});
test("evaluates with parentheses", () => {
const result = evaluateExpression("(2 + 3) * 4", {
lookup: () => undefined,
});
expect(result.value).toBe(20);
const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "number", value: 20 });
});
test("evaluates variable references", () => {
@@ -298,54 +276,105 @@ describe("evaluateExpression", () => {
return undefined;
},
});
expect(result.value).toBe(80); // 12*5 + 20
expect(result.value).toEqual({ kind: "number", value: 80 }); // 12*5 + 20
});
test("returns 0 for undefined variables", () => {
const result = evaluateExpression("$unknown + 5", {
lookup: () => undefined,
});
expect(result.value).toBe(5);
expect(result.value).toEqual({ kind: "number", value: 5 });
});
test("throws on tag values in arithmetic", () => {
test("throws on type mismatch (tagmap + number)", () => {
expect(() =>
evaluateExpression("$class + 5", {
lookup: (name) => (name === "class" ? "#warrior" : undefined),
}),
).toThrow("$class is a tag");
).toThrow("Type mismatch");
});
test("resolves tagmap variable without arithmetic", () => {
const result = evaluateExpression("$class", {
lookup: (name) => (name === "class" ? "#warrior:1" : undefined),
});
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
});
test("evaluates tagmap literals", () => {
const result = evaluateExpression("#warrior:1", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
});
test("evaluates bare tag as tagmap", () => {
const result = evaluateExpression("#warrior", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
});
test("evaluates multi-entry tagmap literal", () => {
const result = evaluateExpression("#warrior:1;#druid:2", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } });
});
test("merges tagmaps with +", () => {
const result = evaluateExpression("#warrior:1 + #druid:2", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } });
});
test("adds counts for same tag with +", () => {
const result = evaluateExpression("#warrior:1 + #warrior:2", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 3 } });
});
test("subtracts tagmaps with -", () => {
const result = evaluateExpression("#warrior:3;#druid:2 - #druid:2", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 3 } });
});
test("throws on tagmap * tagmap", () => {
expect(() =>
evaluateExpression("#warrior:1 * #druid:2", { lookup: () => undefined }),
).toThrow("Type mismatch");
});
test("throws on tagmap / tagmap", () => {
expect(() =>
evaluateExpression("#warrior:1 / #druid:2", { lookup: () => undefined }),
).toThrow("Type mismatch");
});
test("evaluates floor function", () => {
const result = evaluateExpression("floor(3.7)", {
lookup: () => undefined,
});
expect(result.value).toBe(3);
const result = evaluateExpression("floor(3.7)", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "number", value: 3 });
});
test("evaluates ceil function", () => {
const result = evaluateExpression("ceil(3.2)", { lookup: () => undefined });
expect(result.value).toBe(4);
expect(result.value).toEqual({ kind: "number", value: 4 });
});
test("evaluates round function", () => {
const result = evaluateExpression("round(3.5)", {
lookup: () => undefined,
});
expect(result.value).toBe(4);
const result = evaluateExpression("round(3.5)", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "number", value: 4 });
});
test("evaluates unary minus", () => {
const result = evaluateExpression("-5 + 10", { lookup: () => undefined });
expect(result.value).toBe(5);
expect(result.value).toEqual({ kind: "number", value: 5 });
});
test("throws on negating tagmap", () => {
expect(() =>
evaluateExpression("-#warrior", { lookup: () => undefined }),
).toThrow("Type mismatch");
});
test("evaluates dice notation", () => {
const result = evaluateExpression("3d6 + 5", { lookup: () => undefined });
expect(typeof result.value).toBe("number");
expect(result.value.kind).toBe("number");
// 3d6 is between 3 and 18, +5 gives 8-23
expect(result.value).toBeGreaterThanOrEqual(8);
expect(result.value).toBeLessThanOrEqual(23);
expect(result.value.value).toBeGreaterThanOrEqual(8);
expect(result.value.value).toBeLessThanOrEqual(23);
});
test("throws on division by zero", () => {
@@ -368,7 +397,7 @@ describe("evaluateExpression", () => {
test("handles decimal numbers", () => {
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
expect(result.value).toBeCloseTo(6);
expect(result.value).toEqual({ kind: "number", value: 6 });
});
test("nested function calls", () => {
@@ -376,7 +405,7 @@ describe("evaluateExpression", () => {
lookup: () => undefined,
});
// ceil(3.2) = 4, floor(4) = 4
expect(result.value).toBe(4);
expect(result.value).toEqual({ kind: "number", value: 4 });
});
test("complex expression with variables and functions", () => {
@@ -387,7 +416,20 @@ describe("evaluateExpression", () => {
return undefined;
},
});
expect(result.value).toBe(10); // floor(7.5) + 3 = 7 + 3
expect(result.value).toEqual({ kind: "number", value: 10 }); // floor(7.5) + 3 = 7 + 3
});
test("exprValueToString serializes number", () => {
expect(exprValueToString({ kind: "number", value: 42 })).toBe("42");
});
test("exprValueToString serializes tagmap", () => {
expect(exprValueToString({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } }))
.toBe("#warrior:1;#druid:2");
});
test("exprValueToString returns 0 for empty tagmap", () => {
expect(exprValueToString({ kind: "tagmap", value: {} })).toBe("0");
});
});
@@ -927,6 +969,32 @@ describe("parseInput", () => {
// content-registry (spark tables)
// ---------------------------------------------------------------------------
describe("isSparkTableHeader", () => {
test("detects plain dice formula", () => {
expect(isSparkTableHeader("d6")).toBe(true);
expect(isSparkTableHeader("d20")).toBe(true);
expect(isSparkTableHeader("d100")).toBe(true);
});
test("detects dice count and modifiers", () => {
expect(isSparkTableHeader("2d6")).toBe(true);
expect(isSparkTableHeader("3d6+5")).toBe(true);
expect(isSparkTableHeader("1d8-2")).toBe(true);
});
test("rejects non-dice headers", () => {
expect(isSparkTableHeader("Name")).toBe(false);
expect(isSparkTableHeader("d")).toBe(false);
expect(isSparkTableHeader("6")).toBe(false);
expect(isSparkTableHeader("d6x")).toBe(false);
expect(isSparkTableHeader("roll")).toBe(false);
});
test("trims surrounding whitespace", () => {
expect(isSparkTableHeader(" d20 ")).toBe(true);
});
});
describe("inspectSparkTableCsv", () => {
test("detects spark table from CSV headers", () => {
const csv = `d6,Name,Description
+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);
});
});
+167 -278
View File
@@ -24,7 +24,6 @@ import {
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
} from "./completions/block-scanner.js";
import type {
CompletionsPayload,
@@ -47,8 +46,6 @@ export interface DocContent {
body: string;
/** Origin role that produced this content, for debugging. */
role?: string;
/** Origin `as` value, for debugging. */
as?: string;
}
export interface ContentRegistry {
@@ -113,86 +110,77 @@ export interface DocScanResult {
/**
* Process a single markdown doc:
* - strips/replaces attributed fenced code blocks based on `as`
* - coerces spark-shaped markdown tables to `:md-table` directives
* - collects inline content (role=file, md-* bodies, spark tables, declare)
* into the doc's content store
* - dispatches attributed fenced code blocks by `role`
* - converts `markdown`-lang `role=spark-table` bodies (pipe tables) to CSV
* - collects inline content (spark-table, declare, file) into the doc's
* content store
*
* Does NOT touch the path index — the caller assembles the registry.
*/
export function scanDoc(content: string, docPath: string): DocScanResult {
const contentStore: Record<string, DocContent> = {};
// ---- Pass 1: attributed fenced code blocks ----
const stripped = content.replace(
FENCED_BLOCK_RE,
(
_match: string,
match: string,
lang: string,
infoString: string,
body: string,
): string => {
const attrs = parseBlockAttrs(infoString);
attrs.lang = attrs.lang || lang;
const effectiveAs = resolveBlockAs(attrs.role, attrs.as);
if (attrs.role === "declare") {
const id = deriveContentId("declare", body, attrs.id);
contentStore[id] = {
id,
kind: "declare",
body,
role: attrs.role,
as: effectiveAs,
};
switch (attrs.role) {
case "declare": {
const id = deriveContentId("declare", body, attrs.id);
contentStore[id] = { id, kind: "declare", body, role: attrs.role };
return "";
}
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 ----
const rewritten = coerceSparkTables(stripped, contentStore);
return { stripped: rewritten, content: contentStore };
return { stripped, 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
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
* 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
* `scanDoc` and the spark-injection pass, other extensions are stored raw.
* `scanDoc`, other extensions are stored raw.
*/
export function buildRegistryFromIndex(
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;
}
@@ -239,7 +219,14 @@ export function buildRegistryFromIndex(
// Spark table coercion
// ---------------------------------------------------------------------------
const DICE_HEADER_RE = /^\d*d\d+$/i;
/**
* Whether a table header cell is a dice formula (a "spark table" first
* column). Used to validate `role=spark-table` blocks (CSV or markdown
* pipe-table bodies).
*/
export function isSparkTableHeader(header: string): boolean {
return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim());
}
/** Split a markdown table row into cells. */
function splitTableRow(row: string): string[] {
@@ -287,167 +274,104 @@ function markdownTableToCsv(
}
/**
* Coerce spark-shaped markdown tables (first column header is a dice formula)
* into `:md-table` directives, storing the CSV in the doc's content store and
* injecting `data-spark` for the reveal feature.
* Languages whose fenced-block bodies are markdown pipe tables. Used with
* `role=spark-table` to convert the table to CSV at scan time — explicitly
* authorized by the role, never by content shape.
*/
function coerceSparkTables(
content: string,
contentStore: Record<string, DocContent>,
): string {
const mdTableRegex = /^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
interface TableMatch {
fullMatch: string;
headerRow: string;
separatorRow: string;
bodyRowsText: string;
index: number;
}
const tableMatches: TableMatch[] = [];
let mdMatch: RegExpExecArray | null;
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
const headers = splitTableRow(headerRow);
if (!DICE_HEADER_RE.test(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;
function isMarkdownTableLang(lang: string): boolean {
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
}
// ---------------------------------------------------------------------------
// Directive spark injection (real-file `:md-table` / `:md-card` references)
// ---------------------------------------------------------------------------
/**
* Scan a doc's stripped content for `:md-table[...]` / `:md-card[...]`
* directives that resolve to a spark table, and inject `data-spark` so the
* reveal feature can match them. Idempotent — only injects when missing.
*
* Returns the possibly-rewritten content.
* Convert a fenced markdown pipe-table body to CSV for `role=spark-table`
* blocks. Validates the dice-formula first column (warning only — the role
* already declared intent) and falls back to storing the body as-is when it
* is not a recognizable pipe table.
*/
export function injectSparkDirectives(
content: string,
docPath: string,
registry: ContentRegistry,
): string {
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
let rewritten = content;
let m: RegExpExecArray | null;
while ((m = tableDirectiveRegex.exec(content)) !== null) {
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);
function markdownTableBodyToCsv(body: string, docPath: string): string {
const lines = body
.trim()
.split(/\r?\n/)
.filter((l) => l.trim().startsWith("|"));
if (lines.length < 2) {
console.warn(
`[content-registry] ${docPath}: role=spark-table markdown body is not a pipe table; storing as-is`,
);
return body;
}
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
// ---------------------------------------------------------------------------
/**
* 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.
*
* - `ref` is inline CSV → returned as-is.
* - `ref` is an absolute path → path index.
* - `ref` is a relative path → resolved against the doc directory; checks
* the path index first, then the doc's inline content store.
* the doc's inline content store first, then the path index.
*
* Refs are always ids or paths — inline content must be defined in a fenced
* block and referenced by its `./{id}`.
*
* Returns `null` when nothing matches.
*/
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(
registry: ContentRegistry,
docPath: string,
ref: string,
): string | null {
const trimmed = ref.trim();
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;
return resolveContentEntry(registry, docPath, ref)?.body ?? null;
}
/**
@@ -470,25 +394,6 @@ export function resolveInlineByPath(
return null;
}
/** Naive CSV sniff — matches the frontend `isCSV` heuristic. */
function looksLikeCsv(str: string): boolean {
const trimmed = str.trim();
if (trimmed.startsWith("---\n") || trimmed.startsWith("---\r\n")) return true;
const lines = trimmed.split(/\r?\n/).filter((line) => line.trim() !== "");
if (lines.length < 2) return false;
const separators = [",", "\t", ";", "|"];
const firstLine = lines[0];
for (const sep of separators) {
if (firstLine.includes(sep)) {
const hasInOthers = lines.slice(1).some((line) => line.includes(sep));
if (hasInOthers) return true;
}
}
return false;
}
// ---------------------------------------------------------------------------
// Derived completions
// ---------------------------------------------------------------------------
@@ -537,26 +442,18 @@ function scanDocDirectives(
const dice = scanDice(content, docPath);
const sparkTables: SparkTableCompletion[] = [];
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
const tableDirectiveRegex = /:md-(table|card)\[([^\[\]]+)\](?:\{([^}]*)\})?/gi;
let m: RegExpExecArray | null;
while ((m = tableDirectiveRegex.exec(content)) !== null) {
const [, , ref, extraStr] = m;
const csv = resolveContent(registry, docPath, ref);
if (!csv) continue;
const resolved = resolveContentEntry(registry, docPath, ref);
if (!resolved) continue;
// csvPath: content id for inline content, resolved path for real files.
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 attrs = parseBlockAttrs(extraStr || "").extra;
const st = buildSparkTableCompletion(
csv,
resolved.body,
docPath,
csvPath,
resolved.path,
attrs["remix"] === "true",
);
if (st) sparkTables.push(st);
@@ -565,31 +462,6 @@ function scanDocDirectives(
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. */
export function deriveBlocks(registry: ContentRegistry): {
declarations: VarDeclaration[];
@@ -618,12 +490,37 @@ export function deriveBlocks(registry: ContentRegistry): {
// 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. */
function deriveLinks(pathIndex: Record<string, string>): 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;
// 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 fileName = fileNameFromPath(basePath);
const slugger = new Slugger();
@@ -674,19 +571,11 @@ export function inspectSparkTableCsv(csv: string): string[] | null {
const headers = lines[0].split(",").map((h) => h.trim());
if (headers.length < 2) return null;
if (!DICE_HEADER_RE.test(headers[0])) return null;
if (!isSparkTableHeader(headers[0])) return null;
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. */
export function buildSparkTableCompletion(
csv: string,
+7 -14
View File
@@ -10,19 +10,12 @@ import { createSignal } from "solid-js";
import { parseInput } from "./command-parser";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
import { evaluateExpression } from "./variable-expression";
import { evaluateExpression, exprValueToString } from "./variable-expression";
import { computeCascade, getCombined, setBase } from "./var-reactivity";
import type { VarDeclaration, TagModifier } from "./declare-parser";
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
const TAGMAP_PATTERN =
/^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/;
function isTagMapExpr(expr: string): boolean {
const t = expr.trim();
return BARE_TAG_PATTERN.test(t) || TAGMAP_PATTERN.test(t);
}
function normalizeTagMap(expr: string): string {
const t = expr.trim();
@@ -140,11 +133,14 @@ export async function dispatchCommand(
const ev = evaluateExpression(arg, {
lookup: (name: string) => getCombined("$" + name, ctx.variables),
});
if (ev.value.kind !== "number") {
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
}
const payload = {
notation: arg,
label: arg,
result: {
total: ev.value,
total: ev.value.value,
detail: "",
plainDetail: "",
pools: [] as { rolls: number[]; subtotal: number }[],
@@ -205,11 +201,8 @@ function dispatchSet(
// Rolltag: pick random tag, format as tagmap entry
const idx = Math.floor(Math.random() * p.tags.length);
newValue = normalizeTagMap(p.tags[idx]);
} else if (p.expr && isTagMapExpr(p.expr)) {
// Tagmap value (e.g. "#warrior:1;#druid:2" or bare "#warrior")
newValue = normalizeTagMap(p.expr);
} else if (p.expr) {
// Numeric expression — evaluate using combined values
// Evaluate expression — handles both numeric and tagmap values
const result = evaluateExpression(p.expr, {
lookup: (name: string) => {
const k = "$" + name;
@@ -217,7 +210,7 @@ function dispatchSet(
return k === key ? undefined : getCombined(k, ctx.variables);
},
});
newValue = String(result.value);
newValue = exprValueToString(result.value);
} else {
return { ok: false, error: "缺少表达式" };
}
+10 -1
View File
@@ -13,6 +13,7 @@ import { createSignal } from "solid-js";
import {
getPathsByExtension,
getIndexedData,
setIndexedData,
setInlineResolver,
} from "../../data-loader/file-index";
import {
@@ -109,7 +110,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md");
// 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> = {};
for (const filePath of paths) {
const content = await getIndexedData(filePath);
@@ -119,6 +120,14 @@ async function scanClientSide(): Promise<JournalCompletions> {
const registry = buildRegistryFromIndex(index);
activeRegistry = registry;
// Write the processed (stripped) content back into the file index so
// Article/md-embed render the same content as CLI mode (attributed blocks
// processed by role).
for (const [path, content] of Object.entries(registry.pathIndex)) {
setIndexedData(path, content);
}
return deriveCompletions(registry);
}
+2 -2
View File
@@ -58,8 +58,8 @@ export type { CompletionsContext } from "./command-completions";
export { VariableView } from "./VariableView";
export { parseDeclareCsv } from "./declare-parser";
export type { VarDeclaration, TagModifier } from "./declare-parser";
export { evaluateExpression, expressionIsTag } from "./variable-expression";
export type { EvalContext, EvalResult } from "./variable-expression";
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
export type { VarReactivityState, VariableStore } from "./var-reactivity";
export { JournalContext, useJournalContext } from "./JournalContext";
+10 -4
View File
@@ -22,7 +22,7 @@
*/
import type { VarDeclaration, TagModifier } from "./declare-parser";
import { evaluateExpression } from "./variable-expression";
import { evaluateExpression, exprValueToString } from "./variable-expression";
// ---------------------------------------------------------------------------
// Types
@@ -349,7 +349,7 @@ export function computeInitialValues(
},
});
const rawValue = String(result.value);
const rawValue = exprValueToString(result.value);
baseValues.set(key, rawValue);
// Check if this is a tagmap value — if so, activate matching modifiers
@@ -422,7 +422,13 @@ function applyTagMapActivations(
},
});
const value = evalResult.value;
// Modifier expressions must evaluate to a number
if (evalResult.value.kind !== "number") {
throw new Error(
`Modifier expression "${mod.expression}" must evaluate to a number`,
);
}
const value = evalResult.value.value;
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
if (targetIsTagMap) {
@@ -571,7 +577,7 @@ function reevaluateDependents(
},
});
const rawValue = String(result.value);
const rawValue = exprValueToString(result.value);
// Check for tagmap transition on this declared variable
const oldCombined = getCombined(key, fallback);
+208 -36
View File
@@ -4,14 +4,15 @@
*
* Supports:
* - Number literals (integer or decimal)
* - $var references (resolved via lookup, must be numeric)
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior → #warrior:1)
* - $var references (resolved via lookup; auto-detects number vs tagmap)
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
* - Arithmetic: + - * /
* - Functions: floor(x), ceil(x), round(x)
* - Arithmetic: + - * / (type-checked via registry)
* - Functions: floor(x), ceil(x), round(x) (numbers only)
* - Parentheses for grouping
*
* Throws on:
* - Type mismatch (e.g. $var resolves to a tag value like "#warrior")
* - Type mismatch (e.g. number + tagmap, tagmap * number)
* - Circular variable references (detected by caller)
* - Division by zero
* - Unknown functions
@@ -24,14 +25,19 @@ import { rollFormula } from "../md-commander/hooks";
// Types
// ---------------------------------------------------------------------------
/** A value produced by the expression evaluator. */
export type ExprValue =
| { kind: "number"; value: number }
| { kind: "tagmap"; value: Record<string, number> };
export interface EvalContext {
/** Resolve $var → numeric string, or a tag string like "#warrior".
/** Resolve $var → string (numeric or tagmap serialized form).
* Return undefined if the variable doesn't exist. */
lookup: (varName: string) => string | undefined;
}
export interface EvalResult {
value: number;
value: ExprValue;
}
// ---------------------------------------------------------------------------
@@ -40,8 +46,7 @@ export interface EvalResult {
/**
* Evaluate an expression string.
* Throws if any variable resolves to a non-numeric (tag) value,
* or if the expression is malformed.
* Throws if the expression is malformed or contains a type mismatch.
*/
export function evaluateExpression(
expr: string,
@@ -63,19 +68,109 @@ export function expressionIsTag(expr: string): boolean {
return trimmed.startsWith("#");
}
/** Serialize an ExprValue back to a string (for var-reactivity integration). */
export function exprValueToString(v: ExprValue): string {
if (v.kind === "number") return String(v.value);
// tagmap
const entries = Object.entries(v.value).filter(([, c]) => c > 0);
if (entries.length === 0) return "0";
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
}
// ---------------------------------------------------------------------------
// Binary operation registry
// ---------------------------------------------------------------------------
type BinaryOp = (a: ExprValue, b: ExprValue) => ExprValue;
/* Helpers that narrow ExprValue to specific kinds for use in registry callbacks. */
const num = (a: ExprValue, b: ExprValue): [number, number] =>
[a.value as number, b.value as number];
const tmap = (a: ExprValue, b: ExprValue): [Record<string, number>, Record<string, number>] =>
[a.value as Record<string, number>, b.value as Record<string, number>];
/** Registry: binaryOps[leftKind][rightKind][operator] → implementation.
* Any undefined combination throws a type-mismatch error. */
const binaryOps: Record<
string,
Record<string, Record<string, BinaryOp>>
> = {
number: {
number: {
"+": (a, b) => {
const [l, r] = num(a, b);
return { kind: "number", value: l + r };
},
"-": (a, b) => {
const [l, r] = num(a, b);
return { kind: "number", value: l - r };
},
"*": (a, b) => {
const [l, r] = num(a, b);
return { kind: "number", value: l * r };
},
"/": (a, b) => {
const [l, r] = num(a, b);
if (r === 0) throw new Error("Division by zero");
return { kind: "number", value: l / r };
},
},
},
tagmap: {
tagmap: {
"+": (a, b) => {
const [leftMap, rightMap] = tmap(a, b);
const result: Record<string, number> = { ...leftMap };
for (const [tag, count] of Object.entries(rightMap)) {
result[tag] = (result[tag] ?? 0) + count;
}
return { kind: "tagmap", value: result };
},
"-": (a, b) => {
const [leftMap, rightMap] = tmap(a, b);
const result: Record<string, number> = { ...leftMap };
for (const [tag, count] of Object.entries(rightMap)) {
result[tag] = (result[tag] ?? 0) - count;
if (result[tag] <= 0) delete result[tag];
}
return { kind: "tagmap", value: result };
},
},
},
};
function getBinaryOp(
left: ExprValue,
right: ExprValue,
op: string,
): BinaryOp {
const opFn = binaryOps[left.kind]?.[right.kind]?.[op];
if (!opFn) {
throw new Error(
`Type mismatch: cannot ${op} ${left.kind} with ${right.kind}`,
);
}
return opFn;
}
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
interface Token {
kind: "number" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
kind: "number" | "tagmap" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
value: string;
raw: string;
/** Pre-parsed tagmap data (only set when kind === "tagmap") */
tagmap?: Record<string, number>;
}
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
/** Single tagmap entry: "#warrior" or "#warrior:1" */
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
@@ -114,6 +209,50 @@ function tokenize(input: string): Token[] {
continue;
}
// Tagmap literal: #warrior:1;#druid:2 or bare #warrior
if (ch === "#") {
let raw = "";
const map: Record<string, number> = {};
while (i < input.length && input[i] === "#") {
// Match one entry from the current position
const remaining = input.slice(i);
const m = TAGMAP_ENTRY_RE.exec(remaining);
if (!m) {
throw new Error(`Invalid tagmap entry at position ${i}: "${remaining.slice(0, 20)}..."`);
}
const matched = m[0];
raw += (raw ? ";" : "") + matched;
const tag = "#" + m[1];
const count = m[2] !== undefined ? parseInt(m[2], 10) : 1;
if (count > 0) {
map[tag] = (map[tag] ?? 0) + count;
}
i += matched.length;
// Skip whitespace after the entry
while (i < input.length && input[i] === " ") i++;
// Check for semicolon separator (continue to next entry)
if (i < input.length && input[i] === ";") {
raw += ";";
i++;
// Skip whitespace after semicolon
while (i < input.length && input[i] === " ") i++;
// If the next char is not '#', we're done with the tagmap
if (i >= input.length || input[i] !== "#") break;
} else {
break;
}
}
if (Object.keys(map).length === 0) {
throw new Error(`Empty tagmap: "${raw}"`);
}
tokens.push({ kind: "tagmap", value: raw, raw, tagmap: map });
continue;
}
// Variable reference: $var
if (ch === "$") {
let ident = "$";
@@ -186,7 +325,7 @@ function rollDice(notation: string): number {
// ---------------------------------------------------------------------------
interface ParseResult {
value: number;
value: ExprValue;
next: number; // index of next unconsumed token
}
@@ -203,11 +342,8 @@ function parseExpression(
const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
const right = parseTerm(tokens, pos + 1, ctx);
if (tok.value === "+") {
result = { value: result.value + right.value, next: right.next };
} else {
result = { value: result.value - right.value, next: right.next };
}
const opFn = getBinaryOp(result.value, right.value, tok.value);
result = { value: opFn(result.value, right.value), next: right.next };
pos = result.next;
} else {
break;
@@ -230,12 +366,8 @@ function parseTerm(
const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
const right = parseFactor(tokens, pos + 1, ctx);
if (tok.value === "*") {
result = { value: result.value * right.value, next: right.next };
} else {
if (right.value === 0) throw new Error("Division by zero");
result = { value: result.value / right.value, next: right.next };
}
const opFn = getBinaryOp(result.value, right.value, tok.value);
result = { value: opFn(result.value, right.value), next: right.next };
pos = result.next;
} else {
break;
@@ -245,7 +377,7 @@ function parseTerm(
return result;
}
/** factor := number | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
/** factor := number | tagmap | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
function parseFactor(
tokens: Token[],
pos: number,
@@ -257,15 +389,23 @@ function parseFactor(
const tok = tokens[pos];
// Unary minus
// Unary minus (numbers only)
if (tok.kind === "op" && tok.value === "-") {
const inner = parseFactor(tokens, pos + 1, ctx);
return { value: -inner.value, next: inner.next };
if (inner.value.kind !== "number") {
throw new Error(`Type mismatch: cannot negate ${inner.value.kind}`);
}
return { value: { kind: "number", value: -inner.value.value }, next: inner.next };
}
// Number literal (including already-rolled dice patterns)
if (tok.kind === "number") {
return { value: parseFloat(tok.value), next: pos + 1 };
return { value: { kind: "number", value: parseFloat(tok.value) }, next: pos + 1 };
}
// Tagmap literal
if (tok.kind === "tagmap") {
return { value: { kind: "tagmap", value: { ...tok.tagmap! } }, next: pos + 1 };
}
// Variable reference: $var
@@ -273,13 +413,17 @@ function parseFactor(
const varName = tok.value; // includes $ prefix
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
if (resolved === undefined) {
return { value: 0, next: pos + 1 };
return { value: { kind: "number", value: 0 }, next: pos + 1 };
}
// Tag values cannot be used in arithmetic
// Auto-detect: tagmap or numeric
if (resolved.startsWith("#")) {
throw new Error(
`Type mismatch: ${varName} is a tag ("${resolved}"), not a number`,
);
const map = parseTagMapValue(resolved);
if (!map) {
throw new Error(
`Type mismatch: ${varName} is not a valid tagmap ("${resolved}")`,
);
}
return { value: { kind: "tagmap", value: map }, next: pos + 1 };
}
const num = parseFloat(resolved);
if (isNaN(num)) {
@@ -287,7 +431,7 @@ function parseFactor(
`Type mismatch: ${varName} is not numeric ("${resolved}")`,
);
}
return { value: num, next: pos + 1 };
return { value: { kind: "number", value: num }, next: pos + 1 };
}
// Parenthesized expression
@@ -319,15 +463,43 @@ function parseFactor(
throw new Error(`Unexpected token: "${tok.raw}"`);
}
function applyFunction(name: string, arg: number): number {
function applyFunction(name: string, arg: ExprValue): ExprValue {
if (arg.kind !== "number") {
throw new Error(`Type mismatch: ${name}() requires a number, got ${arg.kind}`);
}
switch (name.toLowerCase()) {
case "floor":
return Math.floor(arg);
return { kind: "number", value: Math.floor(arg.value) };
case "ceil":
return Math.ceil(arg);
return { kind: "number", value: Math.ceil(arg.value) };
case "round":
return Math.round(arg);
return { kind: "number", value: Math.round(arg.value) };
default:
throw new Error(`Unknown function: ${name}`);
}
}
}
// ---------------------------------------------------------------------------
// Tagmap parsing (shared with var-reactivity, duplicated to avoid circular deps)
// ---------------------------------------------------------------------------
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
function parseTagMapValue(value: string): Record<string, number> | null {
const trimmed = value.trim();
if (!trimmed.startsWith("#")) return null;
const parts = trimmed.split(";");
const map: Record<string, number> = {};
for (const part of parts) {
const m = TAGMAP_RE.exec(part.trim());
if (!m) return null;
const tag = "#" + m[1];
const count = parseInt(m[2], 10);
if (count <= 0) continue;
map[tag] = (map[tag] ?? 0) + count;
}
return Object.keys(map).length > 0 ? map : null;
}
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store";
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
import {resolvePath} from "../../utils/path";
import {loadCSV} from "../../utils/csv-loader";
import {loadCSVFromPath} from "../../utils/csv-loader";
const defaultCommands: MdCommanderCommandMap = {
help: setupHelpCommand({}),
@@ -111,7 +111,7 @@ export async function loadCommandTemplatesFromCSV(
setCommandsError(undefined);
try {
const csv = await loadCSV<CommandTemplateRow>(resolvePath(articlePath, path));
const csv = await loadCSVFromPath<CommandTemplateRow>(resolvePath(articlePath, path));
// 按命令分组模板
const templatesByCommand = new Map<string, CommandTemplateRow[]>();
+21 -8
View File
@@ -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 { 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 { processVariables } from "../utils/csv-loader";
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
@@ -31,10 +31,21 @@ export function CardLayer(props: CardLayerProps) {
) as string;
}
const getAlignStyle = (align?: "l" | "c" | "r") => {
if (align === "l") return "left";
if (align === "r") return "right";
return "center";
const getAlignStyle = (align?: Align) => {
const horizontal = align?.includes("l")
? "left"
: 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) =>
@@ -87,9 +98,11 @@ export function CardLayer(props: CardLayerProps) {
style={{
...getLayerStyle(layer, dimensions()),
"font-size": `${layer.fontSize || 3}mm`,
"text-align": getAlignStyle(layer.align),
...getAlignStyle(layer.align),
}}
innerHTML={renderLayerContent(props.cardData[layer.prop])}
innerHTML={renderLayerContent(
layer.template ?? props.cardData[layer.prop ?? ""],
)}
onClick={(e) => handleLayerClick(index(), e)}
/>
<Show when={isSelected() && isEditing()}>
+131
View File
@@ -0,0 +1,131 @@
import { layersToConfigs, normalizeDeckConfig } from "./config";
describe("layersToConfigs", () => {
test("parses compact string format (legacy)", () => {
const layers = layersToConfigs("title:1,1-5,1f8 body:1,5-8,8f3");
expect(layers).toHaveLength(2);
expect(layers[0]).toMatchObject({
prop: "title",
visible: true,
x1: 1,
y1: 1,
x2: 5,
y2: 1,
fontSize: 8,
});
expect(layers[1].prop).toBe("body");
});
test("parses structured list with prop layers", () => {
const layers = layersToConfigs([
{ prop: "name", pos: "1,1-5,2", font: 12 },
]);
expect(layers).toHaveLength(1);
expect(layers[0]).toMatchObject({
prop: "name",
template: undefined,
visible: true,
x1: 1,
y1: 1,
x2: 5,
y2: 2,
fontSize: 12,
});
});
test("parses structured list with template layers", () => {
const layers = layersToConfigs([
{ template: "**{{name}}**", pos: "1,3-5,8", align: "l" },
]);
expect(layers).toHaveLength(1);
expect(layers[0]).toMatchObject({
prop: undefined,
template: "**{{name}}**",
visible: true,
x1: 1,
y1: 3,
x2: 5,
y2: 8,
align: "l",
});
});
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", () => {
const layers = layersToConfigs([{ prop: "x" }, { prop: "y", pos: "bogus" }]);
expect(layers[0]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
expect(layers[1]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
});
test("returns [] for absent/empty input", () => {
expect(layersToConfigs()).toEqual([]);
expect(layersToConfigs("")).toEqual([]);
expect(layersToConfigs([])).toEqual([]);
});
});
describe("normalizeDeckConfig", () => {
test("parses string dimensions", () => {
const cfg = normalizeDeckConfig({ size: "54x86", grid: "5x8" });
expect(cfg.sizeW).toBe(54);
expect(cfg.sizeH).toBe(86);
expect(cfg.gridW).toBe(5);
expect(cfg.gridH).toBe(8);
});
test("parses array dimensions", () => {
const cfg = normalizeDeckConfig({ size: [63, 88] });
expect(cfg.sizeW).toBe(63);
expect(cfg.sizeH).toBe(88);
});
test("coerces numeric strings for bleed/padding", () => {
const cfg = normalizeDeckConfig({ bleed: "2", padding: "3" });
expect(cfg.bleed).toBe(2);
expect(cfg.padding).toBe(3);
});
test("normalizes layers and back_layers", () => {
const cfg = normalizeDeckConfig({
layers: [
{ prop: "name", pos: "1,1-5,1" },
{ template: "{{body}}", pos: "1,2-5,8" },
],
back_layers: "logo:1,1-2,2",
});
expect(cfg.frontLayers).toHaveLength(2);
expect(cfg.frontLayers[1].template).toBe("{{body}}");
expect(cfg.backLayers).toHaveLength(1);
expect(cfg.backLayers[0].prop).toBe("logo");
});
test("missing fields stay undefined", () => {
const cfg = normalizeDeckConfig({});
expect(cfg.sizeW).toBeUndefined();
expect(cfg.sizeH).toBeUndefined();
expect(cfg.gridW).toBeUndefined();
expect(cfg.gridH).toBeUndefined();
expect(cfg.bleed).toBeUndefined();
expect(cfg.padding).toBeUndefined();
expect(cfg.frontLayers).toEqual([]);
expect(cfg.backLayers).toEqual([]);
});
});
+148
View File
@@ -0,0 +1,148 @@
import type { Align, CardShape, LayerConfig } from "./types";
import { parseLayers } from "./hooks/layer-parser";
/**
* YAML/JSON shape of an `md-deck` `data-config`.
*
* Mirrors the frontmatter `deck:` block and the `:md-deck` directive attrs,
* but allows `layers`/`back_layers` as structured lists where each layer is
* either a CSV `prop` or a markdown `template`.
*/
export interface DeckConfigYaml {
size?: string | [number, number];
grid?: string | [number, number];
bleed?: number | string;
padding?: number | string;
shape?: CardShape;
fixed?: boolean;
layers?: string | DeckLayerYaml[];
back_layers?: string | DeckLayerYaml[];
}
export interface DeckLayerYaml {
prop?: string;
template?: string;
/** Grid placement "x1,y1-x2,y2" (1-based), same shape as the compact layers string. */
pos?: string;
font?: number;
fontSize?: number;
orientation?: "n" | "s" | "e" | "w";
align?: Align;
visible?: boolean;
}
export interface NormalizedDeckConfig {
sizeW?: number;
sizeH?: number;
gridW?: number;
gridH?: number;
bleed?: number;
padding?: number;
shape?: CardShape;
fixed?: boolean;
frontLayers: LayerConfig[];
backLayers: LayerConfig[];
}
/** Parse a "54x86" string or [54, 86] array into [w, h]. */
function parseDimension(
v?: string | [number, number],
): [number, number] | undefined {
if (!v) return undefined;
if (Array.isArray(v)) {
const [w, h] = v;
if (typeof w === "number" && typeof h === "number") return [w, h];
return undefined;
}
const parts = String(v)
.toLowerCase()
.split("x")
.map((n) => Number(n.trim()));
if (
parts.length === 2 &&
Number.isFinite(parts[0]) &&
Number.isFinite(parts[1])
) {
return [parts[0], parts[1]];
}
return undefined;
}
/** Parse a "x1,y1-x2,y2" placement string into grid coordinates. */
export function parsePos(
pos?: string,
): { x1: number; y1: number; x2: number; y2: number } | undefined {
if (!pos) return undefined;
const m = /^(\d+)\s*,\s*(\d+)\s*-\s*(\d+)\s*,\s*(\d+)$/.exec(pos.trim());
if (!m) return undefined;
return {
x1: Number(m[1]),
y1: Number(m[2]),
x2: Number(m[3]),
y2: Number(m[4]),
};
}
/** Map a structured YAML layer to a LayerConfig. */
function layerYamlToConfig(l: DeckLayerYaml): LayerConfig {
const pos = parsePos(l.pos);
return {
prop: l.prop,
template: l.template,
visible: l.visible ?? true,
x1: pos?.x1 ?? 1,
y1: pos?.y1 ?? 1,
x2: pos?.x2 ?? 2,
y2: pos?.y2 ?? 2,
orientation: l.orientation,
fontSize: l.fontSize ?? l.font,
align: l.align,
};
}
/**
* Normalize a `layers`/`back_layers` value (compact string or structured list)
* into LayerConfig[]. Empty/absent → [].
*/
export function layersToConfigs(
layers?: string | DeckLayerYaml[],
): LayerConfig[] {
if (!layers) return [];
if (typeof layers === "string") {
return parseLayers(layers).map((l) => ({
prop: l.prop,
visible: true,
x1: l.x1,
y1: l.y1,
x2: l.x2,
y2: l.y2,
orientation: l.orientation,
fontSize: l.fontSize,
align: l.align,
}));
}
return layers.map(layerYamlToConfig);
}
/**
* Normalize a parsed `data-config` object into concrete numeric config +
* ready-to-use layer lists. Missing fields are left undefined so callers can
* apply defaults.
*/
export function normalizeDeckConfig(cfg: DeckConfigYaml): NormalizedDeckConfig {
const size = parseDimension(cfg.size);
const grid = parseDimension(cfg.grid);
return {
sizeW: size?.[0],
sizeH: size?.[1],
gridW: grid?.[0],
gridH: grid?.[1],
bleed: typeof cfg.bleed === "string" ? Number(cfg.bleed) : cfg.bleed,
padding:
typeof cfg.padding === "string" ? Number(cfg.padding) : cfg.padding,
shape: cfg.shape,
fixed: cfg.fixed,
frontLayers: layersToConfigs(cfg.layers),
backLayers: layersToConfigs(cfg.back_layers),
};
}
@@ -1,7 +1,7 @@
import { For, createSignal, onCleanup, onMount } from "solid-js";
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
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 alignCenterIcon from "./icons/align-center.png";
import alignRightIcon from "./icons/align-right.png";
@@ -17,7 +17,7 @@ export interface LayerRowProps {
setOpenDropdown: (val: string | null) => void;
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
onUpdateFontSize: (fs?: number) => void;
onUpdateAlign: (a?: "l" | "c" | "r") => void;
onUpdateAlign: (a?: Align) => void;
onSelect: () => void;
onRemove: () => void;
}
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
{ value: "w" as const, label: "← 西" },
];
const ALIGNS = [
{ value: "" as const, icon: alignCenterIcon },
{ value: "l" as const, icon: alignLeftIcon },
{ value: "c" as const, icon: alignCenterIcon },
{ value: "r" as const, icon: alignRightIcon },
const ALIGNS: { value: Align | ""; icon: string }[] = [
{ value: "", icon: alignCenterIcon },
{ value: "l", icon: alignLeftIcon },
{ value: "c", icon: alignCenterIcon },
{ 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;
@@ -53,14 +59,28 @@ function orientChar(v: string) {
}
}
function alignSrc(v: string) {
function alignIcon(v: string): import("solid-js").JSX.Element {
switch (v) {
case "tl":
return "↖";
case "tc":
return "↑";
case "tr":
return "↗";
case "bl":
return "↙";
case "bc":
return "↓";
case "br":
return "↘";
case "l":
return alignLeftIcon;
return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
case "r":
return alignRightIcon;
return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
default:
return alignCenterIcon;
return (
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
);
}
}
@@ -120,7 +140,7 @@ export function LayerRow(props: LayerRowProps) {
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
onClick={props.onSelect}
>
{props.layer.prop}
{props.layer.prop || (props.layer.template ? "(模板)" : "")}
</span>
<DropdownButton
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
</DropdownButton>
<DropdownButton
icon={
<img
src={alignSrc(props.layer.align || "")}
alt="align"
class="w-5 h-5 not-prose"
/>
}
icon={alignIcon(props.layer.align || "")}
visible={props.layer.visible}
open={props.openDropdown === `align-${props.index}`}
onToggle={() =>
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
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"
>
<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>
)}
</For>
+65 -13
View File
@@ -1,7 +1,8 @@
import { createStore } from "solid-js/store";
import yaml from "js-yaml";
import { calculateDimensions } from "./dimensions";
import { loadCSV, CSV } from "../../utils/csv-loader";
import { formatLayers, initLayerConfigsForSide } from "./layer-parser";
import { loadCSVFromPath, CSV } from "../../utils/csv-loader";
import { formatLayers } from "./layer-parser";
import * as layerCrud from "./layer-crud";
import type {
CardData,
@@ -41,6 +42,8 @@ export interface DeckState {
cornerRadius: number;
shape: CardShape;
fixed: boolean;
/** True when the deck was configured via a yaml role=tag codeblock (data-config). */
isYamlBlock: boolean;
src: string;
rawSrc: string;
@@ -85,6 +88,7 @@ export interface DeckActions {
setPadding: (padding: number) => void;
setCornerRadius: (cornerRadius: number) => void;
setShape: (shape: CardShape) => void;
setIsYamlBlock: (isYamlBlock: boolean) => void;
setCards: (cards: CSV<CardData>) => void;
setActiveTab: (index: number) => void;
@@ -138,8 +142,8 @@ export interface DeckActions {
loadCardsFromPath: (
path: string,
rawSrc: string,
layersStr?: string,
backLayersStr?: string,
frontLayers?: LayerConfig[],
backLayers?: LayerConfig[],
) => Promise<void>;
setError: (error: string | null) => void;
clearError: () => void;
@@ -176,6 +180,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
cornerRadius: DECK_DEFAULTS.CORNER_RADIUS,
shape: "rectangle",
fixed: false,
isYamlBlock: false,
src: initialSrc,
rawSrc: initialSrc,
dimensions: null,
@@ -244,6 +249,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const setShape = (shape: CardShape) => {
setState({ shape });
};
const setIsYamlBlock = (isYamlBlock: boolean) => {
setState({ isYamlBlock });
};
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
const setActiveTab = (index: number) => setState({ activeTab: index });
@@ -442,8 +450,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const loadCardsFromPath = async (
path: string,
rawSrc: string,
layersStr: string = "",
backLayersStr: string = "",
frontLayers: LayerConfig[] = [],
backLayers: LayerConfig[] = [],
) => {
if (!path) {
setState({ error: "未指定 CSV 文件路径" });
@@ -453,7 +461,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
try {
const data = await loadCSV(path);
const data = await loadCSVFromPath(path);
if (data.length === 0) {
setState({
@@ -466,12 +474,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({
cards: data,
activeTab: 0,
frontLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, layersStr),
),
backLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, backLayersStr),
),
frontLayerConfigs: layerCrud.withKeys(frontLayers),
backLayerConfigs: layerCrud.withKeys(backLayers),
isLoading: false,
});
updateDimensions();
@@ -487,6 +491,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const clearError = () => setState({ error: null });
const generateCode = (backLayersStr?: string) => {
if (state.isYamlBlock) {
return generateYamlCode();
}
const frontLayersStr = formatLayers(state.frontLayerConfigs);
const backLayersString =
backLayersStr || formatLayers(state.backLayerConfigs);
@@ -514,6 +521,50 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
return parts.join("");
};
/** Serialize the deck back to a yaml codeblock (round-trips templates). */
const generateYamlCode = () => {
const toYamlLayer = (l: LayerConfig) => {
const out: Record<string, unknown> = {};
if (l.template) {
out.template = l.template;
} else {
out.prop = l.prop ?? "";
}
out.pos = `${l.x1},${l.y1}-${l.x2},${l.y2}`;
if (l.fontSize) out.font = l.fontSize;
if (l.orientation && l.orientation !== "n") out.orientation = l.orientation;
if (l.align) out.align = l.align;
if (!l.visible) out.visible = false;
return out;
};
const config: Record<string, unknown> = {
size: `${state.sizeW}x${state.sizeH}`,
grid: `${state.gridW}x${state.gridH}`,
layers: state.frontLayerConfigs.map(toYamlLayer),
};
if (state.bleed !== DECK_DEFAULTS.BLEED) config.bleed = state.bleed;
if (state.padding !== DECK_DEFAULTS.PADDING) config.padding = state.padding;
if (state.shape !== "rectangle") config.shape = state.shape;
if (state.backLayerConfigs.length > 0) {
config.back_layers = state.backLayerConfigs.map(toYamlLayer);
}
const doc = {
tag: "md-deck",
body: state.rawSrc || state.src,
"data-config": config,
};
const yamlStr = yaml.dump(doc, {
indent: 2,
lineWidth: -1,
noRefs: true,
});
const fence = "```yaml role=tag";
const backtick = "`";
return `${fence}\n${yamlStr}${backtick.repeat(3)}`;
};
const copyCode = async (fallback?: (code: string) => void) => {
const code = generateCode();
try {
@@ -573,6 +624,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setPadding,
setCornerRadius,
setShape,
setIsYamlBlock,
setCards,
setActiveTab,
updateCardData,
+8 -5
View File
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
/**
* 解析 layers 字符串
* 格式: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[] {
if (!layersStr) return [];
const layers: Layer[] = [];
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align]
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
const regex =
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g;
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
let match;
while ((match = regex.exec(layersStr)) !== null) {
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
y2: parseInt(match[5]),
fontSize: match[6] ? parseFloat(match[6]) : 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"],
});
}
@@ -35,8 +36,10 @@ export function parseLayers(layersStr: string): Layer[] {
* 格式化 layers 为字符串
*/
export function formatLayers(layers: LayerConfig[]): string {
// Template-only layers have no prop and can't be represented in the
// compact string format, so they are skipped here.
return layers
.filter((l) => l.visible)
.filter((l) => l.visible && l.prop)
.map((l) => {
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
if (l.fontSize) {
+48 -19
View File
@@ -2,9 +2,10 @@ import { customElement, noShadowDOM } from "solid-element";
import { Show, onCleanup } from "solid-js";
import { resolvePath } from "../utils/path";
import { v4 as uuidv4 } from "uuid";
import { createDeckStore } from "./hooks/deckStore";
import { createDeckStore, DECK_DEFAULTS } from "./hooks/deckStore";
import { registerDeck, unregisterDeck } from "./hooks/deck-registry";
import type { CardShape } from "./types";
import type { CardShape, LayerConfig } from "./types";
import { normalizeDeckConfig, layersToConfigs } from "./config";
import { DeckHeader } from "./DeckHeader";
import { CardList } from "./CardList";
import { DeckContent } from "./DeckContent";
@@ -68,49 +69,77 @@ customElement<DeckProps>(
const deckId = `deck-${uuidv4()}`;
registerDeck(deckId, store, resolvedSrc, csvPath);
// 读取 data-configyaml role=tag 代码块方式):结构化配置优先
let config:
| ReturnType<typeof normalizeDeckConfig>
| undefined;
const dataConfigAttr = element?.getAttribute("data-config");
if (dataConfigAttr) {
try {
config = normalizeDeckConfig(JSON.parse(dataConfigAttr));
// 记录来源,复制代码时输出 yaml role=tag 代码块
store.actions.setIsYamlBlock(true);
} catch (e) {
console.error("Invalid data-config on md-deck:", e);
}
}
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
if (props.size && props.size.includes("x")) {
if (config?.sizeW !== undefined && config.sizeH !== undefined) {
store.actions.setSizeW(config.sizeW);
store.actions.setSizeH(config.sizeH);
} else if (props.size && props.size.includes("x")) {
const [w, h] = props.size.split("x").map(Number);
store.actions.setSizeW(w);
store.actions.setSizeH(h);
} else {
store.actions.setSizeW(props.sizeW ?? 54);
store.actions.setSizeH(props.sizeH ?? 86);
store.actions.setSizeW(props.sizeW ?? DECK_DEFAULTS.SIZE_W);
store.actions.setSizeH(props.sizeH ?? DECK_DEFAULTS.SIZE_H);
}
// 解析 grid 属性(支持旧格式 "5x8" 和新格式)
if (props.grid && props.grid.includes("x")) {
if (config?.gridW !== undefined && config.gridH !== undefined) {
store.actions.setGridW(config.gridW);
store.actions.setGridH(config.gridH);
} else if (props.grid && props.grid.includes("x")) {
const [w, h] = props.grid.split("x").map(Number);
store.actions.setGridW(w);
store.actions.setGridH(h);
} else {
store.actions.setGridW(props.gridW ?? 5);
store.actions.setGridH(props.gridH ?? 8);
store.actions.setGridW(props.gridW ?? DECK_DEFAULTS.GRID_W);
store.actions.setGridH(props.gridH ?? DECK_DEFAULTS.GRID_H);
}
// 解析 bleed 和 padding(支持旧字符串格式和新数字格式)
if (typeof props.bleed === "string") {
if (config?.bleed !== undefined) {
store.actions.setBleed(config.bleed);
} else if (typeof props.bleed === "string") {
store.actions.setBleed(Number(props.bleed));
} else {
store.actions.setBleed(props.bleed ?? 1);
store.actions.setBleed(props.bleed ?? DECK_DEFAULTS.BLEED);
}
if (typeof props.padding === "string") {
if (config?.padding !== undefined) {
store.actions.setPadding(config.padding);
} else if (typeof props.padding === "string") {
store.actions.setPadding(Number(props.padding));
} else {
store.actions.setPadding(props.padding ?? 2);
store.actions.setPadding(props.padding ?? DECK_DEFAULTS.PADDING);
}
// 设置形状
store.actions.setShape(props.shape ?? "rectangle");
store.actions.setShape(config?.shape ?? props.shape ?? "rectangle");
// 确定前后图层(data-config 优先,回退旧 layers 字符串)
const frontLayers: LayerConfig[] = config
? config.frontLayers
: layersToConfigs((props.layers as string) || "");
const backLayers: LayerConfig[] = config
? config.backLayers
: layersToConfigs((props.backLayers as string) || "");
// 加载 CSV 数据
store.actions.loadCardsFromPath(
resolvedSrc,
csvPath,
(props.layers as string) || "",
(props.backLayers as string) || "",
);
store.actions.loadCardsFromPath(resolvedSrc, csvPath, frontLayers, backLayers);
// 清理函数
onCleanup(() => {
+26 -4
View File
@@ -4,21 +4,43 @@ export interface CardData {
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 interface Layer {
prop: string;
/** CSV column the layer reads, when it renders a prop value. */
prop?: string;
/** Markdown template (with {{var}} substitution) when the layer renders a template. */
template?: string;
x1: number;
y1: number;
x2: number;
y2: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: "l" | "c" | "r";
align?: Align;
}
export interface LayerConfig {
prop: string;
/** CSV column the layer reads, when it renders a prop value. */
prop?: string;
/** Markdown template (with {{var}} substitution) when the layer renders a template. */
template?: string;
visible: boolean;
x1: number;
y1: number;
@@ -26,7 +48,7 @@ export interface LayerConfig {
y2: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: "l" | "c" | "r";
align?: Align;
_key?: number;
}
+2 -2
View File
@@ -42,7 +42,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
});
return (
<div class="md-embed not-prose">
<div class="md-embed">
<Show when={content.loading}>
<div class="text-gray-400 italic">...</div>
</Show>
@@ -54,7 +54,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
<Show when={!content.loading && !content.error && content()}>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
<div
class="prose"
class="prose text-black prose-sm"
innerHTML={parseMarkdown(content()!, resolvedPath)}
/>
</Show>
+32 -19
View File
@@ -7,13 +7,14 @@ import {
createMemo,
createResource,
} from "solid-js";
import { marked } from "../markdown";
import { loadCSV, CSV, processVariables, isCSV } from "./utils/csv-loader";
import { resolvePath } from "./utils/path";
import { parseMarkdown } from "../markdown";
import { parseCSVString, CSV, processVariables } from "./utils/csv-loader";
import { resolveContentRef } from "./utils/resolve-content";
import { parseSparkTableCsv } from "./utils/spark-table";
import {
areAllLabelsNumeric,
weightedRandomIndex,
} from "./utils/weighted-random";
} from "./utils/weighted-random";;
export interface TableProps {
roll?: boolean;
@@ -51,20 +52,31 @@ customElement(
const articleEl = element?.closest("article[data-src]");
const articlePath = articleEl?.getAttribute("data-src") || "";
// 如果是 inline CSV直接使用否则解析相对路径
const contentOrPath = isCSV(rawContent)
? rawContent
: resolvePath(articlePath, rawContent);
// 解析引用:inline CSV 直接使用否则通过 registry 解析(含内联内容 id
const [csvData] = createResource(
() => ({ ref: rawContent, docPath: articlePath }),
async ({ ref, docPath }) => {
const content = await resolveContentRef(ref, docPath);
if (content === null) {
throw new Error(`Failed to resolve table content: "${ref}"`);
}
return content;
},
);
// 使用 createResource 加载 CSV,自动响应路径变化并避免重复加载
const [csvData] = createResource(() => contentOrPath, loadCSV);
// 当数据加载完成后更新 rows
// 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager
// 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。
createEffect(() => {
const data = csvData();
if (data) {
// 将加载的数据赋值给 rowsCSV 类型已经包含 sourcePath 等属性
setRows(data as unknown as CSV<TableRow>);
const content = csvData();
if (!content) return;
setRows(parseCSVString(content) as unknown as CSV<TableRow>);
if (element) {
const meta = parseSparkTableCsv(content);
if (meta) {
element.setAttribute("data-spark", meta.slug);
} else {
element.removeAttribute("data-spark");
}
}
});
@@ -97,10 +109,11 @@ customElement(
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
const processBody = (body: string, currentRow: TableRow): string => {
// 使用 marked 解析 markdown
return marked.parse(
// 使用 parseMarkdown 统一入口(设置图标 base path 等)
return parseMarkdown(
processVariables(body, currentRow, rows(), filteredRows(), props.remix),
) as string;
articlePath,
);
};
// 更新 body 内容
+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 字符串
* @template T 返回数据的类型,默认为 Record<string, string>
@@ -102,18 +66,12 @@ export function parseCSVString<T = Record<string, string>>(csvString: string, so
/**
* 加载 CSV 文件
* @template T 返回数据的类型,默认为 Record<string, string>
* @param pathOrContent 文件路径或 inline CSV 字符串
* @param path 文件路径(通过 file-index 获取内容)
* @returns 解析后的 CSV 数据
*/
export async function loadCSV<T = Record<string, string>>(pathOrContent: string): Promise<CSV<T>> {
// 检测是否是 inline CSV 数据
if (isCSV(pathOrContent)) {
return parseCSVString<T>(pathOrContent, 'inline');
}
// 从索引获取文件内容
const content = await getIndexedData(pathOrContent);
return parseCSVString<T>(content, pathOrContent);
export async function loadCSVFromPath<T = Record<string, string>>(path: string): Promise<CSV<T>> {
const content = await getIndexedData(path);
return parseCSVString<T>(content, path);
}
type JSONData = JSONArray | JSONObject | string | number | boolean | null;
+36
View File
@@ -0,0 +1,36 @@
import { resolveContent } from "../../cli/content-registry";
import { getRegistry } from "../journal/completions";
import { getIndexedData } from "../../data-loader/file-index";
import { resolvePath } from "./path";
/**
* Resolve a content reference (a directive body) to raw content.
*
* Prefers the shared content-registry resolution, which understands:
* - inline CSV bodies (returned as-is)
* - inline doc content refs like `./csv_abc123` (scoped to the current doc)
* - absolute paths and relative paths resolved against the current doc
*
* Falls back to path resolution + the file index when the registry isn't
* populated yet (e.g. before completions load) or the ref isn't indexed.
*
* Returns `null` when nothing matches.
*/
export async function resolveContentRef(
ref: string,
docPath: string,
): Promise<string | null> {
const trimmed = ref.trim();
if (!trimmed) return null;
const resolved = resolveContent(getRegistry(), docPath, trimmed);
if (resolved !== null) return resolved;
// Fallback: resolve relative path and fetch through the file index.
const path = resolvePath(docPath, trimmed);
try {
return await getIndexedData(path);
} catch {
return null;
}
}
+3 -1
View File
@@ -53,7 +53,9 @@ export interface SparkTableMeta {
// 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.
+12 -1
View File
@@ -17,6 +17,7 @@ import {
removeHandle,
ensurePermission,
} from "./file-index-db";
import { normalizePathKey } from "../cli/content-registry";
type FileIndex = Record<string, string>;
@@ -99,7 +100,7 @@ async function scanDirectory(
Object.assign(index, sub);
} else if (entry.kind === "file" && acceptedExt.test(name)) {
const file = await (entry as FileSystemFileHandle).getFile();
const path = prefix + name;
const path = normalizePathKey(prefix + name);
index[path] = await file.text();
}
}
@@ -211,6 +212,16 @@ export async function getIndexedData(path: string): Promise<string> {
return content;
}
/**
* 写入/覆盖索引中的文件内容。
* 用于将处理后的内容(如 registry 的 stripped markdown)写回索引,
* 使浏览器模式与 CLI 模式渲染一致。
*/
export function setIndexedData(path: string, content: string): void {
fileIndex = fileIndex || {};
fileIndex[normalizePathKey(path)] = content;
}
/**
* 获取指定扩展名的文件路径
*/
+27 -1
View File
@@ -6,7 +6,7 @@ title: 卡牌组件
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
**语法:** `:md-deck[./cards.csv]{选项}`
**语法:** `:md-deck[./cards.csv]{选项}` 或 yaml 代码块
**基础卡牌:**
:md-deck[./spells.csv]{grid="3x3"}
@@ -16,6 +16,32 @@ title: 卡牌组件
CSV 包含数据字段列,通过图层定义控制各字段的位置、大小和对齐。
## 结构化配置 (yaml 代码块)
推荐 ```yaml role=tag 代码块(`yaml` 语言可被语法高亮),用 `body` 指定 CSV 路径,`data-config` 提供结构化配置,图层可以是字段(`prop`)或 markdown 模板(`template`,支持 `{{变量}}` 替换):
````markdown
```yaml role=tag
tag: md-deck
body: ./cards.csv
data-config:
size: 63x88
grid: 5x5
layers:
- prop: title
pos: 1,1-5,1
font: 12
- template: |
**{{name}}** — {{type}}
{{description}}
pos: 1,3-5,8
font: 3
align: l
```
````
`pos` 为网格位置 `x1,y1-x2,y2`1-based,与紧凑 layers 字符串同格式)。
## 图层格式
`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 };
}
+82
View File
@@ -0,0 +1,82 @@
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
const ext = markedCodeBlockYamlTag().extensions?.[0] as unknown as {
tokenizer: (src: string) => any;
renderer: (token: any) => string;
};
function render(src: string): string {
const token = ext.tokenizer(src);
return ext.renderer(token);
}
describe("code-block-yaml-tag", () => {
test("renders body and scalar props as attributes", () => {
const html = render(
"```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\nsize: 54x86\n```",
);
expect(html).toContain("<md-deck size=\"54x86\">./cards.csv</md-deck>");
});
test("serializes data-config to a JSON attribute", () => {
const html = render(
[
"```yaml role=tag",
"tag: md-deck",
"body: ./cards.csv",
"data-config:",
" size: 63x88",
" layers:",
" - prop: title",
" pos: 1,1-5,1",
" font: 12",
"```",
].join("\n"),
);
expect(html).toContain("<md-deck data-config=");
expect(html).toContain("./cards.csv</md-deck>");
// data-config must be valid JSON with escaped quotes for the attribute
const m = html.match(/data-config="([^"]*)"/);
expect(m).not.toBeNull();
const decoded = (m![1] || "").replace(/&quot;/g, '"');
const config = JSON.parse(decoded);
expect(config.size).toBe("63x88");
expect(config.layers).toHaveLength(1);
expect(config.layers[0]).toMatchObject({
prop: "title",
pos: "1,1-5,1",
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", () => {
const html = render("```yaml role=tag\nclass: foo\n```");
expect(html).toContain("<tag-unknown class=\"foo\"></tag-unknown>");
});
});
+34 -8
View File
@@ -1,6 +1,20 @@
import type { MarkedExtension } from "marked";
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 {
return {
extensions: [
@@ -8,22 +22,30 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
name: "code-block-yaml-tag",
level: "block",
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) {
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);
if (match) {
const yamlContent = match[1]?.trim() || "";
let props: Record<string, unknown> = {};
const yamlContent = match[2]?.trim() || "";
let yamlProps: Record<string, unknown> = {};
try {
props =
yamlProps =
(yaml.load(yamlContent) as Record<string, unknown>) || {};
} catch (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 { tag, ...rest } = props;
@@ -33,9 +55,13 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
delete (rest as Record<string, unknown>).body;
}
// `data-*` props may hold structured YAML values, so they are
// serialized to JSON strings (e.g. md-deck layers/templates).
const propsStr = Object.entries(rest)
.map(([key, value]) => {
const strValue = String(value);
const strValue = key.startsWith("data-")
? JSON.stringify(value)
: String(value);
if (strValue.includes(" ") || strValue.includes('"')) {
return `${key}="${strValue.replace(/"/g, "&quot;")}"`;
}
@@ -59,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 markedAlert from "marked-alert";
import markedMermaid from "./mermaid";
import markedTable from "./table";
import { gfmHeadingId } from "marked-gfm-heading-id";
import markedColumns from "./columns";
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
@@ -14,7 +13,6 @@ const marked = new Marked()
.use(gfmHeadingId())
.use(markedAlert())
.use(markedMermaid())
.use(markedTable())
.use(markedCodeBlockYamlTag())
.use(
createDirectives([
-80
View File
@@ -1,80 +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 = "";
const labelIndex = header.findIndex((cell) => {
if (cell.text === "md-roll-label" || cell.text.match(/(\d+)?d\d+/)) {
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`;
},
},
};
}
+5
View File
@@ -84,6 +84,11 @@ icon.big .icon-label-stroke {
}
}
/* md-embed — inherit parent font size (e.g. card layer font-size) */
.md-embed .prose {
font-size: inherit;
}
/*alert*/
.markdown-alert-title {