Compare commits
21
Commits
18cad20d2c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b83123b6e | ||
|
|
b7a804f1cf | ||
|
|
256c685f6c | ||
|
|
5061c4d19d | ||
|
|
638e8f6526 | ||
|
|
80e2b5b209 | ||
|
|
5fc2bc5262 | ||
|
|
d75df5280f | ||
|
|
f1ddc8fb8e | ||
|
|
16dfc8a88c | ||
|
|
023d3a0cb9 | ||
|
|
7c4865ba8c | ||
|
|
49260ae4f6 | ||
|
|
750af4a50c | ||
|
|
c03528a293 | ||
|
|
0a68122efd | ||
|
|
e8aa7165cb | ||
|
|
9e081891df | ||
|
|
e3daac3080 | ||
|
|
a69a41875d | ||
|
|
6bcdca3a77 |
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* CJS mock of `github-slugger` (v2 is ESM-only, which jest's CJS runtime
|
||||||
|
* cannot require). Auto-applied to all test files via the root `__mocks__`
|
||||||
|
* directory — no `jest.mock()` call needed.
|
||||||
|
*/
|
||||||
|
class Slugger {
|
||||||
|
constructor() {
|
||||||
|
this.seen = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
slug(value, maintainCase) {
|
||||||
|
let slug = String(value).trim();
|
||||||
|
if (!maintainCase) slug = slug.toLowerCase();
|
||||||
|
slug = slug
|
||||||
|
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
||||||
|
.replace(/\s/g, "-");
|
||||||
|
|
||||||
|
// github-slugger dedups repeated slugs with a -1, -2, ... suffix
|
||||||
|
const count = this.seen.get(slug) || 0;
|
||||||
|
this.seen.set(slug, count + 1);
|
||||||
|
if (count > 0) return `${slug}-${count}`;
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Slugger;
|
||||||
+24
-4
@@ -1,13 +1,12 @@
|
|||||||
# yaml/tag 代码块格式测试
|
# yaml role=tag 代码块格式测试
|
||||||
|
|
||||||
:md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
|
:md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
|
||||||
|
|
||||||
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
|
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
|
||||||
|
|
||||||
## 使用 yaml/tag 语法创建 md-deck
|
## 使用 yaml role=tag 语法创建 md-deck(fence 行内联 tag,简洁写法)
|
||||||
|
|
||||||
```yaml/tag
|
```yaml role=tag tag=md-deck
|
||||||
tag: md-deck
|
|
||||||
body: ./names.csv
|
body: ./names.csv
|
||||||
size: 54x86
|
size: 54x86
|
||||||
grid: 5x8
|
grid: 5x8
|
||||||
@@ -15,3 +14,24 @@ bleed: 1
|
|||||||
padding: 2
|
padding: 2
|
||||||
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
|
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```yaml role=tag
|
||||||
|
tag: md-deck
|
||||||
|
body: ./names.csv
|
||||||
|
data-config:
|
||||||
|
size: 54x86
|
||||||
|
grid: 5x8
|
||||||
|
layers:
|
||||||
|
- prop: name1
|
||||||
|
pos: 1,1-2,2
|
||||||
|
font: 12
|
||||||
|
- prop: num2
|
||||||
|
pos: 4,5-5,6
|
||||||
|
font: 12
|
||||||
|
orientation: s
|
||||||
|
- template: |
|
||||||
|
**{{name1}}** / {{num1}}
|
||||||
|
pos: 1,3-5,4
|
||||||
|
font: 6
|
||||||
|
align: l
|
||||||
|
```
|
||||||
|
|||||||
+55
-16
@@ -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
|
||||||
|
|||||||
@@ -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?$': [
|
||||||
|
|||||||
+36
-110
@@ -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")) {
|
|
||||||
const result = processBlocks(content, relPath, contentIndex);
|
|
||||||
contentIndex[relPath] = result.stripped;
|
|
||||||
// Re-scan to get fresh blocks (simpler than per-file merge)
|
|
||||||
const rescan = scanDirectory(contentDir);
|
|
||||||
collectedBlocks = rescan.blocks;
|
|
||||||
directiveResults = rescan.directiveResults;
|
|
||||||
recomputeCompletions();
|
recomputeCompletions();
|
||||||
} else {
|
console.log(`[新增] ${path}`);
|
||||||
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("/");
|
|
||||||
if (relPath.endsWith(".md")) {
|
|
||||||
const result = processBlocks(content, relPath, contentIndex);
|
|
||||||
contentIndex[relPath] = result.stripped;
|
|
||||||
const rescan = scanDirectory(contentDir);
|
|
||||||
collectedBlocks = rescan.blocks;
|
|
||||||
directiveResults = rescan.directiveResults;
|
|
||||||
recomputeCompletions();
|
recomputeCompletions();
|
||||||
} else {
|
console.log(`[更新] ${path}`);
|
||||||
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];
|
|
||||||
console.log(`[删除] ${relPath}`);
|
|
||||||
if (relPath.endsWith(".md")) {
|
|
||||||
const rescan = scanDirectory(contentDir);
|
|
||||||
collectedBlocks = rescan.blocks;
|
|
||||||
directiveResults = rescan.directiveResults;
|
|
||||||
recomputeCompletions();
|
recomputeCompletions();
|
||||||
}
|
console.log(`[删除] ${path}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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("正在关闭内容服务器...");
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -2,25 +2,21 @@
|
|||||||
* 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
|
||||||
@@ -28,40 +24,3 @@ export interface BlockAttrs {
|
|||||||
|
|
||||||
/** 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";
|
|
||||||
}
|
|
||||||
@@ -16,34 +16,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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
|
||||||
@@ -104,14 +76,10 @@ 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(
|
throw new Error(`${source}: key must start with $, got "${key}"`);
|
||||||
`${source}: key must start with $, got "${key}"`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const thresholdRaw = row.threshold?.trim() ?? "";
|
const thresholdRaw = row.threshold?.trim() ?? "";
|
||||||
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
|
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
|
||||||
@@ -124,9 +92,7 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
|||||||
} 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -154,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 #",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,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 $",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,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 $",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,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", () => {
|
||||||
@@ -233,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" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,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)", () => {
|
||||||
@@ -252,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
|
||||||
@@ -278,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", () => {
|
||||||
@@ -294,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", () => {
|
||||||
@@ -360,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", () => {
|
||||||
@@ -368,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", () => {
|
||||||
@@ -379,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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,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");
|
||||||
@@ -527,14 +575,28 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
{ tag: "#warrior", target: "$mod_str", expression: "5", threshold: 1 },
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_str",
|
||||||
|
expression: "5",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set $class to #warrior:1 — should activate both modifiers
|
// Set $class to #warrior:1 — should activate both modifiers
|
||||||
setBase("$class", "#warrior:1");
|
setBase("$class", "#warrior:1");
|
||||||
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
|
const cascade = computeCascade(
|
||||||
|
"$class",
|
||||||
|
undefined,
|
||||||
|
store({ $class: "#warrior:1" }),
|
||||||
|
);
|
||||||
|
|
||||||
// Should produce combined values for both targets
|
// 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");
|
||||||
@@ -547,7 +609,12 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -572,7 +639,12 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
{ tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 },
|
{ tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -600,19 +672,32 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 2,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Count 1 < threshold 2 — should NOT activate
|
// Count 1 < threshold 2 — should NOT activate
|
||||||
setBase("$class", "#warrior:1");
|
setBase("$class", "#warrior:1");
|
||||||
const cascade1 = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
|
const cascade1 = computeCascade(
|
||||||
|
"$class",
|
||||||
|
undefined,
|
||||||
|
store({ $class: "#warrior:1" }),
|
||||||
|
);
|
||||||
const modHp1 = cascade1.find((r) => r.key === "$mod_hp");
|
const modHp1 = cascade1.find((r) => r.key === "$mod_hp");
|
||||||
expect(modHp1).toBeUndefined();
|
expect(modHp1).toBeUndefined();
|
||||||
|
|
||||||
// Increase to count 2 >= threshold 2 — should activate
|
// Increase to count 2 >= threshold 2 — should activate
|
||||||
setBase("$class", "#warrior:2");
|
setBase("$class", "#warrior:2");
|
||||||
const cascade2 = computeCascade("$class", "#warrior:1", store({ $class: "#warrior:2" }));
|
const cascade2 = computeCascade(
|
||||||
|
"$class",
|
||||||
|
"#warrior:1",
|
||||||
|
store({ $class: "#warrior:2" }),
|
||||||
|
);
|
||||||
const modHp2 = cascade2.find((r) => r.key === "$mod_hp");
|
const modHp2 = cascade2.find((r) => r.key === "$mod_hp");
|
||||||
expect(modHp2?.value).toBe("20");
|
expect(modHp2?.value).toBe("20");
|
||||||
});
|
});
|
||||||
@@ -621,7 +706,12 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 2,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -645,13 +735,22 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
{ tag: "#druid", target: "$mod_mp", expression: "15", threshold: 1 },
|
{ tag: "#druid", target: "$mod_mp", expression: "15", threshold: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
setBase("$class", "#warrior:2;#druid:1");
|
setBase("$class", "#warrior:2;#druid:1");
|
||||||
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:2;#druid:1" }));
|
const cascade = computeCascade(
|
||||||
|
"$class",
|
||||||
|
undefined,
|
||||||
|
store({ $class: "#warrior:2;#druid:1" }),
|
||||||
|
);
|
||||||
|
|
||||||
const modHp = cascade.find((r) => r.key === "$mod_hp");
|
const modHp = cascade.find((r) => r.key === "$mod_hp");
|
||||||
const modMp = cascade.find((r) => r.key === "$mod_mp");
|
const modMp = cascade.find((r) => r.key === "$mod_mp");
|
||||||
@@ -663,9 +762,7 @@ describe("var-reactivity", () => {
|
|||||||
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: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -701,11 +798,14 @@ describe("var-reactivity", () => {
|
|||||||
|
|
||||||
test("handles tagmap transition during re-evaluation", () => {
|
test("handles tagmap transition during re-evaluation", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [
|
declarations: [{ key: "$class", expression: "0" }],
|
||||||
{ key: "$class", expression: "0" },
|
|
||||||
],
|
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
{ tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 },
|
{ tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -744,7 +844,12 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -861,9 +966,35 @@ describe("parseInput", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// directive-scanner
|
// content-registry (spark tables)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("isSparkTableHeader", () => {
|
||||||
|
test("detects plain dice formula", () => {
|
||||||
|
expect(isSparkTableHeader("d6")).toBe(true);
|
||||||
|
expect(isSparkTableHeader("d20")).toBe(true);
|
||||||
|
expect(isSparkTableHeader("d100")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects dice count and modifiers", () => {
|
||||||
|
expect(isSparkTableHeader("2d6")).toBe(true);
|
||||||
|
expect(isSparkTableHeader("3d6+5")).toBe(true);
|
||||||
|
expect(isSparkTableHeader("1d8-2")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-dice headers", () => {
|
||||||
|
expect(isSparkTableHeader("Name")).toBe(false);
|
||||||
|
expect(isSparkTableHeader("d")).toBe(false);
|
||||||
|
expect(isSparkTableHeader("6")).toBe(false);
|
||||||
|
expect(isSparkTableHeader("d6x")).toBe(false);
|
||||||
|
expect(isSparkTableHeader("roll")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trims surrounding whitespace", () => {
|
||||||
|
expect(isSparkTableHeader(" d20 ")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("inspectSparkTableCsv", () => {
|
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
|
||||||
@@ -915,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");
|
||||||
@@ -933,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();
|
||||||
});
|
});
|
||||||
@@ -947,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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -10,18 +10,12 @@ 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 } 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.
|
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
||||||
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
const TAGMAP_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/;
|
|
||||||
|
|
||||||
function isTagMapExpr(expr: string): boolean {
|
|
||||||
const t = expr.trim();
|
|
||||||
return BARE_TAG_PATTERN.test(t) || TAGMAP_PATTERN.test(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTagMap(expr: string): string {
|
function normalizeTagMap(expr: string): string {
|
||||||
const t = expr.trim();
|
const t = expr.trim();
|
||||||
@@ -33,17 +27,16 @@ function normalizeTagMap(expr: string): string {
|
|||||||
// 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
|
||||||
@@ -57,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) */
|
||||||
@@ -94,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 命令" });
|
||||||
@@ -109,8 +109,14 @@ export async function dispatchCommand(
|
|||||||
if (match) {
|
if (match) {
|
||||||
try {
|
try {
|
||||||
const csvPath = match.csvPath ?? "";
|
const csvPath = match.csvPath ?? "";
|
||||||
|
const docPath = match.docPath;
|
||||||
const remix = match.remix ?? false;
|
const remix = match.remix ?? false;
|
||||||
const p = await resolveSparkPayload({ key: arg, csvPath, remix });
|
const p = await resolveSparkPayload({
|
||||||
|
key: arg,
|
||||||
|
csvPath,
|
||||||
|
docPath,
|
||||||
|
remix,
|
||||||
|
});
|
||||||
const result = sendMessage("spark", p);
|
const result = sendMessage("spark", p);
|
||||||
return finish(unwrap(result));
|
return finish(unwrap(result));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -127,11 +133,14 @@ export async function dispatchCommand(
|
|||||||
const ev = evaluateExpression(arg, {
|
const ev = evaluateExpression(arg, {
|
||||||
lookup: (name: string) => getCombined("$" + name, ctx.variables),
|
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 = {
|
const payload = {
|
||||||
notation: arg,
|
notation: arg,
|
||||||
label: arg,
|
label: arg,
|
||||||
result: {
|
result: {
|
||||||
total: ev.value,
|
total: ev.value.value,
|
||||||
detail: "",
|
detail: "",
|
||||||
plainDetail: "",
|
plainDetail: "",
|
||||||
pools: [] as { rolls: number[]; subtotal: number }[],
|
pools: [] as { rolls: number[]; subtotal: number }[],
|
||||||
@@ -192,11 +201,8 @@ function dispatchSet(
|
|||||||
// Rolltag: pick random tag, format as tagmap entry
|
// 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 = normalizeTagMap(p.tags[idx]);
|
newValue = normalizeTagMap(p.tags[idx]);
|
||||||
} else if (p.expr && isTagMapExpr(p.expr)) {
|
|
||||||
// Tagmap value (e.g. "#warrior:1;#druid:2" or bare "#warrior")
|
|
||||||
newValue = normalizeTagMap(p.expr);
|
|
||||||
} else if (p.expr) {
|
} 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;
|
||||||
@@ -204,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: "缺少表达式" };
|
||||||
}
|
}
|
||||||
@@ -226,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
|
||||||
|
|||||||
@@ -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 registry = buildRegistryFromIndex(index);
|
||||||
const declareResult = scanDeclareBlocks(content, filePath);
|
activeRegistry = registry;
|
||||||
declarations.push(...declareResult.variables);
|
|
||||||
tagModifiers.push(...declareResult.tagModifiers);
|
|
||||||
|
|
||||||
// ---- Directive scanning (dice + spark tables) ----
|
// Write the processed (stripped) content back into the file index so
|
||||||
const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
|
// Article/md-embed render the same content as CLI mode (attributed blocks
|
||||||
const directiveResult = scanDirectives(content, filePath, tempIndex, fileDir);
|
// processed by role).
|
||||||
dice.push(...directiveResult.dice);
|
for (const [path, content] of Object.entries(registry.pathIndex)) {
|
||||||
sparkTables.push(...directiveResult.sparkTables);
|
setIndexedData(path, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { dice, links, sparkTables, declarations, tagModifiers };
|
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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
// Inline content ids resolve through the registry (docPath + content id);
|
||||||
|
// real file paths fall back to the file index.
|
||||||
|
const registry = getRegistry();
|
||||||
|
const docStore = raw.docPath ? registry.docContent[raw.docPath] : undefined;
|
||||||
|
const inline = docStore?.[raw.csvPath];
|
||||||
|
if (inline) {
|
||||||
|
csv = inline.body;
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
csv = await getIndexedData(raw.csvPath);
|
csv = await getIndexedData(raw.csvPath);
|
||||||
} catch {
|
} catch {
|
||||||
|
csv = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (csv === null) {
|
||||||
throw new Error(`Failed to load CSV: "${raw.csvPath}"`);
|
throw new Error(`Failed to load CSV: "${raw.csvPath}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,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
|
||||||
@@ -349,7 +349,7 @@ 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 tagmap value — if so, activate matching modifiers
|
// Check if this is a tagmap value — if so, activate matching modifiers
|
||||||
@@ -422,7 +422,13 @@ function applyTagMapActivations(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const value = evalResult.value;
|
// Modifier expressions must evaluate to a number
|
||||||
|
if (evalResult.value.kind !== "number") {
|
||||||
|
throw new Error(
|
||||||
|
`Modifier expression "${mod.expression}" must evaluate to a number`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const value = evalResult.value.value;
|
||||||
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
|
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
|
||||||
|
|
||||||
if (targetIsTagMap) {
|
if (targetIsTagMap) {
|
||||||
@@ -571,7 +577,7 @@ function reevaluateDependents(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawValue = String(result.value);
|
const rawValue = exprValueToString(result.value);
|
||||||
|
|
||||||
// Check for tagmap transition on this declared variable
|
// Check for tagmap transition on this declared variable
|
||||||
const oldCombined = getCombined(key, fallback);
|
const oldCombined = getCombined(key, fallback);
|
||||||
|
|||||||
@@ -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,21 +413,25 @@ 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("#")) {
|
||||||
|
const map = parseTagMapValue(resolved);
|
||||||
|
if (!map) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Type mismatch: ${varName} is a tag ("${resolved}"), not a number`,
|
`Type mismatch: ${varName} is not a valid tagmap ("${resolved}")`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return { value: { kind: "tagmap", value: map }, next: pos + 1 };
|
||||||
|
}
|
||||||
const num = parseFloat(resolved);
|
const num = parseFloat(resolved);
|
||||||
if (isNaN(num)) {
|
if (isNaN(num)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`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[]>();
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,8 +11,28 @@ 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
|
<button
|
||||||
@@ -33,23 +54,50 @@ export function DeckHeader(props: DeckHeaderProps) {
|
|||||||
📥 导出 PDF
|
📥 导出 PDF
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Tab 选择器 */}
|
{/* 复制代码按钮 */}
|
||||||
<div class="flex gap-1 overflow-x-auto flex-1 min-w-0 flex-wrap">
|
|
||||||
<For each={store.state.cards}>
|
|
||||||
{(card, index) => (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => store.actions.setActiveTab(index())}
|
onClick={handleCopy}
|
||||||
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer px-2 py-1 rounded ${
|
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
|
||||||
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>
|
||||||
|
</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>
|
</button>
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
{o.icon.endsWith(".png") ? (
|
||||||
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
|
<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"
|
||||||
@@ -103,24 +88,20 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
|
|||||||
</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"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{shape === 'rectangle' && '矩形'}
|
{shapeOptions.map((s) => (
|
||||||
{shape === 'circle' && '圆形'}
|
<option value={s.value}>{s.label}</option>
|
||||||
{shape === 'triangle' && '三角形'}
|
|
||||||
{shape === 'hexagon' && '六边形'}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -85,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;
|
||||||
@@ -138,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;
|
||||||
@@ -176,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,
|
||||||
@@ -244,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 });
|
||||||
@@ -442,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 文件路径" });
|
||||||
@@ -453,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({
|
||||||
@@ -466,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();
|
||||||
@@ -487,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);
|
||||||
@@ -514,15 +521,63 @@ 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);
|
||||||
|
if (fallback) {
|
||||||
|
fallback(code);
|
||||||
|
} else {
|
||||||
alert("复制失败,请手动复制");
|
alert("复制失败,请手动复制");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setExporting = (exporting: boolean) =>
|
const setExporting = (exporting: boolean) =>
|
||||||
@@ -569,6 +624,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
setPadding,
|
setPadding,
|
||||||
setCornerRadius,
|
setCornerRadius,
|
||||||
setShape,
|
setShape,
|
||||||
|
setIsYamlBlock,
|
||||||
setCards,
|
setCards,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
updateCardData,
|
updateCardData,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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-config(yaml 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>
|
||||||
|
|
||||||
|
{/* 中间:内容区域(错误/加载/卡牌预览/空状态) */}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
<DeckContent store={store} isLoading={store.state.isLoading} />
|
<DeckContent store={store} isLoading={store.state.isLoading} />
|
||||||
|
|
||||||
{/* 右侧:属性/图层编辑面板 */}
|
|
||||||
<Show when={store.state.isEditing && !store.state.fixed}>
|
|
||||||
<div class="flex-1">
|
|
||||||
<LayerEditorPanel store={store} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:属性/图层编辑面板(标签页切换) */}
|
||||||
|
<Show when={store.state.isEditing && !store.state.fixed}>
|
||||||
|
<EditorTabs store={store} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
// 将加载的数据赋值给 rows,CSV 类型已经包含 sourcePath 等属性
|
setRows(parseCSVString(content) as unknown as CSV<TableRow>);
|
||||||
setRows(data as unknown as CSV<TableRow>);
|
if (element) {
|
||||||
|
const meta = parseSparkTableCsv(content);
|
||||||
|
if (meta) {
|
||||||
|
element.setAttribute("data-spark", meta.slug);
|
||||||
|
} else {
|
||||||
|
element.removeAttribute("data-spark");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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 内容
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定扩展名的文件路径
|
* 获取指定扩展名的文件路径
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ title: 卡牌组件
|
|||||||
|
|
||||||
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
|
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
|
||||||
|
|
||||||
**语法:** `:md-deck[./cards.csv]{选项}`
|
**语法:** `:md-deck[./cards.csv]{选项}` 或 yaml 代码块
|
||||||
|
|
||||||
**基础卡牌:**
|
**基础卡牌:**
|
||||||
:md-deck[./spells.csv]{grid="3x3"}
|
:md-deck[./spells.csv]{grid="3x3"}
|
||||||
@@ -16,6 +16,32 @@ title: 卡牌组件
|
|||||||
|
|
||||||
CSV 包含数据字段列,通过图层定义控制各字段的位置、大小和对齐。
|
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` 属性格式为 `字段:起始行,起始列-结束列,字号`,多个图层用空格分隔:
|
`layers` 属性格式为 `字段:起始行,起始列-结束列,字号`,多个图层用空格分隔:
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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(/"/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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, """)}"`;
|
return `${key}="${strValue.replace(/"/g, """)}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Marked, type MarkedExtension } from "marked";
|
|||||||
import { createDirectives, presetDirectiveConfigs } from "marked-directive";
|
import { createDirectives, presetDirectiveConfigs } from "marked-directive";
|
||||||
import markedAlert from "marked-alert";
|
import markedAlert from "marked-alert";
|
||||||
import markedMermaid from "./mermaid";
|
import markedMermaid from "./mermaid";
|
||||||
import markedTable from "./table";
|
|
||||||
import { gfmHeadingId } from "marked-gfm-heading-id";
|
import { gfmHeadingId } from "marked-gfm-heading-id";
|
||||||
import markedColumns from "./columns";
|
import markedColumns from "./columns";
|
||||||
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
|
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
|
||||||
@@ -14,7 +13,6 @@ const marked = new Marked()
|
|||||||
.use(gfmHeadingId())
|
.use(gfmHeadingId())
|
||||||
.use(markedAlert())
|
.use(markedAlert())
|
||||||
.use(markedMermaid())
|
.use(markedMermaid())
|
||||||
.use(markedTable())
|
|
||||||
.use(markedCodeBlockYamlTag())
|
.use(markedCodeBlockYamlTag())
|
||||||
.use(
|
.use(
|
||||||
createDirectives([
|
createDirectives([
|
||||||
|
|||||||
@@ -1,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`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user