Compare commits

..
21 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
hyper 0a68122efd refactor(cli): centralize content registry building
Extract buildRegistryFromIndex and normalizePathKey from the CLI serve
command and the browser completions module into the shared
content-registry module. This ensures both paths produce identical
pathIndex/docContent, including the spark-injection pass that was
previously only applied in the CLI.
2026-08-07 12:46:08 +08:00
hyper e8aa7165cb refactor(completions): centralize content scanning
Add a browser-safe ContentRegistry owning path and inline content
stores, then derive completions from it. Remove the old block-processor,
directive-scanner, and link-source modules in favor of scanDoc,
deriveCompletions, and injectSparkDirectives, and register an inline
resolver for the frontend file index.
2026-08-07 12:17:35 +08:00
hyper 9e081891df feat(md-deck): Add fallback copy dialog for clipboard failure 2026-08-07 12:13:40 +08:00
hyper e3daac3080 fix(md-deck): Make active card state reactive
Wrap active in a getter so it updates when activeTab changes.
2026-08-07 12:07:54 +08:00
hyper a69a41875d fix: Prevent layer interactions when not editing
Guard layer selection, cursor, and resize interactions behind
the editing state to avoid unintended modifications in view mode.
2026-08-07 11:24:57 +08:00
hyper 6bcdca3a77 feat(md-deck): Add card list navigation and tabbed editor panels
- Move card tab selector to a left sidebar (CardList) with pagination
- Replace separate side toggle and layer panels with EditorTabs
- Move copy code button from layer panel to DeckHeader
- Simplify PropertiesEditorPanel by removing front/back toggle and
  using a select for shape
2026-08-07 11:20:26 +08:00
49 changed files with 2631 additions and 1388 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?$': [
+36 -110
View File
@@ -2,23 +2,18 @@ import type { ServeCommandHandler } from "../types.js";
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
import { createReadStream } from "fs";
import { join, resolve, extname, sep, relative, dirname } from "path";
import { join, resolve, extname, relative, dirname } from "path";
import { watch } from "chokidar";
import { networkInterfaces } from "os";
import { fileURLToPath } from "url";
import { createJournalServer } from "../journal.js";
import {
scanCompletions,
type CompletionsPayload,
} from "../completions/index.js";
import {
processBlocks,
type ProcessedBlocks,
} from "../completions/block-processor.js";
import {
scanDirectives,
type DirectiveScanResult,
} from "../completions/directive-scanner.js";
buildRegistryFromIndex,
normalizePathKey,
deriveCompletions,
type ContentRegistry,
} from "../content-registry.js";
import type { CompletionsPayload } from "../completions/types.js";
interface ContentIndex {
[path: string]: string;
@@ -88,20 +83,10 @@ function getBestIP(): string {
}
/**
* 扫描目录内的 .md 等文件,生成内容索引与块数据
* 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
*/
export function scanDirectory(dir: string): {
index: ContentIndex;
blocks: ProcessedBlocks;
directiveResults: DirectiveScanResult[];
} {
const index: ContentIndex = {};
const blocks: ProcessedBlocks = {
declarations: [],
tagModifiers: [],
};
const directiveResults: DirectiveScanResult[] = [];
const mdFiles: { content: string; relPath: string }[] = [];
export function buildRegistry(dir: string): ContentRegistry {
const index: Record<string, string> = {};
function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath);
@@ -111,7 +96,7 @@ export function scanDirectory(dir: string): {
const fullPath = join(currentPath, entry);
const relPath = relativePath ? join(relativePath, entry) : entry;
const normalizedRelPath = "/" + relPath.split(sep).join("/");
const normalizedRelPath = normalizePathKey(relPath);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
@@ -123,16 +108,7 @@ export function scanDirectory(dir: string): {
entry.endsWith(".svg")
) {
try {
const content = readFileSync(fullPath, "utf-8");
if (entry.endsWith(".md")) {
const result = processBlocks(content, normalizedRelPath, index);
index[normalizedRelPath] = result.stripped;
blocks.declarations.push(...result.blocks.declarations);
blocks.tagModifiers.push(...result.blocks.tagModifiers);
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
} else {
index[normalizedRelPath] = content;
}
index[normalizedRelPath] = readFileSync(fullPath, "utf-8");
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
}
@@ -142,31 +118,7 @@ export function scanDirectory(dir: string): {
scan(dir, "");
// ---- Directive scanning pass (after all blocks processed) ----
const posixDir = dir.split(sep).join("/");
for (const { content, relPath } of mdFiles) {
const fileDir = posixRelDir(relPath);
const result = scanDirectives(content, relPath, index, fileDir);
// Apply rewritten content back to index
index[relPath] = result.rewritten;
// Inject new index entries (inline CSV bodies)
for (const [key, value] of Object.entries(result.newIndexEntries)) {
index[key] = value;
}
directiveResults.push(result);
}
return { index, blocks, directiveResults };
}
/** Get the POSIX directory of a file path */
function posixRelDir(filePath: string): string {
const parts = filePath.split("/");
parts.pop();
return parts.join("/") || ".";
return buildRegistryFromIndex(index);
}
/**
@@ -231,6 +183,7 @@ function createRequestHandler(
distDir: string,
getIndex: () => ContentIndex,
getCompletions: () => CompletionsPayload,
getRegistry: () => ContentRegistry,
) {
return (req: IncomingMessage, res: ServerResponse) => {
const url = req.url || "/";
@@ -248,6 +201,12 @@ function createRequestHandler(
return;
}
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
if (filePath === "/__CONTENT_REGISTRY.json") {
sendJson(res, getRegistry());
return;
}
// 2. 处理 /static/ 目录(从 dist/web
if (filePath.startsWith("/static/")) {
if (tryServeStatic(res, filePath, distDir)) {
@@ -308,12 +267,7 @@ export function createContentServer(
distPath: string = distDir,
host: string = "0.0.0.0",
): ContentServer {
let contentIndex: ContentIndex = {};
let collectedBlocks: ProcessedBlocks = {
declarations: [],
tagModifiers: [],
};
let directiveResults: DirectiveScanResult[] = [];
let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
@@ -322,21 +276,18 @@ export function createContentServer(
tagModifiers: [],
};
/** 从当前内容索引和已收集的块重新扫描补全数据 */
/** 从当前注册表重新派生补全数据 */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
completionsIndex = deriveCompletions(registry);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
);
}
// 扫描内容目录生成索引
// 扫描内容目录生成注册表
console.log("正在扫描内容目录...");
const scanResult = scanDirectory(contentDir);
contentIndex = scanResult.index;
collectedBlocks = scanResult.blocks;
directiveResults = scanResult.directiveResults;
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
registry = buildRegistry(contentDir);
console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
recomputeCompletions();
// 监听文件变化
@@ -356,20 +307,10 @@ export function createContentServer(
path.endsWith(".svg")
) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
if (relPath.endsWith(".md")) {
const result = processBlocks(content, relPath, contentIndex);
contentIndex[relPath] = result.stripped;
// Re-scan to get fresh blocks (simpler than per-file merge)
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
// 全量重建注册表以刷新跨文件派生(spark 注入)
registry = buildRegistry(contentDir);
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
console.log(`[新增] ${relPath}`);
console.log(`[新增] ${path}`);
} catch (e) {
console.error(`读取新增文件失败:${path}`, e);
}
@@ -383,19 +324,9 @@ export function createContentServer(
path.endsWith(".svg")
) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
if (relPath.endsWith(".md")) {
const result = processBlocks(content, relPath, contentIndex);
contentIndex[relPath] = result.stripped;
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
registry = buildRegistry(contentDir);
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
console.log(`[更新] ${relPath}`);
console.log(`[更新] ${path}`);
} catch (e) {
console.error(`读取更新文件失败:${path}`, e);
}
@@ -408,15 +339,9 @@ export function createContentServer(
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath];
console.log(`[删除] ${relPath}`);
if (relPath.endsWith(".md")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
registry = buildRegistry(contentDir);
recomputeCompletions();
}
console.log(`[删除] ${path}`);
}
});
@@ -427,8 +352,9 @@ export function createContentServer(
const handleRequest = createRequestHandler(
contentDir,
distPath,
() => contentIndex,
() => registry.pathIndex,
() => completionsIndex,
() => registry,
);
const server = createServer(handleRequest);
@@ -452,7 +378,7 @@ export function createContentServer(
return {
server,
watcher,
index: contentIndex,
index: registry.pathIndex,
completions: completionsIndex,
close() {
console.log("正在关闭内容服务器...");
-144
View File
@@ -1,144 +0,0 @@
/**
* CLI block processor — wraps block-scanner with Node-specific index injection
* and content stripping.
*
* Uses Node `crypto` and `path` — not safe for browser import.
*/
import { posix } from "path";
import { createHash } from "crypto";
import { parseDeclareCsv, type VarDeclaration, type TagModifier } from "./declare-parser.js";
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
} from "./block-scanner.js";
// Re-export shared pieces for convenience
export {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
type BlockAttrs,
} from "./block-scanner.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ProcessedBlocks {
declarations: VarDeclaration[];
tagModifiers: TagModifier[];
}
export interface BlockResult {
/** Content with blocks processed (stripped or replaced with directives) */
stripped: string;
/** Parsed blocks for completions */
blocks: ProcessedBlocks;
}
// ---------------------------------------------------------------------------
// Content hash
// ---------------------------------------------------------------------------
function contentHash(body: string): string {
return createHash("md5").update(body).digest("hex").slice(0, 8);
}
// ---------------------------------------------------------------------------
// Main processor
// ---------------------------------------------------------------------------
/**
* Process all attributed fenced code blocks in a markdown file.
*
* - Strips/replaces blocks based on `as`
* - Injects `role=file` bodies into the content index
* - Collects declare blocks for completions
* - role=spark-table blocks are converted to :md-table directives
* (spark table completions are collected later by the directive scanner)
*/
export function processBlocks(
content: string,
fileRelativePath: string,
index: Record<string, string>,
): BlockResult {
const fileDir = posix.dirname(fileRelativePath);
const blocks: ProcessedBlocks = {
declarations: [],
tagModifiers: [],
};
const stripped = content.replace(
FENCED_BLOCK_RE,
(
_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);
// ---- Dispatch by role ----
if (attrs.role === "declare") {
try {
const result = parseDeclareCsv(body, fileRelativePath);
blocks.declarations.push(...result.variables);
blocks.tagModifiers.push(...result.tagModifiers);
} catch (e) {
console.warn(`[block-processor] ${fileRelativePath}: ${e}`);
}
}
if (attrs.role === "file") {
const filename = attrs.id
? `${attrs.id}.${attrs.lang || "txt"}`
: `_inline_${contentHash(body)}.${attrs.lang || "txt"}`;
const resolvedPath = posix.join(fileDir, filename);
index[resolvedPath] = body;
}
// ---- Render by as ----
if (effectiveAs === "codeblock") {
return _match; // keep as-is
}
if (effectiveAs === "none") {
return ""; // strip
}
// Directive: :md-table[./file.csv], :md-card[./file.csv], :md-dice[./file.csv]
if (effectiveAs.startsWith("md-")) {
const filename = attrs.id
? `${attrs.id}.${attrs.lang || "txt"}`
: `_inline_${contentHash(body)}.${attrs.lang || "txt"}`;
const resolvedPath = posix.join(fileDir, filename);
// Ensure body is in the index for directive rendering
if (!index[resolvedPath]) {
index[resolvedPath] = body;
}
// Collect extra attrs for the directive
const extra = { ...attrs.extra };
const extraStr = Object.keys(extra).length
? `{${Object.entries(extra)
.map(([k, v]) => `${k}=${v}`)
.join(" ")}}`
: "";
return `:${effectiveAs}[./${filename}]${extraStr}`;
}
// Unknown as → strip
return "";
},
);
return { stripped, blocks };
}
+12 -53
View File
@@ -2,25 +2,21 @@
* 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
@@ -28,40 +24,3 @@ export interface BlockAttrs {
/** 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";
}
+3 -37
View File
@@ -16,34 +16,6 @@
*/
import { parse } from "csv-parse/browser/esm/sync";
import { FENCED_BLOCK_RE, parseBlockAttrs } from "./block-scanner.js";
/**
* Scan markdown content for ```csv role=declare blocks and return
* parsed declarations and tag modifiers. Shared between CLI and client.
*/
export function scanDeclareBlocks(content: string, filePath: string): DeclareResult {
const variables: VarDeclaration[] = [];
const tagModifiers: TagModifier[] = [];
FENCED_BLOCK_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = FENCED_BLOCK_RE.exec(content)) !== null) {
const [, , infoString, body] = m;
const attrs = parseBlockAttrs(infoString);
if (attrs.role !== "declare") continue;
try {
const result = parseDeclareCsv(body, filePath);
variables.push(...result.variables);
tagModifiers.push(...result.tagModifiers);
} catch (e) {
console.warn(`[declare-parser] ${filePath}: ${e}`);
}
}
return { variables, tagModifiers };
}
// ---------------------------------------------------------------------------
// Types
@@ -104,14 +76,10 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
if (tag) {
// Tag modifier
if (!tag.startsWith("#")) {
throw new Error(
`${source}: tag must start with #, got "${tag}"`,
);
throw new Error(`${source}: tag must start with #, got "${tag}"`);
}
if (!key.startsWith("$")) {
throw new Error(
`${source}: key must start with $, got "${key}"`,
);
throw new Error(`${source}: key must start with $, got "${key}"`);
}
const thresholdRaw = row.threshold?.trim() ?? "";
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
@@ -124,9 +92,7 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
} else {
// Variable declaration
if (!key.startsWith("$")) {
throw new Error(
`${source}: key must start with $, got "${key}"`,
);
throw new Error(`${source}: key must start with $, got "${key}"`);
}
variables.push({ key, expression: expr });
}
-341
View File
@@ -1,341 +0,0 @@
/**
* Unified directive scanner — shared between CLI and browser.
*
* One pass over stripped markdown content that:
* 1. Detects markdown tables that look like spark tables → coerces to
* :md-table[./_inline_{hash}.csv] directives
* 2. Scans :md-dice[...] directives → collects DiceCompletion
* 3. Scans :md-table[...] directives → resolves CSV, checks if spark table
* → collects SparkTableCompletion
* 4. Scans :md-card[...] directives → same as md-table
*
* Safe for both Node and browser. No Node-specific imports.
*/
import Slugger from "github-slugger";
import type { DiceCompletion, SparkTableCompletion } from "./types.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface DirectiveScanResult {
/** Rewritten content with markdown tables coerced to directives */
rewritten: string;
/** Dice completions discovered */
dice: DiceCompletion[];
/** Spark table completions discovered */
sparkTables: SparkTableCompletion[];
/** New index entries for inline CSV bodies (key → CSV content) */
newIndexEntries: Record<string, string>;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const DICE_HEADER_RE = /^\d*d\d+$/i;
function contentHash(body: string): string {
// Simple hash suitable for both Node and browser
let hash = 0;
for (let i = 0; i < body.length; i++) {
const ch = body.charCodeAt(i);
hash = ((hash << 5) - hash + ch) | 0;
}
return Math.abs(hash).toString(16).slice(0, 8);
}
function looksLikeDice(raw: string): boolean {
if (raw.length > 80) return false;
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
}
/** Parse key=value pairs from 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;
}
// ---------------------------------------------------------------------------
// Markdown table → CSV conversion
// ---------------------------------------------------------------------------
/**
* Split a markdown table row into cells.
* Handles leading/trailing pipes and trims whitespace.
*/
function splitTableRow(row: string): string[] {
return row
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((c) => c.trim());
}
/**
* Escape a cell value for CSV output.
*/
function escapeCsvCell(cell: string): string {
if (
cell.includes(",") ||
cell.includes("\n") ||
cell.includes('"') ||
cell.includes("#")
) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
}
/**
* Convert a markdown table (header + separator + rows) to a CSV string.
*/
function markdownTableToCsv(
headerRow: string,
separatorRow: string,
bodyRows: string[],
): string | null {
const headers = splitTableRow(headerRow);
if (headers.length === 0) return null;
// Validate separator row (must contain dashes)
const sepCells = splitTableRow(separatorRow);
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null;
if (sepCells.length !== headers.length) return null;
const csvHeader = headers.map(escapeCsvCell).join(",");
const csvRows = bodyRows.map((row) => {
const cells = splitTableRow(row);
// Pad to match header length
while (cells.length < headers.length) cells.push("");
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
});
return [csvHeader, ...csvRows].join("\n");
}
// ---------------------------------------------------------------------------
// Spark table CSV inspection
// ---------------------------------------------------------------------------
/**
* Check if a CSV body represents a spark table.
* Returns the data column headers (excluding the dice column) if so, or null.
*/
export function inspectSparkTableCsv(csv: string): string[] | null {
const lines = csv.trim().split(/\r?\n/);
if (lines.length < 2) return 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;
return headers.slice(1);
}
/**
* Build a SparkTableCompletion from CSV data and file path.
*/
export function buildSparkTableCompletion(
csv: string,
filePath: string,
csvPath: string,
remix: boolean,
slugger: Slugger,
): SparkTableCompletion | null {
const dataHeaders = inspectSparkTableCsv(csv);
if (!dataHeaders) return null;
const lines = csv.trim().split(/\r?\n/);
const headers = lines[0].split(",").map((h) => h.trim());
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
const combinedSlug = `${fileName}-${slug}`;
return {
label: `${fileName} § ${slug}`,
notation: headers[0],
slug: combinedSlug,
filePath: basePath,
csvPath,
headers: dataHeaders,
remix,
};
}
// ---------------------------------------------------------------------------
// Main scanner
// ---------------------------------------------------------------------------
/**
* Scan a single markdown file's stripped content for directives and
* spark-shaped markdown tables.
*
* @param content - Stripped markdown content (after block processing)
* @param filePath - The file's path (e.g. "/rules/combat.md")
* @param index - The content index for resolving CSV paths
* @param fileDir - Directory of the file (for resolving relative paths)
*/
export function scanDirectives(
content: string,
filePath: string,
index: Record<string, string>,
fileDir: string,
): DirectiveScanResult {
const slugger = new Slugger();
const dice: DiceCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const newIndexEntries: Record<string, string> = {};
// ------------------------------------------------------------------
// Pass 1: Coerce spark-shaped markdown tables to :md-table directives
// ------------------------------------------------------------------
const mdTableRegex =
/^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
let rewritten = content;
let mdMatch: RegExpExecArray | null;
// Collect matches first (rewriting while iterating is tricky with regex)
interface TableMatch {
fullMatch: string;
headerRow: string;
separatorRow: string;
bodyRowsText: string;
index: number;
}
const tableMatches: TableMatch[] = [];
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
const headers = splitTableRow(headerRow);
// Check if this looks like a spark table: first column is a dice formula
const isSpark = DICE_HEADER_RE.test(headers[0]);
if (!isSpark) 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,
});
}
// Replace matches from end to start to preserve indices
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 hash = contentHash(csv);
const filename = `_spark_md_${hash}.csv`;
const resolvedPath = `${fileDir}/${filename}`;
newIndexEntries[resolvedPath] = csv;
// Collect spark table completion
const st = buildSparkTableCompletion(csv, filePath, resolvedPath, false, slugger);
if (st) {
sparkTables.push(st);
}
// Replace markdown table with :md-table directive
const directive = `:md-table[./${filename}]{data-spark="${st?.slug ?? ""}"}`;
rewritten =
rewritten.slice(0, m.index) +
directive +
rewritten.slice(m.index + m.fullMatch.length);
}
// ------------------------------------------------------------------
// Pass 2: Scan :md-dice[...] directives
// ------------------------------------------------------------------
const diceRegex = /:md-dice\[([^[\]]+)\]/gi;
let diceMatch: RegExpExecArray | null;
while ((diceMatch = diceRegex.exec(rewritten)) !== null) {
const raw = diceMatch[1].trim();
if (!raw || !looksLikeDice(raw)) continue;
dice.push({ label: raw, notation: raw, source: filePath });
}
// ------------------------------------------------------------------
// Pass 3: Scan :md-table[...] and :md-card[...] directives
// ------------------------------------------------------------------
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
let tableMatch: RegExpExecArray | null;
while ((tableMatch = tableDirectiveRegex.exec(rewritten)) !== null) {
const [, /* type */ , path, extraStr] = tableMatch;
// Resolve the CSV path
const csvPath = path.startsWith("./")
? `${fileDir}/${path.slice(2)}`
: path;
let csv = index[csvPath] ?? newIndexEntries[csvPath];
if (!csv) continue;
// Parse extra attrs for remix flag
const attrs = parseDirectiveAttrs(extraStr);
const isRemix = attrs["remix"] === "true";
const st = buildSparkTableCompletion(csv, filePath, csvPath, isRemix, slugger);
if (!st) continue;
// Check if data-spark is already set in extra attrs
if (!extraStr || !extraStr.includes("data-spark=")) {
// Inject data-spark attribute into the directive
const fullMatch = tableMatch[0];
const insertPos = fullMatch.indexOf("]") + 1;
const before = fullMatch.slice(0, insertPos);
const after = fullMatch.slice(insertPos);
const sparkAttr = `{data-spark="${st.slug}"}`;
let replacement: string;
if (after.startsWith("{")) {
// Merge into existing attrs
replacement = before + after.replace(/^\{/, `{data-spark="${st.slug}" `);
} else {
replacement = before + sparkAttr + after;
}
rewritten =
rewritten.slice(0, tableMatch.index) +
replacement +
rewritten.slice(tableMatch.index + fullMatch.length);
}
sparkTables.push(st);
}
return { rewritten, dice, sparkTables, newIndexEntries };
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Completion index — orchestrates all registered completion sources.
*/
import { linksSource } from "./sources/links.js";
import type { ProcessedBlocks } from "./block-processor.js";
import type { CompletionsPayload } from "./types.js";
import type { DirectiveScanResult } from "./directive-scanner.js";
export type {
CompletionsPayload,
DiceCompletion,
LinkCompletion,
SparkTableCompletion,
VarDeclaration,
TagModifier,
} from "./types.js";
/**
* Build completions from the content index, pre-collected blocks,
* and directive scan results.
* Called at server startup and on any file change.
*/
export function scanCompletions(
index: Record<string, string>,
blocks: ProcessedBlocks,
directiveResults: DirectiveScanResult[],
): CompletionsPayload {
const links = linksSource.scan(index) as CompletionsPayload["links"];
// Merge all directive scan results
const dice: CompletionsPayload["dice"] = [];
const sparkTables: CompletionsPayload["sparkTables"] = [];
for (const dr of directiveResults) {
dice.push(...dr.dice);
sparkTables.push(...dr.sparkTables);
}
return {
dice,
links,
sparkTables,
declarations: blocks.declarations,
tagModifiers: blocks.tagModifiers,
};
}
-55
View File
@@ -1,55 +0,0 @@
/**
* Link completion source — extracts markdown headings from all .md files.
*
* Produces two entries per heading section, plus one for the file itself.
* Uses github-slugger to match marked-gfm-heading-id's generated IDs.
*/
import Slugger from "github-slugger";
import type { CompletionSource, LinkCompletion } from "../types.js";
export const linksSource: CompletionSource = {
key: "links",
scan(index) {
const items: LinkCompletion[] = [];
for (const [filePath, content] of Object.entries(index)) {
if (!filePath.endsWith(".md")) continue;
// Strip .md extension for the router-friendly path
const basePath = filePath.replace(/\.md$/, "");
const fileName = fileNameFromPath(basePath);
const slugger = new Slugger();
// Add the file itself as a link (whole article)
items.push({
path: basePath,
label: fileName,
section: null,
});
// Parse headings for section-scoped links
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
let match: RegExpExecArray | null;
while ((match = headingRegex.exec(content)) !== null) {
const title = match[2].trim();
const id = slugger.slug(title.toLowerCase());
items.push({
path: basePath,
label: `${fileName} § ${title}`,
section: id,
});
}
}
return items;
},
};
function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || path;
}
+2
View File
@@ -36,6 +36,8 @@ export interface SparkTableCompletion {
slug: string;
/** File path of the containing .md file (without extension) */
filePath: string;
/** Path of the containing .md file (with extension) — for inline lookup */
docPath: string;
/** Resolved path to the .csv file backing this spark table */
csvPath: string;
/** Data column headers for display */
+207 -82
View File
@@ -5,18 +5,15 @@
* - variable-expression (expression evaluation)
* - var-reactivity (dependency graph, cascade, tag activation)
* - command-parser (input parsing)
* - directive-scanner (spark table detection)
* - content-registry (spark table detection)
*/
// ---------------------------------------------------------------------------
// 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,8 +44,8 @@ import { parseInput } from "../../components/journal/command-parser";
import {
inspectSparkTableCsv,
buildSparkTableCompletion,
} from "./directive-scanner";
import Slugger from "github-slugger";
isSparkTableHeader,
} from "../content-registry";
// ---------------------------------------------------------------------------
// Helpers
@@ -70,7 +67,10 @@ describe("parseDeclareCsv", () => {
,$ac,10+$dex`;
const result = parseDeclareCsv(csv, "test.md");
expect(result.variables).toHaveLength(2);
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5+$mod_hp" });
expect(result.variables[0]).toEqual({
key: "$hp",
expression: "$con*5+$mod_hp",
});
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
expect(result.tagModifiers).toHaveLength(0);
});
@@ -154,7 +154,7 @@ describe("parseDeclareCsv", () => {
const csv = `tag,key,expr
warrior,$mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'tag must start with #',
"tag must start with #",
);
});
@@ -162,7 +162,7 @@ warrior,$mod_hp,20`;
const csv = `tag,key,expr
,hp,$con*5`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'key must start with $',
"key must start with $",
);
});
@@ -170,7 +170,7 @@ warrior,$mod_hp,20`;
const csv = `tag,key,expr
#warrior,mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'key must start with $',
"key must start with $",
);
});
@@ -219,11 +219,10 @@ describe("parseBlockAttrs", () => {
test("parses standard attributes", () => {
// parseBlockAttrs receives the info string AFTER the lang.
// The lang is extracted from the fenced block regex capture group
// and applied separately in block-processor.
const attrs = parseBlockAttrs('id=stats role=declare as=none');
// and applied separately in content-registry.
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", () => {
@@ -233,7 +232,7 @@ describe("parseBlockAttrs", () => {
});
test("collects unknown attributes in extra", () => {
const attrs = parseBlockAttrs('csv role=declare foo=bar baz=42');
const attrs = parseBlockAttrs("csv role=declare foo=bar baz=42");
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
});
@@ -242,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)", () => {
@@ -252,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
@@ -278,12 +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);
expect(result.value).toEqual({ kind: "number", value: 20 });
});
test("evaluates variable references", () => {
@@ -294,50 +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);
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);
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", () => {
@@ -360,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", () => {
@@ -368,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", () => {
@@ -379,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");
});
});
@@ -424,9 +474,7 @@ describe("var-reactivity", () => {
test("detects self-referencing circular dependency", () => {
expect(() =>
initReactivity({
declarations: [
{ key: "$a", expression: "$a + 1" },
],
declarations: [{ key: "$a", expression: "$a + 1" }],
tagModifiers: [],
}),
).toThrow("Circular dependency");
@@ -527,14 +575,28 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{ tag: "#warrior", target: "$mod_str", expression: "5", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{
tag: "#warrior",
target: "$mod_str",
expression: "5",
threshold: 1,
},
],
});
// Set $class to #warrior:1 — should activate both modifiers
setBase("$class", "#warrior:1");
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
const cascade = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:1" }),
);
// Should produce combined values for both targets
const modHp = cascade.find((r) => r.key === "$mod_hp");
@@ -547,7 +609,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
],
});
@@ -572,7 +639,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 },
],
});
@@ -600,19 +672,32 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 2,
},
],
});
// Count 1 < threshold 2 — should NOT activate
setBase("$class", "#warrior:1");
const cascade1 = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
const cascade1 = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:1" }),
);
const modHp1 = cascade1.find((r) => r.key === "$mod_hp");
expect(modHp1).toBeUndefined();
// Increase to count 2 >= threshold 2 — should activate
setBase("$class", "#warrior:2");
const cascade2 = computeCascade("$class", "#warrior:1", store({ $class: "#warrior:2" }));
const cascade2 = computeCascade(
"$class",
"#warrior:1",
store({ $class: "#warrior:2" }),
);
const modHp2 = cascade2.find((r) => r.key === "$mod_hp");
expect(modHp2?.value).toBe("20");
});
@@ -621,7 +706,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 2,
},
],
});
@@ -645,13 +735,22 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#druid", target: "$mod_mp", expression: "15", threshold: 1 },
],
});
setBase("$class", "#warrior:2;#druid:1");
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:2;#druid:1" }));
const cascade = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:2;#druid:1" }),
);
const modHp = cascade.find((r) => r.key === "$mod_hp");
const modMp = cascade.find((r) => r.key === "$mod_mp");
@@ -663,9 +762,7 @@ describe("var-reactivity", () => {
describe("computeCascade — declaration re-evaluation", () => {
test("re-evaluates dependents when a dependency changes", () => {
initReactivity({
declarations: [
{ key: "$hp", expression: "$con * 5" },
],
declarations: [{ key: "$hp", expression: "$con * 5" }],
tagModifiers: [],
});
@@ -701,11 +798,14 @@ describe("var-reactivity", () => {
test("handles tagmap transition during re-evaluation", () => {
initReactivity({
declarations: [
{ key: "$class", expression: "0" },
],
declarations: [{ key: "$class", expression: "0" }],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 },
],
});
@@ -744,7 +844,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
],
});
@@ -861,9 +966,35 @@ describe("parseInput", () => {
});
// ---------------------------------------------------------------------------
// directive-scanner
// 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
@@ -915,13 +1046,11 @@ describe("buildSparkTableCompletion", () => {
test("builds completion from CSV data", () => {
const csv = `d6,Name,Description
1,Alice,The brave`;
const slugger = new Slugger();
const result = buildSparkTableCompletion(
csv,
"/rules/combat.md",
"/rules/combat/test.csv",
false,
slugger,
);
expect(result).not.toBeNull();
expect(result!.notation).toBe("d6");
@@ -933,13 +1062,11 @@ describe("buildSparkTableCompletion", () => {
test("returns null for non-spark CSV", () => {
const csv = `Name,Value
Alice,10`;
const slugger = new Slugger();
const result = buildSparkTableCompletion(
csv,
"/test.md",
"/test.csv",
false,
slugger,
);
expect(result).toBeNull();
});
@@ -947,13 +1074,11 @@ Alice,10`;
test("sets remix flag", () => {
const csv = `d6,Result
1,Yes`;
const slugger = new Slugger();
const result = buildSparkTableCompletion(
csv,
"/test.md",
"/test.csv",
true,
slugger,
);
expect(result!.remix).toBe(true);
});
+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);
});
});
+628
View File
@@ -0,0 +1,628 @@
/**
* Content registry the single source of truth for all content in a
* TTRPG Tools project.
*
* Two stores:
* - `pathIndex`: real files on disk, keyed by path (`.md`, `.csv`, `.yarn`, `.svg`)
* - `docContent`: inline content *defined inside* a markdown doc, keyed by
* a stable id and owned by that doc.
*
* Everything structured (completions, declarations, tag modifiers) is a
* *derived* view over this registry see `deriveCompletions`.
*
* This module is browser-safe (no Node-only imports) so the CLI and the
* frontend share one implementation. The filesystem walk lives in the CLI
* (`buildRegistry` in `commands/serve.ts`).
*/
import Slugger from "github-slugger";
import {
parseDeclareCsv,
type VarDeclaration,
type TagModifier,
} from "./completions/declare-parser.js";
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
} from "./completions/block-scanner.js";
import type {
CompletionsPayload,
DiceCompletion,
LinkCompletion,
SparkTableCompletion,
} from "./completions/types.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ContentKind = "csv" | "text" | "declare";
/** A single piece of inline content defined inside a doc. */
export interface DocContent {
/** Stable id — author-supplied or derived from the body. */
id: string;
kind: ContentKind;
body: string;
/** Origin role that produced this content, for debugging. */
role?: string;
}
export interface ContentRegistry {
/** Real files on disk, keyed by path. */
pathIndex: Record<string, string>;
/** Inline content defined inside each doc, keyed by id. */
docContent: Record<string, Record<string, DocContent>>;
}
export const EMPTY_REGISTRY: ContentRegistry = {
pathIndex: {},
docContent: {},
};
/**
* Normalize a file path into a stable registry key: forward slashes and a
* single leading `/`. Both the CLI filesystem walk and the browser folder
* scan funnel through this so identical files always share a key.
*/
export function normalizePathKey(path: string): string {
const normalized = path.split("\\").join("/");
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
// ---------------------------------------------------------------------------
// Id / hash derivation (single source of truth)
// ---------------------------------------------------------------------------
/** Browser-safe content hash — stable across CLI and frontend. */
export function contentHash(body: string): string {
let hash = 0;
for (let i = 0; i < body.length; i++) {
const ch = body.charCodeAt(i);
hash = ((hash << 5) - hash + ch) | 0;
}
return Math.abs(hash).toString(16).slice(0, 8);
}
/**
* Derive a stable content id.
* Author-supplied `id` wins; otherwise `{kind}_{hash}`.
*/
export function deriveContentId(
kind: ContentKind,
body: string,
id?: string,
): string {
if (id) return id;
return `${kind}_${contentHash(body)}`;
}
// ---------------------------------------------------------------------------
// Per-doc scanning
// ---------------------------------------------------------------------------
export interface DocScanResult {
/** Content with blocks processed (stripped or replaced with directives). */
stripped: string;
/** Inline content defined in this doc, keyed by id. */
content: Record<string, DocContent>;
}
/**
* Process a single markdown doc:
* - 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> = {};
const stripped = content.replace(
FENCED_BLOCK_RE,
(
match: string,
lang: string,
infoString: string,
body: string,
): string => {
const attrs = parseBlockAttrs(infoString);
attrs.lang = attrs.lang || lang;
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 "";
}
},
);
return { stripped, content: contentStore };
}
/**
* Build a `ContentRegistry` from a raw `{ path: content }` index.
*
* 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
* 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`, other extensions are stored raw.
*/
export function buildRegistryFromIndex(
index: Record<string, string>,
): ContentRegistry {
const registry: ContentRegistry = { pathIndex: {}, docContent: {} };
for (const [rawPath, content] of Object.entries(index)) {
const path = normalizePathKey(rawPath);
if (path.endsWith(".md")) {
const result = scanDoc(content, path);
registry.pathIndex[path] = result.stripped;
registry.docContent[path] = result.content;
} else {
registry.pathIndex[path] = content;
}
}
return registry;
}
// ---------------------------------------------------------------------------
// Spark table coercion
// ---------------------------------------------------------------------------
/**
* 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[] {
return row
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((c) => c.trim());
}
/** Escape a cell value for CSV output. */
function escapeCsvCell(cell: string): string {
if (
cell.includes(",") ||
cell.includes("\n") ||
cell.includes('"') ||
cell.includes("#")
) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
}
/** Convert a markdown table (header + separator + rows) to a CSV string. */
function markdownTableToCsv(
headerRow: string,
separatorRow: string,
bodyRows: string[],
): string | null {
const headers = splitTableRow(headerRow);
if (headers.length === 0) return null;
const sepCells = splitTableRow(separatorRow);
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null;
if (sepCells.length !== headers.length) return null;
const csvHeader = headers.map(escapeCsvCell).join(",");
const csvRows = bodyRows.map((row) => {
const cells = splitTableRow(row);
while (cells.length < headers.length) cells.push("");
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
});
return [csvHeader, ...csvRows].join("\n");
}
/**
* 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.
*/
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
function isMarkdownTableLang(lang: string): boolean {
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
}
/**
* Convert a fenced markdown pipe-table body to CSV for `role=spark-table`
* blocks. Validates the dice-formula first column (warning only the role
* already declared intent) and falls back to storing the body as-is when it
* is not a recognizable pipe table.
*/
function markdownTableBodyToCsv(body: string, docPath: string): string {
const lines = body
.trim()
.split(/\r?\n/)
.filter((l) => l.trim().startsWith("|"));
if (lines.length < 2) {
console.warn(
`[content-registry] ${docPath}: role=spark-table markdown body is not a pipe table; storing as-is`,
);
return body;
}
const [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 an absolute path path index.
* - `ref` is a relative path resolved against the doc directory; checks
* 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 {
return resolveContentEntry(registry, docPath, ref)?.body ?? null;
}
/**
* Resolve a *resolved* path (e.g. `/content/csv_abc123`) to inline content
* by searching every doc's content store for a matching id. Used by the
* frontend when a directive ref has already been resolved to a path.
*
* Returns `null` when no inline content matches.
*/
export function resolveInlineByPath(
registry: ContentRegistry,
resolvedPath: string,
): string | null {
const id = resolvedPath.split("/").filter(Boolean).pop() || "";
if (!id) return null;
for (const store of Object.values(registry.docContent)) {
const entry = store[id];
if (entry) return entry.body;
}
return null;
}
// ---------------------------------------------------------------------------
// Derived completions
// ---------------------------------------------------------------------------
/**
* Derive the completions payload from the registry.
* Pure function recompute on any file change.
*
* Dice + spark completions come from scanning each doc's stripped content
* for directives (resolving CSV refs through the registry), so both inline
* and real-file spark tables are covered. Declarations come from the doc
* content store.
*/
export function deriveCompletions(
registry: ContentRegistry,
): CompletionsPayload {
const links = deriveLinks(registry.pathIndex);
const dice: DiceCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const declarations: VarDeclaration[] = [];
const tagModifiers: TagModifier[] = [];
for (const [docPath, content] of Object.entries(registry.pathIndex)) {
if (!docPath.endsWith(".md")) continue;
const found = scanDocDirectives(content, docPath, registry);
dice.push(...found.dice);
sparkTables.push(...found.sparkTables);
}
const blocks = deriveBlocks(registry);
declarations.push(...blocks.declarations);
tagModifiers.push(...blocks.tagModifiers);
return { dice, links, sparkTables, declarations, tagModifiers };
}
/**
* Scan a doc's stripped content for `:md-dice` and `:md-table`/`:md-card`
* directives, resolving CSV refs through the registry.
*/
function scanDocDirectives(
content: string,
docPath: string,
registry: ContentRegistry,
): { dice: DiceCompletion[]; sparkTables: SparkTableCompletion[] } {
const dice = scanDice(content, docPath);
const sparkTables: SparkTableCompletion[] = [];
const tableDirectiveRegex = /:md-(table|card)\[([^\[\]]+)\](?:\{([^}]*)\})?/gi;
let m: RegExpExecArray | null;
while ((m = tableDirectiveRegex.exec(content)) !== null) {
const [, , ref, extraStr] = m;
const resolved = resolveContentEntry(registry, docPath, ref);
if (!resolved) continue;
const attrs = parseBlockAttrs(extraStr || "").extra;
const st = buildSparkTableCompletion(
resolved.body,
docPath,
resolved.path,
attrs["remix"] === "true",
);
if (st) sparkTables.push(st);
}
return { dice, sparkTables };
}
/** Derive variable declarations + tag modifiers from the registry. */
export function deriveBlocks(registry: ContentRegistry): {
declarations: VarDeclaration[];
tagModifiers: TagModifier[];
} {
const declarations: VarDeclaration[] = [];
const tagModifiers: TagModifier[] = [];
for (const [docPath, store] of Object.entries(registry.docContent)) {
for (const entry of Object.values(store)) {
if (entry.kind !== "declare") continue;
try {
const result = parseDeclareCsv(entry.body, docPath);
declarations.push(...result.variables);
tagModifiers.push(...result.tagModifiers);
} catch (e) {
console.warn(`[content-registry] ${docPath}: ${e}`);
}
}
}
return { declarations, tagModifiers };
}
// ---------------------------------------------------------------------------
// 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, 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();
items.push({ path: basePath, label: fileName, section: null });
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
let match: RegExpExecArray | null;
while ((match = headingRegex.exec(content)) !== null) {
const title = match[2].trim();
const id = slugger.slug(title.toLowerCase());
items.push({
path: basePath,
label: `${fileName} § ${title}`,
section: id,
});
}
}
return items;
}
const DICE_DIRECTIVE_RE = /:md-dice\[([^[\]]+)\]/gi;
function looksLikeDice(raw: string): boolean {
if (raw.length > 80) return false;
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
}
/** Scan a text body for `:md-dice[...]` directives. */
function scanDice(body: string, source: string): DiceCompletion[] {
const dice: DiceCompletion[] = [];
let m: RegExpExecArray | null;
while ((m = DICE_DIRECTIVE_RE.exec(body)) !== null) {
const raw = m[1].trim();
if (!raw || !looksLikeDice(raw)) continue;
dice.push({ label: raw, notation: raw, source });
}
return dice;
}
/**
* Inspect a CSV body and return its data-column headers if it's a spark
* table (first column header is a dice formula), or null otherwise.
*/
export function inspectSparkTableCsv(csv: string): string[] | null {
const lines = csv.trim().split(/\r?\n/);
if (lines.length < 2) return null;
const headers = lines[0].split(",").map((h) => h.trim());
if (headers.length < 2) return null;
if (!isSparkTableHeader(headers[0])) return null;
return headers.slice(1);
}
/** Build a SparkTableCompletion from a CSV body. */
export function buildSparkTableCompletion(
csv: string,
docPath: string,
contentId: string,
remix: boolean,
): SparkTableCompletion | null {
const dataHeaders = inspectSparkTableCsv(csv);
if (!dataHeaders) return null;
const notation = csv.trim().split(/\r?\n/)[0].split(",")[0].trim();
const slugger = new Slugger();
const slug = dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-");
const basePath = docPath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
const combinedSlug = `${fileName}-${slug}`;
return {
label: `${fileName} § ${slug}`,
notation,
slug: combinedSlug,
filePath: basePath,
docPath,
csvPath: contentId,
headers: dataHeaders,
remix,
};
}
// ---------------------------------------------------------------------------
// Path helpers (browser-safe posix)
// ---------------------------------------------------------------------------
function posixDir(path: string): string {
const idx = path.lastIndexOf("/");
return idx >= 0 ? path.slice(0, idx) : ".";
}
function posixJoin(dir: string, rel: string): string {
if (dir === ".") return rel.startsWith("/") ? rel : `/${rel}`;
const base = dir.replace(/\/+$/, "");
const r = rel.replace(/^\/+/, "");
return `${base}/${r}`;
}
function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || path;
}
+32 -22
View File
@@ -10,18 +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();
@@ -33,17 +27,16 @@ function normalizeTagMap(expr: string): string {
// Result
// ---------------------------------------------------------------------------
export type DispatchResult =
| { ok: true }
| { ok: false; error: string };
export type DispatchResult = { ok: true } | { ok: false; error: string };
/**
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
* Components that show errors (JournalInput) read from here; callers that
* want errors surfaced (CommandLinkManager) write to it.
*/
export const [dispatchError, setDispatchError] =
createSignal<string | null>(null);
export const [dispatchError, setDispatchError] = createSignal<string | null>(
null,
);
// ---------------------------------------------------------------------------
// Main dispatch
@@ -57,7 +50,12 @@ export interface DispatchContext {
/** The raw text to dispatch (with or without leading `/`) */
command: string;
/** Spark table lookup data (from completions) */
sparkTables: { slug: string; csvPath?: string; remix?: boolean }[];
sparkTables: {
slug: string;
csvPath?: string;
docPath?: string;
remix?: boolean;
}[];
/** Current runtime variable values */
variables: Record<string, string>;
/** Variable declarations (from role=declare blocks) */
@@ -94,7 +92,9 @@ export async function dispatchCommand(
}
if (parsed.type === "set" || parsed.type === "rolltag") {
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
return finish(
dispatchSet(parsed.payload as Record<string, unknown>, ctx),
);
}
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
@@ -109,8 +109,14 @@ export async function dispatchCommand(
if (match) {
try {
const csvPath = match.csvPath ?? "";
const docPath = match.docPath;
const remix = match.remix ?? false;
const p = await resolveSparkPayload({ key: arg, csvPath, remix });
const p = await resolveSparkPayload({
key: arg,
csvPath,
docPath,
remix,
});
const result = sendMessage("spark", p);
return finish(unwrap(result));
} catch (e) {
@@ -127,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 }[],
@@ -192,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;
@@ -204,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: "缺少表达式" };
}
@@ -226,7 +232,11 @@ function dispatchSet(
try {
const cascade = computeCascade(key, oldValue, workingVars);
for (const change of cascade) {
sendMessage("var", { action: "set", key: change.key, value: change.value });
sendMessage("var", {
action: "set",
key: change.key,
value: change.value,
});
}
} catch (e) {
// Cascade errors are non-fatal — the direct set already succeeded
+68 -49
View File
@@ -10,15 +10,18 @@
*/
import { createSignal } from "solid-js";
import { extractHeadings } from "../../data-loader/toc";
import {
getPathsByExtension,
getIndexedData,
setIndexedData,
setInlineResolver,
} from "../../data-loader/file-index";
import {
scanDirectives,
} from "../../cli/completions/directive-scanner";
import { scanDeclareBlocks } from "../../cli/completions/declare-parser";
buildRegistryFromIndex,
deriveCompletions,
resolveInlineByPath,
type ContentRegistry,
} from "../../cli/content-registry";
import type {
CompletionsPayload,
DiceCompletion,
@@ -50,6 +53,23 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
status: "loading",
});
// The registry backing the completions. Populated in both CLI and client
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
/**
* The registry backing the current completions.
* In CLI mode this is fetched from the server; in browser mode it is built
* client-side. Used to resolve inline content ids (e.g. spark table CSVs).
*/
export function getRegistry(): ContentRegistry {
return activeRegistry;
}
// Register the inline-content resolver so `getIndexedData` can resolve
// directive refs (e.g. `./csv_abc123`) that aren't real files.
setInlineResolver((path) => resolveInlineByPath(activeRegistry, path));
// ------------------- Fetch (CLI mode) -------------------
async function tryServer(): Promise<JournalCompletions | null> {
@@ -61,66 +81,54 @@ async function tryServer(): Promise<JournalCompletions | null> {
dice: Array.isArray(data.dice) ? data.dice : [],
links: Array.isArray(data.links) ? data.links : [],
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
declarations: Array.isArray(data.declarations)
? data.declarations
: [],
tagModifiers: Array.isArray(data.tagModifiers)
? data.tagModifiers
: [],
declarations: Array.isArray(data.declarations) ? data.declarations : [],
tagModifiers: Array.isArray(data.tagModifiers) ? data.tagModifiers : [],
};
} catch {
return null;
}
}
/** Load the content registry from the server (CLI mode). */
async function tryServerRegistry(): Promise<void> {
try {
const resp = await fetch("/__CONTENT_REGISTRY.json");
if (!resp.ok) return;
const data = await resp.json();
activeRegistry = {
pathIndex: data.pathIndex ?? {},
docContent: data.docContent ?? {},
};
} catch {
// Registry unavailable — leave empty; client scan will populate it.
}
}
// ------------------- Client-side fallback scan -------------------
async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md");
const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const declarations: VarDeclaration[] = [];
const tagModifiers: TagModifier[] = [];
// Build a temporary index for resolving CSV paths
const tempIndex: Record<string, string> = {};
// First pass: load all .md content into temp index
// Load all .md content into a raw index, then build the registry through
// the same shared pipeline as the CLI (scanDoc).
const index: Record<string, string> = {};
for (const filePath of paths) {
const content = await getIndexedData(filePath);
if (content) tempIndex[filePath] = content;
}
for (const filePath of paths) {
const content = tempIndex[filePath];
if (!content) continue;
// ---- Links (headings) - from original content ----
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
links.push({ path: basePath, label: fileName, section: null });
for (const heading of extractHeadings(content)) {
links.push({
path: basePath,
label: `${fileName} § ${heading.title}`,
section: heading.id ?? null,
});
index[filePath] = content;
}
// ---- Declare block scanning (shared with CLI) ----
const declareResult = scanDeclareBlocks(content, filePath);
declarations.push(...declareResult.variables);
tagModifiers.push(...declareResult.tagModifiers);
const registry = buildRegistryFromIndex(index);
activeRegistry = registry;
// ---- Directive scanning (dice + spark tables) ----
const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
const directiveResult = scanDirectives(content, filePath, tempIndex, fileDir);
dice.push(...directiveResult.dice);
sparkTables.push(...directiveResult.sparkTables);
// 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 { dice, links, sparkTables, declarations, tagModifiers };
return deriveCompletions(registry);
}
// ------------------- Init (runs eagerly at import time) -------------------
@@ -131,10 +139,16 @@ const _initPromise: Promise<void> = (async () => {
const serverData = await tryServer();
if (serverData) {
setCompletionsState({ status: "loaded", data: serverData });
await tryServerRegistry();
try {
initReactivity({ declarations: serverData.declarations, tagModifiers: serverData.tagModifiers });
initReactivity({
declarations: serverData.declarations,
tagModifiers: serverData.tagModifiers,
});
seedDeclaredVariables();
} catch (e) { console.warn("[completions] reactivity init error:", e); }
} catch (e) {
console.warn("[completions] reactivity init error:", e);
}
return;
}
@@ -144,9 +158,14 @@ const _initPromise: Promise<void> = (async () => {
if (data.dice.length > 0 || data.links.length > 0) {
setCompletionsState({ status: "loaded", data });
try {
initReactivity({ declarations: data.declarations, tagModifiers: data.tagModifiers });
initReactivity({
declarations: data.declarations,
tagModifiers: data.tagModifiers,
});
seedDeclaredVariables();
} catch (e) { console.warn("[completions] reactivity init error:", e); }
} catch (e) {
console.warn("[completions] reactivity init error:", e);
}
} else {
setCompletionsState({ status: "empty" });
}
+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";
+18 -5
View File
@@ -16,11 +16,9 @@ import { z } from "zod";
import { For } from "solid-js";
import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks";
import {
parseSparkTableCsv,
rollSparkTable,
} from "../../utils/spark-table";
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
import { getIndexedData } from "../../../data-loader/file-index";
import { getRegistry } from "../../journal/completions";
// ---------------------------------------------------------------------------
// Schema
@@ -75,12 +73,27 @@ export type SparkPayload = z.infer<typeof schema>;
export async function resolveSparkPayload(raw: {
key: string;
csvPath: string;
docPath?: string;
remix: boolean;
}): Promise<SparkPayload> {
let csv: string;
let csv: string | null;
// Inline content ids resolve through the registry (docPath + content id);
// real file paths fall back to the file index.
const registry = getRegistry();
const docStore = raw.docPath ? registry.docContent[raw.docPath] : undefined;
const inline = docStore?.[raw.csvPath];
if (inline) {
csv = inline.body;
} else {
try {
csv = await getIndexedData(raw.csvPath);
} catch {
csv = null;
}
}
if (csv === null) {
throw new Error(`Failed to load CSV: "${raw.csvPath}"`);
}
+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);
+205 -33
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,21 +413,25 @@ 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("#")) {
const map = parseTagMapValue(resolved);
if (!map) {
throw new Error(
`Type mismatch: ${varName} is a tag ("${resolved}"), not a number`,
`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)) {
throw new Error(
`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[]>();
+28 -11
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,15 +31,29 @@ 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) =>
selectedLayer() === layerIndex;
const isEditing = () =>
props.store.state.isEditing && !props.store.state.fixed;
const getFrameBounds = (layer: LayerConfig) => {
const dims = dimensions();
const left = (layer.x1 - 1) * dims.cellWidth;
@@ -75,20 +89,23 @@ export function CardLayer(props: CardLayerProps) {
return (
<>
<article
class="absolute flex flex-col items-stretch justify-center prose text-black prose-sm cursor-pointer"
class="absolute flex flex-col items-stretch justify-center prose text-black prose-sm"
classList={{
"cursor-pointer": isEditing(),
"ring-2 ring-blue-500 ring-offset-1":
isSelected() && !draggingState(),
isSelected() && !draggingState() && isEditing(),
}}
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()}>
<Show when={isSelected() && isEditing()}>
<div
class="absolute border-2 border-blue-500 pointer-events-none z-10"
style={{
+86
View File
@@ -0,0 +1,86 @@
import { For, Show, createEffect, createSignal, on } from "solid-js";
import type { DeckStore } from "./hooks/deckStore";
export interface CardListProps {
store: DeckStore;
}
const PAGE_SIZE = 10;
/**
*
*/
export function CardList(props: CardListProps) {
const { store } = props;
const [page, setPage] = createSignal(0);
const totalPages = () =>
Math.max(1, Math.ceil(store.state.cards.length / PAGE_SIZE));
const pageCards = () => {
const start = page() * PAGE_SIZE;
return store.state.cards.slice(start, start + PAGE_SIZE);
};
// 当活动卡牌变化时,自动跳转到其所在页(只响应 activeTab,避免与手动翻页互相干扰)
createEffect(
on(
() => store.state.activeTab,
(tab) => {
setPage(Math.floor(tab / PAGE_SIZE));
},
),
);
return (
<nav class="w-44 shrink-0 border-r border-gray-200 pr-3 flex flex-col">
<div class="flex flex-col gap-1 flex-1">
<For each={pageCards()}>
{(card, index) => {
const globalIndex = () => page() * PAGE_SIZE + index();
const active = () => store.state.activeTab === globalIndex();
return (
<button
onClick={() => store.actions.setActiveTab(globalIndex())}
class={`flex items-center gap-2 w-full text-left px-3 py-2 rounded text-sm font-medium transition-colors cursor-pointer ${
active()
? "bg-blue-100 text-blue-600"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<span class="shrink-0 text-xs text-gray-400 tabular-nums w-5">
{globalIndex() + 1}
</span>
<span class="truncate">
{card.label || card.name || `Card ${globalIndex() + 1}`}
</span>
</button>
);
}}
</For>
</div>
<Show when={totalPages() > 1}>
<div class="flex items-center justify-between mt-2 pt-2 border-t border-gray-200">
<button
onClick={() => setPage(Math.max(0, page() - 1))}
disabled={page() === 0}
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
</button>
<span class="text-xs text-gray-500 tabular-nums">
{page() + 1} / {totalPages()}
</span>
<button
onClick={() => setPage(Math.min(totalPages() - 1, page() + 1))}
disabled={page() >= totalPages() - 1}
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
</button>
</div>
</Show>
</nav>
);
}
+62 -14
View File
@@ -1,4 +1,5 @@
import { For } from "solid-js";
import { Show, createSignal, onMount } from "solid-js";
import { Portal } from "solid-js/web";
import type { DeckStore } from "./hooks/deckStore";
export interface DeckHeaderProps {
@@ -10,8 +11,28 @@ export interface DeckHeaderProps {
*/
export function DeckHeader(props: DeckHeaderProps) {
const { store } = props;
const [showCopyFallback, setShowCopyFallback] = createSignal(false);
const [fallbackCode, setFallbackCode] = createSignal("");
let textareaRef: HTMLTextAreaElement | undefined;
onMount(() => {
if (showCopyFallback() && textareaRef) {
textareaRef.focus();
textareaRef.select();
}
});
const handleCopy = () => {
store.actions.copyCode((code) => {
setFallbackCode(code);
setShowCopyFallback(true);
});
};
const closeFallback = () => setShowCopyFallback(false);
return (
<>
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
{/* 编辑按钮 */}
<button
@@ -33,23 +54,50 @@ export function DeckHeader(props: DeckHeaderProps) {
📥 PDF
</button>
{/* Tab 选择器 */}
<div class="flex gap-1 overflow-x-auto flex-1 min-w-0 flex-wrap">
<For each={store.state.cards}>
{(card, index) => (
{/* 复制代码按钮 */}
<button
onClick={() => store.actions.setActiveTab(index())}
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer px-2 py-1 rounded ${
store.state.activeTab === index()
? "bg-blue-100 text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
onClick={handleCopy}
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
>
{card.label || card.name || `Card ${index() + 1}`}
📋
</button>
</div>
{/* 剪贴板不可用时的手动复制弹窗 */}
<Show when={showCopyFallback()}>
<Portal>
<div
class="fixed inset-0 bg-black/50 z-60 flex items-center justify-center p-4"
onClick={closeFallback}
>
<div
class="bg-white rounded-lg shadow-xl w-full max-w-lg p-4"
onClick={(e) => e.stopPropagation()}
>
<h3 class="font-bold mb-2"></h3>
<p class="text-sm text-gray-600 mb-3">
HTTPS
</p>
<textarea
ref={textareaRef}
readonly
value={fallbackCode()}
class="w-full h-40 border border-gray-300 rounded px-3 py-2 text-xs font-mono bg-gray-50 resize-none"
onFocus={(e) => e.currentTarget.select()}
onClick={(e) => e.currentTarget.select()}
/>
<div class="flex justify-end gap-2 mt-3">
<button
onClick={closeFallback}
class="px-3 py-1.5 rounded text-sm font-medium cursor-pointer bg-gray-100 text-gray-700 hover:bg-gray-200"
>
</button>
)}
</For>
</div>
</div>
</div>
</Portal>
</Show>
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { createSignal } from "solid-js";
import type { DeckStore } from "./hooks/deckStore";
import { LayerEditorPanel, PropertiesEditorPanel } from "./editor-panel";
export interface EditorTabsProps {
store: DeckStore;
}
type TabKey = "sizing" | "front" | "back";
/**
* / /
*/
export function EditorTabs(props: EditorTabsProps) {
const { store } = props;
const [tab, setTab] = createSignal<TabKey>("sizing");
const tabs: { key: TabKey; label: string }[] = [
{ key: "sizing", label: "尺寸" },
{ key: "front", label: "正面图层" },
{ key: "back", label: "背面图层" },
];
const selectTab = (key: TabKey) => {
setTab(key);
// 切换正/背面标签时,同步预览的显示面
if (key === "front") store.actions.setActiveSide("front");
if (key === "back") store.actions.setActiveSide("back");
};
return (
<div class="w-64 shrink-0">
{/* 标签页切换 */}
<div class="flex gap-1 mb-3">
{tabs.map((t) => (
<button
onClick={() => selectTab(t.key)}
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer ${
tab() === t.key
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{t.label}
</button>
))}
</div>
{tab() === "sizing" ? (
<PropertiesEditorPanel store={store} />
) : (
<LayerEditorPanel
store={store}
side={tab() === "front" ? "front" : "back"}
/>
)}
</div>
);
}
+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),
};
}
@@ -6,11 +6,13 @@ import {
closestCenter,
} from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore";
import type { CardSide } from "../types";
import { toSortableId } from "../hooks/layer-crud";
import { LayerRow } from "./LayerRow";
export interface LayerEditorPanelProps {
store: DeckStore;
side?: CardSide;
}
function LayerEditorPanel(props: LayerEditorPanelProps) {
@@ -20,7 +22,7 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
const [addMenuOpen, setAddMenuOpen] = createSignal(false);
let addMenuRef: HTMLDivElement | undefined;
const side = () => store.state.activeSide;
const side = () => props.side || "front";
const layers = () =>
side() === "front"
? store.state.frontLayerConfigs
@@ -143,15 +145,6 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
</SortableProvider>
</DragDropSensors>
</DragDropProvider>
<hr class="my-4" />
<button
onClick={() => store.actions.copyCode()}
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded text-sm font-medium cursor-pointer flex items-center gap-2 justify-center"
>
<span>📋</span>
<span></span>
</button>
</div>
);
}
@@ -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"
>
{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>
@@ -1,49 +1,32 @@
import type { DeckStore } from '../hooks/deckStore';
import type { CardShape } from '../types';
import type { DeckStore } from "../hooks/deckStore";
import type { CardShape } from "../types";
export interface PropertiesEditorPanelProps {
store: DeckStore;
}
/**
*
*
*/
export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
const { store } = props;
return (
<div class="w-64 flex-shrink-0">
<h3 class="font-bold mb-2 mt-0"></h3>
const shapeOptions: { value: CardShape; label: string }[] = [
{ value: "rectangle", label: "矩形" },
{ value: "circle", label: "圆形" },
{ value: "triangle", label: "三角形" },
{ value: "hexagon", label: "六边形" },
];
{/* 正面/背面切换标签页 */}
<div class="mb-4">
<div class="flex gap-1">
<button
onClick={() => store.actions.setActiveSide('front')}
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
store.state.activeSide === 'front'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
</button>
<button
onClick={() => store.actions.setActiveSide('back')}
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
store.state.activeSide === 'back'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
</button>
</div>
</div>
return (
<div class="w-64 shrink-0">
<h3 class="font-bold mb-2 mt-0"></h3>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700"> (mm)</label>
<label class="block text-sm font-medium text-gray-700">
(mm)
</label>
<div class="flex gap-2">
<input
type="number"
@@ -83,7 +66,9 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
</div>
<div>
<label class="block text-sm font-medium text-gray-700"> / (mm)</label>
<label class="block text-sm font-medium text-gray-700">
/ (mm)
</label>
<div class="flex gap-2">
<input
type="number"
@@ -103,24 +88,20 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"></label>
<div class="grid grid-cols-2 gap-2">
{(['rectangle', 'circle', 'triangle', 'hexagon'] as CardShape[]).map((shape) => (
<button
onClick={() => store.actions.setShape(shape)}
class={`px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
store.state.shape === shape
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
<label class="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={store.state.shape}
onChange={(e) =>
store.actions.setShape(e.target.value as CardShape)
}
class="w-full border border-gray-300 rounded px-2 py-1 text-sm bg-white"
>
{shape === 'rectangle' && '矩形'}
{shape === 'circle' && '圆形'}
{shape === 'triangle' && '三角形'}
{shape === 'hexagon' && '六边形'}
</button>
{shapeOptions.map((s) => (
<option value={s.value}>{s.label}</option>
))}
</div>
</select>
</div>
</div>
</div>
+72 -16
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,14 +142,14 @@ 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;
generateCode: (backLayersStr?: string) => string;
copyCode: (backLayersStr?: string) => Promise<void>;
copyCode: (fallback?: (code: string) => void) => Promise<void>;
setExporting: (exporting: boolean) => void;
exportDeck: () => 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,15 +521,63 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
return parts.join("");
};
const copyCode = async (backLayersStr?: string) => {
const code = generateCode(backLayersStr);
/** 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 {
await navigator.clipboard.writeText(code);
alert("已复制到剪贴板!");
} catch (err) {
console.error("复制失败:", err);
if (fallback) {
fallback(code);
} else {
alert("复制失败,请手动复制");
}
}
};
const setExporting = (exporting: boolean) =>
@@ -569,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) {
@@ -87,6 +87,8 @@ export function useLayerInteraction(
const handleLayerClick = (index: number, e: MouseEvent) => {
e.stopPropagation();
if (!store.state.isEditing || store.state.fixed) return;
const currentlySelected = store.state.selectedLayer;
if (currentlySelected === index) {
@@ -100,6 +102,7 @@ export function useLayerInteraction(
const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => {
if (store.state.draggingState) return;
if (!store.state.isEditing || store.state.fixed) return;
const { gridX, gridY } = calculateGridCoords(e, cardEl);
const overlapping = getOverlappingLayers(gridX, gridY);
@@ -147,6 +150,7 @@ export function useLayerInteraction(
edge?: "n" | "s" | "e" | "w",
e?: MouseEvent,
) => {
if (!store.state.isEditing || store.state.fixed) return;
if (store.state.selectedLayer === null) return;
if (e) e.stopPropagation();
+60 -43
View File
@@ -2,17 +2,16 @@ 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";
import { EditorTabs } from "./EditorTabs";
import { PrintPreview } from "./PrintPreview";
import {
DataEditorPanel,
LayerEditorPanel,
PropertiesEditorPanel,
} from "./editor-panel";
import { DataEditorPanel } from "./editor-panel";
interface DeckProps {
size?: string;
@@ -70,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(() => {
@@ -139,29 +166,19 @@ customElement<DeckProps>(
</Show>
<div class="flex gap-4">
{/* 内容区域:错误/加载/卡牌预览/空状态 */}
{/* 左侧:CSV 数据编辑 */}
{/*<Show when={store.state.isEditing && !store.state.fixed}>*/}
{/* <DataEditorPanel*/}
{/* activeTab={store.state.activeTab}*/}
{/* cards={store.state.cards}*/}
{/* updateCardData={store.actions.updateCardData}*/}
{/* />*/}
{/*</Show>*/}
<Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1">
<PropertiesEditorPanel store={store} />
</div>
{/* 左侧:卡牌列表导航 */}
<Show when={store.state.cards.length > 0 && !store.state.error}>
<CardList store={store} />
</Show>
{/* 中间:内容区域(错误/加载/卡牌预览/空状态) */}
<div class="flex-1 min-w-0">
<DeckContent store={store} isLoading={store.state.isLoading} />
{/* 右侧:属性/图层编辑面板 */}
<Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1">
<LayerEditorPanel store={store} />
</div>
{/* 右侧:属性/图层编辑面板(标签页切换) */}
<Show when={store.state.isEditing && !store.state.fixed}>
<EditorTabs store={store} />
</Show>
</div>
</div>
+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.
+35 -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>;
@@ -24,6 +25,20 @@ let fileIndex: FileIndex | null = null;
let indexLoadPromise: Promise<void> | null = null;
let activeSource: "cli" | "folder" | null = null;
/**
* Optional registry for resolving inline content ids (set by the journal
* completions module). When present, `getIndexedData` resolves ids that
* aren't real files through it.
*/
let inlineResolver: ((path: string) => string | null) | null = null;
/** Register a resolver for inline content ids (see journal/completions). */
export function setInlineResolver(
fn: ((path: string) => string | null) | null,
): void {
inlineResolver = fn;
}
/** Currently active directory handle (if folder source) */
let activeDirHandle: FileSystemDirectoryHandle | null = null;
@@ -85,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();
}
}
@@ -181,6 +196,15 @@ export async function getIndexedData(path: string): Promise<string> {
if (fileIndex && fileIndex[path]) {
return fileIndex[path];
}
// Resolve inline content ids through the registry before fetching.
if (inlineResolver) {
const inline = inlineResolver(path);
if (inline !== null) {
fileIndex = fileIndex || {};
fileIndex[path] = inline;
return inline;
}
}
const res = await fetch(path);
const content = await res.text();
fileIndex = fileIndex || {};
@@ -188,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>");
});
});
+33 -7
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;")}"`;
}
-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 {