Compare commits

..
29 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
hyper 18cad20d2c feat: add PLT double cut option to plotter export 2026-07-23 20:08:51 +08:00
hypercross 8c9506c106 fix: strip .md extension from resolved article paths
Ensures that navigation to markdown files strips the extension,
allowing the SPA fallback to handle refreshes instead of requesting
the raw file.
2026-07-14 15:56:38 +08:00
hypercross a934901cce feat: add heading flash animation on hash change 2026-07-14 15:38:39 +08:00
hypercross d78d15d8ec fix(FileTree): use base path for anchor navigation 2026-07-14 15:23:30 +08:00
hypercross f877a58a31 fix(Article): resolve relative hrefs during navigation 2026-07-14 14:18:26 +08:00
hypercross 99dd49b077 refactor: simplify DocDialog and move documentation to markdown body
Simplify the `DocDialog` component by removing hardcoded property
tables and syntax blocks. Instead, rely on the raw markdown body
provided in the `DocEntry` to render documentation content. This
shifts the responsibility of formatting (syntax, props, etc.) from
the component to the individual markdown files, allowing for more
flexible and detailed documentation.
2026-07-13 15:29:08 +08:00
hypercross 7cb28e631e feat: add threshold support and tagmap support to variable system
Introduce a `threshold` column to tag modifiers in CSV declarations,
allowing modifiers to activate only when a tag's count meets a minimum
requirement.

Implement support for "tagmap" variables (e.g., `#warrior:1;#druid:2`)
which allow for complex, multi-tag state tracking. These variables
can receive specialized tagmap modifications rather than simple
numeric additions.
2026-07-13 15:09:50 +08:00
hypercross d74ba69fdf refactor: merge /spark command into /roll
Consolidate the `/spark` command into `/roll` to simplify the CLI.
The `/roll` command now handles both dice notation and spark table
slugs.

- Update `parseInput` to treat `/roll` arguments as a generic string.
- Update `dispatchCommand` to check for spark table matches before
  evaluating dice notation or variable expressions.
- Update command completions to show both dice and spark table
  suggestions under `/roll`.
- Update documentation and UI placeholders to reflect the change.
2026-07-13 14:43:55 +08:00
78 changed files with 3611 additions and 1955 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" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" } :md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
## 使用 yaml/tag 语法创建 md-deck ## 使用 yaml role=tag 语法创建 md-deck(fence 行内联 tag,简洁写法)
```yaml/tag ```yaml role=tag tag=md-deck
tag: md-deck
body: ./names.csv body: ./names.csv
size: 54x86 size: 54x86
grid: 5x8 grid: 5x8
@@ -15,3 +14,24 @@ bleed: 1
padding: 2 padding: 2
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
``` ```
```yaml role=tag
tag: md-deck
body: ./names.csv
data-config:
size: 54x86
grid: 5x8
layers:
- prop: name1
pos: 1,1-2,2
font: 12
- prop: num2
pos: 4,5-5,6
font: 12
orientation: s
- template: |
**{{name1}}** / {{num1}}
pos: 1,3-5,4
font: 6
align: l
```
+55 -16
View File
@@ -373,26 +373,30 @@ label,name,description
:md-table[./quests.csv]{roll=true remix=true} :md-table[./quests.csv]{roll=true remix=true}
``` ```
**自动表格转换** **内联表格(显式声明)**
标准 Markdown 表格会自动转换`md-table` 组件,当表头包含 `label``md-table-label` 列时 Markdown 表格会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`
```markdown ````markdown
| label | name | description | ```markdown role=spark-table
|-------|------|-------------| | d6 | 结果 |
| 1 | 战士 | 近战专家 | |----|------|
| 2 | 法师 | 奥术施法者 | | 1 | 遭遇强盗 |
| 2 | 平安无事 |
``` ```
````
自动转换为 `:md-table` 组件。 扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
**特殊表头标识:** ````markdown
```csv role=spark-table
d6,结果
1,遭遇强盗
2,平安无事
```
````
| 表头 | 效果 | 普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
|------|------|
| `label``md-table-label` | 转换为 md-table |
| `md-roll-label` 或骰子格式(如 `1d6` | 添加 `roll=true` |
| `md-remix-label` | 添加 `roll=true remix=true` |
### 🃏 卡牌组件 (md-deck) ### 🃏 卡牌组件 (md-deck)
@@ -423,6 +427,32 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。 示例:`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) ### 🧶 叙事线组件 (md-yarn-spinner)
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。 用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
@@ -463,10 +493,10 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
## YAML 标签 ## YAML 标签
使用 ```yaml/tag 代码块创建自定义标签: 使用 ```yaml role=tag 代码块创建自定义标签`yaml` 语言可被语法高亮)
````markdown ````markdown
```yaml/tag ```yaml role=tag
tag: tag-name tag: tag-name
class: custom-class class: custom-class
id: my-id id: my-id
@@ -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 ```html
+4
View File
@@ -7,6 +7,10 @@ export default {
moduleNameMapper: { moduleNameMapper: {
// Resolve .js imports to .ts source files (ESM convention in TS source) // Resolve .js imports to .ts source files (ESM convention in TS source)
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'], '^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
// github-slugger v2 is ESM-only; jest's CJS runtime cannot require it.
'^github-slugger$': '<rootDir>/__mocks__/github-slugger.js',
// Same for the browser ESM build of csv-parse — map to the CJS build.
'^csv-parse/browser/esm/sync$': 'csv-parse/sync',
}, },
transform: { transform: {
'^.+\\.tsx?$': [ '^.+\\.tsx?$': [
+3
View File
@@ -23,6 +23,7 @@ import {
} from "./components"; } from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader"; import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal"; import { JournalPanel } from "./components/journal";
import { useHeadingFlash } from "./components/useHeadingFlash";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Scroll container context lets child components (FileTree, RevealManager) // Scroll container context lets child components (FileTree, RevealManager)
@@ -70,6 +71,8 @@ const App: Component = () => {
void loadToc(); void loadToc();
}); });
useHeadingFlash();
const handleSourceChanged = () => { const handleSourceChanged = () => {
setTocKey((k) => k + 1); setTocKey((k) => k + 1);
}; };
+39 -113
View File
@@ -2,23 +2,18 @@ import type { ServeCommandHandler } from "../types.js";
import { createServer, Server, IncomingMessage, ServerResponse } from "http"; import { createServer, Server, IncomingMessage, ServerResponse } from "http";
import { readdirSync, statSync, readFileSync, existsSync } from "fs"; import { readdirSync, statSync, readFileSync, existsSync } from "fs";
import { createReadStream } 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 { watch } from "chokidar";
import { networkInterfaces } from "os"; import { networkInterfaces } from "os";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { createJournalServer } from "../journal.js"; import { createJournalServer } from "../journal.js";
import { import {
scanCompletions, buildRegistryFromIndex,
type CompletionsPayload, normalizePathKey,
} from "../completions/index.js"; deriveCompletions,
import { type ContentRegistry,
processBlocks, } from "../content-registry.js";
type ProcessedBlocks, import type { CompletionsPayload } from "../completions/types.js";
} from "../completions/block-processor.js";
import {
scanDirectives,
type DirectiveScanResult,
} from "../completions/directive-scanner.js";
interface ContentIndex { interface ContentIndex {
[path: string]: string; [path: string]: string;
@@ -88,20 +83,10 @@ function getBestIP(): string {
} }
/** /**
* 扫描目录内的 .md 等文件,生成内容索引与块数据 * 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
*/ */
export function scanDirectory(dir: string): { export function buildRegistry(dir: string): ContentRegistry {
index: ContentIndex; const index: Record<string, string> = {};
blocks: ProcessedBlocks;
directiveResults: DirectiveScanResult[];
} {
const index: ContentIndex = {};
const blocks: ProcessedBlocks = {
declarations: [],
tagModifiers: [],
};
const directiveResults: DirectiveScanResult[] = [];
const mdFiles: { content: string; relPath: string }[] = [];
function scan(currentPath: string, relativePath: string) { function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath); const entries = readdirSync(currentPath);
@@ -111,7 +96,7 @@ export function scanDirectory(dir: string): {
const fullPath = join(currentPath, entry); const fullPath = join(currentPath, entry);
const relPath = relativePath ? join(relativePath, entry) : entry; const relPath = relativePath ? join(relativePath, entry) : entry;
const normalizedRelPath = "/" + relPath.split(sep).join("/"); const normalizedRelPath = normalizePathKey(relPath);
const stats = statSync(fullPath); const stats = statSync(fullPath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
@@ -123,16 +108,7 @@ export function scanDirectory(dir: string): {
entry.endsWith(".svg") entry.endsWith(".svg")
) { ) {
try { try {
const content = readFileSync(fullPath, "utf-8"); index[normalizedRelPath] = 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;
}
} catch (e) { } catch (e) {
console.error(`读取文件失败:${fullPath}`, e); console.error(`读取文件失败:${fullPath}`, e);
} }
@@ -142,31 +118,7 @@ export function scanDirectory(dir: string): {
scan(dir, ""); scan(dir, "");
// ---- Directive scanning pass (after all blocks processed) ---- return buildRegistryFromIndex(index);
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("/") || ".";
} }
/** /**
@@ -231,6 +183,7 @@ function createRequestHandler(
distDir: string, distDir: string,
getIndex: () => ContentIndex, getIndex: () => ContentIndex,
getCompletions: () => CompletionsPayload, getCompletions: () => CompletionsPayload,
getRegistry: () => ContentRegistry,
) { ) {
return (req: IncomingMessage, res: ServerResponse) => { return (req: IncomingMessage, res: ServerResponse) => {
const url = req.url || "/"; const url = req.url || "/";
@@ -248,6 +201,12 @@ function createRequestHandler(
return; return;
} }
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
if (filePath === "/__CONTENT_REGISTRY.json") {
sendJson(res, getRegistry());
return;
}
// 2. 处理 /static/ 目录(从 dist/web // 2. 处理 /static/ 目录(从 dist/web
if (filePath.startsWith("/static/")) { if (filePath.startsWith("/static/")) {
if (tryServeStatic(res, filePath, distDir)) { if (tryServeStatic(res, filePath, distDir)) {
@@ -308,12 +267,7 @@ export function createContentServer(
distPath: string = distDir, distPath: string = distDir,
host: string = "0.0.0.0", host: string = "0.0.0.0",
): ContentServer { ): ContentServer {
let contentIndex: ContentIndex = {}; let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
let collectedBlocks: ProcessedBlocks = {
declarations: [],
tagModifiers: [],
};
let directiveResults: DirectiveScanResult[] = [];
let completionsIndex: CompletionsPayload = { let completionsIndex: CompletionsPayload = {
dice: [], dice: [],
links: [], links: [],
@@ -322,21 +276,18 @@ export function createContentServer(
tagModifiers: [], tagModifiers: [],
}; };
/** 从当前内容索引和已收集的块重新扫描补全数据 */ /** 从当前注册表重新派生补全数据 */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults); completionsIndex = deriveCompletions(registry);
console.log( console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`, `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
); );
} }
// 扫描内容目录生成索引 // 扫描内容目录生成注册表
console.log("正在扫描内容目录..."); console.log("正在扫描内容目录...");
const scanResult = scanDirectory(contentDir); registry = buildRegistry(contentDir);
contentIndex = scanResult.index; console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
collectedBlocks = scanResult.blocks;
directiveResults = scanResult.directiveResults;
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
recomputeCompletions(); recomputeCompletions();
// 监听文件变化 // 监听文件变化
@@ -356,20 +307,10 @@ export function createContentServer(
path.endsWith(".svg") path.endsWith(".svg")
) { ) {
try { try {
const content = readFileSync(path, "utf-8"); // 全量重建注册表以刷新跨文件派生(spark 注入)
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); registry = buildRegistry(contentDir);
if (relPath.endsWith(".md")) { recomputeCompletions();
const result = processBlocks(content, relPath, contentIndex); console.log(`[新增] ${path}`);
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;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
console.log(`[新增] ${relPath}`);
} catch (e) { } catch (e) {
console.error(`读取新增文件失败:${path}`, e); console.error(`读取新增文件失败:${path}`, e);
} }
@@ -383,19 +324,9 @@ export function createContentServer(
path.endsWith(".svg") path.endsWith(".svg")
) { ) {
try { try {
const content = readFileSync(path, "utf-8"); registry = buildRegistry(contentDir);
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); recomputeCompletions();
if (relPath.endsWith(".md")) { console.log(`[更新] ${path}`);
const result = processBlocks(content, relPath, contentIndex);
contentIndex[relPath] = result.stripped;
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
console.log(`[更新] ${relPath}`);
} catch (e) { } catch (e) {
console.error(`读取更新文件失败:${path}`, e); console.error(`读取更新文件失败:${path}`, e);
} }
@@ -408,15 +339,9 @@ export function createContentServer(
path.endsWith(".yarn") || path.endsWith(".yarn") ||
path.endsWith(".svg") path.endsWith(".svg")
) { ) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); registry = buildRegistry(contentDir);
delete contentIndex[relPath]; recomputeCompletions();
console.log(`[删除] ${relPath}`); console.log(`[删除] ${path}`);
if (relPath.endsWith(".md")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
} }
}); });
@@ -427,8 +352,9 @@ export function createContentServer(
const handleRequest = createRequestHandler( const handleRequest = createRequestHandler(
contentDir, contentDir,
distPath, distPath,
() => contentIndex, () => registry.pathIndex,
() => completionsIndex, () => completionsIndex,
() => registry,
); );
const server = createServer(handleRequest); const server = createServer(handleRequest);
@@ -452,7 +378,7 @@ export function createContentServer(
return { return {
server, server,
watcher, watcher,
index: contentIndex, index: registry.pathIndex,
completions: completionsIndex, completions: completionsIndex,
close() { close() {
console.log("正在关闭内容服务器..."); 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 };
}
+13 -54
View File
@@ -2,66 +2,25 @@
* Shared block scanning utilities — safe for both CLI and browser. * Shared block scanning utilities — safe for both CLI and browser.
* *
* Parses fenced code blocks with attributes: * Parses fenced code blocks with attributes:
* ```lang id=xxx role=xxx as=xxx * ```lang id=xxx role=xxx key=value
* *
* - `role` dispatches to content scanners (declare, spark-table) * The `role` fully determines block behavior (see `scanDoc` in
* - `as` controls rendering (none, codeblock, md-table, md-card, md-dice) * `content-registry.ts`):
* - `id` is used for cross-references (file paths) * - spark-table → stored as CSV, rendered as an `:md-table` directive
* - declare / file → stored, block stripped
* - tag → block kept intact for the yaml-tag render extension
* - unknown role → warned about and stripped
* - no role → kept as a visible code block
*
* Attribute parsing itself lives in `src/markdown/block-attrs.ts` so render
* extensions can share the same syntax.
*/ */
// --------------------------------------------------------------------------- export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js";
// Types
// ---------------------------------------------------------------------------
export interface BlockAttrs {
lang: string;
id?: string;
role?: string;
as?: string;
/** Any other attributes not in the standard set */
extra: Record<string, string>;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Regex // Regex
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Matches fenced code blocks with an info string (at least one attr). */ /** Matches fenced code blocks with an info string (at least one attr). */
export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm; export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm;
// ---------------------------------------------------------------------------
// Attribute parsing
// ---------------------------------------------------------------------------
/** Parse key="value" and key=value pairs from an attribute string. */
export function parseBlockAttrs(info: string): BlockAttrs {
const attrs: Record<string, string> = {};
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(info)) !== null) {
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
}
const { lang, id, role, as, ...extra } = attrs;
return { lang: lang || "", id, role, as, extra };
}
// ---------------------------------------------------------------------------
// as resolution
// ---------------------------------------------------------------------------
/**
* Determine the effective `as` value.
*
* Defaults:
* - role is set, no explicit as → "none" (strip — it's metadata)
* - role=spark-table → "md-table" (render as md-table directive)
* - no role, no as → "codeblock" (keep as visible code block)
*/
export function resolveBlockAs(role: string | undefined, as: string | undefined): string {
if (as) return as;
// spark-table blocks render as md-table by default
if (role === "spark-table") return "md-table";
if (role) return "none";
return "codeblock";
}
+23 -48
View File
@@ -3,60 +3,34 @@
* *
* Shared between CLI completions scanner and browser-side completions. * Shared between CLI completions scanner and browser-side completions.
* *
* Format (columns can be in any order; `tag` is optional): * Format (columns can be in any order; `tag` and `threshold` are optional):
* tag,key,expr * tag,threshold,key,expr
* ,$hp,$con*5+$mod_hp ← variable declaration * ,,$hp,$con*5+$mod_hp ← variable declaration
* ,$ac,10+$dex ← variable declaration * ,,$ac,10+$dex ← variable declaration
* #warrior,$mod_hp,20 ← tag modifier * #warrior,2,$mod_hp,20 ← tag modifier (threshold 2)
* #warrior,$mod_str,1 ← tag modifier * #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1)
* *
* When `tag` is empty: $key is a reactively computed variable. * When `tag` is empty: $key is a reactively computed variable.
* When `tag` is present: when #tag is active, $key gets expr added to its base. * When `tag` is present: when #tag met (source's tagmap value >= threshold),
* $key gets expr added to its base.
*/ */
import { parse } from "csv-parse/browser/esm/sync"; 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 // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface VarDeclaration { export interface VarDeclaration {
key: string; // "$hp" (always starts with $) key: string; // "$hp" (always starts with $)
expression: string; // "$con*5+$mod_hp" expression: string; // "$con*5+$mod_hp"
} }
export interface TagModifier { export interface TagModifier {
tag: string; // "#warrior" tag: string; // "#warrior"
target: string; // "$mod_hp" target: string; // "$mod_hp"
expression: string; // "20" expression: string; // "20"
threshold: number; // minimum tagmap count to activate (default 1)
} }
export interface DeclareResult { export interface DeclareResult {
@@ -84,7 +58,7 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
columns: true, columns: true,
trim: true, trim: true,
skipEmptyLines: true, skipEmptyLines: true,
}) as Array<{ tag?: string; key: string; expr: string }>; }) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
const variables: VarDeclaration[] = []; const variables: VarDeclaration[] = [];
const tagModifiers: TagModifier[] = []; const tagModifiers: TagModifier[] = [];
@@ -102,26 +76,27 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
if (tag) { if (tag) {
// Tag modifier // Tag modifier
if (!tag.startsWith("#")) { if (!tag.startsWith("#")) {
throw new Error( throw new Error(`${source}: tag must start with #, got "${tag}"`);
`${source}: tag must start with #, got "${tag}"`,
);
} }
if (!key.startsWith("$")) { if (!key.startsWith("$")) {
throw new Error(`${source}: key must start with $, got "${key}"`);
}
const thresholdRaw = row.threshold?.trim() ?? "";
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
if (isNaN(threshold) || threshold < 1) {
throw new Error( throw new Error(
`${source}: key must start with $, got "${key}"`, `${source}: threshold must be a positive integer, got "${row.threshold}"`,
); );
} }
tagModifiers.push({ tag, target: key, expression: expr }); tagModifiers.push({ tag, target: key, expression: expr, threshold });
} else { } else {
// Variable declaration // Variable declaration
if (!key.startsWith("$")) { if (!key.startsWith("$")) {
throw new Error( throw new Error(`${source}: key must start with $, got "${key}"`);
`${source}: key must start with $, got "${key}"`,
);
} }
variables.push({ key, expression: expr }); variables.push({ key, expression: expr });
} }
} }
return { variables, tagModifiers }; return { variables, tagModifiers };
} }
-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;
}
+3 -1
View File
@@ -36,6 +36,8 @@ export interface SparkTableCompletion {
slug: string; slug: string;
/** File path of the containing .md file (without extension) */ /** File path of the containing .md file (without extension) */
filePath: string; 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 */ /** Resolved path to the .csv file backing this spark table */
csvPath: string; csvPath: string;
/** Data column headers for display */ /** Data column headers for display */
@@ -61,4 +63,4 @@ export interface CompletionSource {
key: string; key: string;
/** Scan the content index and return structured completion items */ /** Scan the content index and return structured completion items */
scan(index: Record<string, string>): unknown[]; scan(index: Record<string, string>): unknown[];
} }
+331 -114
View File
@@ -5,18 +5,15 @@
* - variable-expression (expression evaluation) * - variable-expression (expression evaluation)
* - var-reactivity (dependency graph, cascade, tag activation) * - var-reactivity (dependency graph, cascade, tag activation)
* - command-parser (input parsing) * - command-parser (input parsing)
* - directive-scanner (spark table detection) * - content-registry (spark table detection)
*/ */
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mocks // Mocks
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
jest.mock("csv-parse/browser/esm/sync", () => { // csv-parse/browser/esm/sync and github-slugger are mapped to CJS builds
// Redirect browser-specific import to Node-compatible sync parser // globally in jest.config.js moduleNameMapper.
const actual = jest.requireActual("csv-parse/sync");
return { parse: actual.parse };
});
jest.mock("github-slugger", () => { jest.mock("github-slugger", () => {
// Simple slugger mock for testing // Simple slugger mock for testing
@@ -31,8 +28,8 @@ jest.mock("github-slugger", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { parseDeclareCsv } from "./declare-parser"; import { parseDeclareCsv } from "./declare-parser";
import { parseBlockAttrs, resolveBlockAs } from "./block-scanner"; import { parseBlockAttrs } from "./block-scanner";
import { evaluateExpression } from "../../components/journal/variable-expression"; import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
import { import {
initReactivity, initReactivity,
computeCascade, computeCascade,
@@ -47,8 +44,8 @@ import { parseInput } from "../../components/journal/command-parser";
import { import {
inspectSparkTableCsv, inspectSparkTableCsv,
buildSparkTableCompletion, buildSparkTableCompletion,
} from "./directive-scanner"; isSparkTableHeader,
import Slugger from "github-slugger"; } from "../content-registry";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -70,7 +67,10 @@ describe("parseDeclareCsv", () => {
,$ac,10+$dex`; ,$ac,10+$dex`;
const result = parseDeclareCsv(csv, "test.md"); const result = parseDeclareCsv(csv, "test.md");
expect(result.variables).toHaveLength(2); 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.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
expect(result.tagModifiers).toHaveLength(0); expect(result.tagModifiers).toHaveLength(0);
}); });
@@ -86,14 +86,44 @@ describe("parseDeclareCsv", () => {
tag: "#warrior", tag: "#warrior",
target: "$mod_hp", target: "$mod_hp",
expression: "20", expression: "20",
threshold: 1,
}); });
expect(result.tagModifiers[1]).toEqual({ expect(result.tagModifiers[1]).toEqual({
tag: "#mage", tag: "#mage",
target: "$mod_mp", target: "$mod_mp",
expression: "15", expression: "15",
threshold: 1,
}); });
}); });
test("parses tag modifiers with threshold", () => {
const csv = `tag,threshold,key,expr
#warrior,2,$mod_hp,20
#mage,,$mod_mp,15`;
const result = parseDeclareCsv(csv, "test.md");
expect(result.tagModifiers).toHaveLength(2);
expect(result.tagModifiers[0]).toEqual({
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 2,
});
expect(result.tagModifiers[1]).toEqual({
tag: "#mage",
target: "$mod_mp",
expression: "15",
threshold: 1,
});
});
test("rejects invalid threshold", () => {
const csv = `tag,threshold,key,expr
#warrior,0,$mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
"threshold must be a positive integer",
);
});
test("parses mixed declarations and tag modifiers", () => { test("parses mixed declarations and tag modifiers", () => {
const csv = `tag,key,expr const csv = `tag,key,expr
,$hp,$con*5 ,$hp,$con*5
@@ -124,7 +154,7 @@ describe("parseDeclareCsv", () => {
const csv = `tag,key,expr const csv = `tag,key,expr
warrior,$mod_hp,20`; warrior,$mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow( expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'tag must start with #', "tag must start with #",
); );
}); });
@@ -132,7 +162,7 @@ warrior,$mod_hp,20`;
const csv = `tag,key,expr const csv = `tag,key,expr
,hp,$con*5`; ,hp,$con*5`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow( expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'key must start with $', "key must start with $",
); );
}); });
@@ -140,7 +170,7 @@ warrior,$mod_hp,20`;
const csv = `tag,key,expr const csv = `tag,key,expr
#warrior,mod_hp,20`; #warrior,mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow( expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'key must start with $', "key must start with $",
); );
}); });
@@ -176,6 +206,7 @@ $con*5,$hp,
tag: "#warrior", tag: "#warrior",
target: "$mod_hp", target: "$mod_hp",
expression: "20", expression: "20",
threshold: 1,
}); });
}); });
}); });
@@ -188,11 +219,10 @@ describe("parseBlockAttrs", () => {
test("parses standard attributes", () => { test("parses standard attributes", () => {
// parseBlockAttrs receives the info string AFTER the lang. // parseBlockAttrs receives the info string AFTER the lang.
// The lang is extracted from the fenced block regex capture group // The lang is extracted from the fenced block regex capture group
// and applied separately in block-processor. // and applied separately in content-registry.
const attrs = parseBlockAttrs('id=stats role=declare as=none'); const attrs = parseBlockAttrs("id=stats role=declare");
expect(attrs.id).toBe("stats"); expect(attrs.id).toBe("stats");
expect(attrs.role).toBe("declare"); expect(attrs.role).toBe("declare");
expect(attrs.as).toBe("none");
}); });
test("parses quoted values", () => { test("parses quoted values", () => {
@@ -202,7 +232,7 @@ describe("parseBlockAttrs", () => {
}); });
test("collects unknown attributes in extra", () => { 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" }); expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
}); });
@@ -211,7 +241,6 @@ describe("parseBlockAttrs", () => {
expect(attrs.lang).toBe(""); expect(attrs.lang).toBe("");
expect(attrs.id).toBeUndefined(); expect(attrs.id).toBeUndefined();
expect(attrs.role).toBeUndefined(); expect(attrs.role).toBeUndefined();
expect(attrs.as).toBeUndefined();
}); });
test("handles empty info string (lang extracted separately)", () => { test("handles empty info string (lang extracted separately)", () => {
@@ -221,24 +250,8 @@ describe("parseBlockAttrs", () => {
}); });
}); });
describe("resolveBlockAs", () => { // Role behavior is covered by the scanDoc tests in
test("returns explicit as value", () => { // src/cli/content-registry.test.ts.
expect(resolveBlockAs("declare", "codeblock")).toBe("codeblock");
});
test("defaults to none when role is set", () => {
expect(resolveBlockAs("declare", undefined)).toBe("none");
expect(resolveBlockAs("file", undefined)).toBe("none");
});
test("defaults to md-table for spark-table role", () => {
expect(resolveBlockAs("spark-table", undefined)).toBe("md-table");
});
test("defaults to codeblock when no role and no as", () => {
expect(resolveBlockAs(undefined, undefined)).toBe("codeblock");
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// variable-expression // variable-expression
@@ -247,12 +260,12 @@ describe("resolveBlockAs", () => {
describe("evaluateExpression", () => { describe("evaluateExpression", () => {
test("evaluates simple arithmetic", () => { test("evaluates simple arithmetic", () => {
const result = evaluateExpression("2 + 3 * 4", { lookup: () => undefined }); 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", () => { test("evaluates with parentheses", () => {
const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined }); 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", () => { test("evaluates variable references", () => {
@@ -263,50 +276,105 @@ describe("evaluateExpression", () => {
return undefined; 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", () => { test("returns 0 for undefined variables", () => {
const result = evaluateExpression("$unknown + 5", { const result = evaluateExpression("$unknown + 5", {
lookup: () => undefined, 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(() => expect(() =>
evaluateExpression("$class + 5", { evaluateExpression("$class + 5", {
lookup: (name) => (name === "class" ? "#warrior" : undefined), 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", () => { test("evaluates floor function", () => {
const result = evaluateExpression("floor(3.7)", { lookup: () => undefined }); 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", () => { test("evaluates ceil function", () => {
const result = evaluateExpression("ceil(3.2)", { lookup: () => undefined }); 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", () => { test("evaluates round function", () => {
const result = evaluateExpression("round(3.5)", { lookup: () => undefined }); 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", () => { test("evaluates unary minus", () => {
const result = evaluateExpression("-5 + 10", { lookup: () => undefined }); 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", () => { test("evaluates dice notation", () => {
const result = evaluateExpression("3d6 + 5", { lookup: () => undefined }); 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 // 3d6 is between 3 and 18, +5 gives 8-23
expect(result.value).toBeGreaterThanOrEqual(8); expect(result.value.value).toBeGreaterThanOrEqual(8);
expect(result.value).toBeLessThanOrEqual(23); expect(result.value.value).toBeLessThanOrEqual(23);
}); });
test("throws on division by zero", () => { test("throws on division by zero", () => {
@@ -329,7 +397,7 @@ describe("evaluateExpression", () => {
test("handles decimal numbers", () => { test("handles decimal numbers", () => {
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined }); 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", () => { test("nested function calls", () => {
@@ -337,7 +405,7 @@ describe("evaluateExpression", () => {
lookup: () => undefined, lookup: () => undefined,
}); });
// ceil(3.2) = 4, floor(4) = 4 // 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", () => { test("complex expression with variables and functions", () => {
@@ -348,7 +416,20 @@ describe("evaluateExpression", () => {
return undefined; 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");
}); });
}); });
@@ -393,9 +474,7 @@ describe("var-reactivity", () => {
test("detects self-referencing circular dependency", () => { test("detects self-referencing circular dependency", () => {
expect(() => expect(() =>
initReactivity({ initReactivity({
declarations: [ declarations: [{ key: "$a", expression: "$a + 1" }],
{ key: "$a", expression: "$a + 1" },
],
tagModifiers: [], tagModifiers: [],
}), }),
).toThrow("Circular dependency"); ).toThrow("Circular dependency");
@@ -484,10 +563,10 @@ describe("var-reactivity", () => {
expect(getCombined("$hp", { $hp: "99" })).toBe("99"); expect(getCombined("$hp", { $hp: "99" })).toBe("99");
}); });
test("returns tag value without numeric mods", () => { test("returns tagmap value without numeric mods", () => {
initReactivity({ declarations: [], tagModifiers: [] }); initReactivity({ declarations: [], tagModifiers: [] });
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
expect(getCombined("$class")).toBe("#warrior"); expect(getCombined("$class")).toBe("#warrior:1");
}); });
}); });
@@ -496,14 +575,28 @@ describe("var-reactivity", () => {
initReactivity({ initReactivity({
declarations: [], declarations: [],
tagModifiers: [ tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20" }, {
{ tag: "#warrior", target: "$mod_str", expression: "5" }, tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{
tag: "#warrior",
target: "$mod_str",
expression: "5",
threshold: 1,
},
], ],
}); });
// Set $class to #warrior — should activate both modifiers // Set $class to #warrior:1 — should activate both modifiers
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior" })); const cascade = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:1" }),
);
// Should produce combined values for both targets // Should produce combined values for both targets
const modHp = cascade.find((r) => r.key === "$mod_hp"); const modHp = cascade.find((r) => r.key === "$mod_hp");
@@ -512,24 +605,29 @@ describe("var-reactivity", () => {
expect(modStr?.value).toBe("5"); expect(modStr?.value).toBe("5");
}); });
test("deactivates tag modifiers when tag value changes", () => { test("deactivates tag modifiers when tag is removed from tagmap", () => {
initReactivity({ initReactivity({
declarations: [], declarations: [],
tagModifiers: [ tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20" }, {
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
], ],
}); });
// First activate // First activate
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
computeCascade("$class", undefined, store({ $class: "#warrior" })); computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
// Now change to a different tag // Now change to a different tag
setBase("$class", "#mage"); setBase("$class", "#mage:1");
const cascade = computeCascade( const cascade = computeCascade(
"$class", "$class",
"#warrior", "#warrior:1",
store({ $class: "#mage" }), store({ $class: "#mage:1" }),
); );
// Should deactivate #warrior mods (mod_hp goes back to 0) // Should deactivate #warrior mods (mod_hp goes back to 0)
@@ -541,21 +639,26 @@ describe("var-reactivity", () => {
initReactivity({ initReactivity({
declarations: [], declarations: [],
tagModifiers: [ tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20" }, {
{ tag: "#mage", target: "$mod_hp", expression: "10" }, tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 },
], ],
}); });
// Activate #warrior // Activate #warrior
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
computeCascade("$class", undefined, store({ $class: "#warrior" })); computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
// Switch to #mage // Switch to #mage
setBase("$class", "#mage"); setBase("$class", "#mage:1");
const cascade = computeCascade( const cascade = computeCascade(
"$class", "$class",
"#warrior", "#warrior:1",
store({ $class: "#mage" }), store({ $class: "#mage:1" }),
); );
// Cascade returns both deactivation ("0") and activation ("10") entries. // Cascade returns both deactivation ("0") and activation ("10") entries.
@@ -564,14 +667,102 @@ describe("var-reactivity", () => {
const lastModHp = modHpEntries[modHpEntries.length - 1]; const lastModHp = modHpEntries[modHpEntries.length - 1];
expect(lastModHp?.value).toBe("10"); expect(lastModHp?.value).toBe("10");
}); });
test("respects threshold — activates only when count >= threshold", () => {
initReactivity({
declarations: [],
tagModifiers: [
{
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 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 modHp2 = cascade2.find((r) => r.key === "$mod_hp");
expect(modHp2?.value).toBe("20");
});
test("deactivates when count drops below threshold", () => {
initReactivity({
declarations: [],
tagModifiers: [
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 2,
},
],
});
// Activate with count 2
setBase("$class", "#warrior:2");
computeCascade("$class", undefined, store({ $class: "#warrior:2" }));
expect(getCombined("$mod_hp")).toBe("20");
// Drop to count 1 < threshold 2 — should deactivate
setBase("$class", "#warrior:1");
const cascade = computeCascade(
"$class",
"#warrior:2",
store({ $class: "#warrior:1" }),
);
const modHp = cascade.find((r) => r.key === "$mod_hp");
expect(modHp?.value).toBe("0");
});
test("handles multi-tag tagmaps", () => {
initReactivity({
declarations: [],
tagModifiers: [
{
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 modHp = cascade.find((r) => r.key === "$mod_hp");
const modMp = cascade.find((r) => r.key === "$mod_mp");
expect(modHp?.value).toBe("20");
expect(modMp?.value).toBe("15");
});
}); });
describe("computeCascade — declaration re-evaluation", () => { describe("computeCascade — declaration re-evaluation", () => {
test("re-evaluates dependents when a dependency changes", () => { test("re-evaluates dependents when a dependency changes", () => {
initReactivity({ initReactivity({
declarations: [ declarations: [{ key: "$hp", expression: "$con * 5" }],
{ key: "$hp", expression: "$con * 5" },
],
tagModifiers: [], tagModifiers: [],
}); });
@@ -605,29 +796,30 @@ describe("var-reactivity", () => {
expect(cResult?.value).toBe("20"); // 10 + 10 expect(cResult?.value).toBe("20"); // 10 + 10
}); });
test("handles tag transition during re-evaluation", () => { test("handles tagmap transition during re-evaluation", () => {
initReactivity({ initReactivity({
declarations: [ declarations: [{ key: "$class", expression: "0" }],
{ key: "$class", expression: "$level > 5 ? '#warrior' : '#novice'" },
],
tagModifiers: [ tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20" }, {
{ tag: "#novice", target: "$mod_hp", expression: "5" }, tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 },
], ],
// Note: the expression above uses a ternary which isn't supported
// by the expression evaluator. We'll test with direct tag values.
}); });
// Set $class directly to a tag // Set $class directly to #novice:1
setBase("$class", "#novice"); setBase("$class", "#novice:1");
computeCascade("$class", undefined, store({ $class: "#novice" })); computeCascade("$class", undefined, store({ $class: "#novice:1" }));
// Now change to #warrior // Now change to #warrior:1
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
const cascade = computeCascade( const cascade = computeCascade(
"$class", "$class",
"#novice", "#novice:1",
store({ $class: "#warrior" }), store({ $class: "#warrior:1" }),
); );
// Cascade returns both deactivation and activation entries. // Cascade returns both deactivation and activation entries.
@@ -652,12 +844,17 @@ describe("var-reactivity", () => {
initReactivity({ initReactivity({
declarations: [], declarations: [],
tagModifiers: [ tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20" }, {
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
], ],
}); });
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
computeCascade("$class", undefined, store({ $class: "#warrior" })); computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
const mods = getMods("$mod_hp"); const mods = getMods("$mod_hp");
expect(mods).toHaveLength(1); expect(mods).toHaveLength(1);
@@ -690,16 +887,16 @@ describe("var-reactivity", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("parseInput", () => { describe("parseInput", () => {
test("parses /roll command", () => { test("parses /roll command with spark table slug", () => {
const result = parseInput("/roll 3d6+5"); const result = parseInput("/roll my-table");
expect(result.type).toBe("roll"); expect(result.type).toBe("roll");
expect(result.payload).toEqual({ notation: "3d6+5", label: "3d6+5" }); expect(result.payload).toEqual({ arg: "my-table" });
}); });
test("parses /spark command", () => { test("parses /roll command with dice notation", () => {
const result = parseInput("/spark my-table"); const result = parseInput("/roll 3d6+5");
expect(result.type).toBe("spark"); expect(result.type).toBe("roll");
expect(result.payload).toEqual({ key: "my-table" }); expect(result.payload).toEqual({ arg: "3d6+5" });
}); });
test("parses /link command", () => { test("parses /link command", () => {
@@ -743,7 +940,7 @@ describe("parseInput", () => {
test("returns error for empty /roll", () => { test("returns error for empty /roll", () => {
const result = parseInput("/roll "); const result = parseInput("/roll ");
expect(result.error).toBe("需要骰子表达式"); expect(result.error).toBe("需要骰子表达式或种子表键名");
}); });
test("returns error for empty /set", () => { test("returns error for empty /set", () => {
@@ -769,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", () => { describe("inspectSparkTableCsv", () => {
test("detects spark table from CSV headers", () => { test("detects spark table from CSV headers", () => {
const csv = `d6,Name,Description const csv = `d6,Name,Description
@@ -823,13 +1046,11 @@ describe("buildSparkTableCompletion", () => {
test("builds completion from CSV data", () => { test("builds completion from CSV data", () => {
const csv = `d6,Name,Description const csv = `d6,Name,Description
1,Alice,The brave`; 1,Alice,The brave`;
const slugger = new Slugger();
const result = buildSparkTableCompletion( const result = buildSparkTableCompletion(
csv, csv,
"/rules/combat.md", "/rules/combat.md",
"/rules/combat/test.csv", "/rules/combat/test.csv",
false, false,
slugger,
); );
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!.notation).toBe("d6"); expect(result!.notation).toBe("d6");
@@ -841,13 +1062,11 @@ describe("buildSparkTableCompletion", () => {
test("returns null for non-spark CSV", () => { test("returns null for non-spark CSV", () => {
const csv = `Name,Value const csv = `Name,Value
Alice,10`; Alice,10`;
const slugger = new Slugger();
const result = buildSparkTableCompletion( const result = buildSparkTableCompletion(
csv, csv,
"/test.md", "/test.md",
"/test.csv", "/test.csv",
false, false,
slugger,
); );
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -855,13 +1074,11 @@ Alice,10`;
test("sets remix flag", () => { test("sets remix flag", () => {
const csv = `d6,Result const csv = `d6,Result
1,Yes`; 1,Yes`;
const slugger = new Slugger();
const result = buildSparkTableCompletion( const result = buildSparkTableCompletion(
csv, csv,
"/test.md", "/test.md",
"/test.csv", "/test.csv",
true, true,
slugger,
); );
expect(result!.remix).toBe(true); 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;
}
+9 -1
View File
@@ -108,7 +108,15 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
if (href.startsWith("#")) return; if (href.startsWith("#")) return;
e.preventDefault(); e.preventDefault();
navigate(href); // Resolve relative hrefs against the current page path so that
// e.g. "blah.md" on /content/page.md becomes /content/blah
const resolved = new URL(href, window.location.origin + window.location.pathname);
let pathname = resolved.pathname;
// Strip .md extension so refreshes hit the SPA fallback, not the raw file
if (pathname.endsWith(".md")) {
pathname = pathname.slice(0, -3);
}
navigate(pathname + resolved.hash);
}; };
dom.addEventListener("click", onClick); dom.addEventListener("click", onClick);
+3 -64
View File
@@ -162,73 +162,12 @@ const DocContent: Component<{ entry: AnyEntry }> = (props) => {
return ( return (
<div class="max-w-none"> <div class="max-w-none">
<h2 class="text-2xl font-bold text-gray-900 mb-2"> <h2 class="text-2xl font-bold text-gray-900 mb-6">
{e.icon} {e.title} {e.icon} {e.title}
<code class="ml-3 text-base font-mono bg-gray-100 px-2 py-0.5 rounded text-blue-700">
:{e.tag}
</code>
</h2> </h2>
<p class="text-gray-600 mb-6">{e.description}</p> <div class="text-sm leading-relaxed whitespace-pre-wrap text-gray-800">
{e.body}
<div class="mb-6">
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">
</h3>
<pre class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg text-sm overflow-x-auto">
<code>{e.syntax}</code>
</pre>
</div>
<Show when={e.props.length > 0}>
<div class="mb-6">
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">
</h3>
<table class="w-full text-sm border-collapse">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 pr-4 font-medium text-gray-600">
</th>
<th class="text-left py-2 pr-4 font-medium text-gray-600">
</th>
<th class="text-left py-2 pr-4 font-medium text-gray-600">
</th>
<th class="text-left py-2 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
<For each={e.props}>
{(prop) => (
<tr class="border-b border-gray-100">
<td class="py-2 pr-4">
<code class="bg-gray-100 px-1.5 py-0.5 rounded text-xs text-pink-700">
{prop.name}
</code>
</td>
<td class="py-2 pr-4">
<code class="text-xs text-gray-600">{prop.type}</code>
</td>
<td class="py-2 pr-4 text-xs text-gray-400">
{prop.default ?? "—"}
</td>
<td class="py-2 text-sm text-gray-600">{prop.desc}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
<div class="prose-sm max-w-none">
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2"></h3>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm leading-relaxed whitespace-pre-wrap font-mono text-gray-800">
{e.body}
</div>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -115,7 +115,7 @@ export const HeadingNode: Component<{
if (e.button === 0) { if (e.button === 0) {
// Left-click: use SPA navigation // Left-click: use SPA navigation
e.preventDefault(); e.preventDefault();
navigate(href()); navigate(`${props.basePath}#${anchor}`);
} }
// Middle-click / right-click: let the browser handle the <a> natively // Middle-click / right-click: let the browser handle the <a> natively
// 滚动到目标元素,考虑导航栏高度偏移 // 滚动到目标元素,考虑导航栏高度偏移
+1 -1
View File
@@ -126,7 +126,7 @@ export const RevealManager: Component = () => {
rect: sparkTable.getBoundingClientRect(), rect: sparkTable.getBoundingClientRect(),
action: () => action: () =>
setActionPrefill({ setActionPrefill({
command: "/spark", command: "/roll",
text: combinedSlug, text: combinedSlug,
}), }),
title: "Roll spark table", title: "Roll spark table",
+2 -9
View File
@@ -4,16 +4,12 @@ export interface DocEntry {
tag: string; tag: string;
icon: string; icon: string;
title: string; title: string;
description: string; /** Full markdown body (everything after frontmatter ---) */
syntax: string;
props: { name: string; type: string; default?: string; desc: string }[];
/** Markdown body (everything after frontmatter ---) */
body: string; body: string;
} }
/** Splits frontmatter and markdown body from a raw .md string. */ /** Splits frontmatter and markdown body from a raw .md string. */
function parseFrontmatter(raw: string): Record<string, unknown> | null { function parseFrontmatter(raw: string): Record<string, unknown> | null {
// Handle CRLF line endings by normalizing to LF first
const normalized = raw.replace(/\r\n/g, "\n"); const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return null; if (!match) return null;
@@ -27,7 +23,7 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
function getBody(raw: string): string { function getBody(raw: string): string {
const normalized = raw.replace(/\r\n/g, "\n"); const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/); const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
return match ? match[1] : raw; return (match ? match[1] : raw).trim();
} }
function parseEntry(raw: string): DocEntry | null { function parseEntry(raw: string): DocEntry | null {
@@ -37,9 +33,6 @@ function parseEntry(raw: string): DocEntry | null {
tag: fm.tag as string, tag: fm.tag as string,
icon: fm.icon as string, icon: fm.icon as string,
title: fm.title as string, title: fm.title as string,
description: fm.description as string,
syntax: fm.syntax as string,
props: (fm.props as DocEntry["props"]) ?? [],
body: getBody(raw), body: getBody(raw),
}; };
} }
+2 -9
View File
@@ -4,9 +4,7 @@ export interface JournalDocEntry {
tag: string; tag: string;
icon: string; icon: string;
title: string; title: string;
description: string; /** Full markdown body (everything after frontmatter ---) */
syntax: string;
props: { name: string; type: string; default?: string; desc: string }[];
body: string; body: string;
} }
@@ -24,7 +22,7 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
function getBody(raw: string): string { function getBody(raw: string): string {
const normalized = raw.replace(/\r\n/g, "\n"); const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/); const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
return match ? match[1] : raw; return (match ? match[1] : raw).trim();
} }
function parseEntry(raw: string): JournalDocEntry | null { function parseEntry(raw: string): JournalDocEntry | null {
@@ -34,9 +32,6 @@ function parseEntry(raw: string): JournalDocEntry | null {
tag: fm.tag as string, tag: fm.tag as string,
icon: fm.icon as string, icon: fm.icon as string,
title: fm.title as string, title: fm.title as string,
description: fm.description as string,
syntax: fm.syntax as string,
props: (fm.props as JournalDocEntry["props"]) ?? [],
body: getBody(raw), body: getBody(raw),
}; };
} }
@@ -45,7 +40,6 @@ import journalGmRaw from "../doc-entries/journal-gm.md";
import journalPlayerRaw from "../doc-entries/journal-player.md"; import journalPlayerRaw from "../doc-entries/journal-player.md";
import journalSetRaw from "../doc-entries/journal-set.md"; import journalSetRaw from "../doc-entries/journal-set.md";
import journalRollRaw from "../doc-entries/journal-roll.md"; import journalRollRaw from "../doc-entries/journal-roll.md";
import journalSparkRaw from "../doc-entries/journal-spark.md";
import journalLinkRaw from "../doc-entries/journal-link.md"; import journalLinkRaw from "../doc-entries/journal-link.md";
const rawDocuments: string[] = [ const rawDocuments: string[] = [
@@ -53,7 +47,6 @@ const rawDocuments: string[] = [
journalPlayerRaw, journalPlayerRaw,
journalSetRaw, journalSetRaw,
journalRollRaw, journalRollRaw,
journalSparkRaw,
journalLinkRaw, journalLinkRaw,
]; ];
+1 -1
View File
@@ -265,7 +265,7 @@ export const JournalInput: Component = () => {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
isGm() isGm()
? "输入消息,或使用 /roll、/spark、/link、/set 命令..." ? "输入消息,或使用 /roll、/link、/set 命令..."
: "输入消息,或使用 /set 命令..." : "输入消息,或使用 /set 命令..."
} }
rows={2} rows={2}
+30 -25
View File
@@ -21,7 +21,6 @@ export function buildCompletions(
const commands = [ const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " }, { label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
{ label: "/link", kind: "command" as const, insertText: "/link " }, { label: "/link", kind: "command" as const, insertText: "/link " },
{ label: "/set", kind: "command" as const, insertText: "/set " }, { label: "/set", kind: "command" as const, insertText: "/set " },
]; ];
@@ -40,46 +39,52 @@ export function buildCompletions(
: [{ label: "未知命令", kind: "no-results", insertText: "" }]; : [{ label: "未知命令", kind: "no-results", insertText: "" }];
} }
// After /roll — show dice suggestions // After /roll — show dice AND spark table suggestions
if (raw.startsWith("/roll ")) { if (raw.startsWith("/roll ")) {
const prefix = raw.slice("/roll ".length).toLowerCase(); const prefix = raw.slice("/roll ".length).toLowerCase();
const matches = data.dice
const diceMatches = data.dice
.filter( .filter(
(d) => (d) =>
d.notation.toLowerCase().includes(prefix) || d.notation.toLowerCase().includes(prefix) ||
d.label.toLowerCase().includes(prefix), d.label.toLowerCase().includes(prefix),
) )
.slice(0, 8); .slice(0, 4);
if (matches.length === 0) {
return [{ label: "未找到骰子", kind: "no-results", insertText: "" }];
}
return matches.map((d) => ({
label: d.notation,
kind: "value" as const,
insertText: "/roll " + d.notation,
}));
}
// After /spark — show spark table suggestions const sparkMatches = data.sparkTables
if (raw.startsWith("/spark ")) {
const prefix = raw.slice("/spark ".length).toLowerCase();
const matches = data.sparkTables
.filter( .filter(
(s) => (s) =>
s.slug.toLowerCase().includes(prefix) || s.slug.toLowerCase().includes(prefix) ||
s.label.toLowerCase().includes(prefix), s.label.toLowerCase().includes(prefix),
) )
.slice(0, 8); .slice(0, 4);
if (matches.length === 0) {
return [{ label: "未找到种子表", kind: "no-results", insertText: "" }]; const items: CompletionItem[] = [];
for (const d of diceMatches) {
items.push({
label: `🎲 ${d.notation}`,
kind: "value",
insertText: "/roll " + d.notation,
});
} }
return matches.map((s) => ({
label: `${s.slug} (${s.notation})`, for (const s of sparkMatches) {
kind: "value" as const, items.push({
insertText: `/spark ${s.slug}`, label: ` ${s.slug} (${s.notation})`,
})); kind: "value",
insertText: `/roll ${s.slug}`,
});
}
if (items.length === 0) {
return [{ label: "输入骰子表达式", kind: "no-results", insertText: "" }];
}
return items;
} }
// After /link — show article and heading suggestions // After /link — show article and heading suggestions
if (raw.startsWith("/link ")) { if (raw.startsWith("/link ")) {
const prefix = raw.slice("/link ".length).toLowerCase(); const prefix = raw.slice("/link ".length).toLowerCase();
+89 -37
View File
@@ -10,25 +10,33 @@ import { createSignal } from "solid-js";
import { parseInput } from "./command-parser"; import { parseInput } from "./command-parser";
import { resolveRollPayload } from "./types/roll"; import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark"; import { resolveSparkPayload } from "./types/spark";
import { evaluateExpression, expressionIsTag } from "./variable-expression"; import { evaluateExpression, exprValueToString } from "./variable-expression";
import { computeCascade, getCombined, setBase } from "./var-reactivity"; import { computeCascade, getCombined, setBase } from "./var-reactivity";
import type { VarDeclaration, TagModifier } from "./declare-parser"; 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_]*$/;
function normalizeTagMap(expr: string): string {
const t = expr.trim();
if (BARE_TAG_PATTERN.test(t)) return t + ":1";
return t;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Result // Result
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type DispatchResult = export type DispatchResult = { ok: true } | { ok: false; error: string };
| { ok: true }
| { ok: false; error: string };
/** /**
* Shared signal for dispatch errors from any source (typed or cmd-link clicks). * Shared signal for dispatch errors from any source (typed or cmd-link clicks).
* Components that show errors (JournalInput) read from here; callers that * Components that show errors (JournalInput) read from here; callers that
* want errors surfaced (CommandLinkManager) write to it. * want errors surfaced (CommandLinkManager) write to it.
*/ */
export const [dispatchError, setDispatchError] = export const [dispatchError, setDispatchError] = createSignal<string | null>(
createSignal<string | null>(null); null,
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main dispatch // Main dispatch
@@ -42,7 +50,12 @@ export interface DispatchContext {
/** The raw text to dispatch (with or without leading `/`) */ /** The raw text to dispatch (with or without leading `/`) */
command: string; command: string;
/** Spark table lookup data (from completions) */ /** 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 */ /** Current runtime variable values */
variables: Record<string, string>; variables: Record<string, string>;
/** Variable declarations (from role=declare blocks) */ /** Variable declarations (from role=declare blocks) */
@@ -79,7 +92,9 @@ export async function dispatchCommand(
} }
if (parsed.type === "set" || parsed.type === "rolltag") { 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 命令" }); return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
@@ -87,30 +102,66 @@ export async function dispatchCommand(
// GM: all commands // GM: all commands
if (parsed.type === "roll") { if (parsed.type === "roll") {
const p = resolveRollPayload( const arg = (parsed.payload as { arg: string }).arg;
parsed.payload as { notation: string; label?: string },
); // Try spark table first: if arg matches a known spark table slug, roll it
const match = ctx.sparkTables.find((s) => s.slug === arg);
if (match) {
try {
const csvPath = match.csvPath ?? "";
const docPath = match.docPath;
const remix = match.remix ?? false;
const p = await resolveSparkPayload({
key: arg,
csvPath,
docPath,
remix,
});
const result = sendMessage("spark", p);
return finish(unwrap(result));
} catch (e) {
return finish({
ok: false,
error: e instanceof Error ? e.message : "种子表掷骰失败",
});
}
}
// Variable expression (e.g. "2d6 + $mod" or "$hp / 2")
if (arg.includes("$")) {
try {
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.value,
detail: "",
plainDetail: "",
pools: [] as { rolls: number[]; subtotal: number }[],
},
};
const result = sendMessage("roll", payload);
return finish(unwrap(result));
} catch (e) {
return finish({
ok: false,
error: e instanceof Error ? e.message : "表达式求值失败",
});
}
}
// Otherwise, treat as a dice expression
const p = resolveRollPayload({ notation: arg, label: arg });
const result = sendMessage("roll", p); const result = sendMessage("roll", p);
return finish(unwrap(result)); return finish(unwrap(result));
} }
if (parsed.type === "spark") {
try {
const key = (parsed.payload as { key: string }).key;
const match = ctx.sparkTables.find((s) => s.slug === key);
const csvPath = match?.csvPath ?? "";
const remix = match?.remix ?? false;
const p = await resolveSparkPayload({ key, csvPath, remix });
const result = sendMessage("spark", p);
return finish(unwrap(result));
} catch (e) {
return finish({
ok: false,
error: e instanceof Error ? e.message : "种子表掷骰失败",
});
}
}
if (parsed.type === "set" || parsed.type === "rolltag") { 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));
} }
@@ -147,14 +198,11 @@ function dispatchSet(
try { try {
if (p.tags && p.tags.length > 0) { if (p.tags && p.tags.length > 0) {
// Rolltag: pick random tag // Rolltag: pick random tag, format as tagmap entry
const idx = Math.floor(Math.random() * p.tags.length); const idx = Math.floor(Math.random() * p.tags.length);
newValue = p.tags[idx]; newValue = normalizeTagMap(p.tags[idx]);
} else if (p.expr && expressionIsTag(p.expr)) {
// Bare tag value
newValue = p.expr.trim();
} else if (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, { const result = evaluateExpression(p.expr, {
lookup: (name: string) => { lookup: (name: string) => {
const k = "$" + name; const k = "$" + name;
@@ -162,7 +210,7 @@ function dispatchSet(
return k === key ? undefined : getCombined(k, ctx.variables); return k === key ? undefined : getCombined(k, ctx.variables);
}, },
}); });
newValue = String(result.value); newValue = exprValueToString(result.value);
} else { } else {
return { ok: false, error: "缺少表达式" }; return { ok: false, error: "缺少表达式" };
} }
@@ -184,7 +232,11 @@ function dispatchSet(
try { try {
const cascade = computeCascade(key, oldValue, workingVars); const cascade = computeCascade(key, oldValue, workingVars);
for (const change of cascade) { 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) { } catch (e) {
// Cascade errors are non-fatal — the direct set already succeeded // Cascade errors are non-fatal — the direct set already succeeded
@@ -211,4 +263,4 @@ function unwrap<R>(
r: { success: true; msg: R } | { success: false; error: string }, r: { success: true; msg: R } | { success: false; error: string },
): DispatchResult { ): DispatchResult {
return r.success ? { ok: true } : { ok: false, error: r.error }; return r.success ? { ok: true } : { ok: false, error: r.error };
} }
+6 -12
View File
@@ -12,23 +12,17 @@ export interface CompletionItem {
} }
export interface ParsedInput { export interface ParsedInput {
type: "chat" | "roll" | "spark" | "link" | "set" | "rolltag"; type: "chat" | "roll" | "link" | "set" | "rolltag";
payload: Record<string, unknown>; payload: Record<string, unknown>;
error?: string; error?: string;
} }
export function parseInput(raw: string): ParsedInput { export function parseInput(raw: string): ParsedInput {
if (raw.startsWith("/roll ")) { if (raw.startsWith("/roll ")) {
const notation = raw.slice("/roll ".length).trim(); const arg = raw.slice("/roll ".length).trim();
if (!notation) if (!arg)
return { type: "roll", payload: {}, error: "需要骰子表达式" }; return { type: "roll", payload: {}, error: "需要骰子表达式或种子表键名" };
return { type: "roll", payload: { notation, label: notation } }; return { type: "roll", payload: { arg } };
}
if (raw.startsWith("/spark ")) {
const key = raw.slice("/spark ".length).trim();
if (!key) return { type: "spark", payload: {}, error: "需要种子表键名" };
return { type: "spark", payload: { key } };
} }
if (raw.startsWith("/link ")) { if (raw.startsWith("/link ")) {
@@ -69,7 +63,7 @@ export function parseInput(raw: string): ParsedInput {
return { type: "set", payload: { key, expr } }; return { type: "set", payload: { key, expr } };
} }
if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/set") { if (raw === "/roll" || raw === "/link" || raw === "/set") {
return { type: "chat", payload: {}, error: "请补全命令" }; return { type: "chat", payload: {}, error: "请补全命令" };
} }
+72 -53
View File
@@ -10,15 +10,18 @@
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { extractHeadings } from "../../data-loader/toc";
import { import {
getPathsByExtension, getPathsByExtension,
getIndexedData, getIndexedData,
setIndexedData,
setInlineResolver,
} from "../../data-loader/file-index"; } from "../../data-loader/file-index";
import { import {
scanDirectives, buildRegistryFromIndex,
} from "../../cli/completions/directive-scanner"; deriveCompletions,
import { scanDeclareBlocks } from "../../cli/completions/declare-parser"; resolveInlineByPath,
type ContentRegistry,
} from "../../cli/content-registry";
import type { import type {
CompletionsPayload, CompletionsPayload,
DiceCompletion, DiceCompletion,
@@ -50,6 +53,23 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
status: "loading", 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) ------------------- // ------------------- Fetch (CLI mode) -------------------
async function tryServer(): Promise<JournalCompletions | null> { async function tryServer(): Promise<JournalCompletions | null> {
@@ -61,66 +81,54 @@ async function tryServer(): Promise<JournalCompletions | null> {
dice: Array.isArray(data.dice) ? data.dice : [], dice: Array.isArray(data.dice) ? data.dice : [],
links: Array.isArray(data.links) ? data.links : [], links: Array.isArray(data.links) ? data.links : [],
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [], sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
declarations: Array.isArray(data.declarations) declarations: Array.isArray(data.declarations) ? data.declarations : [],
? data.declarations tagModifiers: Array.isArray(data.tagModifiers) ? data.tagModifiers : [],
: [],
tagModifiers: Array.isArray(data.tagModifiers)
? data.tagModifiers
: [],
}; };
} catch { } catch {
return null; 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 ------------------- // ------------------- Client-side fallback scan -------------------
async function scanClientSide(): Promise<JournalCompletions> { async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md"); 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 // Load all .md content into a raw index, then build the registry through
const tempIndex: Record<string, string> = {}; // the same shared pipeline as the CLI (scanDoc).
const index: Record<string, string> = {};
// First pass: load all .md content into temp index
for (const filePath of paths) { for (const filePath of paths) {
const content = await getIndexedData(filePath); const content = await getIndexedData(filePath);
if (content) tempIndex[filePath] = content;
}
for (const filePath of paths) {
const content = tempIndex[filePath];
if (!content) continue; if (!content) continue;
index[filePath] = content;
// ---- 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,
});
}
// ---- Declare block scanning (shared with CLI) ----
const declareResult = scanDeclareBlocks(content, filePath);
declarations.push(...declareResult.variables);
tagModifiers.push(...declareResult.tagModifiers);
// ---- 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);
} }
return { dice, links, sparkTables, declarations, tagModifiers }; const registry = buildRegistryFromIndex(index);
activeRegistry = registry;
// Write the processed (stripped) content back into the file index so
// Article/md-embed render the same content as CLI mode (attributed blocks
// processed by role).
for (const [path, content] of Object.entries(registry.pathIndex)) {
setIndexedData(path, content);
}
return deriveCompletions(registry);
} }
// ------------------- Init (runs eagerly at import time) ------------------- // ------------------- Init (runs eagerly at import time) -------------------
@@ -131,10 +139,16 @@ const _initPromise: Promise<void> = (async () => {
const serverData = await tryServer(); const serverData = await tryServer();
if (serverData) { if (serverData) {
setCompletionsState({ status: "loaded", data: serverData }); setCompletionsState({ status: "loaded", data: serverData });
await tryServerRegistry();
try { try {
initReactivity({ declarations: serverData.declarations, tagModifiers: serverData.tagModifiers }); initReactivity({
declarations: serverData.declarations,
tagModifiers: serverData.tagModifiers,
});
seedDeclaredVariables(); seedDeclaredVariables();
} catch (e) { console.warn("[completions] reactivity init error:", e); } } catch (e) {
console.warn("[completions] reactivity init error:", e);
}
return; return;
} }
@@ -144,9 +158,14 @@ const _initPromise: Promise<void> = (async () => {
if (data.dice.length > 0 || data.links.length > 0) { if (data.dice.length > 0 || data.links.length > 0) {
setCompletionsState({ status: "loaded", data }); setCompletionsState({ status: "loaded", data });
try { try {
initReactivity({ declarations: data.declarations, tagModifiers: data.tagModifiers }); initReactivity({
declarations: data.declarations,
tagModifiers: data.tagModifiers,
});
seedDeclaredVariables(); seedDeclaredVariables();
} catch (e) { console.warn("[completions] reactivity init error:", e); } } catch (e) {
console.warn("[completions] reactivity init error:", e);
}
} else { } else {
setCompletionsState({ status: "empty" }); setCompletionsState({ status: "empty" });
} }
@@ -201,4 +220,4 @@ function seedDeclaredVariables(): void {
for (const { key, value } of initial) { for (const { key, value } of initial) {
sendMessage("var", { action: "set", key, value }); sendMessage("var", { action: "set", key, value });
} }
} }
+2 -2
View File
@@ -58,8 +58,8 @@ export type { CompletionsContext } from "./command-completions";
export { VariableView } from "./VariableView"; export { VariableView } from "./VariableView";
export { parseDeclareCsv } from "./declare-parser"; export { parseDeclareCsv } from "./declare-parser";
export type { VarDeclaration, TagModifier } from "./declare-parser"; export type { VarDeclaration, TagModifier } from "./declare-parser";
export { evaluateExpression, expressionIsTag } from "./variable-expression"; export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
export type { EvalContext, EvalResult } from "./variable-expression"; export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity"; export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
export type { VarReactivityState, VariableStore } from "./var-reactivity"; export type { VarReactivityState, VariableStore } from "./var-reactivity";
export { JournalContext, useJournalContext } from "./JournalContext"; export { JournalContext, useJournalContext } from "./JournalContext";
+21 -8
View File
@@ -16,11 +16,9 @@ import { z } from "zod";
import { For } from "solid-js"; import { For } from "solid-js";
import { registerMessageType } from "../registry"; import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks"; import { rollFormula } from "../../md-commander/hooks";
import { import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
parseSparkTableCsv,
rollSparkTable,
} from "../../utils/spark-table";
import { getIndexedData } from "../../../data-loader/file-index"; import { getIndexedData } from "../../../data-loader/file-index";
import { getRegistry } from "../../journal/completions";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Schema // Schema
@@ -75,12 +73,27 @@ export type SparkPayload = z.infer<typeof schema>;
export async function resolveSparkPayload(raw: { export async function resolveSparkPayload(raw: {
key: string; key: string;
csvPath: string; csvPath: string;
docPath?: string;
remix: boolean; remix: boolean;
}): Promise<SparkPayload> { }): Promise<SparkPayload> {
let csv: string; let csv: string | null;
try {
csv = await getIndexedData(raw.csvPath); // Inline content ids resolve through the registry (docPath + content id);
} catch { // 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}"`); throw new Error(`Failed to load CSV: "${raw.csvPath}"`);
} }
+276 -165
View File
@@ -5,11 +5,16 @@
* Runs client-side (in the sender's tab, via command-dispatcher). * Runs client-side (in the sender's tab, via command-dispatcher).
* Uses a base/mod separation: * Uses a base/mod separation:
* - baseValues: what /set writes (or declaration evaluation produces) * - baseValues: what /set writes (or declaration evaluation produces)
* - activeMods: per-target list of {tag, value, source} from tag activations * - numericMods: per-target list of {tag, value, source} from tag activations
* - sourceActivations: per-source list of {tag, target, value} for deactivation * - tagMapMods: per-target list of {tag, value, source} for tagmap targets
* - sourceActivations: per-source list of {tag, target, value, threshold, kind}
* for deactivation
* *
* Combined value = base + sum(activeMods). Tag values (starting with #) * Variables have one of two types:
* are not numeric and don't receive mods. * - numeric: base + sum(numericMods)
* - tagmap (#warrior:1;#druid:2): the tagmap is used for threshold-gated
* modifier activation; tagmap variables do not receive numeric mods but
* can receive tagmap mods (adding/subtracting to specific tag counts).
* *
* All functions that resolve variable values accept an explicit `fallback` * All functions that resolve variable values accept an explicit `fallback`
* (the stream VariableStore) rather than relying on mutable module state. * (the stream VariableStore) rather than relying on mutable module state.
@@ -17,7 +22,7 @@
*/ */
import type { VarDeclaration, TagModifier } from "./declare-parser"; import type { VarDeclaration, TagModifier } from "./declare-parser";
import { evaluateExpression } from "./variable-expression"; import { evaluateExpression, exprValueToString } from "./variable-expression";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -32,9 +37,54 @@ export type VariableStore = Record<string, string>;
/** A single modifier applied to a target variable. */ /** A single modifier applied to a target variable. */
export interface ActiveMod { export interface ActiveMod {
tag: string; // "#warrior" tag: string; // "#warrior"
value: number; // evaluated modifier expression result value: number; // evaluated modifier expression result
source: string; // "$class" — which variable activated this tag source: string; // "$class" — which variable activated this tag
threshold: number; // the threshold that triggered this activation
kind: "numeric" | "tagmap";
}
// ---------------------------------------------------------------------------
// Tagmap helpers
// ---------------------------------------------------------------------------
/** Tagmap serialization format: "#warrior:1;#druid:2" */
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
/** Parse a tagmap string. Returns null if the value is not a valid tagmap. */
function parseTagMap(value: string | undefined): Record<string, number> | null {
if (!value) return 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; // invalid entry
const tag = "#" + m[1];
const count = parseInt(m[2], 10);
if (count <= 0) continue; // skip zero/negative counts on parse
map[tag] = (map[tag] ?? 0) + count;
}
return Object.keys(map).length > 0 ? map : null;
}
/** Serialize a tagmap back to string. Returns empty string if map is empty. */
function tagMapToString(map: Record<string, number>): string {
const entries = Object.entries(map).filter(([, c]) => c > 0);
if (entries.length === 0) return "";
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
}
/** Check if a value is a tagmap string. */
function isTagMapValue(value: string | undefined): boolean {
if (!value) return false;
const trimmed = value.trim();
if (!trimmed.startsWith("#")) return false;
return parseTagMap(trimmed) !== null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -47,17 +97,26 @@ let depGraph: Map<string, Set<string>> | null = null;
/** Reverse: $declaredVar → its expression */ /** Reverse: $declaredVar → its expression */
let declExprs: Map<string, string> | null = null; let declExprs: Map<string, string> | null = null;
/** Tag modifiers from declare blocks: #tag → [{target, expression}] */ /** Tag modifiers from declare blocks: #tag → [{target, expression, threshold}] */
let tagModMap: Map<string, Array<{ target: string; expression: string }>> | null = null; let tagModMap: Map<string, Array<{ target: string; expression: string; threshold: number }>> | null = null;
/** Base values set by /set or declaration evaluation */ /** Base values set by /set or declaration evaluation */
const baseValues = new Map<string, string>(); const baseValues = new Map<string, string>();
/** Active mods per target: $target → [{tag, value, source}] */ /** Numeric mods per target: $target → [{tag, value, source, ...}] */
const activeMods = new Map<string, ActiveMod[]>(); const numericMods = new Map<string, ActiveMod[]>();
/** Activations per source: $source → [{tag, target, value}] */ /** Tagmap mods per target: $target → [{tag, value, source, ...}] */
const sourceActivations = new Map<string, Array<{ tag: string; target: string; value: number }>>(); const tagMapMods = new Map<string, ActiveMod[]>();
/** Activations per source: $source → [{tag, target, value, threshold, kind}] */
const sourceActivations = new Map<string, Array<{
tag: string;
target: string;
value: number;
threshold: number;
kind: "numeric" | "tagmap";
}>>();
/** Set of $vars currently being re-evaluated (cycle guard) */ /** Set of $vars currently being re-evaluated (cycle guard) */
const inFlight = new Set<string>(); const inFlight = new Set<string>();
@@ -77,7 +136,8 @@ export function initReactivity(state: VarReactivityState): void {
declExprs = new Map(); declExprs = new Map();
tagModMap = new Map(); tagModMap = new Map();
baseValues.clear(); baseValues.clear();
activeMods.clear(); numericMods.clear();
tagMapMods.clear();
sourceActivations.clear(); sourceActivations.clear();
// Index tag modifiers // Index tag modifiers
@@ -87,7 +147,7 @@ export function initReactivity(state: VarReactivityState): void {
list = []; list = [];
tagModMap.set(tm.tag, list); tagModMap.set(tm.tag, list);
} }
list.push({ target: tm.target, expression: tm.expression }); list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
} }
// Index declarations and build dependency graph // Index declarations and build dependency graph
@@ -120,17 +180,30 @@ export function extractDependencies(expr: string): string[] {
} }
/** /**
* Get the combined value of a variable (base + sum of active mods). * Get the combined value of a variable.
* - For tagmap variables: returns the serialized tagmap (base + tagmap mods).
* - For numeric variables: returns base + sum(numericMods).
* Falls back to the provided stream store for variables not tracked locally. * Falls back to the provided stream store for variables not tracked locally.
*/ */
export function getCombined(key: string, fallback?: VariableStore): string { export function getCombined(key: string, fallback?: VariableStore): string {
const base = baseValues.get(key); const base = baseValues.get(key);
if (base !== undefined && isTagValue(base)) {
return base; // tag values don't receive numeric mods if (base !== undefined && isTagMapValue(base)) {
// Tagmap variable: apply tagmap mods, then serialize
const baseMap = parseTagMap(base);
const mods = tagMapMods.get(key) ?? [];
if (baseMap && mods.length === 0) return base;
const resultMap: Record<string, number> = { ...(baseMap ?? {}) };
for (const m of mods) {
resultMap[m.tag] = (resultMap[m.tag] ?? 0) + m.value;
if (resultMap[m.tag] <= 0) delete resultMap[m.tag];
}
const serialized = tagMapToString(resultMap);
return serialized || "0";
} }
const baseNum = base !== undefined ? parseFloat(base) : NaN; const baseNum = base !== undefined ? parseFloat(base) : NaN;
const mods = activeMods.get(key) ?? []; const mods = numericMods.get(key) ?? [];
const modSum = mods.reduce((sum, m) => sum + m.value, 0); const modSum = mods.reduce((sum, m) => sum + m.value, 0);
if (!isNaN(baseNum)) { if (!isNaN(baseNum)) {
@@ -143,15 +216,35 @@ export function getCombined(key: string, fallback?: VariableStore): string {
const fb = fallback?.[key]; const fb = fallback?.[key];
if (fb !== undefined) return fb; if (fb !== undefined) return fb;
// No base, but has mods // No base, but has numeric mods
if (mods.length > 0) return String(modSum); if (mods.length > 0) return String(modSum);
return "0"; return "0";
} }
/**
* Get the effective tagmap for a variable (base + tagmap mods).
* Returns null if the variable is not a tagmap variable.
*/
export function getTagMap(key: string): Record<string, number> | null {
const base = baseValues.get(key);
if (base === undefined || !isTagMapValue(base)) return null;
const baseMap = parseTagMap(base);
if (!baseMap) return null;
const mods = tagMapMods.get(key) ?? [];
const result: Record<string, number> = { ...baseMap };
for (const m of mods) {
result[m.tag] = (result[m.tag] ?? 0) + m.value;
if (result[m.tag] <= 0) delete result[m.tag];
}
return Object.keys(result).length > 0 ? result : null;
}
/** Get the active mods for a variable (for UI hover display). */ /** Get the active mods for a variable (for UI hover display). */
export function getMods(key: string): ActiveMod[] { export function getMods(key: string): ActiveMod[] {
return activeMods.get(key) ?? []; const nums = numericMods.get(key) ?? [];
const tags = tagMapMods.get(key) ?? [];
return [...nums, ...tags];
} }
/** Get the declaration expression for a variable, if any. */ /** Get the declaration expression for a variable, if any. */
@@ -168,7 +261,7 @@ export function setBase(key: string, value: string): void {
} }
/** /**
* Rebuild local reactivity state (baseValues, activeMods, sourceActivations) * Rebuild local reactivity state (baseValues, mods, sourceActivations)
* from the hydrated stream variable store. Must be called after replayReducers * from the hydrated stream variable store. Must be called after replayReducers
* so that tag activations are correctly tracked for subsequent cascade * so that tag activations are correctly tracked for subsequent cascade
* computations. * computations.
@@ -181,9 +274,12 @@ export function rebuildReactivityFromStore(variables: VariableStore): void {
baseValues.set(key, value); baseValues.set(key, value);
} }
const tag = isTagValue(value); const tagMap = isTagMapValue(value) ? parseTagMap(value) : null;
if (tag && !sourceActivations.has(key)) { if (tagMap && !sourceActivations.has(key)) {
activateTagFromSource(key, tag, variables); // Activate all tags in the tagmap
const added = applyTagMapActivations(key, {}, tagMap, variables);
// We don't return the added entries here — they'll be applied
// to the store separately
} }
} }
} }
@@ -209,26 +305,12 @@ export function computeCascade(
// ---- Tag activation/deactivation ---- // ---- Tag activation/deactivation ----
const newValue = getCombined(changedVar, currentVars); const newValue = getCombined(changedVar, currentVars);
const newTag = isTagValue(newValue); const oldTagMap = isTagMapValue(oldValue) ? parseTagMap(oldValue) : {};
const oldTag = isTagValue(oldValue); const newTagMap = isTagMapValue(newValue) ? parseTagMap(newValue) : {};
if (newTag !== oldTag) { // Diff tagmaps and apply changes
// Deactivate old tag const tagResults = applyTagMapActivations(changedVar, oldTagMap ?? {}, newTagMap ?? {}, currentVars);
if (oldTag) { results.push(...tagResults);
const removed = deactivateTagFromSource(changedVar, oldTag, currentVars);
for (const r of removed) {
results.push(r);
}
}
// Activate new tag
if (newTag) {
const added = activateTagFromSource(changedVar, newTag, currentVars);
for (const r of added) {
results.push(r);
}
}
}
// ---- Declaration re-evaluation ---- // ---- Declaration re-evaluation ----
const reevaluated = reevaluateDependents(changedVar, currentVars); const reevaluated = reevaluateDependents(changedVar, currentVars);
@@ -267,13 +349,13 @@ export function computeInitialValues(
}, },
}); });
const rawValue = String(result.value); const rawValue = exprValueToString(result.value);
baseValues.set(key, rawValue); baseValues.set(key, rawValue);
// Check if this is a tag value — if so, activate it // Check if this is a tagmap value — if so, activate matching modifiers
const tag = isTagValue(rawValue); const tagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : null;
if (tag) { if (tagMap) {
const added = activateTagFromSource(key, tag, currentVars); const added = applyTagMapActivations(key, {}, tagMap, currentVars);
for (const r of added) { for (const r of added) {
if (!results.some((x) => x.key === r.key)) { if (!results.some((x) => x.key === r.key)) {
results.push(r); results.push(r);
@@ -295,112 +377,154 @@ export function computeInitialValues(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tag activation / deactivation (source-based) // Tag activation / deactivation (threshold-based)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function isTagValue(value: string | undefined): string | null { /**
if (!value) return null; * Apply tagmap changes for a source variable. Compares old and new tagmaps,
const trimmed = value.trim(); * activating/deactivating modifiers whose threshold crossing state changed.
return trimmed.startsWith("#") ? trimmed : null; */
} function applyTagMapActivations(
/** Activate a tag from a source variable. Evaluates modifiers once. */
function activateTagFromSource(
source: string, source: string,
tag: string, oldTagMap: Record<string, number>,
newTagMap: Record<string, number>,
fallback: VariableStore, fallback: VariableStore,
): Array<{ key: string; value: string }> { ): Array<{ key: string; value: string }> {
if (!tagModMap) return []; if (!tagModMap) return [];
const mods = tagModMap.get(tag);
if (!mods) return [];
const results: Array<{ key: string; value: string }> = []; const results: Array<{ key: string; value: string }> = [];
const sourceEntries: Array<{ tag: string; target: string; value: number }> = []; const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
for (const mod of mods) { for (const tag of allTags) {
try { const mods = tagModMap.get(tag);
// Snapshot the target's current combined value as its base before if (!mods || mods.length === 0) continue;
// adding the mod, so deactivation can restore it correctly.
if (!baseValues.has(mod.target)) { const oldCount = oldTagMap[tag] ?? 0;
baseValues.set(mod.target, getCombined(mod.target, fallback)); const newCount = newTagMap[tag] ?? 0;
for (let modIdx = 0; modIdx < mods.length; modIdx++) {
const mod = mods[modIdx];
const wasActive = oldCount >= mod.threshold;
const isActive = newCount >= mod.threshold;
if (!wasActive && isActive) {
// Activate this modifier
try {
// Snapshot target's current value as base if needed
if (!baseValues.has(mod.target)) {
baseValues.set(mod.target, getCombined(mod.target, fallback));
}
const evalResult = evaluateExpression(mod.expression, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, fallback);
},
});
// 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) {
// Apply as tagmap mod to the target's tag count
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "tagmap" };
let targetMods = tagMapMods.get(mod.target);
if (!targetMods) {
targetMods = [];
tagMapMods.set(mod.target, targetMods);
}
targetMods.push(entry);
} else {
// Apply as numeric mod
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "numeric" };
let targetMods = numericMods.get(mod.target);
if (!targetMods) {
targetMods = [];
numericMods.set(mod.target, targetMods);
}
targetMods.push(entry);
}
// Track in source activations for deactivation
let sourceEntries = sourceActivations.get(source);
if (!sourceEntries) {
sourceEntries = [];
sourceActivations.set(source, sourceEntries);
}
sourceEntries.push({
tag,
target: mod.target,
value,
threshold: mod.threshold,
kind: targetIsTagMap ? "tagmap" : "numeric",
});
// Emit new combined value
const combined = getCombined(mod.target, fallback);
const existing = results.findIndex((r) => r.key === mod.target);
if (existing >= 0) {
results[existing] = { key: mod.target, value: combined };
} else {
results.push({ key: mod.target, value: combined });
}
} catch {
// skip failed modifier
}
} else if (wasActive && !isActive) {
// Deactivate this modifier
const sourceEntries = sourceActivations.get(source);
if (!sourceEntries) continue;
const idx = sourceEntries.findIndex(
(e) => e.tag === tag && e.target === mod.target && e.threshold === mod.threshold,
);
if (idx < 0) continue;
const entry = sourceEntries[idx];
sourceEntries.splice(idx, 1);
// Remove from the appropriate mod list
if (entry.kind === "tagmap") {
const targetMods = tagMapMods.get(entry.target);
if (targetMods) {
const modIdx = targetMods.findIndex(
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
);
if (modIdx >= 0) targetMods.splice(modIdx, 1);
if (targetMods.length === 0) tagMapMods.delete(entry.target);
}
} else {
const targetMods = numericMods.get(entry.target);
if (targetMods) {
const modIdx = targetMods.findIndex(
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
);
if (modIdx >= 0) targetMods.splice(modIdx, 1);
if (targetMods.length === 0) numericMods.delete(entry.target);
}
}
// Emit updated combined value
const combined = getCombined(entry.target, fallback);
const existing = results.findIndex((r) => r.key === entry.target);
if (existing >= 0) {
results[existing] = { key: entry.target, value: combined };
} else {
results.push({ key: entry.target, value: combined });
}
} }
const result = evaluateExpression(mod.expression, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, fallback);
},
});
const entry: ActiveMod = { tag, value: result.value, source };
let targetMods = activeMods.get(mod.target);
if (!targetMods) {
targetMods = [];
activeMods.set(mod.target, targetMods);
}
targetMods.push(entry);
sourceEntries.push({ tag, target: mod.target, value: result.value });
// Emit new combined value for the target
const combined = getCombined(mod.target, fallback);
const existing = results.findIndex((r) => r.key === mod.target);
if (existing >= 0) {
results[existing] = { key: mod.target, value: combined };
} else {
results.push({ key: mod.target, value: combined });
}
} catch {
// skip failed modifier
} }
} }
sourceActivations.set(source, sourceEntries); // Clean up empty sourceActivations
return results; if (sourceActivations.get(source)?.length === 0) {
} sourceActivations.delete(source);
/** Deactivate a tag from a source variable. Removes exactly the mods it created. */
function deactivateTagFromSource(
source: string,
_tag: string,
fallback: VariableStore,
): Array<{ key: string; value: string }> {
const entries = sourceActivations.get(source);
if (!entries) return [];
const results: Array<{ key: string; value: string }> = [];
const affectedTargets = new Set<string>();
// Remove mods from activeMods
for (const entry of entries) {
const targetMods = activeMods.get(entry.target);
if (!targetMods) continue;
const idx = targetMods.findIndex(
(m) => m.tag === entry.tag && m.source === source,
);
if (idx >= 0) {
targetMods.splice(idx, 1);
affectedTargets.add(entry.target);
}
}
// Clean up empty arrays
for (const target of affectedTargets) {
const mods = activeMods.get(target);
if (mods && mods.length === 0) {
activeMods.delete(target);
}
}
// Remove source activations
sourceActivations.delete(source);
// Emit new combined values for affected targets
for (const target of affectedTargets) {
results.push({ key: target, value: getCombined(target, fallback) });
} }
return results; return results;
@@ -453,31 +577,18 @@ function reevaluateDependents(
}, },
}); });
const rawValue = String(result.value); const rawValue = exprValueToString(result.value);
// Check for tag transition on this declared variable // Check for tagmap transition on this declared variable
const oldCombined = getCombined(key, fallback); const oldCombined = getCombined(key, fallback);
const oldTag = isTagValue(oldCombined); const oldTagMap = isTagMapValue(oldCombined) ? parseTagMap(oldCombined) : {};
const newTag = isTagValue(rawValue); const newTagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : {};
if (oldTag !== newTag) { if (JSON.stringify(oldTagMap) !== JSON.stringify(newTagMap)) {
// Deactivate old tag const tagResults = applyTagMapActivations(key, oldTagMap ?? {}, newTagMap ?? {}, fallback);
if (oldTag) { for (const r of tagResults) {
const removed = deactivateTagFromSource(key, oldTag, fallback); if (!results.some((x) => x.key === r.key)) {
for (const r of removed) { results.push(r);
if (!results.some((x) => x.key === r.key)) {
results.push(r);
}
}
}
// Activate new tag
if (newTag) {
baseValues.set(key, rawValue);
const added = activateTagFromSource(key, newTag, fallback);
for (const r of added) {
if (!results.some((x) => x.key === r.key)) {
results.push(r);
}
} }
} }
} }
+208 -36
View File
@@ -4,14 +4,15 @@
* *
* Supports: * Supports:
* - Number literals (integer or decimal) * - 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) * - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
* - Arithmetic: + - * / * - Arithmetic: + - * / (type-checked via registry)
* - Functions: floor(x), ceil(x), round(x) * - Functions: floor(x), ceil(x), round(x) (numbers only)
* - Parentheses for grouping * - Parentheses for grouping
* *
* Throws on: * 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) * - Circular variable references (detected by caller)
* - Division by zero * - Division by zero
* - Unknown functions * - Unknown functions
@@ -24,14 +25,19 @@ import { rollFormula } from "../md-commander/hooks";
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** A value produced by the expression evaluator. */
export type ExprValue =
| { kind: "number"; value: number }
| { kind: "tagmap"; value: Record<string, number> };
export interface EvalContext { 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. */ * Return undefined if the variable doesn't exist. */
lookup: (varName: string) => string | undefined; lookup: (varName: string) => string | undefined;
} }
export interface EvalResult { export interface EvalResult {
value: number; value: ExprValue;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -40,8 +46,7 @@ export interface EvalResult {
/** /**
* Evaluate an expression string. * Evaluate an expression string.
* Throws if any variable resolves to a non-numeric (tag) value, * Throws if the expression is malformed or contains a type mismatch.
* or if the expression is malformed.
*/ */
export function evaluateExpression( export function evaluateExpression(
expr: string, expr: string,
@@ -63,19 +68,109 @@ export function expressionIsTag(expr: string): boolean {
return trimmed.startsWith("#"); 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 // Tokenizer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface Token { interface Token {
kind: "number" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma"; kind: "number" | "tagmap" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
value: string; value: string;
raw: string; raw: string;
/** Pre-parsed tagmap data (only set when kind === "tagmap") */
tagmap?: Record<string, number>;
} }
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */ /** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i; 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[] { function tokenize(input: string): Token[] {
const tokens: Token[] = []; const tokens: Token[] = [];
let i = 0; let i = 0;
@@ -114,6 +209,50 @@ function tokenize(input: string): Token[] {
continue; 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 // Variable reference: $var
if (ch === "$") { if (ch === "$") {
let ident = "$"; let ident = "$";
@@ -186,7 +325,7 @@ function rollDice(notation: string): number {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface ParseResult { interface ParseResult {
value: number; value: ExprValue;
next: number; // index of next unconsumed token next: number; // index of next unconsumed token
} }
@@ -203,11 +342,8 @@ function parseExpression(
const tok = tokens[pos]; const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) { if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
const right = parseTerm(tokens, pos + 1, ctx); const right = parseTerm(tokens, pos + 1, ctx);
if (tok.value === "+") { const opFn = getBinaryOp(result.value, right.value, tok.value);
result = { value: result.value + right.value, next: right.next }; result = { value: opFn(result.value, right.value), next: right.next };
} else {
result = { value: result.value - right.value, next: right.next };
}
pos = result.next; pos = result.next;
} else { } else {
break; break;
@@ -230,12 +366,8 @@ function parseTerm(
const tok = tokens[pos]; const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) { if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
const right = parseFactor(tokens, pos + 1, ctx); const right = parseFactor(tokens, pos + 1, ctx);
if (tok.value === "*") { const opFn = getBinaryOp(result.value, right.value, tok.value);
result = { value: result.value * right.value, next: right.next }; result = { value: opFn(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 };
}
pos = result.next; pos = result.next;
} else { } else {
break; break;
@@ -245,7 +377,7 @@ function parseTerm(
return result; return result;
} }
/** factor := number | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */ /** factor := number | tagmap | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
function parseFactor( function parseFactor(
tokens: Token[], tokens: Token[],
pos: number, pos: number,
@@ -257,15 +389,23 @@ function parseFactor(
const tok = tokens[pos]; const tok = tokens[pos];
// Unary minus // Unary minus (numbers only)
if (tok.kind === "op" && tok.value === "-") { if (tok.kind === "op" && tok.value === "-") {
const inner = parseFactor(tokens, pos + 1, ctx); 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) // Number literal (including already-rolled dice patterns)
if (tok.kind === "number") { 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 // Variable reference: $var
@@ -273,13 +413,17 @@ function parseFactor(
const varName = tok.value; // includes $ prefix const varName = tok.value; // includes $ prefix
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
if (resolved === undefined) { 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("#")) { if (resolved.startsWith("#")) {
throw new Error( const map = parseTagMapValue(resolved);
`Type mismatch: ${varName} is a tag ("${resolved}"), not a number`, if (!map) {
); throw new Error(
`Type mismatch: ${varName} is not a valid tagmap ("${resolved}")`,
);
}
return { value: { kind: "tagmap", value: map }, next: pos + 1 };
} }
const num = parseFloat(resolved); const num = parseFloat(resolved);
if (isNaN(num)) { if (isNaN(num)) {
@@ -287,7 +431,7 @@ function parseFactor(
`Type mismatch: ${varName} is not numeric ("${resolved}")`, `Type mismatch: ${varName} is not numeric ("${resolved}")`,
); );
} }
return { value: num, next: pos + 1 }; return { value: { kind: "number", value: num }, next: pos + 1 };
} }
// Parenthesized expression // Parenthesized expression
@@ -319,15 +463,43 @@ function parseFactor(
throw new Error(`Unexpected token: "${tok.raw}"`); 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()) { switch (name.toLowerCase()) {
case "floor": case "floor":
return Math.floor(arg); return { kind: "number", value: Math.floor(arg.value) };
case "ceil": case "ceil":
return Math.ceil(arg); return { kind: "number", value: Math.ceil(arg.value) };
case "round": case "round":
return Math.round(arg); return { kind: "number", value: Math.round(arg.value) };
default: default:
throw new Error(`Unknown function: ${name}`); 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 type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands"; import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
import {resolvePath} from "../../utils/path"; import {resolvePath} from "../../utils/path";
import {loadCSV} from "../../utils/csv-loader"; import {loadCSVFromPath} from "../../utils/csv-loader";
const defaultCommands: MdCommanderCommandMap = { const defaultCommands: MdCommanderCommandMap = {
help: setupHelpCommand({}), help: setupHelpCommand({}),
@@ -111,7 +111,7 @@ export async function loadCommandTemplatesFromCSV(
setCommandsError(undefined); setCommandsError(undefined);
try { try {
const csv = await loadCSV<CommandTemplateRow>(resolvePath(articlePath, path)); const csv = await loadCSVFromPath<CommandTemplateRow>(resolvePath(articlePath, path));
// 按命令分组模板 // 按命令分组模板
const templatesByCommand = new Map<string, CommandTemplateRow[]>(); const templatesByCommand = new Map<string, CommandTemplateRow[]>();
+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 { parseMarkdown } from "../../markdown";
import { getLayerStyle } from "./hooks/dimensions"; import { getLayerStyle } from "./hooks/dimensions";
import type { CardData, CardSide, LayerConfig } from "./types"; import type { Align, CardData, CardSide, LayerConfig } from "./types";
import { DeckStore } from "./hooks/deckStore"; import { DeckStore } from "./hooks/deckStore";
import { processVariables } from "../utils/csv-loader"; import { processVariables } from "../utils/csv-loader";
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction"; import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
@@ -31,15 +31,29 @@ export function CardLayer(props: CardLayerProps) {
) as string; ) as string;
} }
const getAlignStyle = (align?: "l" | "c" | "r") => { const getAlignStyle = (align?: Align) => {
if (align === "l") return "left"; const horizontal = align?.includes("l")
if (align === "r") return "right"; ? "left"
return "center"; : align?.includes("r")
? "right"
: "center";
const vertical = align?.includes("t")
? "flex-start"
: align?.includes("b")
? "flex-end"
: "center";
return {
"text-align": horizontal,
"justify-content": vertical,
} as JSX.CSSProperties;
}; };
const isLayerSelected = (layerIndex: number) => const isLayerSelected = (layerIndex: number) =>
selectedLayer() === layerIndex; selectedLayer() === layerIndex;
const isEditing = () =>
props.store.state.isEditing && !props.store.state.fixed;
const getFrameBounds = (layer: LayerConfig) => { const getFrameBounds = (layer: LayerConfig) => {
const dims = dimensions(); const dims = dimensions();
const left = (layer.x1 - 1) * dims.cellWidth; const left = (layer.x1 - 1) * dims.cellWidth;
@@ -75,20 +89,23 @@ export function CardLayer(props: CardLayerProps) {
return ( return (
<> <>
<article <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={{ classList={{
"cursor-pointer": isEditing(),
"ring-2 ring-blue-500 ring-offset-1": "ring-2 ring-blue-500 ring-offset-1":
isSelected() && !draggingState(), isSelected() && !draggingState() && isEditing(),
}} }}
style={{ style={{
...getLayerStyle(layer, dimensions()), ...getLayerStyle(layer, dimensions()),
"font-size": `${layer.fontSize || 3}mm`, "font-size": `${layer.fontSize || 3}mm`,
"text-align": getAlignStyle(layer.align), ...getAlignStyle(layer.align),
}} }}
innerHTML={renderLayerContent(props.cardData[layer.prop])} innerHTML={renderLayerContent(
layer.template ?? props.cardData[layer.prop ?? ""],
)}
onClick={(e) => handleLayerClick(index(), e)} onClick={(e) => handleLayerClick(index(), e)}
/> />
<Show when={isSelected()}> <Show when={isSelected() && isEditing()}>
<div <div
class="absolute border-2 border-blue-500 pointer-events-none z-10" class="absolute border-2 border-blue-500 pointer-events-none z-10"
style={{ 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>
);
}
+85 -37
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"; import type { DeckStore } from "./hooks/deckStore";
export interface DeckHeaderProps { export interface DeckHeaderProps {
@@ -10,46 +11,93 @@ export interface DeckHeaderProps {
*/ */
export function DeckHeader(props: DeckHeaderProps) { export function DeckHeader(props: DeckHeaderProps) {
const { store } = props; 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 ( return (
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4"> <>
{/* 编辑按钮 */} <div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
<button {/* 编辑按钮 */}
onClick={() => store.actions.setIsEditing(!store.state.isEditing)} <button
class={`px-3 py-1 rounded text-sm font-medium transition-colors cursor-pointer ${ onClick={() => store.actions.setIsEditing(!store.state.isEditing)}
store.state.isEditing && !store.state.fixed class={`px-3 py-1 rounded text-sm font-medium transition-colors cursor-pointer ${
? "bg-blue-100 text-blue-600" store.state.isEditing && !store.state.fixed
: "bg-gray-100 text-gray-600 hover:bg-gray-200" ? "bg-blue-100 text-blue-600"
}`} : "bg-gray-100 text-gray-600 hover:bg-gray-200"
> }`}
{store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"} >
</button> {store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"}
</button>
{/* 导出 PDF 按钮 */} {/* 导出 PDF 按钮 */}
<button <button
onClick={() => store.actions.exportDeck()} onClick={() => store.actions.exportDeck()}
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200" class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200"
> >
📥 PDF 📥 PDF
</button> </button>
{/* Tab 选择器 */} {/* 复制代码按钮 */}
<div class="flex gap-1 overflow-x-auto flex-1 min-w-0 flex-wrap"> <button
<For each={store.state.cards}> onClick={handleCopy}
{(card, index) => ( class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
<button >
onClick={() => store.actions.setActiveTab(index())} 📋
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer px-2 py-1 rounded ${ </button>
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"
}`}
>
{card.label || card.name || `Card ${index() + 1}`}
</button>
)}
</For>
</div> </div>
</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>
</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>
);
}
@@ -179,6 +179,20 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
</label> </label>
</div> </div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={store.state.pltDoubleCut}
onChange={(e) =>
store.actions.setPltDoubleCut(e.target.checked)
}
class="cursor-pointer"
/>
<span class="text-sm text-gray-600">PLT </span>
</label>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-sm text-gray-600">:</label> <label class="text-sm text-gray-600">:</label>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
+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, closestCenter,
} from "@thisbeyond/solid-dnd"; } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore"; import type { DeckStore } from "../hooks/deckStore";
import type { CardSide } from "../types";
import { toSortableId } from "../hooks/layer-crud"; import { toSortableId } from "../hooks/layer-crud";
import { LayerRow } from "./LayerRow"; import { LayerRow } from "./LayerRow";
export interface LayerEditorPanelProps { export interface LayerEditorPanelProps {
store: DeckStore; store: DeckStore;
side?: CardSide;
} }
function LayerEditorPanel(props: LayerEditorPanelProps) { function LayerEditorPanel(props: LayerEditorPanelProps) {
@@ -20,7 +22,7 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
const [addMenuOpen, setAddMenuOpen] = createSignal(false); const [addMenuOpen, setAddMenuOpen] = createSignal(false);
let addMenuRef: HTMLDivElement | undefined; let addMenuRef: HTMLDivElement | undefined;
const side = () => store.state.activeSide; const side = () => props.side || "front";
const layers = () => const layers = () =>
side() === "front" side() === "front"
? store.state.frontLayerConfigs ? store.state.frontLayerConfigs
@@ -143,15 +145,6 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
</SortableProvider> </SortableProvider>
</DragDropSensors> </DragDropSensors>
</DragDropProvider> </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> </div>
); );
} }
@@ -1,7 +1,7 @@
import { For, createSignal, onCleanup, onMount } from "solid-js"; import { For, createSignal, onCleanup, onMount } from "solid-js";
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"; import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore"; import type { DeckStore } from "../hooks/deckStore";
import type { LayerConfig } from "../types"; import type { Align, LayerConfig } from "../types";
import alignLeftIcon from "./icons/align-left.png"; import alignLeftIcon from "./icons/align-left.png";
import alignCenterIcon from "./icons/align-center.png"; import alignCenterIcon from "./icons/align-center.png";
import alignRightIcon from "./icons/align-right.png"; import alignRightIcon from "./icons/align-right.png";
@@ -17,7 +17,7 @@ export interface LayerRowProps {
setOpenDropdown: (val: string | null) => void; setOpenDropdown: (val: string | null) => void;
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void; onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
onUpdateFontSize: (fs?: number) => void; onUpdateFontSize: (fs?: number) => void;
onUpdateAlign: (a?: "l" | "c" | "r") => void; onUpdateAlign: (a?: Align) => void;
onSelect: () => void; onSelect: () => void;
onRemove: () => void; onRemove: () => void;
} }
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
{ value: "w" as const, label: "← 西" }, { value: "w" as const, label: "← 西" },
]; ];
const ALIGNS = [ const ALIGNS: { value: Align | ""; icon: string }[] = [
{ value: "" as const, icon: alignCenterIcon }, { value: "", icon: alignCenterIcon },
{ value: "l" as const, icon: alignLeftIcon }, { value: "l", icon: alignLeftIcon },
{ value: "c" as const, icon: alignCenterIcon }, { value: "c", icon: alignCenterIcon },
{ value: "r" as const, icon: alignRightIcon }, { value: "r", icon: alignRightIcon },
{ value: "tl", icon: "↖" },
{ value: "tc", icon: "↑" },
{ value: "tr", icon: "↗" },
{ value: "bl", icon: "↙" },
{ value: "bc", icon: "↓" },
{ value: "br", icon: "↘" },
]; ];
const FONT_PRESETS = [3, 5, 8, 12] as const; const FONT_PRESETS = [3, 5, 8, 12] as const;
@@ -53,14 +59,28 @@ function orientChar(v: string) {
} }
} }
function alignSrc(v: string) { function alignIcon(v: string): import("solid-js").JSX.Element {
switch (v) { switch (v) {
case "tl":
return "↖";
case "tc":
return "↑";
case "tr":
return "↗";
case "bl":
return "↙";
case "bc":
return "↓";
case "br":
return "↘";
case "l": case "l":
return alignLeftIcon; return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
case "r": case "r":
return alignRightIcon; return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
default: default:
return alignCenterIcon; return (
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
);
} }
} }
@@ -120,7 +140,7 @@ export function LayerRow(props: LayerRowProps) {
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none" class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
onClick={props.onSelect} onClick={props.onSelect}
> >
{props.layer.prop} {props.layer.prop || (props.layer.template ? "(模板)" : "")}
</span> </span>
<DropdownButton <DropdownButton
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
</DropdownButton> </DropdownButton>
<DropdownButton <DropdownButton
icon={ icon={alignIcon(props.layer.align || "")}
<img
src={alignSrc(props.layer.align || "")}
alt="align"
class="w-5 h-5 not-prose"
/>
}
visible={props.layer.visible} visible={props.layer.visible}
open={props.openDropdown === `align-${props.index}`} open={props.openDropdown === `align-${props.index}`}
onToggle={() => onToggle={() =>
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
onClick={() => props.onUpdateAlign(o.value || undefined)} onClick={() => props.onUpdateAlign(o.value || undefined)}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap" class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
> >
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" /> {o.icon.endsWith(".png") ? (
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
) : (
<span class="w-4 h-4 flex items-center justify-center not-prose">
{o.icon}
</span>
)}
</button> </button>
)} )}
</For> </For>
@@ -1,49 +1,32 @@
import type { DeckStore } from '../hooks/deckStore'; import type { DeckStore } from "../hooks/deckStore";
import type { CardShape } from '../types'; import type { CardShape } from "../types";
export interface PropertiesEditorPanelProps { export interface PropertiesEditorPanelProps {
store: DeckStore; store: DeckStore;
} }
/** /**
* 卡牌属性编辑面板:尺寸、网格、出血、内边距、正背面切换 * 卡牌尺寸编辑面板:尺寸、网格、出血、内边距、形状
*/ */
export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) { export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
const { store } = props; const { store } = props;
return ( const shapeOptions: { value: CardShape; label: string }[] = [
<div class="w-64 flex-shrink-0"> { value: "rectangle", label: "矩形" },
<h3 class="font-bold mb-2 mt-0"></h3> { value: "circle", label: "圆形" },
{ value: "triangle", label: "三角形" },
{ value: "hexagon", label: "六边形" },
];
{/* 正面/背面切换标签页 */} return (
<div class="mb-4"> <div class="w-64 shrink-0">
<div class="flex gap-1"> <h3 class="font-bold mb-2 mt-0"></h3>
<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>
<div class="space-y-3"> <div class="space-y-3">
<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"> <div class="flex gap-2">
<input <input
type="number" type="number"
@@ -83,7 +66,9 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
</div> </div>
<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"> <div class="flex gap-2">
<input <input
type="number" type="number"
@@ -93,34 +78,30 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
placeholder="出血" placeholder="出血"
/> />
<input <input
type="number" type="number"
class="w-full border border-gray-300 rounded px-2 py-1 text-sm" class="w-full border border-gray-300 rounded px-2 py-1 text-sm"
value={store.state.padding} value={store.state.padding}
onChange={(e) => store.actions.setPadding(Number(e.target.value))} onChange={(e) => store.actions.setPadding(Number(e.target.value))}
placeholder="内边距" placeholder="内边距"
/> />
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"></label> <label class="block text-sm font-medium text-gray-700 mb-2">
<div class="grid grid-cols-2 gap-2">
{(['rectangle', 'circle', 'triangle', 'hexagon'] as CardShape[]).map((shape) => ( </label>
<button <select
onClick={() => store.actions.setShape(shape)} value={store.state.shape}
class={`px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${ onChange={(e) =>
store.state.shape === shape store.actions.setShape(e.target.value as CardShape)
? 'bg-blue-600 text-white border-blue-600' }
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50' class="w-full border border-gray-300 rounded px-2 py-1 text-sm bg-white"
}`} >
> {shapeOptions.map((s) => (
{shape === 'rectangle' && '矩形'} <option value={s.value}>{s.label}</option>
{shape === 'circle' && '圆形'}
{shape === 'triangle' && '三角形'}
{shape === 'hexagon' && '六边形'}
</button>
))} ))}
</div> </select>
</div> </div>
</div> </div>
</div> </div>
+81 -17
View File
@@ -1,7 +1,8 @@
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import yaml from "js-yaml";
import { calculateDimensions } from "./dimensions"; import { calculateDimensions } from "./dimensions";
import { loadCSV, CSV } from "../../utils/csv-loader"; import { loadCSVFromPath, CSV } from "../../utils/csv-loader";
import { formatLayers, initLayerConfigsForSide } from "./layer-parser"; import { formatLayers } from "./layer-parser";
import * as layerCrud from "./layer-crud"; import * as layerCrud from "./layer-crud";
import type { import type {
CardData, CardData,
@@ -41,6 +42,8 @@ export interface DeckState {
cornerRadius: number; cornerRadius: number;
shape: CardShape; shape: CardShape;
fixed: boolean; fixed: boolean;
/** True when the deck was configured via a yaml role=tag codeblock (data-config). */
isYamlBlock: boolean;
src: string; src: string;
rawSrc: string; rawSrc: string;
@@ -73,6 +76,7 @@ export interface DeckState {
printFrontOddPageOffsetX: number; printFrontOddPageOffsetX: number;
printFrontOddPageOffsetY: number; printFrontOddPageOffsetY: number;
printDoubleSided: boolean; printDoubleSided: boolean;
pltDoubleCut: boolean;
} }
export interface DeckActions { export interface DeckActions {
@@ -84,6 +88,7 @@ export interface DeckActions {
setPadding: (padding: number) => void; setPadding: (padding: number) => void;
setCornerRadius: (cornerRadius: number) => void; setCornerRadius: (cornerRadius: number) => void;
setShape: (shape: CardShape) => void; setShape: (shape: CardShape) => void;
setIsYamlBlock: (isYamlBlock: boolean) => void;
setCards: (cards: CSV<CardData>) => void; setCards: (cards: CSV<CardData>) => void;
setActiveTab: (index: number) => void; setActiveTab: (index: number) => void;
@@ -137,14 +142,14 @@ export interface DeckActions {
loadCardsFromPath: ( loadCardsFromPath: (
path: string, path: string,
rawSrc: string, rawSrc: string,
layersStr?: string, frontLayers?: LayerConfig[],
backLayersStr?: string, backLayers?: LayerConfig[],
) => Promise<void>; ) => Promise<void>;
setError: (error: string | null) => void; setError: (error: string | null) => void;
clearError: () => void; clearError: () => void;
generateCode: (backLayersStr?: string) => string; generateCode: (backLayersStr?: string) => string;
copyCode: (backLayersStr?: string) => Promise<void>; copyCode: (fallback?: (code: string) => void) => Promise<void>;
setExporting: (exporting: boolean) => void; setExporting: (exporting: boolean) => void;
exportDeck: () => void; exportDeck: () => void;
@@ -156,6 +161,7 @@ export interface DeckActions {
setPrintFrontOddPageOffsetX: (offset: number) => void; setPrintFrontOddPageOffsetX: (offset: number) => void;
setPrintFrontOddPageOffsetY: (offset: number) => void; setPrintFrontOddPageOffsetY: (offset: number) => void;
setPrintDoubleSided: (doubleSided: boolean) => void; setPrintDoubleSided: (doubleSided: boolean) => void;
setPltDoubleCut: (doubleCut: boolean) => void;
} }
export interface DeckStore { export interface DeckStore {
@@ -174,6 +180,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
cornerRadius: DECK_DEFAULTS.CORNER_RADIUS, cornerRadius: DECK_DEFAULTS.CORNER_RADIUS,
shape: "rectangle", shape: "rectangle",
fixed: false, fixed: false,
isYamlBlock: false,
src: initialSrc, src: initialSrc,
rawSrc: initialSrc, rawSrc: initialSrc,
dimensions: null, dimensions: null,
@@ -197,6 +204,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
printFrontOddPageOffsetX: 0, printFrontOddPageOffsetX: 0,
printFrontOddPageOffsetY: 0, printFrontOddPageOffsetY: 0,
printDoubleSided: false, printDoubleSided: false,
pltDoubleCut: false,
}); });
const updateDimensions = () => { const updateDimensions = () => {
@@ -241,6 +249,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const setShape = (shape: CardShape) => { const setShape = (shape: CardShape) => {
setState({ shape }); setState({ shape });
}; };
const setIsYamlBlock = (isYamlBlock: boolean) => {
setState({ isYamlBlock });
};
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 }); const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
const setActiveTab = (index: number) => setState({ activeTab: index }); const setActiveTab = (index: number) => setState({ activeTab: index });
@@ -439,8 +450,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const loadCardsFromPath = async ( const loadCardsFromPath = async (
path: string, path: string,
rawSrc: string, rawSrc: string,
layersStr: string = "", frontLayers: LayerConfig[] = [],
backLayersStr: string = "", backLayers: LayerConfig[] = [],
) => { ) => {
if (!path) { if (!path) {
setState({ error: "未指定 CSV 文件路径" }); setState({ error: "未指定 CSV 文件路径" });
@@ -450,7 +461,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc }); setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
try { try {
const data = await loadCSV(path); const data = await loadCSVFromPath(path);
if (data.length === 0) { if (data.length === 0) {
setState({ setState({
@@ -463,12 +474,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ setState({
cards: data, cards: data,
activeTab: 0, activeTab: 0,
frontLayerConfigs: layerCrud.withKeys( frontLayerConfigs: layerCrud.withKeys(frontLayers),
initLayerConfigsForSide(data, layersStr), backLayerConfigs: layerCrud.withKeys(backLayers),
),
backLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, backLayersStr),
),
isLoading: false, isLoading: false,
}); });
updateDimensions(); updateDimensions();
@@ -484,6 +491,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const clearError = () => setState({ error: null }); const clearError = () => setState({ error: null });
const generateCode = (backLayersStr?: string) => { const generateCode = (backLayersStr?: string) => {
if (state.isYamlBlock) {
return generateYamlCode();
}
const frontLayersStr = formatLayers(state.frontLayerConfigs); const frontLayersStr = formatLayers(state.frontLayerConfigs);
const backLayersString = const backLayersString =
backLayersStr || formatLayers(state.backLayerConfigs); backLayersStr || formatLayers(state.backLayerConfigs);
@@ -511,14 +521,62 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
return parts.join(""); return parts.join("");
}; };
const copyCode = async (backLayersStr?: string) => { /** Serialize the deck back to a yaml codeblock (round-trips templates). */
const code = generateCode(backLayersStr); 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 { try {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
alert("已复制到剪贴板!"); alert("已复制到剪贴板!");
} catch (err) { } catch (err) {
console.error("复制失败:", err); console.error("复制失败:", err);
alert("复制失败,请手动复制"); if (fallback) {
fallback(code);
} else {
alert("复制失败,请手动复制");
}
} }
}; };
@@ -553,6 +611,10 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ printDoubleSided: doubleSided }); setState({ printDoubleSided: doubleSided });
}; };
const setPltDoubleCut = (doubleCut: boolean) => {
setState({ pltDoubleCut: doubleCut });
};
const actions: DeckActions = { const actions: DeckActions = {
setSizeW, setSizeW,
setSizeH, setSizeH,
@@ -562,6 +624,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setPadding, setPadding,
setCornerRadius, setCornerRadius,
setShape, setShape,
setIsYamlBlock,
setCards, setCards,
setActiveTab, setActiveTab,
updateCardData, updateCardData,
@@ -599,6 +662,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setPrintFrontOddPageOffsetX, setPrintFrontOddPageOffsetX,
setPrintFrontOddPageOffsetY, setPrintFrontOddPageOffsetY,
setPrintDoubleSided, setPrintDoubleSided,
setPltDoubleCut,
}; };
return { state, actions }; return { state, actions };
+8 -5
View File
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
/** /**
* 解析 layers 字符串 * 解析 layers 字符串
* 格式:body:1,7-5,8 title:1,1-4,1f6.6sl * 格式:body:1,7-5,8 title:1,1-4,1f6.6sl
* f[fontSize] 表示字体大小(可选),方向字母(可选),对齐字母 l/c/r(可选) * f[fontSize] 表示字体大小(可选),方向字母(可选),
* 对齐字母(可选):水平 l/c/r,可加垂直前缀 t/b 组成 tl/tr/bl/br
*/ */
export function parseLayers(layersStr: string): Layer[] { export function parseLayers(layersStr: string): Layer[] {
if (!layersStr) return []; if (!layersStr) return [];
const layers: Layer[] = []; const layers: Layer[] = [];
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align] // 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
const regex = const regex =
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g; /([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
let match; let match;
while ((match = regex.exec(layersStr)) !== null) { while ((match = regex.exec(layersStr)) !== null) {
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
y2: parseInt(match[5]), y2: parseInt(match[5]),
fontSize: match[6] ? parseFloat(match[6]) : undefined, fontSize: match[6] ? parseFloat(match[6]) : undefined,
orientation: match[7] as "n" | "s" | "e" | "w" | undefined, orientation: match[7] as "n" | "s" | "e" | "w" | undefined,
align: match[8] as "l" | "c" | "r" | undefined, align: [match[8], match[9]].filter(Boolean).join("") as Layer["align"],
}); });
} }
@@ -35,8 +36,10 @@ export function parseLayers(layersStr: string): Layer[] {
* 格式化 layers 为字符串 * 格式化 layers 为字符串
*/ */
export function formatLayers(layers: LayerConfig[]): string { 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 return layers
.filter((l) => l.visible) .filter((l) => l.visible && l.prop)
.map((l) => { .map((l) => {
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`; let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
if (l.fontSize) { if (l.fontSize) {
@@ -87,6 +87,8 @@ export function useLayerInteraction(
const handleLayerClick = (index: number, e: MouseEvent) => { const handleLayerClick = (index: number, e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!store.state.isEditing || store.state.fixed) return;
const currentlySelected = store.state.selectedLayer; const currentlySelected = store.state.selectedLayer;
if (currentlySelected === index) { if (currentlySelected === index) {
@@ -100,6 +102,7 @@ export function useLayerInteraction(
const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => { const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => {
if (store.state.draggingState) return; if (store.state.draggingState) return;
if (!store.state.isEditing || store.state.fixed) return;
const { gridX, gridY } = calculateGridCoords(e, cardEl); const { gridX, gridY } = calculateGridCoords(e, cardEl);
const overlapping = getOverlappingLayers(gridX, gridY); const overlapping = getOverlappingLayers(gridX, gridY);
@@ -147,6 +150,7 @@ export function useLayerInteraction(
edge?: "n" | "s" | "e" | "w", edge?: "n" | "s" | "e" | "w",
e?: MouseEvent, e?: MouseEvent,
) => { ) => {
if (!store.state.isEditing || store.state.fixed) return;
if (store.state.selectedLayer === null) return; if (store.state.selectedLayer === null) return;
if (e) e.stopPropagation(); if (e) e.stopPropagation();
@@ -35,6 +35,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
const cardHeight = () => store.state.dimensions?.cardHeight || 88; const cardHeight = () => store.state.dimensions?.cardHeight || 88;
const shape = () => store.state.shape; const shape = () => store.state.shape;
const orientation = () => store.state.printOrientation || 'landscape'; const orientation = () => store.state.printOrientation || 'landscape';
const doubleCut = () => store.state.pltDoubleCut;
/** /**
* PLT * PLT
@@ -60,7 +61,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
// 将卡片路径转换为相对于 frameBoundsWithMargin 的坐标 // 将卡片路径转换为相对于 frameBoundsWithMargin 的坐标
// 原点在 frameBoundsWithMargin 的左上角 // 原点在 frameBoundsWithMargin 的左上角
const relativePaths = layout.cardPaths.map(cardPath => { const relativePaths = layout.cardPaths.flatMap(cardPath => {
const relativePoints = cardPath.points.map(([x, y]) => { const relativePoints = cardPath.points.map(([x, y]) => {
// 转换为相对于 frameBounds 左上角的坐标 // 转换为相对于 frameBounds 左上角的坐标
const relativeX = x - frameBounds.x; const relativeX = x - frameBounds.x;
@@ -71,7 +72,11 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
// 所以 plotterY = pltHeight - relativeY // 所以 plotterY = pltHeight - relativeY
return [relativeX, pltHeight - relativeY] as [number, number]; return [relativeX, pltHeight - relativeY] as [number, number];
}); });
return relativePoints; if (doubleCut()) {
// 重复一次:某些切割机需要每张卡牌切两次
return [relativePoints, relativePoints];
}
return [relativePoints];
}); });
// 起点和终点都在 frameBoundsWithMargin 的左上角 (0, pltHeight) // 起点和终点都在 frameBoundsWithMargin 的左上角 (0, pltHeight)
+60 -43
View File
@@ -2,17 +2,16 @@ import { customElement, noShadowDOM } from "solid-element";
import { Show, onCleanup } from "solid-js"; import { Show, onCleanup } from "solid-js";
import { resolvePath } from "../utils/path"; import { resolvePath } from "../utils/path";
import { v4 as uuidv4 } from "uuid"; 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 { 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 { DeckHeader } from "./DeckHeader";
import { CardList } from "./CardList";
import { DeckContent } from "./DeckContent"; import { DeckContent } from "./DeckContent";
import { EditorTabs } from "./EditorTabs";
import { PrintPreview } from "./PrintPreview"; import { PrintPreview } from "./PrintPreview";
import { import { DataEditorPanel } from "./editor-panel";
DataEditorPanel,
LayerEditorPanel,
PropertiesEditorPanel,
} from "./editor-panel";
interface DeckProps { interface DeckProps {
size?: string; size?: string;
@@ -70,49 +69,77 @@ customElement<DeckProps>(
const deckId = `deck-${uuidv4()}`; const deckId = `deck-${uuidv4()}`;
registerDeck(deckId, store, resolvedSrc, csvPath); 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" 和新格式) // 解析 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); const [w, h] = props.size.split("x").map(Number);
store.actions.setSizeW(w); store.actions.setSizeW(w);
store.actions.setSizeH(h); store.actions.setSizeH(h);
} else { } else {
store.actions.setSizeW(props.sizeW ?? 54); store.actions.setSizeW(props.sizeW ?? DECK_DEFAULTS.SIZE_W);
store.actions.setSizeH(props.sizeH ?? 86); store.actions.setSizeH(props.sizeH ?? DECK_DEFAULTS.SIZE_H);
} }
// 解析 grid 属性(支持旧格式 "5x8" 和新格式) // 解析 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); const [w, h] = props.grid.split("x").map(Number);
store.actions.setGridW(w); store.actions.setGridW(w);
store.actions.setGridH(h); store.actions.setGridH(h);
} else { } else {
store.actions.setGridW(props.gridW ?? 5); store.actions.setGridW(props.gridW ?? DECK_DEFAULTS.GRID_W);
store.actions.setGridH(props.gridH ?? 8); store.actions.setGridH(props.gridH ?? DECK_DEFAULTS.GRID_H);
} }
// 解析 bleed 和 padding(支持旧字符串格式和新数字格式) // 解析 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)); store.actions.setBleed(Number(props.bleed));
} else { } 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)); store.actions.setPadding(Number(props.padding));
} else { } 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 数据 // 加载 CSV 数据
store.actions.loadCardsFromPath( store.actions.loadCardsFromPath(resolvedSrc, csvPath, frontLayers, backLayers);
resolvedSrc,
csvPath,
(props.layers as string) || "",
(props.backLayers as string) || "",
);
// 清理函数 // 清理函数
onCleanup(() => { onCleanup(() => {
@@ -139,29 +166,19 @@ customElement<DeckProps>(
</Show> </Show>
<div class="flex gap-4"> <div class="flex gap-4">
{/* 内容区域:错误/加载/卡牌预览/空状态 */} {/* 左侧:卡牌列表导航 */}
{/* 左侧:CSV 数据编辑 */} <Show when={store.state.cards.length > 0 && !store.state.error}>
{/*<Show when={store.state.isEditing && !store.state.fixed}>*/} <CardList store={store} />
{/* <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> </Show>
<DeckContent store={store} isLoading={store.state.isLoading} /> {/* 中间:内容区域(错误/加载/卡牌预览/空状态) */}
<div class="flex-1 min-w-0">
<DeckContent store={store} isLoading={store.state.isLoading} />
</div>
{/* 右侧:属性/图层编辑面板 */} {/* 右侧:属性/图层编辑面板(标签页切换) */}
<Show when={store.state.isEditing && !store.state.fixed}> <Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1"> <EditorTabs store={store} />
<LayerEditorPanel store={store} />
</div>
</Show> </Show>
</div> </div>
</div> </div>
+26 -4
View File
@@ -4,21 +4,43 @@ export interface CardData {
export type CardSide = "front" | "back"; export type CardSide = "front" | "back";
/**
* Layer alignment: horizontal (l/c/r) optionally combined with vertical
* (t/b). "tl" = top-left, "br" = bottom-right, "tc" = top-center,
* "bc" = bottom-center, etc. Plain "l"/"c"/"r" means vertically centered.
*/
export type Align =
| "l"
| "c"
| "r"
| "tl"
| "tc"
| "tr"
| "bl"
| "bc"
| "br";
export type { CardShape } from "../../plotcutter/contour"; export type { CardShape } from "../../plotcutter/contour";
export interface Layer { export interface Layer {
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; x1: number;
y1: number; y1: number;
x2: number; x2: number;
y2: number; y2: number;
orientation?: "n" | "s" | "e" | "w"; orientation?: "n" | "s" | "e" | "w";
fontSize?: number; fontSize?: number;
align?: "l" | "c" | "r"; align?: Align;
} }
export interface LayerConfig { export interface LayerConfig {
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; visible: boolean;
x1: number; x1: number;
y1: number; y1: number;
@@ -26,7 +48,7 @@ export interface LayerConfig {
y2: number; y2: number;
orientation?: "n" | "s" | "e" | "w"; orientation?: "n" | "s" | "e" | "w";
fontSize?: number; fontSize?: number;
align?: "l" | "c" | "r"; align?: Align;
_key?: number; _key?: number;
} }
+2 -2
View File
@@ -42,7 +42,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
}); });
return ( return (
<div class="md-embed not-prose"> <div class="md-embed">
<Show when={content.loading}> <Show when={content.loading}>
<div class="text-gray-400 italic">...</div> <div class="text-gray-400 italic">...</div>
</Show> </Show>
@@ -54,7 +54,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
<Show when={!content.loading && !content.error && content()}> <Show when={!content.loading && !content.error && content()}>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */} {/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
<div <div
class="prose" class="prose text-black prose-sm"
innerHTML={parseMarkdown(content()!, resolvedPath)} innerHTML={parseMarkdown(content()!, resolvedPath)}
/> />
</Show> </Show>
+32 -19
View File
@@ -7,13 +7,14 @@ import {
createMemo, createMemo,
createResource, createResource,
} from "solid-js"; } from "solid-js";
import { marked } from "../markdown"; import { parseMarkdown } from "../markdown";
import { loadCSV, CSV, processVariables, isCSV } from "./utils/csv-loader"; import { parseCSVString, CSV, processVariables } from "./utils/csv-loader";
import { resolvePath } from "./utils/path"; import { resolveContentRef } from "./utils/resolve-content";
import { parseSparkTableCsv } from "./utils/spark-table";
import { import {
areAllLabelsNumeric, areAllLabelsNumeric,
weightedRandomIndex, weightedRandomIndex,
} from "./utils/weighted-random"; } from "./utils/weighted-random";;
export interface TableProps { export interface TableProps {
roll?: boolean; roll?: boolean;
@@ -51,20 +52,31 @@ customElement(
const articleEl = element?.closest("article[data-src]"); const articleEl = element?.closest("article[data-src]");
const articlePath = articleEl?.getAttribute("data-src") || ""; const articlePath = articleEl?.getAttribute("data-src") || "";
// 如果是 inline CSV直接使用否则解析相对路径 // 解析引用:inline CSV 直接使用否则通过 registry 解析(含内联内容 id
const contentOrPath = isCSV(rawContent) const [csvData] = createResource(
? rawContent () => ({ ref: rawContent, docPath: articlePath }),
: resolvePath(articlePath, rawContent); 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,自动响应路径变化并避免重复加载 // 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager
const [csvData] = createResource(() => contentOrPath, loadCSV); // 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。
// 当数据加载完成后更新 rows
createEffect(() => { createEffect(() => {
const data = csvData(); const content = csvData();
if (data) { if (!content) return;
// 将加载的数据赋值给 rowsCSV 类型已经包含 sourcePath 等属性 setRows(parseCSVString(content) as unknown as CSV<TableRow>);
setRows(data as unknown as CSV<TableRow>); if (element) {
const meta = parseSparkTableCsv(content);
if (meta) {
element.setAttribute("data-spark", meta.slug);
} else {
element.removeAttribute("data-spark");
}
} }
}); });
@@ -97,10 +109,11 @@ customElement(
// 处理 body 内容中的 {{prop}} 语法并解析 markdown // 处理 body 内容中的 {{prop}} 语法并解析 markdown
const processBody = (body: string, currentRow: TableRow): string => { const processBody = (body: string, currentRow: TableRow): string => {
// 使用 marked 解析 markdown // 使用 parseMarkdown 统一入口(设置图标 base path 等)
return marked.parse( return parseMarkdown(
processVariables(body, currentRow, rows(), filteredRows(), props.remix), processVariables(body, currentRow, rows(), filteredRows(), props.remix),
) as string; articlePath,
);
}; };
// 更新 body 内容 // 更新 body 内容
+56
View File
@@ -0,0 +1,56 @@
/**
* useHeadingFlash watches location.hash and applies a fading highlight
* animation to the target heading element on every hash change.
*
* Handles page refresh / initial load by retrying up to ~500ms until the
* target element exists in the DOM.
*/
import { createEffect, onCleanup } from "solid-js";
import { useLocation } from "@solidjs/router";
const FLASH_CLASS = "heading-flash";
const MAX_RETRIES = 10;
const RETRY_INTERVAL = 50;
function flashElement(el: HTMLElement) {
// Re-trigger the animation: remove and re-add the class
el.classList.remove(FLASH_CLASS);
void el.offsetWidth; // force reflow
el.classList.add(FLASH_CLASS);
}
export function useHeadingFlash() {
const location = useLocation();
createEffect(() => {
const hash = location.hash;
if (!hash) return;
const id = decodeURIComponent(hash.startsWith("#") ? hash.slice(1) : hash);
if (!id) return;
let retries = 0;
let timer: ReturnType<typeof setTimeout>;
const tryFlash = () => {
const el = document.getElementById(id);
if (el) {
flashElement(el);
el.addEventListener("animationend", function onEnd() {
el.classList.remove(FLASH_CLASS);
el.removeEventListener("animationend", onEnd);
}, { once: true });
} else if (retries < MAX_RETRIES) {
retries++;
timer = setTimeout(tryFlash, RETRY_INTERVAL);
}
};
// Delay slightly so Solid has a chance to flush DOM updates from
// a concurrent SPA navigation before we look for the element.
timer = setTimeout(tryFlash, 0);
onCleanup(() => clearTimeout(timer));
});
}
+4 -46
View File
@@ -31,42 +31,6 @@ function parseFrontMatter(content: string): { frontmatter?: JSONObject; remainin
} }
} }
/**
* CSV
* @param str
* @returns CSV true
*/
export function isCSV(str: string): boolean {
const trimmed = str.trim();
// 检查是否以 YAML front matter 开头
if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) {
return true;
}
// 检查是否包含 CSV 特征:多行且有分隔符
const lines = trimmed.split(/\r?\n/).filter(line => line.trim() !== '');
if (lines.length < 2) {
return false;
}
// 检测常见 CSV 分隔符
const separators = [',', '\t', ';', '|'];
const firstLine = lines[0];
for (const sep of separators) {
if (firstLine.includes(sep)) {
// 检查其他行是否也有相同的分隔符
const hasSeparatorInOtherLines = lines.slice(1).some(line => line.includes(sep));
if (hasSeparatorInOtherLines) {
return true;
}
}
}
return false;
}
/** /**
* CSV * CSV
* @template T Record<string, string> * @template T Record<string, string>
@@ -102,18 +66,12 @@ export function parseCSVString<T = Record<string, string>>(csvString: string, so
/** /**
* CSV * CSV
* @template T Record<string, string> * @template T Record<string, string>
* @param pathOrContent inline CSV * @param path file-index
* @returns CSV * @returns CSV
*/ */
export async function loadCSV<T = Record<string, string>>(pathOrContent: string): Promise<CSV<T>> { export async function loadCSVFromPath<T = Record<string, string>>(path: string): Promise<CSV<T>> {
// 检测是否是 inline CSV 数据 const content = await getIndexedData(path);
if (isCSV(pathOrContent)) { return parseCSVString<T>(content, path);
return parseCSVString<T>(pathOrContent, 'inline');
}
// 从索引获取文件内容
const content = await getIndexedData(pathOrContent);
return parseCSVString<T>(content, pathOrContent);
} }
type JSONData = JSONArray | JSONObject | string | number | boolean | null; type JSONData = JSONArray | JSONObject | string | number | boolean | null;
+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 // CSV parsing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const DICE_HEADER_RE = /^d\d+$/i; // Matches the scanner's `isSparkTableHeader` (content-registry): plain dice,
// counts, and modifiers all count as spark-table first columns.
const DICE_HEADER_RE = /^\d*d\d+(?:[+-]\d+)?$/i;
/** /**
* Parse a CSV string into a SparkTableMeta. * Parse a CSV string into a SparkTableMeta.
+35 -1
View File
@@ -17,6 +17,7 @@ import {
removeHandle, removeHandle,
ensurePermission, ensurePermission,
} from "./file-index-db"; } from "./file-index-db";
import { normalizePathKey } from "../cli/content-registry";
type FileIndex = Record<string, string>; type FileIndex = Record<string, string>;
@@ -24,6 +25,20 @@ let fileIndex: FileIndex | null = null;
let indexLoadPromise: Promise<void> | null = null; let indexLoadPromise: Promise<void> | null = null;
let activeSource: "cli" | "folder" | 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) */ /** Currently active directory handle (if folder source) */
let activeDirHandle: FileSystemDirectoryHandle | null = null; let activeDirHandle: FileSystemDirectoryHandle | null = null;
@@ -85,7 +100,7 @@ async function scanDirectory(
Object.assign(index, sub); Object.assign(index, sub);
} else if (entry.kind === "file" && acceptedExt.test(name)) { } else if (entry.kind === "file" && acceptedExt.test(name)) {
const file = await (entry as FileSystemFileHandle).getFile(); const file = await (entry as FileSystemFileHandle).getFile();
const path = prefix + name; const path = normalizePathKey(prefix + name);
index[path] = await file.text(); index[path] = await file.text();
} }
} }
@@ -181,6 +196,15 @@ export async function getIndexedData(path: string): Promise<string> {
if (fileIndex && fileIndex[path]) { if (fileIndex && fileIndex[path]) {
return 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 res = await fetch(path);
const content = await res.text(); const content = await res.text();
fileIndex = fileIndex || {}; fileIndex = fileIndex || {};
@@ -188,6 +212,16 @@ export async function getIndexedData(path: string): Promise<string> {
return content; return content;
} }
/**
* /
* registry stripped markdown
* 使 CLI
*/
export function setIndexedData(path: string, content: string): void {
fileIndex = fileIndex || {};
fileIndex[normalizePathKey(path)] = content;
}
/** /**
* *
*/ */
+13 -26
View File
@@ -2,20 +2,12 @@
tag: journal-gm tag: journal-gm
icon: 🎭 icon: 🎭
title: 主持人指南 title: 主持人指南
description: 作为 GM 创建会话、管理玩家、使用动态表单和消息类型。
syntax: '点击导航栏 📋 图标打开 Journal 面板'
props:
- name: 会话管理
type: —
desc: 创建、切换、删除会话;通过邀请链接添加玩家
- name: 消息类型
type: —
desc: 发送叙述、掷骰、表单等多种消息类型
- name: 动态表单
type: —
desc: 创建自定义表单收集玩家输入
--- ---
作为 GM 创建会话、管理玩家、使用命令与玩家互动。
点击顶部导航栏的 📋 按钮打开 Journal 面板。
## 快速开始 ## 快速开始
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板 1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
@@ -32,25 +24,20 @@ props:
玩家通过邀请链接加入后会自动以玩家身份连接。 玩家通过邀请链接加入后会自动以玩家身份连接。
## 消息类型 ## 消息与命令
Journal 支持多种消息类型,通过输入框上方的标签切换 Journal 使用斜杠命令来发送不同类型的消息。在输入框中输入命令,按 Enter 发送
- **叙述**普通的文字描述,用于推进剧情 - **聊天**直接输入文字,按 Enter 发送普通叙述消息
- **掷骰**:发送掷骰结果,可附带公式 - **`/roll 表达式`**:掷骰或抽取火花表。传入骰子表达式(如 `/roll 3d6`)则掷骰;传入火花表键名(如 `/roll npc`)则抽取火花表
- **表单**:创建动态表单,收集玩家选择或输入 - **`/link 路径#章节`**:发送可点击的文档链接
- **`/set $key 值`**:设置变量值,支持数值、标签映射和随机标签
## 动态表单 输入 `/` 后按 Tab 可打开命令补全下拉菜单。
表单消息允许你创建交互式问卷:
- 添加文本输入、选择框、复选框等字段
- 玩家提交后,GM 可以看到汇总结果
- 适用于投票、决策、角色创建等场景
## 撤回消息 ## 撤回消息
GM 可以撤回自己发送的最新消息,点击消息旁的撤回按钮即可。 GM 可以撤回自己发送的最新消息,点击消息卡片右上角的 × 按钮即可。
## 连接状态 ## 连接状态
@@ -59,4 +46,4 @@ GM 可以撤回自己发送的最新消息,点击消息旁的撤回按钮即
- 🔴 红色:连接错误 - 🔴 红色:连接错误
- ⚪ 灰色:未连接 - ⚪ 灰色:未连接
断开连接可点击标题栏的 ⏻ 按钮。 断开连接可点击标题栏的 ⏻ 按钮。
+10 -15
View File
@@ -2,27 +2,22 @@
tag: journal-link tag: journal-link
icon: 🔗 icon: 🔗
title: 链接命令 title: 链接命令
description: 在 Journal 中发送可点击的文档链接,可指向特定章节。
syntax: '/link 路径#章节'
props:
- name: 路径
type: string
desc: 文档路径(不含 .md 扩展名)
- name: 章节
type: string
desc: 可选,文档中的章节标题
--- ---
在 Journal 中发送可点击的文档链接,可指向特定章节。
**语法:** `/link 路径#章节`
## 概述 ## 概述
`/link` 命令用于在 Journal 流中发送文档链接,点击后可在文章区域打开对应文档。 `/link` 命令用于在 Journal 流中发送文档链接,点击后可在文章区域打开对应文档。
## 语法 ## 参数
``` | 参数 | 类型 | 说明 |
/link 路径 |---|---|---|
/link 路径#章节 | 路径 | string | 文档路径(不含 `.md` 扩展名) |
``` | 章节 | string | 可选,文档中的章节标题 |
## 示例 ## 示例
@@ -36,4 +31,4 @@ props:
- **GM**:可使用 - **GM**:可使用
- **玩家**:不可使用 - **玩家**:不可使用
- **观察者**:不可使用 - **观察者**:不可使用
+11 -21
View File
@@ -2,20 +2,12 @@
tag: journal-player tag: journal-player
icon: 🎲 icon: 🎲
title: 玩家指南 title: 玩家指南
description: 作为玩家加入 GM 的会话,查看消息、提交表单、参与互动。
syntax: '通过 GM 发送的邀请链接加入'
props:
- name: 加入方式
type: —
desc: 点击邀请链接自动加入,或手动输入名字和角色
- name: 查看消息
type: —
desc: 实时查看 GM 和其他玩家的消息
- name: 提交表单
type: —
desc: 填写 GM 发送的动态表单并提交
--- ---
作为玩家加入 GM 的会话,查看消息、使用 `/set` 命令设置变量、参与互动。
通过 GM 发送的邀请链接加入。
## 加入会话 ## 加入会话
有两种方式加入 GM 的会话: 有两种方式加入 GM 的会话:
@@ -38,20 +30,17 @@ GM 会发送一个包含 `?session=xxx&player=你的名字&autojoin=1` 的链接
- GM 发送的叙述消息 - GM 发送的叙述消息
- 掷骰结果 - 掷骰结果
- 火花表抽取结果
- 其他玩家的消息 - 其他玩家的消息
- 动态表单
消息实时更新,无需刷新页面。 消息实时更新,无需刷新页面。
## 填写表单 ## 使用命令
当 GM 发送动态表单时 玩家可在输入框中使用以下命令
1. 表单会显示在消息流中 - **聊天**:直接输入文字,按 Enter 发送普通消息
2. 根据表单类型填写(文本、选择、复选框等) - **`/set $key 值`**:设置变量值,支持数值和标签映射
3. 点击提交按钮发送你的回答
GM 可以看到所有玩家的提交结果。
## 角色标识 ## 角色标识
@@ -63,4 +52,5 @@ GM 可以看到所有玩家的提交结果。
- 玩家无法创建或切换会话 - 玩家无法创建或切换会话
- 玩家无法撤回消息 - 玩家无法撤回消息
- 断开连接后重新加入会保留之前的消息记录 - 玩家无法使用 `/roll``/link` 命令
- 在 CLI 模式下,断开连接后重新加入会保留之前的消息记录
+24 -13
View File
@@ -1,27 +1,24 @@
--- ---
tag: journal-roll tag: journal-roll
icon: 🎲 icon: 🎲
title: 掷骰命令 title: 掷骰与火花表命令
description: 在 Journal 中发送掷骰结果,支持标准骰子表达式。
syntax: '/roll 骰子表达式'
props:
- name: 骰子表达式
type: string
desc: 标准骰子表达式,如 3d6、2d8kh1、d20+5
--- ---
在 Journal 中掷骰或随机抽取火花表内容。优先匹配火花表,否则作为骰子表达式处理。
**语法:** `/roll 骰子表达式 | 火花表键名`
## 概述 ## 概述
`/roll` 命令用于在 Journal 流中掷骰并发布结果。结果会同步到所有连接的客户端。 `/roll` 命令用于掷骰或随机抽取火花表,并将结果同步到所有客户端。
## 语法 - 如果参数匹配已定义的**火花表** slug,则抽取并发布对应火花表的结果。
- 否则,作为**骰子表达式**求值并发布掷骰结果。
```
/roll 骰子表达式
```
## 示例 ## 示例
### 掷骰
``` ```
/roll d20 /roll d20
/roll 3d6 /roll 3d6
@@ -30,6 +27,14 @@ props:
/roll 4d6k3 /roll 4d6k3
``` ```
### 火花表
```
/roll npc
/roll encounter
/roll loot
```
## 支持的骰子表达式 ## 支持的骰子表达式
| 表达式 | 说明 | | 表达式 | 说明 |
@@ -40,6 +45,12 @@ props:
| `1d20+5` | 1 个 20 面骰加 5 | | `1d20+5` | 1 个 20 面骰加 5 |
| `4d6k3` | 4 个 6 面骰保留最高 3 个 | | `4d6k3` | 4 个 6 面骰保留最高 3 个 |
## 火花表定义
火花表在 markdown 文档中通过 `:spark[CSV路径]` 标记声明,系统扫描文档时自动发现。CSV 文件的第一列表头为骰子公式(如 `d6``d20`),后续列为数据列。
抽取时根据骰子公式投掷,查找对应行并将各列数据作为火花表结果发布。
## 权限 ## 权限
- **GM**:可使用 - **GM**:可使用
+66 -39
View File
@@ -10,7 +10,7 @@ props:
desc: 在任意 .md 文档中使用 ```csv role=declare 代码块定义变量和标签 desc: 在任意 .md 文档中使用 ```csv role=declare 代码块定义变量和标签
- name: 命令 - name: 命令
type: — type: —
desc: /set $key expression | /set $key #tag1|#tag2|#tag3 desc: /set $key expression | /set $key #tag1:count1;#tag2:count2
- name: 权限 - name: 权限
type: — type: —
desc: GM 和玩家均可设置变量 desc: GM 和玩家均可设置变量
@@ -21,23 +21,25 @@ props:
变量系统允许你定义数值变量和标签,通过命令设置值,系统自动计算派生变量和标签效果。 变量系统允许你定义数值变量和标签,通过命令设置值,系统自动计算派生变量和标签效果。
核心概念: 核心概念:
- **变量**`$name`):存储数值或标签,通过 `/set` 命令修改 - **变量**`$name`):存储数值或标签映射,通过 `/set` 命令修改
- **标签**`#tag`):当某个变量的值为 `#tag` 时,该标签被激活,触发相应的修饰效果 - **标签映射**`#tag:count`):变量可以存储一个标签映射(如 `#warrior:2;#druid:1`),每个标签有一个计数值
- **标签修饰符**:当变量中的标签计数值达到阈值时,修饰符生效
- **声明**`declare`):在 `role=declare` 代码块中定义派生变量和标签修饰符 - **声明**`declare`):在 `role=declare` 代码块中定义派生变量和标签修饰符
## 代码块语法 ## 代码块语法
```csv role=declare ```csv role=declare
tag,key,expr tag,threshold,key,expr
,$hp,$con*5+$mod_hp ,,$hp,$con*5+$mod_hp
,$ac,10+$dex ,,$ac,10+$dex
#warrior,$mod_hp,20 #warrior,1,$mod_hp,20
#warrior,$mod_str,1 #warrior,2,$mod_str,1
``` ```
| 列 | 说明 | | 列 | 说明 |
|---|---| |---|---|
| `tag` | 标签名(空表示普通变量声明) | | `tag` | 标签名(空表示普通变量声明) |
| `threshold` | 激活阈值(可选,默认 `1`)。标签计数值 ≥ 阈值时激活 |
| `key` | 变量名,以 `$` 开头 | | `key` | 变量名,以 `$` 开头 |
| `expr` | 表达式,支持数字、`$var` 引用、骰子、算术、函数 | | `expr` | 表达式,支持数字、`$var` 引用、骰子、算术、函数 |
@@ -51,14 +53,18 @@ tag,key,expr
,$ac,10+$dex ,$ac,10+$dex
``` ```
**标签修饰符**(tag 非空):当 `#tag` 被激活时,`$key` 的当前值加上表达式的计算结果。 **标签修饰符**tag 非空):当源变量的标签映射中 `#tag` 的计数值 ≥ `threshold` 时,`$key` 加上表达式的计算结果。修饰符按阈值独立激活。
```csv role=declare ```csv role=declare
tag,key,expr tag,threshold,key,expr
#warrior,$mod_hp,20 #warrior,1,$mod_hp,20
#warrior,$mod_str,1 #warrior,2,$mod_str,1
``` ```
上例中:
- `#warrior` 计数 ≥ 1 时,`$mod_hp` 获得 +20
- `#warrior` 计数 ≥ 2 时,`$mod_str` 获得 +1(需要更高等级)
## 表达式语法 ## 表达式语法
支持以下表达式元素: 支持以下表达式元素:
@@ -72,7 +78,7 @@ tag,key,expr
| 函数 | `floor(x)`, `ceil(x)`, `round(x)` | 取整函数 | | 函数 | `floor(x)`, `ceil(x)`, `round(x)` | 取整函数 |
| 括号 | `(2+3)*4` | 分组 | | 括号 | `(2+3)*4` | 分组 |
变量引用必须解析为数值,不能是标签值(`#warrior`)。如果引用的变量是标签类型,求值会报错。 变量引用必须解析为数值,不能是标签映射值(`#warrior:1`)。如果引用的变量是标签类型,求值会报错。
## 命令 ## 命令
@@ -81,7 +87,9 @@ tag,key,expr
| 命令 | 示例 | 说明 | | 命令 | 示例 | 说明 |
|---|---|---| |---|---|---|
| `/set $key expr` | `/set $str 16` | 设置变量为数值 | | `/set $key expr` | `/set $str 16` | 设置变量为数值 |
| `/set $key #tag` | `/set $class #warrior` | 设置变量为标签 | | `/set $key #tag` | `/set $class #warrior` | 设置单个标签(等价 `#warrior:1` |
| `/set $key #tag:count` | `/set $class #warrior:3` | 设置标签及计数值 |
| `/set $key #t1:c1;#t2:c2` | `/set $class #warrior:2;#druid:1` | 设置多个标签 |
| `/set $key #a\|#b\|#c` | `/set $class #w\|#d\|#s` | 随机选择一个标签 | | `/set $key #a\|#b\|#c` | `/set $class #w\|#d\|#s` | 随机选择一个标签 |
### 设置数值 ### 设置数值
@@ -93,16 +101,23 @@ tag,key,expr
设置后,依赖 `$con``$dex` 的派生变量(如 `$hp``$ac`)会自动重新计算。 设置后,依赖 `$con``$dex` 的派生变量(如 `$hp``$ac`)会自动重新计算。
### 设置标签 ### 设置标签映射
``` ```
/set $class #warrior /set $class #warrior
``` ```
`$class` 设置为 `#warrior` 时: 等价于 `/set $class #warrior:1``$class` `#warrior` 计数 ≥ 1 时:
1. `#warrior` 标签被激活 1. `#warrior` 修饰符中阈值 ≤ 1 的生效
2. 所有 `#warrior` 的修饰符生效:`$mod_hp += 20``$mod_str += 1` 2. `$mod_hp += 20`依赖 `$mod_hp` 的派生变量自动重新计算
3. 依赖这些变量的派生变量自动重新计算
**多标签示例:**
```
/set $class #warrior:2;#druid:1
```
变量 `$class` 同时拥有 `#warrior` 计数 2 和 `#druid` 计数 1,两者的修饰符会同时生效(各自按阈值判断)。
### 随机标签 ### 随机标签
@@ -110,27 +125,39 @@ tag,key,expr
/set $class #warrior|#druid|#sorcerer|#wizard /set $class #warrior|#druid|#sorcerer|#wizard
``` ```
从给定标签中随机选择一个设置为变量值 从给定标签中随机选择一个,计数值为 1
## 标签激活 ## 标签激活
标签的激活状态由变量值决定: 标签的激活状态由变量中标签映射的计数值决定:
- 如果**任何**变量的值为 `#tag`,该标签被激活 - 如果变量的标签映射中 `#tag` 的计数值 = 0(或不存在),该标签停用
- 如果**没有**变量的值为 `#tag`,该标签被停用 - 计数值 ≥ 修饰符的 `threshold` 时,该修饰符激活
- 计数值 < `threshold` 时,修饰符停用
- 标签激活时,其修饰符**加到**目标变量上 - 标签激活时,其修饰符**加到**目标变量上
- 标签停用时,修饰符**从**目标变量中减去 - 标签停用时,修饰符**从**目标变量中减去
一个变量只能存储一个值类型:数值或标签,不能同时存储两者。 ### 阈值机制
阈值允许同一标签产生不同层级的效果:
```csv role=declare
tag,threshold,key,expr
#warrior,1,$mod_hp,10
#warrior,3,$mod_hp,20
```
- `#warrior` 计数 1-2`$mod_hp` +10
- `#warrior` 计数 ≥ 3`$mod_hp` +30(两个修饰符叠加)
一个变量可以存储多个标签(标签映射),支持多职业、混合属性等场景。
## 变量视图 ## 变量视图
在 Journal 面板顶部点击 **变量** 标签切换视图: 在 Journal 面板顶部点击 **变量** 标签切换到变量视图,显示所有已定义的变量及其当前值。
- **激活标签**:显示当前激活的标签、激活来源和修饰效果 - 每行显示变量名和当前值,标签类型变量以紫色高亮显示
- **声明变量**:显示由表达式定义的派生变量及其当前值 - 鼠标悬停在变量行上,会弹出气泡显示:声明表达式(如有)和当前激活的标签修饰符(标签名、数值、来源变量)
- **直接设置**:显示通过 `/set` 直接赋值的变量
- **标签变量**:显示当前值为标签的变量
## 文档模板 ## 文档模板
@@ -148,20 +175,20 @@ tag,key,expr
在文档中定义: 在文档中定义:
```csv role=declare ```csv role=declare
tag,key,expr tag,threshold,key,expr
,$con,10 ,,$con,10
,$dex,12 ,,$dex,12
,$hp,$con*5+$mod_hp ,,$hp,$con*5+$mod_hp
,$ac,10+$dex ,,$ac,10+$dex
#warrior,$mod_hp,20 #warrior,1,$mod_hp,20
#warrior,$mod_str,1 #warrior,2,$mod_str,1
``` ```
在 Journal 中输入: 在 Journal 中输入:
``` ```
/set $con 14 /set $con 14
/set $class #warrior /set $class #warrior:2
``` ```
结果: 结果:
@@ -169,8 +196,8 @@ tag,key,expr
- `$dex = 12` - `$dex = 12`
- `$hp = 14*5 + 20 = 90` - `$hp = 14*5 + 20 = 90`
- `$ac = 10 + 12 = 22` - `$ac = 10 + 12 = 22`
- `$mod_hp = 20`(来自 `#warrior` 修饰符) - `$mod_hp = 20`(来自 `#warrior` 修饰符,阈值 1,计数 2 ≥ 1
- `$mod_str = 1`(来自 `#warrior` 修饰符) - `$mod_str = 1`(来自 `#warrior` 修饰符,阈值 2,计数 2 ≥ 2
## 循环依赖 ## 循环依赖
+12 -20
View File
@@ -1,36 +1,28 @@
--- ---
tag: journal-spark tag: journal-spark
icon: 🎰 icon: 🎰
title: 种子表命令 title: 火花表
description: 在 Journal 中随机生成种子表内容,用于生成随机遭遇、NPC、物品等。
syntax: '/spark 种子表键名'
props:
- name: 种子表键名
type: string
desc: 在文档中通过 :spark[CSV路径] 指令定义的种子表 slug
--- ---
在 Journal 中通过 `/roll` 命令随机抽取火花表内容,用于生成随机遭遇、NPC、物品等。
**语法:** `/roll 火花表键名`
## 概述 ## 概述
`/spark` 命令用于从种子表中随机抽取一行并发布结果。种子表在 markdown 文档中通过 `:spark[CSV路径]` 指令定义 火花表通过 `/roll` 命令触发:当参数匹配已定义的火花表 slug 时,自动抽取并发布结果,而非作为骰子表达式处理
## 语法
```
/spark 种子表键名
```
## 示例 ## 示例
``` ```
/spark npc /roll npc
/spark encounter /roll encounter
/spark loot /roll loot
``` ```
## 种子表定义 ## 火花表定义
种子表在文档中定义: 火花表在 markdown 文档中通过 `:spark[CSV路径]` 标记声明,CSV 文件的第一列表头为骰子公式(如 `d6``d20`),后续列为数据列。系统扫描文档时自动发现这些标记。
``` ```
:spark[npc] :spark[npc]
@@ -38,7 +30,7 @@ props:
:spark[loot] :spark[loot]
``` ```
CSV 文件的每一行对应种子表的一个条目,随机选取一行发布 抽取时根据骰子公式投掷,查找对应行并发布结果
## 权限 ## 权限
+11 -8
View File
@@ -2,15 +2,12 @@
tag: md-bg tag: md-bg
icon: 🖼️ icon: 🖼️
title: 背景组件 title: 背景组件
description: 设置背景图片或纯色作为文章卡片背景,支持多种适配方式。
syntax: ':md-bg[#ff00dd]'
props:
- name: fit
type: cover | contain | fill | none | scale-down
default: cover
desc: 背景适配方式(仅图片时生效)
--- ---
设置背景图片或纯色作为文章卡片背景,支持多种适配方式。
**语法:** `:md-bg[颜色或图片路径]{选项}`
**设置背景图:** **设置背景图:**
:md-bg[./images/dungeon-bg.jpg]{fit="cover"} :md-bg[./images/dungeon-bg.jpg]{fit="cover"}
@@ -23,6 +20,12 @@ props:
支持图片路径或任意 CSS 颜色值(hex、rgb、颜色名等)。 支持图片路径或任意 CSS 颜色值(hex、rgb、颜色名等)。
## 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `fit` | `cover` \| `contain` \| `fill` \| `none` \| `scale-down` | `cover` | 背景适配方式(仅图片时生效) |
## 使用场景 ## 使用场景
用于营造场景氛围,如地牢、森林、城镇等不同环境的背景,或纯色区分不同主题。 用于营造场景氛围,如地牢、森林、城镇等不同环境的背景,或纯色区分不同主题。
+43 -14
View File
@@ -2,19 +2,12 @@
tag: md-border tag: md-border
icon: 🖼️ icon: 🖼️
title: 边框组件 title: 边框组件
description: 为文档卡片添加纯色或图片边框,支持指定边、样式和重复模式。
syntax: ':md-border[#3b82f6]{.l .dashed}'
props:
- name: width
type: string
default: 纯色 .2mm,图片 2mm
desc: 边框宽度
- name: slice
type: string
default: '"10"'
desc: border-image-slice(仅图片模式)
--- ---
为文档卡片添加纯色或图片边框,支持指定边、样式和重复模式。
**语法:** `:md-border[颜色或图片路径]{方位类 样式类 选项}`
**纯色左边框:** **纯色左边框:**
:md-border[#3b82f6]{.l} :md-border[#3b82f6]{.l}
@@ -27,9 +20,45 @@ props:
**左右图片竖条:** **左右图片竖条:**
:md-border[./images/ornament.png]{.l .r .repeat} :md-border[./images/ornament.png]{.l .r .repeat}
边 class`t` `b` `l` `r` `all`(默认) ## 方位类
样式 class`solid` `dashed` `dotted` `double`
图片 class`stretch` `repeat` `round` `space` | class | 说明 |
|---|---|
| `t` | 上边框 |
| `b` | 下边框 |
| `l` | 左边框 |
| `r` | 右边框 |
| `all` | 全部(默认) |
## 样式类
| class | 说明 |
|---|---|
| `solid` | 实线 |
| `dashed` | 虚线 |
| `dotted` | 点线 |
| `double` | 双线 |
| `groove` | 凹槽 |
| `ridge` | 凸脊 |
| `inset` | 内嵌 |
| `outset` | 外凸 |
| `none` | 无边框 |
## 图片类
| class | 说明 |
|---|---|
| `stretch` | 拉伸 |
| `repeat` | 重复 |
| `round` | 缩放 |
| `space` | 间隔 |
## 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `width` | string | 纯色 `.2mm`,图片 `2mm` | 边框宽度 |
| `slice` | string | `"10"` | `border-image-slice`(仅图片模式) |
## 使用场景 ## 使用场景
+34 -6
View File
@@ -2,28 +2,56 @@
tag: md-commander tag: md-commander
icon: 📋 icon: 📋
title: 命令追踪器 title: 命令追踪器
description: 支持命令历史和游戏状态追踪,使用类 Emmet 语法创建追踪项。
syntax: ':md-commander'
props: []
--- ---
支持命令历史和游戏状态追踪,使用类 Emmet 语法创建追踪项,支持 CSV 命令模板加载。
**语法:** `:md-commander[./templates.csv]{选项}`
**追踪 NPC 血量和防御:** **追踪 NPC 血量和防御:**
``` ```
track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"] track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
``` ```
**语法规则** ## 语法规则
- `#id` 设置 ID - `#id` 设置 ID
- `.class` 添加类别 - `.class` 添加类别
- `[attr=value]` 设置属性 - `[attr=value]` 设置属性
**属性类型** ## 属性类型
| 格式 | 显示 | | 格式 | 显示 |
|---|---| |---|---|
| x/y | 进度条 | | `x/y` | 进度条 |
| 整数 | 计数器 | | 整数 | 计数器 |
| 文本 | 文本字段 | | 文本 | 文本字段 |
## 视图模式
组件有两个标签页,通过顶部标签栏切换:
- **历史**:显示命令执行历史,点击可重新填入命令
- **追踪**:显示当前追踪的所有项目,支持属性编辑、类管理、排序和删除
## 键盘快捷键
| 快捷键 | 说明 |
|---|---|
| `Enter` | 执行命令 |
| `Tab` | 接受自动补全 |
| `↑` / `↓` | 补全列表导航 / 命令历史导航 |
| `Escape` | 关闭补全菜单 |
## 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `placeholder` | string | `"输入命令..."` | 输入框占位符 |
| `class` | string | — | 额外 CSS 类 |
| `height` | string | `"400px"` | 组件高度 |
| `commandTemplates` | string | — | CSV 模板文件路径 |
## 使用场景 ## 使用场景
用于追踪战斗中的 NPC 血量、AC、状态等游戏信息。 用于追踪战斗中的 NPC 血量、AC、状态等游戏信息。
+58 -10
View File
@@ -2,24 +2,72 @@
tag: md-deck tag: md-deck
icon: 🃏 icon: 🃏
title: 卡牌组件 title: 卡牌组件
description: 将 CSV 数据渲染为卡牌布局,支持自定义网格和图层的排版。
syntax: ':md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-8,8f3"}'
props:
- name: grid
type: string
desc: 卡牌布局,格式 行x列
- name: layers
type: string
desc: 图层定义,格式 字段:行,列-列,字号
--- ---
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
**语法:** `:md-deck[./cards.csv]{选项}` 或 yaml 代码块
**基础卡牌:** **基础卡牌:**
:md-deck[./spells.csv]{grid="3x3"} :md-deck[./spells.csv]{grid="3x3"}
**多层卡牌:** **多层卡牌:**
:md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-8,8f3"} :md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-8,8f3"}
CSV 包含 label 和显示字段列,通过图层定义控制各字段的位置大小。 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` 属性格式为 `字段:起始行,起始列-结束列,字号`,多个图层用空格分隔:
- **字段**CSV 中的列名
- **起始行,起始列-结束列**:图层在网格中的位置(1-based
- **字号**:末尾带 `f` 前缀,如 `f8` 表示 8mm
示例:`"title:1,1-5,1f8 body:1,5-8,8f3"` 表示 title 字段占第 1 行、第 1 到 5 列、字号 8mm;body 字段占第 1 行、第 5 到 8 列、字号 3mm。
## 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `grid` | string | — | 卡牌网格布局,格式 `行x列`(如 `"5x8"` |
| `gridW` | number | `5` | 网格列数 |
| `gridH` | number | `8` | 网格行数 |
| `size` | string | — | 卡牌尺寸,格式 `宽x高`(如 `"54x86"`,单位 mm |
| `sizeW` | number | `54` | 卡牌宽度(mm |
| `sizeH` | number | `86` | 卡牌高度(mm |
| `bleed` | number | `1` | 出血线(mm |
| `padding` | number | `2` | 内边距(mm |
| `shape` | string | `"rectangle"` | 卡牌形状(`rectangle` 等) |
| `layers` | string | — | 正面图层定义 |
| `backLayers` | string | — | 背面图层定义 |
| `fixed` | boolean | `false` | 固定模式(只读不可编辑) |
## 使用场景 ## 使用场景
+11 -7
View File
@@ -2,21 +2,25 @@
tag: md-dice tag: md-dice
icon: 🎲 icon: 🎲
title: 骰子组件 title: 骰子组件
description: 点击文字执行掷骰,可用于属性检定、伤害投掷等场景。
syntax: ':md-dice[2d6+d8]{key="attack"}'
props:
- name: key
type: string
desc: URL 参数标识,结果记录到 ?dice-key=15
--- ---
点击骰子图标执行掷骰,点击文字重置为公式,可用于属性检定、伤害投掷等场景。
**语法:** `:md-dice[公式]{key="标识"}`
**攻击检定:** :md-dice[1d20+5]{key="attack"} **攻击检定:** :md-dice[1d20+5]{key="attack"}
**伤害掷骰:** :md-dice[2d6+3]{key="damage"} **伤害掷骰:** :md-dice[2d6+3]{key="damage"}
**优势检定:** :md-dice[2d20k1+5]{key="advantage"} **优势检定:** :md-dice[2d20k1+5]{key="advantage"}
点击掷骰文字执行投掷,再次点击重置为公式。 点击 🎲 图标执行投掷,显示结果;点击文字重置为原始公式。
## 属性
| 属性 | 类型 | 说明 |
|---|---|---|
| `key` | string | URL 参数标识,结果记录到 `?dice-key=15` |
## 使用场景 ## 使用场景
+10 -3
View File
@@ -2,11 +2,12 @@
tag: md-embed tag: md-embed
icon: 📄 icon: 📄
title: 嵌入组件 title: 嵌入组件
description: 将另一个 Markdown 文件的内容内联嵌入到当前文档中。
syntax: ':md-embed[./rules.md#combat]'
props: []
--- ---
将另一个 Markdown 文件的内容内联嵌入到当前文档中。
**语法:** `:md-embed[路径#章节]{选项}`
**嵌入完整文档:** **嵌入完整文档:**
:md-embed[./rules.md] :md-embed[./rules.md]
@@ -15,6 +16,12 @@ props: []
嵌入后内容直接在当前位置显示,包括其中的表格、骰子等组件也会正常渲染。 嵌入后内容直接在当前位置显示,包括其中的表格、骰子等组件也会正常渲染。
## 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `headingBase` | number | `0` | 提升嵌入内容中每一级标题的层级(如 `1``#` 提升为 `##` |
## 使用场景 ## 使用场景
用于在文档中引用通用规则、重复使用的数据表格等。 用于在文档中引用通用规则、重复使用的数据表格等。
+13 -15
View File
@@ -2,22 +2,12 @@
tag: md-font tag: md-font
icon: 🔤 icon: 🔤
title: 字体组件 title: 字体组件
description: 设置整个文档的字体和文字颜色,支持 Google Fonts、emfont 和系统本地字体三种来源。
syntax: ':md-font[Noto Sans SC]{source="google" weight="400" color="#ff00dd"}'
props:
- name: source
type: google | emfont | local
default: local
desc: 字体来源
- name: weight
type: string
default: '"400"'
desc: 字体粗细
- name: color
type: string
desc: 文字颜色(CSS 颜色值,如 #ff00dd、rgb(255,0,0)
--- ---
设置整个文档的字体和文字颜色,支持 Google Fonts、emfont 和系统本地字体三种来源。
**语法:** `:md-font[字体名]{选项}`
**Google 字体:** **Google 字体:**
:md-font[Noto Sans SC]{source="google"} :md-font[Noto Sans SC]{source="google"}
@@ -34,6 +24,14 @@ props:
字体和颜色将应用到整个文档卡片。 字体和颜色将应用到整个文档卡片。
## 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `source` | `google` \| `emfont` \| `local` | `local` | 字体来源 |
| `weight` | string | `"400"` | 字体粗细 |
| `color` | string | — | 文字颜色(CSS 颜色值,如 `#ff00dd``rgb(255,0,0)` |
## 使用场景 ## 使用场景
用于切换文档的显示字体和文字颜色,适配不同风格需求。 用于切换文档的显示字体和文字颜色,适配不同风格需求。
+5 -4
View File
@@ -2,11 +2,12 @@
tag: md-link tag: md-link
icon: 🔗 icon: 🔗
title: 链接组件 title: 链接组件
description: 点击链接在当前页面内展开显示目标文章内容,支持章节定位。
syntax: ':md-link[./rules.md#combat]'
props: []
--- ---
点击链接在当前页面内展开显示目标文章内容,支持章节定位。
**语法:** `:md-link[路径#章节]`
**展开完整文档:** **展开完整文档:**
:md-link[./rules.md] :md-link[./rules.md]
@@ -17,4 +18,4 @@ props: []
## 使用场景 ## 使用场景
用于引用规则书章节、怪物数据、快速预览等场景。 用于引用规则书章节、怪物数据、快速预览等场景。
+17 -16
View File
@@ -2,23 +2,12 @@
tag: md-pins tag: md-pins
icon: 📍 icon: 📍
title: 标记组件 title: 标记组件
description: 在地图或图片上添加可编辑或固定的位置标记,支持字母或数字标签。
syntax: ':md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed}'
props:
- name: pins
type: string
default: '""'
desc: 标记列表,格式 "A:x,y B:x,y"
- name: fixed
type: boolean
default: "false"
desc: 固定模式(只读不可编辑)
- name: labelStart
type: string
default: '"A"'
desc: 标签起始值,支持字母或数字
--- ---
在地图或图片上添加可编辑或固定的位置标记,支持字母或数字标签。
**语法:** `:md-pins[图片路径]{选项}`
**固定标记(只读):** **固定标记(只读):**
:md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30" fixed} :md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30" fixed}
@@ -28,7 +17,19 @@ props:
**数字标签:** **数字标签:**
:md-pins[./images/dungeon.png]{labelStart="1"} :md-pins[./images/dungeon.png]{labelStart="1"}
非 fixed 模式下点击图片添加标记,点击标记删除。 `fixed` 模式下点击图片添加标记,点击标记删除。
## 复制坐标
`fixed` 模式下,图片右上角会显示 📋 复制按钮,点击可将所有标记的坐标复制到剪贴板,方便粘贴回文档的 `pins` 属性中。
## 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `pins` | string | `""` | 标记列表,格式 `A:x,y B:x,y` |
| `fixed` | boolean | `false` | 固定模式(只读不可编辑) |
| `labelStart` | string | `"A"` | 标签起始值,支持字母或数字 |
## 使用场景 ## 使用场景
+37 -10
View File
@@ -2,17 +2,12 @@
tag: md-table tag: md-table
icon: 📊 icon: 📊
title: 表格组件 title: 表格组件
description: 将 CSV 数据转换为可切换标签页的表格,支持随机抽取和变量引用。
syntax: ':md-table[./data.csv]{roll=true remix=true}'
props:
- name: roll
type: boolean
desc: 显示随机切换按钮
- name: remix
type: boolean
desc: 支持 {{prop}} 引用同行其他列
--- ---
将 CSV 数据转换为可切换标签页的表格,支持内联 CSV、随机抽取、加权随机和变量引用。
**语法:** `:md-table[./data.csv]{选项}`
**基础表格:** **基础表格:**
```markdown ```markdown
:md-table[./npcs.csv] :md-table[./npcs.csv]
@@ -28,7 +23,39 @@ props:
:md-table[./quests.csv]{roll=true remix=true} :md-table[./quests.csv]{roll=true remix=true}
``` ```
CSV 要求包含 label, body 列,可选 group 列分组。支持 YAML front matter 继承行属性。 **内联 CSV(直接写入内容):**
```markdown
:md-table[label,body,group
A,这是一段描述,第一章
B,另一段描述,第二章]{}
```
支持通过文件路径加载 CSV,也可以直接将 CSV 数据内联写在指令中。CSV 要求包含 `label``body` 列,可选 `group` 列分组。
## 分组
当 CSV 包含 `group` 列时,表格顶部会显示分组标签页,可切换查看不同分组的数据。
## 随机抽取
`roll=true` 时,表格顶部显示 🎲 随机按钮:
- 普通随机:从所有行中均匀随机选取
- **加权随机**:当 `label` 列全部为整数或整数范围格式(如 `1-3`)时,以 label 值为权重进行加权随机抽取
## 变量引用
`remix=true` 时,`body` 列中可使用 `{{prop}}` 语法引用同行其他列或 YAML front matter 中的数据。开启 `remix` 后每次随机抽取会从所有行中随机选取一行来解析变量。
## YAML Front Matter
CSV 文件可包含 YAML front matter(文件开头的 `---` 块),其中定义的属性可通过 `{{prop}}``body` 中引用。
## 属性
| 属性 | 类型 | 说明 |
|---|---|---|
| `roll` | boolean | 显示随机切换按钮 |
| `remix` | boolean | 支持 `{{prop}}` 引用同行其他列 |
## 使用场景 ## 使用场景
+17 -3
View File
@@ -2,16 +2,30 @@
tag: md-yarn-spinner tag: md-yarn-spinner
icon: 🧶 icon: 🧶
title: 叙事线组件 title: 叙事线组件
description: 展示 Yarn Spinner 格式的分支叙事结构,支持对话选择和分支。
syntax: ':md-yarn-spinner[./story.yarn]'
props: []
--- ---
展示 Yarn Spinner 格式的分支叙事结构,支持对话选择和分支。
**语法:** `:md-yarn-spinner[./story.yarn]{选项}`
**加载叙事文件:** **加载叙事文件:**
:md-yarn-spinner[./story.yarn] :md-yarn-spinner[./story.yarn]
Yarn Spinner 是用于游戏对话系统的格式,支持选项、条件分支和变量。 Yarn Spinner 是用于游戏对话系统的格式,支持选项、条件分支和变量。
## 交互方式
- **对话历史**:上半部分显示已进行的对话,说话者以蓝色粗体显示,命令以灰色斜体显示
- **当前选项**:下半部分显示可选的对话选项,点击选项推进剧情
- **⏩ 继续**:点击右上角继续按钮推进到下一段对话
- **🔄 重新开始**:点击右上角重启按钮从头开始对话
## 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `start` | string | `"start"` | 起始节点名称 |
## 使用场景 ## 使用场景
用于互动故事、分支对话、冒险剧本等。 用于互动故事、分支对话、冒险剧本等。
+30
View File
@@ -0,0 +1,30 @@
/**
* Fenced code block attribute parsing shared by the content scanner
* (CLI + browser registry) and the markdown render extensions.
*
* Parses info strings like:
* ```lang id=xxx role=xxx as=xxx key=value
*/
export interface BlockAttrs {
lang: string;
id?: string;
role?: string;
/** Any other attributes not in the standard set */
extra: Record<string, string>;
}
/** Parse key="value" and key=value pairs from an attribute string. */
export function parseBlockAttrs(info: string): BlockAttrs {
const attrs: Record<string, string> = {};
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(info)) !== null) {
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
}
// `as` was a render-target override; roles now fully determine behavior,
// so it is parsed out and ignored (kept out of `extra` for directives).
const { lang, id, role, as: _legacyAs, ...extra } = attrs;
return { lang: lang || "", id, role, extra };
}
+82
View File
@@ -0,0 +1,82 @@
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
const ext = markedCodeBlockYamlTag().extensions?.[0] as unknown as {
tokenizer: (src: string) => any;
renderer: (token: any) => string;
};
function render(src: string): string {
const token = ext.tokenizer(src);
return ext.renderer(token);
}
describe("code-block-yaml-tag", () => {
test("renders body and scalar props as attributes", () => {
const html = render(
"```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\nsize: 54x86\n```",
);
expect(html).toContain("<md-deck size=\"54x86\">./cards.csv</md-deck>");
});
test("serializes data-config to a JSON attribute", () => {
const html = render(
[
"```yaml role=tag",
"tag: md-deck",
"body: ./cards.csv",
"data-config:",
" size: 63x88",
" layers:",
" - prop: title",
" pos: 1,1-5,1",
" font: 12",
"```",
].join("\n"),
);
expect(html).toContain("<md-deck data-config=");
expect(html).toContain("./cards.csv</md-deck>");
// data-config must be valid JSON with escaped quotes for the attribute
const m = html.match(/data-config="([^"]*)"/);
expect(m).not.toBeNull();
const decoded = (m![1] || "").replace(/&quot;/g, '"');
const config = JSON.parse(decoded);
expect(config.size).toBe("63x88");
expect(config.layers).toHaveLength(1);
expect(config.layers[0]).toMatchObject({
prop: "title",
pos: "1,1-5,1",
font: 12,
});
});
test("supports tag= and id= on the info string", () => {
const html = render(
"```yaml role=tag tag=md-deck id=my-deck\nbody: ./cards.csv\n```",
);
expect(html).toContain("<md-deck id=\"my-deck\">./cards.csv</md-deck>");
});
test("fence attributes override YAML body values", () => {
const html = render(
"```yaml role=tag tag=md-deck size=54x86\ntag: md-other\nsize: 63x88\n```",
);
expect(html).toContain("<md-deck size=\"54x86\"></md-deck>");
expect(html).not.toContain("md-other");
expect(html).not.toContain("63x88");
});
test("does not swallow plain yaml code blocks", () => {
const token = ext.tokenizer("```yaml\nsize: 54x86\n```");
expect(token).toBeUndefined();
});
test("does not swallow yaml blocks without role=tag", () => {
const token = ext.tokenizer("```yaml tag=md-deck\nsize: 54x86\n```");
expect(token).toBeUndefined();
});
test("handles missing tag and body", () => {
const html = render("```yaml role=tag\nclass: foo\n```");
expect(html).toContain("<tag-unknown class=\"foo\"></tag-unknown>");
});
});
+34 -8
View File
@@ -1,6 +1,20 @@
import type { MarkedExtension } from "marked"; import type { MarkedExtension } from "marked";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { parseBlockAttrs } from "./block-attrs";
/**
* YAML-defined tag blocks:
*
* ```yaml role=tag tag=md-deck id=my-deck
* body: ./cards.csv
* data-config: ...
* ```
*
* `role=tag` on the info string marks the block (and lets the content
* scanner keep it intact). `tag=` / `id=` / any other `key=value` fence
* attributes override the same keys in the YAML body, so simple cases
* stay on one line.
*/
export default function markedCodeBlockYamlTag(): MarkedExtension { export default function markedCodeBlockYamlTag(): MarkedExtension {
return { return {
extensions: [ extensions: [
@@ -8,22 +22,30 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
name: "code-block-yaml-tag", name: "code-block-yaml-tag",
level: "block", level: "block",
start(src: string) { start(src: string) {
return src.match(/^```yaml\/tag\s*\n/m)?.index; return src.match(/^```yaml\s+role=tag(?:\s|\n)/m)?.index;
}, },
tokenizer(src: string) { tokenizer(src: string) {
const rule = /^```yaml\/tag\s*\n([\s\S]*?)\n```/; const rule = /^```yaml\s+role=tag([^\n]*)\n([\s\S]*?)\n```/;
const match = rule.exec(src); const match = rule.exec(src);
if (match) { if (match) {
const yamlContent = match[1]?.trim() || ""; const yamlContent = match[2]?.trim() || "";
let props: Record<string, unknown> = {}; let yamlProps: Record<string, unknown> = {};
try { try {
props = yamlProps =
(yaml.load(yamlContent) as Record<string, unknown>) || {}; (yaml.load(yamlContent) as Record<string, unknown>) || {};
} catch (e) { } catch (e) {
console.error("YAML Parse Error in code-block-yaml-tag:", e); console.error("YAML Parse Error in code-block-yaml-tag:", e);
props = { error: "Invalid YAML content" }; yamlProps = { error: "Invalid YAML content" };
} }
// Fence attributes override YAML body values.
const attrs = parseBlockAttrs(match[1] || "");
const fenceProps: Record<string, unknown> = {
...(attrs.id ? { id: attrs.id } : {}),
...attrs.extra,
};
const props: Record<string, unknown> = { ...yamlProps, ...fenceProps };
const tagName = (props.tag as string) || "tag-unknown"; const tagName = (props.tag as string) || "tag-unknown";
const { tag, ...rest } = props; const { tag, ...rest } = props;
@@ -33,9 +55,13 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
delete (rest as Record<string, unknown>).body; 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) const propsStr = Object.entries(rest)
.map(([key, value]) => { .map(([key, value]) => {
const strValue = String(value); const strValue = key.startsWith("data-")
? JSON.stringify(value)
: String(value);
if (strValue.includes(" ") || strValue.includes('"')) { if (strValue.includes(" ") || strValue.includes('"')) {
return `${key}="${strValue.replace(/"/g, "&quot;")}"`; return `${key}="${strValue.replace(/"/g, "&quot;")}"`;
} }
@@ -59,4 +85,4 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
}, },
], ],
}; };
} }
-2
View File
@@ -2,7 +2,6 @@ import { Marked, type MarkedExtension } from "marked";
import { createDirectives, presetDirectiveConfigs } from "marked-directive"; import { createDirectives, presetDirectiveConfigs } from "marked-directive";
import markedAlert from "marked-alert"; import markedAlert from "marked-alert";
import markedMermaid from "./mermaid"; import markedMermaid from "./mermaid";
import markedTable from "./table";
import { gfmHeadingId } from "marked-gfm-heading-id"; import { gfmHeadingId } from "marked-gfm-heading-id";
import markedColumns from "./columns"; import markedColumns from "./columns";
import markedCodeBlockYamlTag from "./code-block-yaml-tag"; import markedCodeBlockYamlTag from "./code-block-yaml-tag";
@@ -14,7 +13,6 @@ const marked = new Marked()
.use(gfmHeadingId()) .use(gfmHeadingId())
.use(markedAlert()) .use(markedAlert())
.use(markedMermaid()) .use(markedMermaid())
.use(markedTable())
.use(markedCodeBlockYamlTag()) .use(markedCodeBlockYamlTag())
.use( .use(
createDirectives([ createDirectives([
-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`;
},
},
};
}
+16
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*/ /*alert*/
.markdown-alert-title { .markdown-alert-title {
@@ -160,3 +165,14 @@ icon.big .icon-label-stroke {
-webkit-user-select: none; -webkit-user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
/* heading flash highlight */
@keyframes heading-flash {
0% { background-color: #fef08a; }
100% { background-color: transparent; }
}
.heading-flash {
animation: heading-flash 1.5s ease-out;
}