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:
parent
c38d9bdc4f
commit
06c3916a95
17
README.md
17
README.md
|
|
@ -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 组件**: 骰子、表格、卡牌、标记、命令追踪器等
|
||||||
|
|
||||||
|
|
|
||||||
32
docs/cli.md
32
docs/cli.md
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ npm test
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
ttrpg-tools/
|
tttk/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── cli/ # CLI 工具源码
|
│ ├── cli/ # CLI 工具源码
|
||||||
│ │ ├── commands/ # 命令实现 (serve, compile)
|
│ │ ├── commands/ # 命令实现 (serve, compile)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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("编译完成!");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue