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:14:45 +08:00
parent c38d9bdc4f
commit 06c3916a95
8 changed files with 111 additions and 95 deletions
+6 -6
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) => {
console.log(`开始编译...`);
console.log(`目录:${dir}`);
console.log("开始编译...");
console.log(`目录:${dir}`);
console.log(`输出目录:${options.output}`);
// TODO: 实现编译逻辑
// 1. 扫描目录下的所有 .md 文件
// 2. 解析 markdown 并生成路由
// 3. 打包为带 hash 路由的单个 HTML 入口
console.log('编译完成!');
console.log("编译完成!");
};
+28 -24
View File
@@ -22,14 +22,14 @@ interface ContentIndex {
}
/**
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 文件夹
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 目录
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
/**
* MIME 类型映射
* MIME 类型映射
*/
const MIME_TYPES: Record<string, string> = {
".html": "text/html",
@@ -48,7 +48,7 @@ const MIME_TYPES: Record<string, string> = {
};
/**
* 获取文件扩展名对应的 MIME 类型
* 根据文件扩展名获取对应的 MIME 类型
*/
function getMimeType(filePath: string): string {
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 {
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 {
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 {
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): {
index: ContentIndex;
@@ -170,7 +168,7 @@ export function scanDirectory(dir: string): {
}
/**
* 发送文件响应
* 发送文件响应(流式传输)
*/
function sendFile(res: ServerResponse, filePath: string) {
res.writeHead(200, {
@@ -181,7 +179,7 @@ function sendFile(res: ServerResponse, filePath: string) {
}
/**
* 发送 404 响应
* 发送 404 未找到响应
*/
function send404(res: ServerResponse) {
res.writeHead(404, { "Content-Type": "text/plain" });
@@ -189,7 +187,7 @@ function send404(res: ServerResponse) {
}
/**
* 发送 JSON 响应
* 发送 JSON 格式的响应
*/
function sendJson(res: ServerResponse, data: unknown) {
res.writeHead(200, {
@@ -201,7 +199,7 @@ function sendJson(res: ServerResponse, data: unknown) {
/**
* 尝试提供静态文件
* @returns 如果文件存在并成功发送返回 true
* @returns 文件存在并成功发送返回 true
*/
function tryServeStatic(
res: ServerResponse,
@@ -224,7 +222,7 @@ function tryServeStatic(
}
/**
* 创建请求处理器
* 创建 HTTP 请求处理器
*/
function createRequestHandler(
contentDir: string,
@@ -274,7 +272,7 @@ function createRequestHandler(
}
/**
* 内容服务器接口
* 内容服务器实例接口
*/
export interface ContentServer {
/**
@@ -300,7 +298,7 @@ export interface ContentServer {
}
/**
* 创建内容服务器
* 创建内容服务器HTTP + WebSocket + 文件监听)
*/
export function createContentServer(
contentDir: string,
@@ -324,7 +322,7 @@ export function createContentServer(
statSheets: [],
};
/** Re-scan completions from current content index and collected blocks */
/** 从当前内容索引和已收集的块重新扫描补全数据 */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks);
console.log(
@@ -333,7 +331,7 @@ export function createContentServer(
}
// 扫描内容目录生成索引
console.log("扫描内容目录...");
console.log("正在扫描内容目录...");
const scanResult = scanDirectory(contentDir);
contentIndex = scanResult.index;
collectedBlocks = scanResult.blocks;
@@ -341,7 +339,7 @@ export function createContentServer(
recomputeCompletions();
// 监听文件变化
console.log("监听文件变化...");
console.log("正在监听文件变化...");
const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\./,
persistent: true,
@@ -431,7 +429,7 @@ export function createContentServer(
// ---- Journal / MQTT broker ----
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
// 创建 HTTP 服务器 BEFORE journal so we can pass it in
// 在 journal 之前创建 HTTP 服务器,以便传入给 journal
const handleRequest = createRequestHandler(
contentDir,
distPath,
@@ -454,7 +452,7 @@ export function createContentServer(
}
console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`);
console.log(`Journal 连接:ws://${bestIP}:${port}\n`);
console.log(`日志连接:ws://${bestIP}:${port}\n`);
});
return {
@@ -463,7 +461,7 @@ export function createContentServer(
index: contentIndex,
completions: completionsIndex,
close() {
console.log("关闭内容服务器...");
console.log("正在关闭内容服务器...");
server.close();
watcher.close();
if (journal) journal.close();
@@ -476,6 +474,12 @@ export function createContentServer(
*/
export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir);
if (!existsSync(contentDir)) {
console.error(`错误:目录不存在:${contentDir}`);
process.exit(1);
}
const port = parseInt(options.port, 10);
const host = options.host || "0.0.0.0";
+23 -6
View File
@@ -8,25 +8,42 @@ import type { ServeOptions, CompileOptions } from "./types.js";
const program = new Command();
program
.name("ttrpg")
.description("TTRPG 工具箱 - 用于编译和预览 TTRPG 文档")
.name("tttk")
.description(
"TTRPG 内容预览服务器。不带参数运行时默认启动当前目录的预览服务。",
)
.version("0.0.1");
program
.command("serve")
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
.description("启动 web 服务器预览目录中的内容,并实时监听文件更新")
.argument("[dir]", "要预览的目录", ".")
.option("-p, --port <port>", "HTTP 端口号", "3000")
.option("-h, --host <host>", "主机地址", "0.0.0.0")
.option("--host <host>", "主机地址", "0.0.0.0")
.action(serveCommand);
program
.command("compile")
.description("将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用")
.description("[实验性] 将目录内容编译为带 hash 路由的单页 Web 应用 (开发中)")
.argument("[dir]", "要编译的目录", ".")
.option("-o, --output <dir>", "输出目录", "./dist/output")
.action(compileCommand);
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]);
+27 -27
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 server. Uses ws v8's built-in createWebSocketStream.
* 在现有 HTTP 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
* 使用 ws v8 内置的 createWebSocketStream
*
* Pattern from aedes docs:
* 实现参考 aedes 文档:
* https://github.com/moscajs/aedes#mqtt-server-over-websocket
*
* Persistence uses aedes's internal subscribe/publish API, so the
* server process doesn't need to connect to itself as an MQTT client.
* 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
* 以 MQTT 客户端身份连接自身。
*/
import { join, resolve } from "path";
@@ -26,7 +26,7 @@ import type { AedesPublishPacket } from "aedes";
import { WebSocketServer, createWebSocketStream } from "ws";
// ---------------------------------------------------------------------------
// Types
// 类型定义
// ---------------------------------------------------------------------------
interface SessionMeta {
@@ -46,7 +46,7 @@ export interface JournalServer {
}
// ---------------------------------------------------------------------------
// Path helpers
// 路径工具函数
// ---------------------------------------------------------------------------
function dataDir(contentDir: string): string {
@@ -88,7 +88,7 @@ function appendStream(dataRoot: string, id: string, line: string): void {
}
// ---------------------------------------------------------------------------
// Server factory
// 服务器工厂
// ---------------------------------------------------------------------------
const $SESSIONS = "ttrpg/$SESSIONS";
@@ -98,15 +98,15 @@ export async function createJournalServer(
httpServer: HttpServer,
): Promise<JournalServer> {
const root = dataDir(contentDir);
console.log(`[journal] data dir: ${root}`);
console.log(`[日志] 数据目录:${root}`);
// ---- MQTT Broker ----
// ---- MQTT 代理 ----
const { Aedes: AedesFactory } = await import("aedes");
// aedes requires listen() to initialize persistence before handling connections.
// aedes 需要先 listen() 初始化持久化,再处理连接
const broker = await AedesFactory.createBroker();
// ---- WebSocket server (attached to the existing HTTP server) ----
// Pattern from aedes docs: pass { server } to WSS, use 'connection' event
// ---- WebSocket 服务器(挂载到现有 HTTP 服务器上) ----
// 参考 aedes 文档:给 WSS 传入 { server },使用 'connection' 事件
const wss = new WebSocketServer({ server: httpServer });
wss.on("connection", (ws, req) => {
@@ -115,13 +115,13 @@ export async function createJournalServer(
});
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) => {
console.log(`[journal] client disconnected: ${client.id}`);
console.log(`[日志] 客户端已断开:${client.id}`);
});
// ---- Persistence (internal subscribe, no separate MQTT client) ----
// ---- 持久化(内部订阅,无需独立 MQTT 客户端) ----
broker.subscribe(
"ttrpg/+/stream",
(packet, cb) => {
@@ -130,7 +130,7 @@ export async function createJournalServer(
try {
appendStream(root, sessionId, packet.payload.toString());
} catch (e) {
console.error(`[journal] stream append err for ${sessionId}:`, e);
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
}
}
cb();
@@ -150,7 +150,7 @@ export async function createJournalServer(
() => {},
);
// Publish the initial manifest on startup so clients see existing sessions
// 启动时发布初始清单,让客户端能看到已有会话
const initialManifest = loadManifest(root);
broker.publish(
{
@@ -164,14 +164,14 @@ export async function createJournalServer(
() => {},
);
console.log("[journal] persistence listener active");
console.log("[journal] broker available at ws://<host>:<port>");
console.log("[日志] 持久化监听已启动");
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
return {
broker,
wsServer: wss,
async close() {
console.log("[journal] shutting down...");
console.log("[日志] 正在关闭...");
await new Promise<void>((r) => wss.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 {
@@ -188,7 +188,7 @@ function extractSessionId(topic: string): string | null {
}
// ---------------------------------------------------------------------------
// Session lifecycle
// 会话生命周期管理
// ---------------------------------------------------------------------------
function handleMetaChange(
@@ -212,7 +212,7 @@ function handleMetaChange(
} catch {
/* */
}
console.log(`[journal] session deleted: ${sessionId}`);
console.log(`[日志] 会话已删除:${sessionId}`);
}
} else {
manifest.sessions[sessionId] = {
@@ -220,7 +220,7 @@ function handleMetaChange(
created: meta.created || Date.now(),
players: meta.players || [],
};
console.log(`[journal] session updated: ${sessionId} (${meta.name})`);
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
}
saveManifest(root, manifest);
@@ -236,6 +236,6 @@ function handleMetaChange(
() => {},
);
} catch (e) {
console.error(`[journal] meta parse err for ${sessionId}:`, e);
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
}
}