Compare commits

..

No commits in common. "c071cad50a4f0786f6337629320c89fcca68a894" and "c38d9bdc4f4da72bbd4338f48d1759b243ba4489" have entirely different histories.

19 changed files with 960 additions and 723 deletions

View File

@ -1,12 +1,18 @@
# TTTK — Tabletop Toolkit # TTRPG Tools
A TTRPG content preview and compilation tool based on `solid.js` and `rsbuild`. Write markdown, preview in browser. 一个基于 `solid.js``rsbuild` 的 TTRPG 工具箱,支持 Markdown 解析、自定义组件和 CLI 工具。
## 快速开始 ## 快速开始
```bash ```bash
# 无需安装,直接使用 # 安装依赖
npx tttk serve ./content npm install
# 全局安装 CLI
npm link
# 预览内容
ttrpg serve ./content
``` ```
## 文档导航 ## 文档导航
@ -14,7 +20,6 @@ npx tttk serve ./content
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 | | [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 | | [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 | | [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 | | [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 |
@ -22,7 +27,7 @@ npx tttk serve ./content
## 功能概览 ## 功能概览
- **CLI 工具**: `tttk serve` 预览模式 和 `tttk compile` 编译模式 - **CLI 工具**: `serve` 预览模式 和 `compile` 编译模式
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表 - **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等 - **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等

View File

@ -1,18 +1,18 @@
# CLI 使用说明 # CLI 使用说明
TTTK 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。 TTRPG Tools 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
## 安装 ## 安装
```bash ```bash
# 无需安装,直接运行 # 克隆仓库后安装依赖
npx tttk serve ./content npm install
# 或全局安装 # 全局链接 CLI 工具
npm install -g tttk npm link
``` ```
安装完成后,可在任意目录使用 `tttk` 命令。 安装完成后,可在任意目录使用 `ttrpg` 命令。
## 命令 ## 命令
@ -23,7 +23,7 @@ CLI 使用子命令组织:
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。 运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
```bash ```bash
tttk serve [dir] -p 3000 ttrpg serve [dir] -p 3000
``` ```
**参数:** **参数:**
@ -50,13 +50,13 @@ tttk serve [dir] -p 3000
```bash ```bash
# 预览当前目录 # 预览当前目录
tttk serve ttrpg serve
# 预览指定目录 # 预览指定目录
tttk serve ./my-ttrpg-content ttrpg serve ./my-ttrpg-content
# 指定端口 # 指定端口
tttk serve ./docs -p 8080 ttrpg serve ./docs -p 8080
``` ```
### compile - 编译模式 ### compile - 编译模式
@ -64,7 +64,7 @@ tttk serve ./docs -p 8080
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。 将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
```bash ```bash
tttk compile [dir] -o ./dist/output ttrpg compile [dir] -o ./dist/output
``` ```
**参数:** **参数:**
@ -90,13 +90,13 @@ tttk compile [dir] -o ./dist/output
```bash ```bash
# 编译当前目录 # 编译当前目录
tttk compile ttrpg compile
# 编译指定目录并输出到指定位置 # 编译指定目录并输出到指定位置
tttk compile ./content -o ./build ttrpg compile ./content -o ./build
# 编译并部署 # 编译并部署
tttk compile ./docs -o ./public && npm run preview ttrpg compile ./docs -o ./public && npm run preview
``` ```
## 输入文件 ## 输入文件
@ -162,7 +162,7 @@ my-ttrpg-content/
```bash ```bash
# 使用其他端口 # 使用其他端口
tttk serve -p 8080 ttrpg serve -p 8080
``` ```
### 编译输出为空 ### 编译输出为空
@ -174,5 +174,5 @@ tttk serve -p 8080
ls -R ./content ls -R ./content
# 重新编译 # 重新编译
tttk compile ./content -o ./dist/output ttrpg compile ./content -o ./dist/output
``` ```

View File

@ -28,7 +28,7 @@ npm test
## 项目结构 ## 项目结构
``` ```
tttk/ ttrpg-tools/
├── src/ ├── src/
│ ├── cli/ # CLI 工具源码 │ ├── cli/ # CLI 工具源码
│ │ ├── commands/ # 命令实现 (serve, compile) │ │ ├── commands/ # 命令实现 (serve, compile)

View File

@ -535,8 +535,7 @@ src/cli/
│ │ └── write-frontmatter.ts │ │ └── write-frontmatter.ts
│ ├── card/ │ ├── card/
│ │ └── card-crud.ts │ │ └── card-crud.ts
│ ├── deck-utils.ts │ ├── ensure-deck-preview.ts
│ ├── preview-deck.ts
│ └── generate-card-deck.ts │ └── generate-card-deck.ts
└── index.ts └── index.ts
``` ```

View File

@ -1,10 +1,11 @@
{ {
"name": "tttk", "name": "ttrpg-tools",
"version": "0.0.1", "version": "0.0.1",
"description": "A tabletop RPG toolkit for previewing and compiling TTRPG campaign content", "description": "A tabletop RPG toolbox based on solid.js and rsbuild",
"type": "module", "type": "module",
"bin": { "bin": {
"tttk": "./dist/cli/index.js" "ttrpg": "./dist/cli/index.js",
"ttt": "./dist/cli/index.js"
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
@ -21,7 +22,6 @@
"cli:dev": "tsc -p tsconfig.cli.json --watch", "cli:dev": "tsc -p tsconfig.cli.json --watch",
"cli:build": "tsc -p tsconfig.cli.json", "cli:build": "tsc -p tsconfig.cli.json",
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts", "ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
"test": "jest", "test": "jest",
"prepare": "npx husky" "prepare": "npx husky"
}, },

View File

@ -1,8 +1,8 @@
import type { CompileCommandHandler } from "../types.js"; import type { CompileCommandHandler } from '../types.js';
export const compileCommand: CompileCommandHandler = async (dir, options) => { export const compileCommand: CompileCommandHandler = async (dir, options) => {
console.log("开始编译..."); console.log(`开始编译...`);
console.log(`目录:${dir}`); console.log(`目录:${dir}`);
console.log(`输出目录:${options.output}`); console.log(`输出目录:${options.output}`);
// TODO: 实现编译逻辑 // TODO: 实现编译逻辑
@ -10,5 +10,5 @@ export const compileCommand: CompileCommandHandler = async (dir, options) => {
// 2. 解析 markdown 并生成路由 // 2. 解析 markdown 并生成路由
// 3. 打包为带 hash 路由的单个 HTML 入口 // 3. 打包为带 hash 路由的单个 HTML 入口
console.log("编译完成!"); console.log('编译完成!');
}; };

View File

@ -22,14 +22,14 @@ interface ContentIndex {
} }
/** /**
* CLI dist * CLI dist
*/ */
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web"); const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
/** /**
* MIME * MIME
*/ */
const MIME_TYPES: Record<string, string> = { const MIME_TYPES: Record<string, string> = {
".html": "text/html", ".html": "text/html",
@ -48,7 +48,7 @@ const MIME_TYPES: Record<string, string> = {
}; };
/** /**
* MIME * MIME
*/ */
function getMimeType(filePath: string): string { function getMimeType(filePath: string): string {
const ext = extname(filePath).toLowerCase(); const ext = extname(filePath).toLowerCase();
@ -56,7 +56,7 @@ function getMimeType(filePath: string): string {
} }
/** /**
* IP IPv4退 localhost * Get the best network IP for display (prefer LAN IPv4, fallback to localhost).
*/ */
function getBestIP(): string { function getBestIP(): string {
const interfaces = networkInterfaces(); const interfaces = networkInterfaces();
@ -85,7 +85,7 @@ function getBestIP(): string {
} }
/** /**
* SVG <title> null * Extract the <title> from an SVG string, or null if absent.
*/ */
function extractSvgTitle(svg: string): string | null { function extractSvgTitle(svg: string): string | null {
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg); const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
@ -93,15 +93,17 @@ function extractSvgTitle(svg: string): string | null {
} }
/** /**
* * Derive a human-readable label from a filename.
* "character-sheet" -> "Character Sheet" * "character-sheet" -> "Character Sheet"
*/ */
function labelFromId(id: string): string { function labelFromId(id: string): string {
return id.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); return id
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
} }
/** /**
* .md * .md
*/ */
export function scanDirectory(dir: string): { export function scanDirectory(dir: string): {
index: ContentIndex; index: ContentIndex;
@ -168,7 +170,7 @@ export function scanDirectory(dir: string): {
} }
/** /**
* *
*/ */
function sendFile(res: ServerResponse, filePath: string) { function sendFile(res: ServerResponse, filePath: string) {
res.writeHead(200, { res.writeHead(200, {
@ -179,7 +181,7 @@ function sendFile(res: ServerResponse, filePath: string) {
} }
/** /**
* 404 * 404
*/ */
function send404(res: ServerResponse) { function send404(res: ServerResponse) {
res.writeHead(404, { "Content-Type": "text/plain" }); res.writeHead(404, { "Content-Type": "text/plain" });
@ -187,7 +189,7 @@ function send404(res: ServerResponse) {
} }
/** /**
* JSON * JSON
*/ */
function sendJson(res: ServerResponse, data: unknown) { function sendJson(res: ServerResponse, data: unknown) {
res.writeHead(200, { res.writeHead(200, {
@ -199,7 +201,7 @@ function sendJson(res: ServerResponse, data: unknown) {
/** /**
* *
* @returns true * @returns true
*/ */
function tryServeStatic( function tryServeStatic(
res: ServerResponse, res: ServerResponse,
@ -222,7 +224,7 @@ function tryServeStatic(
} }
/** /**
* HTTP *
*/ */
function createRequestHandler( function createRequestHandler(
contentDir: string, contentDir: string,
@ -272,7 +274,7 @@ function createRequestHandler(
} }
/** /**
* *
*/ */
export interface ContentServer { export interface ContentServer {
/** /**
@ -298,7 +300,7 @@ export interface ContentServer {
} }
/** /**
* HTTP + WebSocket + *
*/ */
export function createContentServer( export function createContentServer(
contentDir: string, contentDir: string,
@ -322,7 +324,7 @@ export function createContentServer(
statSheets: [], statSheets: [],
}; };
/** 从当前内容索引和已收集的块重新扫描补全数据 */ /** Re-scan completions from current content index and collected blocks */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks); completionsIndex = scanCompletions(contentIndex, collectedBlocks);
console.log( console.log(
@ -331,7 +333,7 @@ export function createContentServer(
} }
// 扫描内容目录生成索引 // 扫描内容目录生成索引
console.log("正在扫描内容目录..."); console.log("扫描内容目录...");
const scanResult = scanDirectory(contentDir); const scanResult = scanDirectory(contentDir);
contentIndex = scanResult.index; contentIndex = scanResult.index;
collectedBlocks = scanResult.blocks; collectedBlocks = scanResult.blocks;
@ -339,7 +341,7 @@ export function createContentServer(
recomputeCompletions(); recomputeCompletions();
// 监听文件变化 // 监听文件变化
console.log("正在监听文件变化..."); console.log("监听文件变化...");
const watcher = watch(contentDir, { const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\./, ignored: /(^|[\/\\])\./,
persistent: true, persistent: true,
@ -429,7 +431,7 @@ export function createContentServer(
// ---- Journal / MQTT broker ---- // ---- Journal / MQTT broker ----
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null; let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
// 在 journal 之前创建 HTTP 服务器,以便传入给 journal // 创建 HTTP 服务器 BEFORE journal so we can pass it in
const handleRequest = createRequestHandler( const handleRequest = createRequestHandler(
contentDir, contentDir,
distPath, distPath,
@ -452,7 +454,7 @@ export function createContentServer(
} }
console.log(`内容目录:${contentDir}`); console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`); console.log(`静态资源目录:${distPath}`);
console.log(`日志连接ws://${bestIP}:${port}\n`); console.log(`Journal 连接ws://${bestIP}:${port}\n`);
}); });
return { return {
@ -461,7 +463,7 @@ export function createContentServer(
index: contentIndex, index: contentIndex,
completions: completionsIndex, completions: completionsIndex,
close() { close() {
console.log("正在关闭内容服务器..."); console.log("关闭内容服务器...");
server.close(); server.close();
watcher.close(); watcher.close();
if (journal) journal.close(); if (journal) journal.close();
@ -474,12 +476,6 @@ export function createContentServer(
*/ */
export const serveCommand: ServeCommandHandler = async (dir, options) => { export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir); const contentDir = resolve(dir);
if (!existsSync(contentDir)) {
console.error(`错误:目录不存在:${contentDir}`);
process.exit(1);
}
const port = parseInt(options.port, 10); const port = parseInt(options.port, 10);
const host = options.host || "0.0.0.0"; const host = options.host || "0.0.0.0";

View File

@ -8,42 +8,25 @@ import type { ServeOptions, CompileOptions } from "./types.js";
const program = new Command(); const program = new Command();
program program
.name("tttk") .name("ttrpg")
.description( .description("TTRPG 工具箱 - 用于编译和预览 TTRPG 文档")
"TTRPG 内容预览服务器。不带参数运行时默认启动当前目录的预览服务。",
)
.version("0.0.1"); .version("0.0.1");
program program
.command("serve") .command("serve")
.description("启动 web 服务器预览目录中的内容,并实时监听文件更新") .description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
.argument("[dir]", "要预览的目录", ".") .argument("[dir]", "要预览的目录", ".")
.option("-p, --port <port>", "HTTP 端口号", "3000") .option("-p, --port <port>", "HTTP 端口号", "3000")
.option("--host <host>", "主机地址", "0.0.0.0") .option("-h, --host <host>", "主机地址", "0.0.0.0")
.action(serveCommand); .action(serveCommand);
program program
.command("compile") .command("compile")
.description("[实验性] 将目录内容编译为带 hash 路由的单页 Web 应用 (开发中)") .description("将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用")
.argument("[dir]", "要编译的目录", ".") .argument("[dir]", "要编译的目录", ".")
.option("-o, --output <dir>", "输出目录", "./dist/output") .option("-o, --output <dir>", "输出目录", "./dist/output")
.action(compileCommand); .action(compileCommand);
program.addCommand(mcpCommand); program.addCommand(mcpCommand);
// 无子命令时默认执行 serve即 `npx tttk` 等同于 `npx tttk serve .` program.parse();
const args = process.argv.slice(2);
if (args.length > 0) {
const first = args[0];
const isKnownCommand =
!first.startsWith("-") &&
program.commands.some(
(c) => c.name() === first || c.aliases().includes(first),
);
if (!isKnownCommand) {
args.unshift("serve");
}
} else {
args.unshift("serve");
}
program.parse([process.argv[0], process.argv[1], ...args]);

View File

@ -1,14 +1,14 @@
/** /**
* MQTT + JSONL + * Journal stream persistence MQTT broker + JSONL append + session manifest
* *
* HTTP aedes MQTT WebSocket * Runs an aedes MQTT broker over WebSocket, attached to the existing
* 使 ws v8 createWebSocketStream * HTTP server. Uses ws v8's built-in createWebSocketStream.
* *
* aedes * Pattern from aedes docs:
* https://github.com/moscajs/aedes#mqtt-server-over-websocket * https://github.com/moscajs/aedes#mqtt-server-over-websocket
* *
* 使 aedes subscribe/publish API * Persistence uses aedes's internal subscribe/publish API, so the
* MQTT * server process doesn't need to connect to itself as an MQTT client.
*/ */
import { join, resolve } from "path"; import { join, resolve } from "path";
@ -26,7 +26,7 @@ import type { AedesPublishPacket } from "aedes";
import { WebSocketServer, createWebSocketStream } from "ws"; import { WebSocketServer, createWebSocketStream } from "ws";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 类型定义 // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface SessionMeta { interface SessionMeta {
@ -46,7 +46,7 @@ export interface JournalServer {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 路径工具函数 // Path helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function dataDir(contentDir: string): string { function dataDir(contentDir: string): string {
@ -88,7 +88,7 @@ function appendStream(dataRoot: string, id: string, line: string): void {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 服务器工厂 // Server factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const $SESSIONS = "ttrpg/$SESSIONS"; const $SESSIONS = "ttrpg/$SESSIONS";
@ -98,15 +98,15 @@ export async function createJournalServer(
httpServer: HttpServer, httpServer: HttpServer,
): Promise<JournalServer> { ): Promise<JournalServer> {
const root = dataDir(contentDir); const root = dataDir(contentDir);
console.log(`[日志] 数据目录:${root}`); console.log(`[journal] data dir: ${root}`);
// ---- MQTT 代理 ---- // ---- MQTT Broker ----
const { Aedes: AedesFactory } = await import("aedes"); const { Aedes: AedesFactory } = await import("aedes");
// aedes 需要先 listen() 初始化持久化,再处理连接 // aedes requires listen() to initialize persistence before handling connections.
const broker = await AedesFactory.createBroker(); const broker = await AedesFactory.createBroker();
// ---- WebSocket 服务器(挂载到现有 HTTP 服务器上) ---- // ---- WebSocket server (attached to the existing HTTP server) ----
// 参考 aedes 文档:给 WSS 传入 { server },使用 'connection' 事件 // Pattern from aedes docs: pass { server } to WSS, use 'connection' event
const wss = new WebSocketServer({ server: httpServer }); const wss = new WebSocketServer({ server: httpServer });
wss.on("connection", (ws, req) => { wss.on("connection", (ws, req) => {
@ -115,13 +115,13 @@ export async function createJournalServer(
}); });
broker.on("client", (client: import("aedes").Client) => { broker.on("client", (client: import("aedes").Client) => {
console.log(`[日志] 客户端已连接:${client.id}`); console.log(`[journal] client ready: ${client.id}`);
}); });
broker.on("clientDisconnect", (client: import("aedes").Client) => { broker.on("clientDisconnect", (client: import("aedes").Client) => {
console.log(`[日志] 客户端已断开:${client.id}`); console.log(`[journal] client disconnected: ${client.id}`);
}); });
// ---- 持久化(内部订阅,无需独立 MQTT 客户端) ---- // ---- Persistence (internal subscribe, no separate MQTT client) ----
broker.subscribe( broker.subscribe(
"ttrpg/+/stream", "ttrpg/+/stream",
(packet, cb) => { (packet, cb) => {
@ -130,7 +130,7 @@ export async function createJournalServer(
try { try {
appendStream(root, sessionId, packet.payload.toString()); appendStream(root, sessionId, packet.payload.toString());
} catch (e) { } catch (e) {
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e); console.error(`[journal] stream append err for ${sessionId}:`, e);
} }
} }
cb(); cb();
@ -150,7 +150,7 @@ export async function createJournalServer(
() => {}, () => {},
); );
// 启动时发布初始清单,让客户端能看到已有会话 // Publish the initial manifest on startup so clients see existing sessions
const initialManifest = loadManifest(root); const initialManifest = loadManifest(root);
broker.publish( broker.publish(
{ {
@ -164,14 +164,14 @@ export async function createJournalServer(
() => {}, () => {},
); );
console.log("[日志] 持久化监听已启动"); console.log("[journal] persistence listener active");
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>"); console.log("[journal] broker available at ws://<host>:<port>");
return { return {
broker, broker,
wsServer: wss, wsServer: wss,
async close() { async close() {
console.log("[日志] 正在关闭..."); console.log("[journal] shutting down...");
await new Promise<void>((r) => wss.close(() => r())); await new Promise<void>((r) => wss.close(() => r()));
await new Promise<void>((r) => broker.close(() => r())); await new Promise<void>((r) => broker.close(() => r()));
}, },
@ -179,7 +179,7 @@ export async function createJournalServer(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 工具函数 // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function extractSessionId(topic: string): string | null { function extractSessionId(topic: string): string | null {
@ -188,7 +188,7 @@ function extractSessionId(topic: string): string | null {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 会话生命周期管理 // Session lifecycle
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function handleMetaChange( function handleMetaChange(
@ -212,7 +212,7 @@ function handleMetaChange(
} catch { } catch {
/* */ /* */
} }
console.log(`[日志] 会话已删除:${sessionId}`); console.log(`[journal] session deleted: ${sessionId}`);
} }
} else { } else {
manifest.sessions[sessionId] = { manifest.sessions[sessionId] = {
@ -220,7 +220,7 @@ function handleMetaChange(
created: meta.created || Date.now(), created: meta.created || Date.now(),
players: meta.players || [], players: meta.players || [],
}; };
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`); console.log(`[journal] session updated: ${sessionId} (${meta.name})`);
} }
saveManifest(root, manifest); saveManifest(root, manifest);
@ -236,6 +236,6 @@ function handleMetaChange(
() => {}, () => {},
); );
} catch (e) { } catch (e) {
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e); console.error(`[journal] meta parse err for ${sessionId}:`, e);
} }
} }

View File

@ -1,11 +1,8 @@
import { readFileSync, writeFileSync, existsSync } from "fs"; import { readFileSync, writeFileSync, existsSync } from 'fs';
import { parse } from "csv-parse/browser/esm/sync"; import { parse } from 'csv-parse/browser/esm/sync';
import { stringify } from "csv-stringify/browser/esm/sync"; import { stringify } from 'csv-stringify/browser/esm/sync';
import type { DeckFrontmatter } from "../frontmatter/read-frontmatter.js"; import yaml from 'js-yaml';
import { import type { DeckFrontmatter } from '../frontmatter/read-frontmatter.js';
parseFrontMatter,
serializeFrontMatter,
} from "../frontmatter/shared.js";
/** /**
* *
@ -26,7 +23,7 @@ export interface CardCrudParams {
/** /**
* *
*/ */
action: "create" | "read" | "update" | "delete"; action: 'create' | 'read' | 'update' | 'delete';
/** /**
* *
*/ */
@ -47,6 +44,41 @@ export interface CardCrudResult {
count?: number; count?: number;
} }
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* frontmatter YAML
*/
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
const yamlStr = yaml.dump(frontmatter, {
indent: 2,
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false
});
return `---\n${yamlStr}---\n`;
}
/** /**
* CSV frontmatter * CSV frontmatter
*/ */
@ -55,19 +87,19 @@ function loadCSVWithFrontmatter(filePath: string): {
records: CardData[]; records: CardData[];
headers: string[]; headers: string[];
} { } {
const content = readFileSync(filePath, "utf-8"); const content = readFileSync(filePath, 'utf-8');
const { frontmatter, csvContent } = parseFrontMatter(content); const { frontmatter, csvContent } = parseFrontMatter(content);
const records = parse(csvContent, { const records = parse(csvContent, {
columns: true, columns: true,
comment: "#", comment: '#',
trim: true, trim: true,
skipEmptyLines: true, skipEmptyLines: true
}) as CardData[]; }) as CardData[];
// 获取表头 // 获取表头
const firstLine = csvContent.split("\n")[0]; const firstLine = csvContent.split('\n')[0];
const headers = firstLine.split(",").map((h) => h.trim()); const headers = firstLine.split(',').map(h => h.trim());
return { frontmatter, records, headers }; return { frontmatter, records, headers };
} }
@ -79,30 +111,30 @@ function saveCSVWithFrontmatter(
filePath: string, filePath: string,
frontmatter: DeckFrontmatter | undefined, frontmatter: DeckFrontmatter | undefined,
records: CardData[], records: CardData[],
headers?: string[], headers?: string[]
): void { ): void {
// 序列化 frontmatter // 序列化 frontmatter
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : ""; const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : '';
// 确定表头 // 确定表头
if (!headers || headers.length === 0) { if (!headers || headers.length === 0) {
// 从 records 和 frontmatter.fields 推断表头 // 从 records 和 frontmatter.fields 推断表头
headers = ["label"]; headers = ['label'];
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) { if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
for (const field of frontmatter.fields) { for (const field of frontmatter.fields) {
if (field.name && typeof field.name === "string") { if (field.name && typeof field.name === 'string') {
headers.push(field.name); headers.push(field.name);
} }
} }
} }
headers.push("body"); headers.push('body');
} }
// 确保所有 record 都有 headers 中的列 // 确保所有 record 都有 headers 中的列
for (const record of records) { for (const record of records) {
for (const header of headers) { for (const header of headers) {
if (!(header in record)) { if (!(header in record)) {
record[header] = ""; record[header] = '';
} }
} }
} }
@ -110,11 +142,11 @@ function saveCSVWithFrontmatter(
// 序列化 CSV // 序列化 CSV
const csvContent = stringify(records, { const csvContent = stringify(records, {
header: true, header: true,
columns: headers, columns: headers
}); });
// 写入文件 // 写入文件
writeFileSync(filePath, frontmatterStr + csvContent, "utf-8"); writeFileSync(filePath, frontmatterStr + csvContent, 'utf-8');
} }
/** /**
@ -135,10 +167,10 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
const { csv_file, action, cards, label } = params; const { csv_file, action, cards, label } = params;
// 检查文件是否存在create 操作可以不存在) // 检查文件是否存在create 操作可以不存在)
if (action !== "create" && !existsSync(csv_file)) { if (action !== 'create' && !existsSync(csv_file)) {
return { return {
success: false, success: false,
message: `文件不存在:${csv_file}`, message: `文件不存在:${csv_file}`
}; };
} }
@ -157,8 +189,8 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
// 执行操作 // 执行操作
switch (action) { switch (action) {
case "create": { case 'create': {
const newCards = Array.isArray(cards) ? cards : cards ? [cards] : []; const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
for (const card of newCards) { for (const card of newCards) {
if (!card.label) { if (!card.label) {
card.label = generateNextLabel(records); card.label = generateNextLabel(records);
@ -170,22 +202,16 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true, success: true,
message: `成功创建 ${newCards.length} 张卡牌`, message: `成功创建 ${newCards.length} 张卡牌`,
cards: newCards, cards: newCards,
count: newCards.length, count: newCards.length
}; };
} }
case "read": { case 'read': {
const labelsToRead = Array.isArray(label) const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null);
? label
: label
? [label]
: null;
let resultCards: CardData[]; let resultCards: CardData[];
if (labelsToRead && labelsToRead.length > 0) { if (labelsToRead && labelsToRead.length > 0) {
resultCards = records.filter((r) => resultCards = records.filter(r => labelsToRead.includes(r.label || ''));
labelsToRead.includes(r.label || ""),
);
} else { } else {
resultCards = records; resultCards = records;
} }
@ -194,26 +220,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true, success: true,
message: `成功读取 ${resultCards.length} 张卡牌`, message: `成功读取 ${resultCards.length} 张卡牌`,
cards: resultCards, cards: resultCards,
count: resultCards.length, count: resultCards.length
}; };
} }
case "update": { case 'update': {
const labelsToUpdate = Array.isArray(label) const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null);
? label const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
: label
? [label]
: null;
const updateCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
let updatedCount = 0; let updatedCount = 0;
if (labelsToUpdate && labelsToUpdate.length > 0) { if (labelsToUpdate && labelsToUpdate.length > 0) {
// 按 label 更新 // 按 label 更新
for (const updateCard of updateCards) { for (const updateCard of updateCards) {
const targetLabel = const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length];
updateCard.label || const index = records.findIndex(r => r.label === targetLabel);
labelsToUpdate[updatedCount % labelsToUpdate.length];
const index = records.findIndex((r) => r.label === targetLabel);
if (index !== -1) { if (index !== -1) {
records[index] = { ...records[index], ...updateCard }; records[index] = { ...records[index], ...updateCard };
updatedCount++; updatedCount++;
@ -223,9 +243,7 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
// 按 cards 中的 label 更新 // 按 cards 中的 label 更新
for (const updateCard of updateCards) { for (const updateCard of updateCards) {
if (updateCard.label) { if (updateCard.label) {
const index = records.findIndex( const index = records.findIndex(r => r.label === updateCard.label);
(r) => r.label === updateCard.label,
);
if (index !== -1) { if (index !== -1) {
records[index] = { ...records[index], ...updateCard }; records[index] = { ...records[index], ...updateCard };
updatedCount++; updatedCount++;
@ -239,29 +257,23 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true, success: true,
message: `成功更新 ${updatedCount} 张卡牌`, message: `成功更新 ${updatedCount} 张卡牌`,
cards: updateCards, cards: updateCards,
count: updatedCount, count: updatedCount
}; };
} }
case "delete": { case 'delete': {
const labelsToDelete = Array.isArray(label) const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null);
? label
: label
? [label]
: null;
let deletedCount = 0; let deletedCount = 0;
if (labelsToDelete && labelsToDelete.length > 0) { if (labelsToDelete && labelsToDelete.length > 0) {
const beforeCount = records.length; const beforeCount = records.length;
records = records.filter( records = records.filter(r => !labelsToDelete.includes(r.label || ''));
(r) => !labelsToDelete.includes(r.label || ""),
);
deletedCount = beforeCount - records.length; deletedCount = beforeCount - records.length;
} else if (cards) { } else if (cards) {
const cardsToDelete = Array.isArray(cards) ? cards : [cards]; const cardsToDelete = Array.isArray(cards) ? cards : [cards];
const beforeCount = records.length; const beforeCount = records.length;
records = records.filter( records = records.filter(r =>
(r) => !cardsToDelete.some((c) => c.label && r.label === c.label), !cardsToDelete.some(c => c.label && r.label === c.label)
); );
deletedCount = beforeCount - records.length; deletedCount = beforeCount - records.length;
} }
@ -270,20 +282,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
return { return {
success: true, success: true,
message: `成功删除 ${deletedCount} 张卡牌`, message: `成功删除 ${deletedCount} 张卡牌`,
count: deletedCount, count: deletedCount
}; };
} }
default: default:
return { return {
success: false, success: false,
message: `未知操作:${action}`, message: `未知操作:${action}`
}; };
} }
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `操作失败:${error instanceof Error ? error.message : "未知错误"}`, message: `操作失败:${error instanceof Error ? error.message : '未知错误'}`
}; };
} }
} }

View File

@ -1,134 +0,0 @@
/**
* Shared deck utility functions used by generate-card-deck and preview-deck.
*/
import { dirname, join, basename, extname, relative } from "path";
import type {
DeckConfig,
DeckFrontmatter,
} from "./frontmatter/read-frontmatter.js";
import { parseFrontMatter } from "./frontmatter/shared.js";
import { readFileSync } from "fs";
/**
* Build a `:md-deck[path]{attrs}` directive string from a CSV path and config.
*/
export function buildDeckComponent(
csvPath: string,
config?: DeckConfig,
): string {
const parts = [`:md-deck[${csvPath}]`];
const attrs: string[] = [];
if (config?.size) {
attrs.push(`size="${config.size}"`);
}
if (config?.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config?.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config?.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config?.shape && config.shape !== "rectangle") {
attrs.push(`shape="${config.shape}"`);
}
if (config?.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config?.back_layers) {
attrs.push(`back-layers="${config.back_layers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(" ")}}`);
}
return parts.join("");
}
/**
* Derive a human-readable title from a CSV filename.
* e.g. "my-deck.csv" "My Deck"
*/
export function titleFromCsvPath(csvPath: string): string {
const name = basename(csvPath, extname(csvPath));
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Compute the relative path from a markdown file to its CSV.
* Handles the case where they are in the same or different directories.
*/
export function relativeCsvPath(
mdFilePath: string,
csvFilePath: string,
): string {
const mdDir = dirname(mdFilePath);
const rel = relative(mdDir, csvFilePath);
return rel.startsWith(".") ? rel : `./${rel}`;
}
/**
* Determine the default markdown file path for a CSV.
* e.g. "foo/bar.csv" "foo/bar.md"
*/
export function defaultMdPath(csvFilePath: string): string {
const dir = dirname(csvFilePath);
const name = basename(csvFilePath, extname(csvFilePath));
return join(dir, `${name}.md`);
}
/**
* Read DeckConfig from a CSV file's frontmatter, if present.
*/
export function readDeckConfig(csvFilePath: string): DeckConfig | undefined {
try {
const content = readFileSync(csvFilePath, "utf-8");
const { frontmatter } = parseFrontMatter(content);
return frontmatter?.deck;
} catch {
return undefined;
}
}
/**
* Generate the standard markdown content for a deck preview file.
*/
export function generateDeckPreviewMd(
deckTitle: string,
deckComponent: string,
description?: string,
): string {
const lines: string[] = [];
lines.push(`# ${deckTitle}`);
lines.push("");
if (description) {
lines.push(description);
lines.push("");
}
lines.push("## 卡牌预览");
lines.push("");
lines.push(deckComponent);
lines.push("");
lines.push("## 使用说明");
lines.push("");
lines.push("- 点击卡牌可以查看详情");
lines.push("- 使用右上角的按钮可以随机抽取卡牌");
lines.push("- 可以通过编辑面板调整卡牌样式和布局");
lines.push("");
return lines.join("\n");
}

View File

@ -0,0 +1,217 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { dirname, join, basename, extname } from 'path';
import yaml from 'js-yaml';
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
/**
* Deck
*/
export interface EnsureDeckPreviewParams {
/**
* CSV
*/
csv_file: string;
/**
* Markdown CSV
*/
md_file?: string;
/**
* CSV
*/
title?: string;
/**
*
*/
description?: string;
}
/**
* Deck
*/
export interface EnsureDeckPreviewResult {
success: boolean;
message: string;
md_file?: string;
created?: boolean;
}
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* md-deck
*/
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
const parts = [`:md-deck[${csvPath}]`];
const attrs: string[] = [];
if (config?.size) {
attrs.push(`size="${config.size}"`);
}
if (config?.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config?.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config?.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config?.shape && config.shape !== 'rectangle') {
attrs.push(`shape="${config.shape}"`);
}
if (config?.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config?.back_layers) {
attrs.push(`back-layers="${config.back_layers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(' ')}}`);
}
return parts.join('');
}
/**
* Markdown
*/
export function ensureDeckPreview(params: EnsureDeckPreviewParams): EnsureDeckPreviewResult {
const { csv_file, md_file, title, description } = params;
// 检查 CSV 文件是否存在
if (!existsSync(csv_file)) {
return {
success: false,
message: `CSV 文件不存在:${csv_file}`
};
}
// 确定 Markdown 文件路径
let mdFilePath = md_file;
if (!mdFilePath) {
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
const dir = dirname(csv_file);
const name = basename(csv_file, extname(csv_file));
mdFilePath = join(dir, `${name}.md`);
}
const created = !existsSync(mdFilePath);
// 如果文件已存在,检查是否已有 md-deck 组件
if (!created) {
try {
const existingContent = readFileSync(mdFilePath, 'utf-8');
// 如果已有 md-deck 组件引用该 CSV直接返回
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
return {
success: true,
message: `预览文件 ${mdFilePath} 已存在`,
md_file: mdFilePath,
created: false
};
}
} catch (error) {
// 读取失败,继续创建
}
}
// 读取 CSV 的 frontmatter 获取配置
let deckConfig: DeckConfig | undefined;
try {
const csvContent = readFileSync(csv_file, 'utf-8');
const { frontmatter } = parseFrontMatter(csvContent);
deckConfig = frontmatter?.deck;
} catch (error) {
// 忽略错误,使用默认配置
}
// 确定标题
let deckTitle = title;
if (!deckTitle) {
// 从 CSV 文件名推断
const name = basename(csv_file, extname(csv_file));
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
// 构建 md-deck 组件代码(使用相对路径)
const mdDir = dirname(mdFilePath);
const csvDir = dirname(csv_file);
let relativeCsvPath: string;
if (mdDir === csvDir) {
relativeCsvPath = `./${basename(csv_file)}`;
} else {
relativeCsvPath = `./${basename(csv_file)}`;
}
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
// 生成 Markdown 内容
const mdLines: string[] = [];
mdLines.push(`# ${deckTitle}`);
mdLines.push('');
if (description) {
mdLines.push(description);
mdLines.push('');
}
mdLines.push('## 卡牌预览');
mdLines.push('');
mdLines.push(deckComponent);
mdLines.push('');
mdLines.push('## 使用说明');
mdLines.push('');
mdLines.push('- 点击卡牌可以查看详情');
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
mdLines.push('');
// 写入文件
try {
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
return {
success: true,
message: created
? `创建预览文件 ${mdFilePath}`
: `更新预览文件 ${mdFilePath}`,
md_file: mdFilePath,
created
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
};
}
}

View File

@ -1,5 +1,5 @@
import { readFileSync, existsSync } from "fs"; import { readFileSync, existsSync } from 'fs';
import { parseFrontMatter } from "./shared.js"; import yaml from 'js-yaml';
/** /**
* CSV frontmatter * CSV frontmatter
@ -52,7 +52,7 @@ export interface CardFieldStyle {
/** /**
* "n" | "w" | "s" | "e"/西// * "n" | "w" | "s" | "e"/西//
*/ */
up?: "n" | "w" | "s" | "e"; up?: 'n' | 'w' | 's' | 'e';
} }
/** /**
@ -74,7 +74,7 @@ export interface DeckConfig {
grid?: string; grid?: string;
bleed?: number; bleed?: number;
padding?: number; padding?: number;
shape?: "rectangle" | "circle" | "hex" | "diamond"; shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
layers?: string; layers?: string;
back_layers?: string; back_layers?: string;
[key: string]: unknown; [key: string]: unknown;
@ -89,25 +89,45 @@ export interface ReadFrontmatterResult {
message: string; message: string;
} }
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
// 至少需要三个部分空字符串、front matter、CSV 内容
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/** /**
* CSV frontmatter * CSV frontmatter
*/ */
export function readFrontmatter( export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult {
params: ReadFrontmatterParams,
): ReadFrontmatterResult {
const { csv_file } = params; const { csv_file } = params;
// 检查文件是否存在 // 检查文件是否存在
if (!existsSync(csv_file)) { if (!existsSync(csv_file)) {
return { return {
success: false, success: false,
message: `文件不存在:${csv_file}`, message: `文件不存在:${csv_file}`
}; };
} }
try { try {
// 读取文件内容 // 读取文件内容
const content = readFileSync(csv_file, "utf-8"); const content = readFileSync(csv_file, 'utf-8');
// 解析 frontmatter // 解析 frontmatter
const { frontmatter } = parseFrontMatter(content); const { frontmatter } = parseFrontMatter(content);
@ -116,19 +136,19 @@ export function readFrontmatter(
return { return {
success: true, success: true,
frontmatter: {}, frontmatter: {},
message: `文件 ${csv_file} 没有 frontmatter返回空对象`, message: `文件 ${csv_file} 没有 frontmatter返回空对象`
}; };
} }
return { return {
success: true, success: true,
frontmatter, frontmatter,
message: `成功读取 ${csv_file} 的 frontmatter`, message: `成功读取 ${csv_file} 的 frontmatter`
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `读取失败:${error instanceof Error ? error.message : "未知错误"}`, message: `读取失败:${error instanceof Error ? error.message : '未知错误'}`
}; };
} }
} }

View File

@ -1,48 +0,0 @@
/**
* Shared frontmatter utilities used by read-frontmatter, write-frontmatter,
* card-crud, preview-deck, and generate-card-deck.
*/
import yaml from "js-yaml";
import type { DeckFrontmatter } from "./read-frontmatter.js";
/**
* Parse YAML frontmatter from a CSV file's raw content.
* Expects `---\n...\n---\n` delimited frontmatter at the top of the file.
*/
export function parseFrontMatter(content: string): {
frontmatter?: DeckFrontmatter;
csvContent: string;
} {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== "") {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as
| DeckFrontmatter
| undefined;
const csvContent = parts.slice(2).join("---\n").trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn("Failed to parse front matter:", error);
return { csvContent: content };
}
}
/**
* Serialize a DeckFrontmatter object to YAML frontmatter string.
*/
export function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
const yamlStr = yaml.dump(frontmatter, {
indent: 2,
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false,
});
return `---\n${yamlStr}---\n`;
}

View File

@ -1,6 +1,6 @@
import { readFileSync, writeFileSync, existsSync } from "fs"; import { readFileSync, writeFileSync, existsSync } from 'fs';
import type { DeckFrontmatter } from "./read-frontmatter.js"; import yaml from 'js-yaml';
import { parseFrontMatter, serializeFrontMatter } from "./shared.js"; import type { DeckFrontmatter } from './read-frontmatter.js';
/** /**
* CSV frontmatter * CSV frontmatter
@ -29,37 +29,70 @@ export interface WriteFrontmatterResult {
csv_file?: string; csv_file?: string;
} }
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
// 至少需要三个部分空字符串、front matter、CSV 内容
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* frontmatter YAML
*/
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
const yamlStr = yaml.dump(frontmatter, {
indent: 2,
lineWidth: -1, // 不限制行宽
noRefs: true, // 不使用引用
quotingType: '"',
forceQuotes: false
});
return `---\n${yamlStr}---\n`;
}
/** /**
* / CSV frontmatter * / CSV frontmatter
*/ */
export function writeFrontmatter( export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult {
params: WriteFrontmatterParams,
): WriteFrontmatterResult {
const { csv_file, frontmatter, merge = true } = params; const { csv_file, frontmatter, merge = true } = params;
let csvContent = ""; let csvContent = '';
let existingFrontmatter: DeckFrontmatter | undefined; let existingFrontmatter: DeckFrontmatter | undefined;
// 如果文件存在且需要合并,先读取现有内容 // 如果文件存在且需要合并,先读取现有内容
if (merge && existsSync(csv_file)) { if (merge && existsSync(csv_file)) {
try { try {
const content = readFileSync(csv_file, "utf-8"); const content = readFileSync(csv_file, 'utf-8');
const result = parseFrontMatter(content); const result = parseFrontMatter(content);
existingFrontmatter = result.frontmatter; existingFrontmatter = result.frontmatter;
csvContent = result.csvContent; csvContent = result.csvContent;
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `读取现有文件失败:${error instanceof Error ? error.message : "未知错误"}`, message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}`
}; };
} }
} }
// 合并或替换 frontmatter // 合并或替换 frontmatter
const finalFrontmatter = const finalFrontmatter = merge && existingFrontmatter
merge && existingFrontmatter ? { ...existingFrontmatter, ...frontmatter }
? { ...existingFrontmatter, ...frontmatter } : frontmatter;
: frontmatter;
// 序列化 frontmatter // 序列化 frontmatter
const frontmatterStr = serializeFrontMatter(finalFrontmatter); const frontmatterStr = serializeFrontMatter(finalFrontmatter);
@ -67,32 +100,32 @@ export function writeFrontmatter(
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容 // 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
if (!csvContent.trim()) { if (!csvContent.trim()) {
// 从 frontmatter 推断 CSV 表头 // 从 frontmatter 推断 CSV 表头
const headers = ["label"]; const headers = ['label'];
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) { if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
for (const field of finalFrontmatter.fields) { for (const field of finalFrontmatter.fields) {
if (field.name && typeof field.name === "string") { if (field.name && typeof field.name === 'string') {
headers.push(field.name); headers.push(field.name);
} }
} }
} }
headers.push("body"); headers.push('body');
csvContent = headers.join(",") + "\n"; csvContent = headers.join(',') + '\n';
} }
// 写入文件 // 写入文件
try { try {
const fullContent = frontmatterStr + csvContent; const fullContent = frontmatterStr + csvContent;
writeFileSync(csv_file, fullContent, "utf-8"); writeFileSync(csv_file, fullContent, 'utf-8');
return { return {
success: true, success: true,
message: `成功写入 frontmatter 到 ${csv_file}`, message: `成功写入 frontmatter 到 ${csv_file}`,
csv_file, csv_file
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`, message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
}; };
} }
} }

View File

@ -1,7 +1,5 @@
import { writeFileSync, mkdirSync, existsSync } from "fs"; import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from "path"; import { join } from 'path';
import { buildDeckComponent, generateDeckPreviewMd } from "./deck-utils.js";
import type { DeckConfig } from "./frontmatter/read-frontmatter.js";
/** /**
* *
@ -26,7 +24,7 @@ export interface CardFieldStyle {
/** /**
* "n" | "w" | "s" | "e"/西// * "n" | "w" | "s" | "e"/西//
*/ */
up?: "n" | "w" | "s" | "e"; up?: 'n' | 'w' | 's' | 'e';
} }
/** /**
@ -47,7 +45,18 @@ export interface CardTemplate {
examples?: Record<string, string>[]; examples?: Record<string, string>[];
} }
export type { DeckConfig }; /**
* Deck
*/
export interface DeckConfig {
size?: string;
grid?: string;
bleed?: number;
padding?: number;
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
layers?: string;
backLayers?: string;
}
/** /**
* *
@ -64,11 +73,14 @@ export interface GenerateCardDeckParams {
/** /**
* CSV * CSV
*/ */
function generateCardCSV(template: CardTemplate, cardCount: number): string { function generateCardCSV(
template: CardTemplate,
cardCount: number
): string {
const fields = template.fields; const fields = template.fields;
// 构建 CSV 表头 // 构建 CSV 表头
const headers = ["label", ...fields.map((f) => f.name), "body"]; const headers = ['label', ...fields.map(f => f.name), 'body'];
// 生成示例数据 // 生成示例数据
const rows: string[][] = []; const rows: string[][] = [];
@ -79,16 +91,13 @@ function generateCardCSV(template: CardTemplate, cardCount: number): string {
// 为每个字段生成值 // 为每个字段生成值
for (const field of fields) { for (const field of fields) {
let value = ""; let value = '';
if (examples.length > 0) { if (examples.length > 0) {
// 从示例中循环取值 // 从示例中循环取值
const exampleIndex = i % examples.length; const exampleIndex = i % examples.length;
const example = examples[exampleIndex]; const example = examples[exampleIndex];
value = value = example[field.name] || field.examples?.[i % (field.examples?.length || 1)] || '';
example[field.name] ||
field.examples?.[i % (field.examples?.length || 1)] ||
"";
} else if (field.examples && field.examples.length > 0) { } else if (field.examples && field.examples.length > 0) {
// 从字段的示例中取值 // 从字段的示例中取值
value = field.examples[i % field.examples.length]; value = field.examples[i % field.examples.length];
@ -105,35 +114,117 @@ function generateCardCSV(template: CardTemplate, cardCount: number): string {
for (const field of fields) { for (const field of fields) {
bodyParts.push(`**${field.name}:** {{${field.name}}}`); bodyParts.push(`**${field.name}:** {{${field.name}}}`);
} }
row.push(bodyParts.join("\n\n")); row.push(bodyParts.join('\n\n'));
rows.push(row); rows.push(row);
} }
// 组合 CSV 内容 // 组合 CSV 内容
const csvLines = [headers.join(",")]; const csvLines = [headers.join(',')];
for (const row of rows) { for (const row of rows) {
csvLines.push( csvLines.push(row.map(cell => {
row // 处理包含逗号或换行的单元格
.map((cell) => { if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) {
// 处理包含逗号或换行的单元格 return `"${cell.replace(/"/g, '""')}"`;
if (cell.includes(",") || cell.includes("\n") || cell.includes('"')) { }
return `"${cell.replace(/"/g, '""')}"`; return cell;
} }).join(','));
return cell;
})
.join(","),
);
} }
return csvLines.join("\n"); return csvLines.join('\n');
}
/**
* Markdown
*/
function generateDeckMarkdown(
deckName: string,
csvFileName: string,
deckConfig: DeckConfig,
description?: string
): string {
const mdLines: string[] = [];
// 标题
mdLines.push(`# ${deckName}`);
mdLines.push('');
// 描述
if (description) {
mdLines.push(description);
mdLines.push('');
}
// 卡牌预览组件
mdLines.push('## 卡牌预览');
mdLines.push('');
// 构建 :md-deck 组件代码
const deckComponent = buildDeckComponent(csvFileName, deckConfig);
mdLines.push(deckComponent);
mdLines.push('');
// 使用说明
mdLines.push('## 使用说明');
mdLines.push('');
mdLines.push('- 点击卡牌可以查看详情');
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
mdLines.push('');
return mdLines.join('\n');
}
/**
* 构建 :md-deck
*/
function buildDeckComponent(
csvFileName: string,
config: DeckConfig
): string {
const parts = [`:md-deck[${csvFileName}]`];
const attrs: string[] = [];
if (config.size) {
attrs.push(`size="${config.size}"`);
}
if (config.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config.shape && config.shape !== 'rectangle') {
attrs.push(`shape="${config.shape}"`);
}
if (config.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config.backLayers) {
attrs.push(`back-layers="${config.backLayers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(' ')}}`);
}
return parts.join('');
} }
/** /**
* *
*/ */
function autoGenerateLayers(fields: CardField[]): string { function autoGenerateLayers(fields: CardField[]): string {
if (fields.length === 0) return ""; if (fields.length === 0) return '';
const layers: string[] = []; const layers: string[] = [];
const totalHeight = 8; const totalHeight = 8;
@ -147,7 +238,7 @@ function autoGenerateLayers(fields: CardField[]): string {
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`); layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
} }
return layers.join(" "); return layers.join(' ');
} }
/** /**
@ -165,7 +256,7 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
card_count = 10, card_count = 10,
card_template, card_template,
deck_config = {}, deck_config = {},
description, description
} = params; } = params;
// 确保输出目录存在 // 确保输出目录存在
@ -174,57 +265,52 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
} }
// 生成文件名 // 生成文件名
const safeName = deck_name.toLowerCase().replace(/\s+/g, "-"); const safeName = deck_name.toLowerCase().replace(/\s+/g, '-');
const csvFileName = `${safeName}.csv`; const csvFileName = `${safeName}.csv`;
const mdFileName = `${safeName}.md`; const mdFileName = `${safeName}.md`;
// 创建默认模板(如果没有提供) // 创建默认模板(如果没有提供)
const template: CardTemplate = card_template || { const template: CardTemplate = card_template || {
fields: [ fields: [
{ { name: 'name', description: '卡牌名称', examples: ['示例卡牌 1', '示例卡牌 2'] },
name: "name", { name: 'type', description: '卡牌类型', examples: ['物品', '法术'] },
description: "卡牌名称", { name: 'cost', description: '费用', examples: ['1', '2'] },
examples: ["示例卡牌 1", "示例卡牌 2"], { name: 'description', description: '效果描述', examples: ['这是一个效果描述', '这是另一个效果'] }
}, ]
{ name: "type", description: "卡牌类型", examples: ["物品", "法术"] },
{ name: "cost", description: "费用", examples: ["1", "2"] },
{
name: "description",
description: "效果描述",
examples: ["这是一个效果描述", "这是另一个效果"],
},
],
}; };
// 创建默认配置(如果没有提供) // 创建默认配置(如果没有提供)
const config: DeckConfig = { const config: DeckConfig = {
size: deck_config.size || "54x86", size: deck_config.size || '54x86',
grid: deck_config.grid || "5x8", grid: deck_config.grid || '5x8',
bleed: deck_config.bleed ?? 1, bleed: deck_config.bleed ?? 1,
padding: deck_config.padding ?? 2, padding: deck_config.padding ?? 2,
shape: deck_config.shape || "rectangle", shape: deck_config.shape || 'rectangle',
layers: deck_config.layers || autoGenerateLayers(template.fields), layers: deck_config.layers || autoGenerateLayers(template.fields)
}; };
// 生成 CSV 内容 // 生成 CSV 内容
const csvContent = generateCardCSV(template, card_count); const csvContent = generateCardCSV(template, card_count);
const csvPath = join(output_dir, csvFileName); const csvPath = join(output_dir, csvFileName);
writeFileSync(csvPath, csvContent, "utf-8"); writeFileSync(csvPath, csvContent, 'utf-8');
// 生成 Markdown 内容 // 生成 Markdown 内容
const deckComponent = buildDeckComponent(`./${csvFileName}`, config); const mdContent = generateDeckMarkdown(
const mdContent = generateDeckPreviewMd(
deck_name, deck_name,
deckComponent, `./${csvFileName}`,
description, config,
description
); );
const mdPath = join(output_dir, mdFileName); const mdPath = join(output_dir, mdFileName);
writeFileSync(mdPath, mdContent, "utf-8"); writeFileSync(mdPath, mdContent, 'utf-8');
// 构建完整的 deck 组件代码
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
return { return {
mdFile: mdPath, mdFile: mdPath,
csvFile: csvPath, csvFile: csvPath,
deckComponent, deckComponent,
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`, message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`
}; };
} }

View File

@ -1,13 +1,8 @@
import { readFileSync, writeFileSync, existsSync } from "fs"; import { readFileSync, writeFileSync, existsSync } from 'fs';
import { relative } from "path"; import { dirname, join, basename, extname, relative } from 'path';
import { execSync } from "child_process"; import { execSync } from 'child_process';
import { import yaml from 'js-yaml';
buildDeckComponent, import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
titleFromCsvPath,
defaultMdPath,
readDeckConfig,
generateDeckPreviewMd,
} from "./deck-utils.js";
/** /**
* Deck * Deck
@ -43,32 +38,85 @@ export interface PreviewDeckResult {
} }
/** /**
* * CSV frontmatter
*/ */
function openBrowser(url: string) { function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try { try {
if (process.platform === "win32") { const frontmatterStr = parts[1].trim();
execSync(`start "" "${url}"`, { stdio: "ignore" }); const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
} else if (process.platform === "darwin") { const csvContent = parts.slice(2).join('---\n').trimStart();
execSync(`open "${url}"`, { stdio: "ignore" }); return { frontmatter, csvContent };
} else {
execSync(`xdg-open "${url}"`, { stdio: "ignore" });
}
console.error(`已打开浏览器:${url}`);
} catch (error) { } catch (error) {
console.error("打开浏览器失败:", error); console.warn('Failed to parse front matter:', error);
return { csvContent: content };
} }
} }
/** /**
* URL * md-deck
*/ */
function computePreviewUrl(mdFilePath: string): string { function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
const relativeMdPath = relative(process.cwd(), mdFilePath) const parts = [`:md-deck[${csvPath}]`];
.split("\\") const attrs: string[] = [];
.join("/")
.replace(/\.md$/, ""); if (config?.size) {
return `http://localhost:3000/${relativeMdPath}`; attrs.push(`size="${config.size}"`);
}
if (config?.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config?.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config?.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config?.shape && config.shape !== 'rectangle') {
attrs.push(`shape="${config.shape}"`);
}
if (config?.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config?.back_layers) {
attrs.push(`back-layers="${config.back_layers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(' ')}}`);
}
return parts.join('');
}
/**
*
*/
function openBrowser(url: string) {
try {
// Windows 使用 start 命令
if (process.platform === 'win32') {
execSync(`start "" "${url}"`, { stdio: 'ignore' });
} else if (process.platform === 'darwin') {
execSync(`open "${url}"`, { stdio: 'ignore' });
} else {
execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
}
console.error(`已打开浏览器:${url}`);
} catch (error) {
console.error('打开浏览器失败:', error);
}
} }
/** /**
@ -81,61 +129,108 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
if (!existsSync(csv_file)) { if (!existsSync(csv_file)) {
return { return {
success: false, success: false,
message: `CSV 文件不存在:${csv_file}`, message: `CSV 文件不存在:${csv_file}`
}; };
} }
// 确定 Markdown 文件路径 // 确定 Markdown 文件路径
const mdFilePath = md_file || defaultMdPath(csv_file); let mdFilePath = md_file;
if (!mdFilePath) {
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
const dir = dirname(csv_file);
const name = basename(csv_file, extname(csv_file));
mdFilePath = join(dir, `${name}.md`);
}
const created = !existsSync(mdFilePath); const created = !existsSync(mdFilePath);
// 如果文件已存在,检查是否已有 md-deck 组件 // 如果文件已存在,检查是否已有 md-deck 组件
if (!created) { if (!created) {
try { try {
const existingContent = readFileSync(mdFilePath, "utf-8"); const existingContent = readFileSync(mdFilePath, 'utf-8');
if ( // 如果已有 md-deck 组件引用该 CSV直接返回
existingContent.includes(`:md-deck[${csv_file}]`) || if (existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${csv_file.split("/").pop()}]`) existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
) { // 仍然打开浏览器
const previewUrl = computePreviewUrl(mdFilePath); const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
const previewUrl = `http://localhost:3000/${relativeMdPath}`;
openBrowser(previewUrl); openBrowser(previewUrl);
return { return {
success: true, success: true,
message: `预览文件 ${mdFilePath} 已存在`, message: `预览文件 ${mdFilePath} 已存在`,
md_file: mdFilePath, md_file: mdFilePath,
created: false, created: false,
preview_url: previewUrl, preview_url: previewUrl
}; };
} }
} catch { } catch (error) {
// 读取失败,继续创建 // 读取失败,继续创建
} }
} }
// 读取 CSV 的 frontmatter 获取配置 // 读取 CSV 的 frontmatter 获取配置
const deckConfig = readDeckConfig(csv_file); let deckConfig: DeckConfig | undefined;
try {
const csvContent = readFileSync(csv_file, 'utf-8');
const { frontmatter } = parseFrontMatter(csvContent);
deckConfig = frontmatter?.deck;
} catch (error) {
// 忽略错误,使用默认配置
}
// 确定标题 // 确定标题
const deckTitle = title || titleFromCsvPath(csv_file); let deckTitle = title;
if (!deckTitle) {
// 从 CSV 文件名推断
const name = basename(csv_file, extname(csv_file));
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
// 构建 md-deck 组件代码 // 构建 md-deck 组件代码(使用相对路径)
const deckComponent = buildDeckComponent( const mdDir = dirname(mdFilePath);
`./${csv_file.split("/").pop()}`, const csvDir = dirname(csv_file);
deckConfig, let relativeCsvPath: string;
);
if (mdDir === csvDir) {
relativeCsvPath = `./${basename(csv_file)}`;
} else {
relativeCsvPath = `./${basename(csv_file)}`;
}
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
// 生成 Markdown 内容 // 生成 Markdown 内容
const mdContent = generateDeckPreviewMd( const mdLines: string[] = [];
deckTitle,
deckComponent, mdLines.push(`# ${deckTitle}`);
description, mdLines.push('');
);
if (description) {
mdLines.push(description);
mdLines.push('');
}
mdLines.push('## 卡牌预览');
mdLines.push('');
mdLines.push(deckComponent);
mdLines.push('');
mdLines.push('## 使用说明');
mdLines.push('');
mdLines.push('- 点击卡牌可以查看详情');
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
mdLines.push('');
// 写入文件 // 写入文件
try { try {
writeFileSync(mdFilePath, mdContent, "utf-8"); writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
const previewUrl = computePreviewUrl(mdFilePath); // 计算预览 URL
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
const previewUrl = `http://localhost:3000/${relativeMdPath.slice(0, -3)}`;
// 打开浏览器
openBrowser(previewUrl); openBrowser(previewUrl);
return { return {
@ -145,12 +240,12 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
: `更新预览文件 ${mdFilePath}`, : `更新预览文件 ${mdFilePath}`,
md_file: mdFilePath, md_file: mdFilePath,
created, created,
preview_url: previewUrl, preview_url: previewUrl
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`, message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
}; };
} }
} }

View File

@ -115,7 +115,7 @@ export function PrintPreview(props: PrintPreviewProps) {
<PltPreview pltCode={pltCode()} onClose={handleClosePltPreview} /> <PltPreview pltCode={pltCode()} onClose={handleClosePltPreview} />
} }
> >
<div class="fixed inset-0 bg-black/50 z-60 overflow-auto"> <div class="fixed inset-0 bg-black/50 z-[60] overflow-auto">
<div class="min-h-screen py-20 px-4"> <div class="min-h-screen py-20 px-4">
<PrintPreviewHeader <PrintPreviewHeader
store={store} store={store}

View File

@ -1,19 +1,9 @@
import { customElement, noShadowDOM } from "solid-element"; import { customElement, noShadowDOM } from 'solid-element';
import { import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
createSignal, import { marked } from '../markdown';
For, import { loadCSV, CSV, processVariables, isCSV } from './utils/csv-loader';
Show, import { resolvePath } from './utils/path';
createEffect, import { areAllLabelsNumeric, weightedRandomIndex } from './utils/weighted-random';
createMemo,
createResource,
} from "solid-js";
import { marked } from "../markdown";
import { loadCSV, CSV, processVariables, isCSV } from "./utils/csv-loader";
import { resolvePath } from "./utils/path";
import {
areAllLabelsNumeric,
weightedRandomIndex,
} from "./utils/weighted-random";
export interface TableProps { export interface TableProps {
roll?: boolean; roll?: boolean;
@ -26,208 +16,191 @@ interface TableRow {
[key: string]: string; [key: string]: string;
} }
customElement( customElement('md-table', { roll: false, remix: false }, (props, { element }) => {
"md-table", noShadowDOM();
{ roll: false, remix: false }, const [rows, setRows] = createSignal<CSV<TableRow>>([] as unknown as CSV<TableRow>);
(props, { element }) => { const [activeTab, setActiveTab] = createSignal(0);
noShadowDOM(); const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
const [rows, setRows] = createSignal<CSV<TableRow>>( const [bodyHtml, setBodyHtml] = createSignal('');
[] as unknown as CSV<TableRow>, let tabsContainer: HTMLDivElement | undefined;
);
const [activeTab, setActiveTab] = createSignal(0);
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
const [bodyHtml, setBodyHtml] = createSignal("");
let tabsContainer: HTMLDivElement | undefined;
// 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据 // 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据
const rawContent = element?.textContent?.trim() || ""; const rawContent = element?.textContent?.trim() || '';
// 隐藏原始文本内容 // 隐藏原始文本内容
if (element) { if (element) {
element.textContent = ""; element.textContent = '';
}
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest('article[data-src]');
const articlePath = articleEl?.getAttribute('data-src') || '';
// 如果是 inline CSV直接使用否则解析相对路径
const contentOrPath = isCSV(rawContent) ? rawContent : resolvePath(articlePath, rawContent);
// 使用 createResource 加载 CSV自动响应路径变化并避免重复加载
const [csvData] = createResource(() => contentOrPath, loadCSV);
// 当数据加载完成后更新 rows
createEffect(() => {
const data = csvData();
if (data) {
// 将加载的数据赋值给 rowsCSV 类型已经包含 sourcePath 等属性
setRows(data as unknown as CSV<TableRow>);
} }
});
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径 // 检测是否有 group 列
const articleEl = element?.closest("article[data-src]"); const hasGroup = createMemo(() => {
const articlePath = articleEl?.getAttribute("data-src") || ""; const allRows = rows();
return allRows.length > 0 && 'group' in allRows[0];
});
// 如果是 inline CSV直接使用否则解析相对路径 // 获取所有分组
const contentOrPath = isCSV(rawContent) const groups = createMemo(() => {
? rawContent if (!hasGroup()) return [];
: resolvePath(articlePath, rawContent); const allRows = rows();
const groupSet = new Set<string>();
// 使用 createResource 加载 CSV自动响应路径变化并避免重复加载 for (const row of allRows) {
const [csvData] = createResource(() => contentOrPath, loadCSV); if (row.group) {
groupSet.add(row.group);
// 当数据加载完成后更新 rows
createEffect(() => {
const data = csvData();
if (data) {
// 将加载的数据赋值给 rowsCSV 类型已经包含 sourcePath 等属性
setRows(data as unknown as CSV<TableRow>);
} }
}); }
return Array.from(groupSet).sort();
});
// 检测是否有 group 列 // 根据当前选中的分组过滤行
const hasGroup = createMemo(() => { const filteredRows = createMemo(() => {
const allRows = rows(); const allRows = rows();
return allRows.length > 0 && "group" in allRows[0]; const group = activeGroup();
}); if (!group) return allRows;
return allRows.filter(row => row.group === group);
});
// 获取所有分组 // 处理 body 内容中的 {{prop}} 语法并解析 markdown
const groups = createMemo(() => { const processBody = (body: string, currentRow: TableRow): string => {
if (!hasGroup()) return []; // 使用 marked 解析 markdown
const allRows = rows(); return marked.parse(processVariables(body, currentRow, rows(), filteredRows(), props.remix)) as string;
const groupSet = new Set<string>(); };
for (const row of allRows) {
if (row.group) {
groupSet.add(row.group);
}
}
return Array.from(groupSet).sort();
});
// 根据当前选中的分组过滤行 // 更新 body 内容
const filteredRows = createMemo(() => { const updateBodyContent = () => {
const allRows = rows(); const filtered = filteredRows();
const group = activeGroup(); if (!csvData.loading && filtered.length > 0) {
if (!group) return allRows; const index = Math.min(activeTab(), filtered.length - 1);
return allRows.filter((row) => row.group === group); const currentRow = filtered[index];
}); setBodyHtml(processBody(currentRow.body, currentRow));
}
};
// 处理 body 内容中的 {{prop}} 语法并解析 markdown // 监听 activeTab 和 activeGroup 变化并更新内容
const processBody = (body: string, currentRow: TableRow): string => { createEffect(() => {
// 使用 marked 解析 markdown activeTab();
return marked.parse( activeGroup();
processVariables(body, currentRow, rows(), filteredRows(), props.remix), updateBodyContent();
) as string; });
};
// 更新 body 内容 // 切换分组时重置 tab 索引
const updateBodyContent = () => { const handleGroupChange = (group: string | null) => {
const filtered = filteredRows(); setActiveGroup(group);
if (!csvData.loading && filtered.length > 0) { setActiveTab(0);
const index = Math.min(activeTab(), filtered.length - 1); };
const currentRow = filtered[index];
setBodyHtml(processBody(currentRow.body, currentRow));
}
};
// 监听 activeTab 和 activeGroup 变化并更新内容 // 随机切换 tab
createEffect(() => { const handleRoll = () => {
activeTab(); const filtered = filteredRows();
activeGroup(); if (filtered.length === 0) return;
updateBodyContent();
});
// 切换分组时重置 tab 索引 // 检查所有 label 是否都是整数/整数范围格式
const handleGroupChange = (group: string | null) => { const labels = filtered.map(row => row.label);
setActiveGroup(group); if (areAllLabelsNumeric(labels)) {
setActiveTab(0); // 使用加权随机
}; const randomIndex = weightedRandomIndex(labels);
if (randomIndex !== -1) {
// 随机切换 tab
const handleRoll = () => {
const filtered = filteredRows();
if (filtered.length === 0) return;
// 检查所有 label 是否都是整数/整数范围格式
const labels = filtered.map((row) => row.label);
if (areAllLabelsNumeric(labels)) {
// 使用加权随机
const randomIndex = weightedRandomIndex(labels);
if (randomIndex !== -1) {
setActiveTab(randomIndex);
}
} else {
// 使用简单随机
const randomIndex = Math.floor(Math.random() * filtered.length);
setActiveTab(randomIndex); setActiveTab(randomIndex);
} }
} else {
// 使用简单随机
const randomIndex = Math.floor(Math.random() * filtered.length);
setActiveTab(randomIndex);
}
// 滚动到可视区域 // 滚动到可视区域
setTimeout(() => { setTimeout(() => {
const activeButton = tabsContainer?.querySelector(".border-blue-600"); const activeButton = tabsContainer?.querySelector('.border-blue-600');
activeButton?.scrollIntoView({ activeButton?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
behavior: "smooth", }, 50);
block: "nearest", };
inline: "nearest",
});
}, 50);
};
return ( return (
<div class="ttrpg-table"> <div class="ttrpg-table">
<div class="flex items-center gap-2 border-b border-gray-200"> <div class="flex items-center gap-2 border-b border-gray-200">
<div class="flex flex-col overflow-x-auto"> <div class="flex flex-col overflow-x-auto">
{/* 分组 tabs */} {/* 分组 tabs */}
<Show when={hasGroup()}> <Show when={hasGroup()}>
<div class="flex gap-2 border-b border-gray-100 pb-2"> <div class="flex gap-2 border-b border-gray-100 pb-2">
<button <button
onClick={() => handleGroupChange(null)} onClick={() => handleGroupChange(null)}
class={`font-medium transition-colors ${ class={`font-medium transition-colors ${
activeGroup() === null activeGroup() === null
? "text-blue-600 border-b-2 border-blue-600" ? 'text-blue-600 border-b-2 border-blue-600'
: "text-gray-500 hover:text-gray-700" : 'text-gray-500 hover:text-gray-700'
}`} }`}
>
</button>
<For each={groups()}>
{(group) => (
<button
onClick={() => handleGroupChange(group)}
class={`font-medium transition-colors ${
activeGroup() === group
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
{group}
</button>
)}
</For>
</div>
</Show>
{/* 内容 tabs */}
<div class="flex items-center gap-2">
<Show when={props.roll}>
<button
onClick={handleRoll}
class="text-gray-500 hover:text-gray-700 shrink-0 cursor-pointer"
title="随机切换"
>
🎲
</button>
</Show>
<div
ref={tabsContainer}
class="flex gap-1 overflow-x-auto flex-1 min-w-0"
> >
<For each={filteredRows()}>
{(row, index) => ( </button>
<button <For each={groups()}>
onClick={() => setActiveTab(index())} {(group) => (
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer ${ <button
activeTab() === index() onClick={() => handleGroupChange(group)}
? "text-blue-600 border-b-2 border-blue-600" class={`font-medium transition-colors ${
: "text-gray-500 hover:text-gray-700" activeGroup() === group
}`} ? 'text-blue-600 border-b-2 border-blue-600'
> : 'text-gray-500 hover:text-gray-700'
{row.label} }`}
</button> >
)} {group}
</For> </button>
</div> )}
</For>
</div>
</Show>
{/* 内容 tabs */}
<div class="flex items-center gap-2">
<Show when={props.roll}>
<button
onClick={handleRoll}
class="text-gray-500 hover:text-gray-700 flex-shrink-0 cursor-pointer"
title="随机切换"
>
🎲
</button>
</Show>
<div ref={tabsContainer} class="flex gap-1 overflow-x-auto flex-1 min-w-0">
<For each={filteredRows()}>
{(row, index) => (
<button
onClick={() => setActiveTab(index())}
class={`font-medium transition-colors flex-shrink-0 min-w-[1.6em] cursor-pointer ${
activeTab() === index()
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{row.label}
</button>
)}
</For>
</div> </div>
</div> </div>
</div> </div>
<div class="p-4 prose max-w-none">
<Show when={!csvData.loading && filteredRows().length > 0}>
<div innerHTML={bodyHtml()} />
</Show>
</div>
</div> </div>
); <div class="p-4 prose max-w-none">
}, <Show when={!csvData.loading && filteredRows().length > 0}>
); <div innerHTML={bodyHtml()} />
</Show>
</div>
</div>
);
});