feat: rename project to TTTK

- Update package name and CLI binary to 'tttk'
- Revise documentation to reflect new name
- Add default serve behavior when no subcommand is given
- Add directory existence check in serve command
- Refactor inline comments to Chinese
This commit is contained in:
hyper 2026-07-09 14:13:34 +08:00
parent c38d9bdc4f
commit 06c3916a95
8 changed files with 111 additions and 95 deletions

View File

@ -1,18 +1,12 @@
# TTRPG Tools # TTTK — Tabletop Toolkit
一个基于 `solid.js``rsbuild` 的 TTRPG 工具箱,支持 Markdown 解析、自定义组件和 CLI 工具。 A TTRPG content preview and compilation tool based on `solid.js` and `rsbuild`. Write markdown, preview in browser.
## 快速开始 ## 快速开始
```bash ```bash
# 安装依赖 # 无需安装,直接使用
npm install npx tttk serve ./content
# 全局安装 CLI
npm link
# 预览内容
ttrpg serve ./content
``` ```
## 文档导航 ## 文档导航
@ -20,6 +14,7 @@ ttrpg 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 文件格式、字段定义、变量语法 |
@ -27,7 +22,7 @@ ttrpg serve ./content
## 功能概览 ## 功能概览
- **CLI 工具**: `serve` 预览模式 和 `compile` 编译模式 - **CLI 工具**: `tttk serve` 预览模式 和 `tttk compile` 编译模式
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表 - **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等 - **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等

View File

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

View File

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

View File

@ -1,11 +1,10 @@
{ {
"name": "ttrpg-tools", "name": "tttk",
"version": "0.0.1", "version": "0.0.1",
"description": "A tabletop RPG toolbox based on solid.js and rsbuild", "description": "A tabletop RPG toolkit for previewing and compiling TTRPG campaign content",
"type": "module", "type": "module",
"bin": { "bin": {
"ttrpg": "./dist/cli/index.js", "tttk": "./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",
@ -22,6 +21,7 @@
"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,14 +1,14 @@
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: 实现编译逻辑
// 1. 扫描目录下的所有 .md 文件 // 1. 扫描目录下的所有 .md 文件
// 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 {
} }
/** /**
* Get the best network IP for display (prefer LAN IPv4, fallback to localhost). * IP IPv4退 localhost
*/ */
function getBestIP(): string { function getBestIP(): string {
const interfaces = networkInterfaces(); const interfaces = networkInterfaces();
@ -85,7 +85,7 @@ function getBestIP(): string {
} }
/** /**
* Extract the <title> from an SVG string, or null if absent. * SVG <title> null
*/ */
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,17 +93,15 @@ 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 return id.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
.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;
@ -170,7 +168,7 @@ export function scanDirectory(dir: string): {
} }
/** /**
* *
*/ */
function sendFile(res: ServerResponse, filePath: string) { function sendFile(res: ServerResponse, filePath: string) {
res.writeHead(200, { res.writeHead(200, {
@ -181,7 +179,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" });
@ -189,7 +187,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, {
@ -201,7 +199,7 @@ function sendJson(res: ServerResponse, data: unknown) {
/** /**
* *
* @returns true * @returns true
*/ */
function tryServeStatic( function tryServeStatic(
res: ServerResponse, res: ServerResponse,
@ -224,7 +222,7 @@ function tryServeStatic(
} }
/** /**
* * HTTP
*/ */
function createRequestHandler( function createRequestHandler(
contentDir: string, contentDir: string,
@ -274,7 +272,7 @@ function createRequestHandler(
} }
/** /**
* *
*/ */
export interface ContentServer { export interface ContentServer {
/** /**
@ -300,7 +298,7 @@ export interface ContentServer {
} }
/** /**
* * HTTP + WebSocket +
*/ */
export function createContentServer( export function createContentServer(
contentDir: string, contentDir: string,
@ -324,7 +322,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(
@ -333,7 +331,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;
@ -341,7 +339,7 @@ export function createContentServer(
recomputeCompletions(); recomputeCompletions();
// 监听文件变化 // 监听文件变化
console.log("监听文件变化..."); console.log("正在监听文件变化...");
const watcher = watch(contentDir, { const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\./, ignored: /(^|[\/\\])\./,
persistent: true, persistent: true,
@ -431,7 +429,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;
// 创建 HTTP 服务器 BEFORE journal so we can pass it in // 在 journal 之前创建 HTTP 服务器,以便传入给 journal
const handleRequest = createRequestHandler( const handleRequest = createRequestHandler(
contentDir, contentDir,
distPath, distPath,
@ -454,7 +452,7 @@ export function createContentServer(
} }
console.log(`内容目录:${contentDir}`); console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`); console.log(`静态资源目录:${distPath}`);
console.log(`Journal 连接ws://${bestIP}:${port}\n`); console.log(`日志连接ws://${bestIP}:${port}\n`);
}); });
return { return {
@ -463,7 +461,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();
@ -476,6 +474,12 @@ 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,25 +8,42 @@ import type { ServeOptions, CompileOptions } from "./types.js";
const program = new Command(); const program = new Command();
program program
.name("ttrpg") .name("tttk")
.description("TTRPG 工具箱 - 用于编译和预览 TTRPG 文档") .description(
"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("-h, --host <host>", "主机地址", "0.0.0.0") .option("--host <host>", "主机地址", "0.0.0.0")
.action(serveCommand); .action(serveCommand);
program program
.command("compile") .command("compile")
.description("将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用") .description("[实验性] 将目录内容编译为带 hash 路由的单页 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);
program.parse(); // 无子命令时默认执行 serve即 `npx tttk` 等同于 `npx tttk serve .`
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 @@
/** /**
* Journal stream persistence MQTT broker + JSONL append + session manifest * MQTT + JSONL +
* *
* Runs an aedes MQTT broker over WebSocket, attached to the existing * HTTP aedes MQTT WebSocket
* HTTP server. Uses ws v8's built-in createWebSocketStream. * 使 ws v8 createWebSocketStream
* *
* Pattern from aedes docs: * aedes
* https://github.com/moscajs/aedes#mqtt-server-over-websocket * https://github.com/moscajs/aedes#mqtt-server-over-websocket
* *
* Persistence uses aedes's internal subscribe/publish API, so the * 使 aedes subscribe/publish API
* server process doesn't need to connect to itself as an MQTT client. * MQTT
*/ */
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(`[journal] data dir: ${root}`); console.log(`[日志] 数据目录:${root}`);
// ---- MQTT Broker ---- // ---- MQTT 代理 ----
const { Aedes: AedesFactory } = await import("aedes"); const { Aedes: AedesFactory } = await import("aedes");
// aedes requires listen() to initialize persistence before handling connections. // aedes 需要先 listen() 初始化持久化,再处理连接
const broker = await AedesFactory.createBroker(); const broker = await AedesFactory.createBroker();
// ---- WebSocket server (attached to the existing HTTP server) ---- // ---- WebSocket 服务器(挂载到现有 HTTP 服务器上) ----
// Pattern from aedes docs: pass { server } to WSS, use 'connection' event // 参考 aedes 文档:给 WSS 传入 { server },使用 'connection' 事件
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(`[journal] client ready: ${client.id}`); console.log(`[日志] 客户端已连接:${client.id}`);
}); });
broker.on("clientDisconnect", (client: import("aedes").Client) => { broker.on("clientDisconnect", (client: import("aedes").Client) => {
console.log(`[journal] client disconnected: ${client.id}`); console.log(`[日志] 客户端已断开:${client.id}`);
}); });
// ---- Persistence (internal subscribe, no separate MQTT client) ---- // ---- 持久化(内部订阅,无需独立 MQTT 客户端) ----
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(`[journal] stream append err for ${sessionId}:`, e); console.error(`[日志] 会话 ${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("[journal] persistence listener active"); console.log("[日志] 持久化监听已启动");
console.log("[journal] broker available at ws://<host>:<port>"); console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
return { return {
broker, broker,
wsServer: wss, wsServer: wss,
async close() { async close() {
console.log("[journal] shutting down..."); console.log("[日志] 正在关闭...");
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(`[journal] session deleted: ${sessionId}`); console.log(`[日志] 会话已删除:${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(`[journal] session updated: ${sessionId} (${meta.name})`); console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
} }
saveManifest(root, manifest); saveManifest(root, manifest);
@ -236,6 +236,6 @@ function handleMetaChange(
() => {}, () => {},
); );
} catch (e) { } catch (e) {
console.error(`[journal] meta parse err for ${sessionId}:`, e); console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
} }
} }