From 8284f5caee42ba976c29a14bca7dcd02f920b1fe Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 6 Jul 2026 16:50:45 +0800 Subject: [PATCH] feat: add completions API and scanning logic Implement a new completions system that scans markdown files for dice notation and headings. The server now exposes a `/__COMPLETIONS.json` endpoint and automatically recomputes the completion index when files are added, updated, or deleted. --- src/cli/commands/serve.ts | 30 +++++++++++++++ src/cli/completions/index.ts | 32 ++++++++++++++++ src/cli/completions/sources/dice.ts | 38 +++++++++++++++++++ src/cli/completions/sources/links.ts | 55 ++++++++++++++++++++++++++++ src/cli/completions/types.ts | 40 ++++++++++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 src/cli/completions/index.ts create mode 100644 src/cli/completions/sources/dice.ts create mode 100644 src/cli/completions/sources/links.ts create mode 100644 src/cli/completions/types.ts diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 596a6e0..47dd126 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -6,6 +6,10 @@ import { join, resolve, extname, sep, relative, dirname } from "path"; import { watch } from "chokidar"; import { fileURLToPath } from "url"; import { createJournalServer } from "../journal.js"; +import { + scanCompletions, + type CompletionsPayload, +} from "../completions/index.js"; interface ContentIndex { [path: string]: string; @@ -144,6 +148,7 @@ function createRequestHandler( contentDir: string, distDir: string, getIndex: () => ContentIndex, + getCompletions: () => CompletionsPayload, ) { return (req: IncomingMessage, res: ServerResponse) => { const url = req.url || "/"; @@ -155,6 +160,12 @@ function createRequestHandler( return; } + // 1b. 处理 /__COMPLETIONS.json + if (filePath === "/__COMPLETIONS.json") { + sendJson(res, getCompletions()); + return; + } + // 2. 处理 /static/ 目录(从 dist/web) if (filePath.startsWith("/static/")) { if (tryServeStatic(res, filePath, distDir)) { @@ -196,6 +207,10 @@ export interface ContentServer { * 内容索引 */ index: ContentIndex; + /** + * 补全数据 + */ + completions: CompletionsPayload; /** * 关闭服务器 */ @@ -212,11 +227,21 @@ export function createContentServer( host: string = "0.0.0.0", ): ContentServer { let contentIndex: ContentIndex = {}; + let completionsIndex: CompletionsPayload = { dice: [], links: [] }; + + /** Re-scan completions from current content index (cached) */ + function recomputeCompletions(): void { + completionsIndex = scanCompletions(contentIndex); + console.log( + `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length}`, + ); + } // 扫描内容目录生成索引 console.log("扫描内容目录..."); contentIndex = scanDirectory(contentDir); console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`); + recomputeCompletions(); // 监听文件变化 console.log("监听文件变化..."); @@ -238,6 +263,7 @@ export function createContentServer( const relPath = "/" + relative(contentDir, path).split(sep).join("/"); contentIndex[relPath] = content; console.log(`[新增] ${relPath}`); + if (relPath.endsWith(".md")) recomputeCompletions(); } catch (e) { console.error(`读取新增文件失败:${path}`, e); } @@ -254,6 +280,7 @@ export function createContentServer( const relPath = "/" + relative(contentDir, path).split(sep).join("/"); contentIndex[relPath] = content; console.log(`[更新] ${relPath}`); + if (relPath.endsWith(".md")) recomputeCompletions(); } catch (e) { console.error(`读取更新文件失败:${path}`, e); } @@ -268,6 +295,7 @@ export function createContentServer( const relPath = "/" + relative(contentDir, path).split(sep).join("/"); delete contentIndex[relPath]; console.log(`[删除] ${relPath}`); + if (relPath.endsWith(".md")) recomputeCompletions(); } }); @@ -279,6 +307,7 @@ export function createContentServer( contentDir, distPath, () => contentIndex, + () => completionsIndex, ); const server = createServer(handleRequest); @@ -298,6 +327,7 @@ export function createContentServer( server, watcher, index: contentIndex, + completions: completionsIndex, close() { console.log("关闭内容服务器..."); server.close(); diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts new file mode 100644 index 0000000..807b4a7 --- /dev/null +++ b/src/cli/completions/index.ts @@ -0,0 +1,32 @@ +/** + * Completion index — orchestrates all registered completion sources. + */ + +import { diceSource } from "./sources/dice.js"; +import { linksSource } from "./sources/links.js"; +import type { CompletionSource, CompletionsPayload } from "./types.js"; + +export type { + CompletionsPayload, + DiceCompletion, + LinkCompletion, +} from "./types.js"; + +/** Registered sources — open for extension */ +const sources: CompletionSource[] = [diceSource, linksSource]; + +/** + * Scan the full content index and return structured completion data. + * Called at server startup and on any file change. + */ +export function scanCompletions( + index: Record, +): CompletionsPayload { + const payload: Record = {}; + + for (const source of sources) { + payload[source.key] = source.scan(index); + } + + return payload as unknown as CompletionsPayload; +} diff --git a/src/cli/completions/sources/dice.ts b/src/cli/completions/sources/dice.ts new file mode 100644 index 0000000..079849c --- /dev/null +++ b/src/cli/completions/sources/dice.ts @@ -0,0 +1,38 @@ +/** + * Dice completion source — extracts `` text content from markdown files. + */ + +import type { CompletionSource, DiceCompletion } from "../types.js"; + +function looksLikeDice(raw: string): boolean { + if (raw.length > 80) return false; + return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw); +} + +export const diceSource: CompletionSource = { + key: "dice", + + scan(index) { + const items: DiceCompletion[] = []; + const tagRegex = /]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; + + for (const [path, content] of Object.entries(index)) { + if (!path.endsWith(".md")) continue; + + let match: RegExpExecArray | null; + tagRegex.lastIndex = 0; + while ((match = tagRegex.exec(content)) !== null) { + const raw = match[1].trim(); + if (!raw || !looksLikeDice(raw)) continue; + + items.push({ + label: raw, + notation: raw, + source: path, + }); + } + } + + return items; + }, +}; diff --git a/src/cli/completions/sources/links.ts b/src/cli/completions/sources/links.ts new file mode 100644 index 0000000..c59ffc0 --- /dev/null +++ b/src/cli/completions/sources/links.ts @@ -0,0 +1,55 @@ +/** + * Link completion source — extracts markdown headings from all .md files. + * + * Produces two entries per heading section, plus one for the file itself. + * Uses github-slugger to match marked-gfm-heading-id's generated IDs. + */ + +import Slugger from "github-slugger"; +import type { CompletionSource, LinkCompletion } from "../types.js"; + +export const linksSource: CompletionSource = { + key: "links", + + scan(index) { + const items: LinkCompletion[] = []; + + for (const [filePath, content] of Object.entries(index)) { + if (!filePath.endsWith(".md")) continue; + + // Strip .md extension for the router-friendly path + const basePath = filePath.replace(/\.md$/, ""); + const fileName = fileNameFromPath(basePath); + const slugger = new Slugger(); + + // Add the file itself as a link (whole article) + items.push({ + path: basePath, + label: fileName, + section: null, + }); + + // Parse headings for section-scoped links + const headingRegex = /^(#{1,6})\s+(.+)$/gm; + let match: RegExpExecArray | null; + + while ((match = headingRegex.exec(content)) !== null) { + const title = match[2].trim(); + const id = slugger.slug(title.toLowerCase()); + + items.push({ + path: basePath, + label: `${fileName} § ${title}`, + section: id, + }); + } + } + + return items; + }, +}; + +function fileNameFromPath(path: string): string { + const parts = path.split("/").filter(Boolean); + return parts[parts.length - 1] || path; +} diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts new file mode 100644 index 0000000..4050094 --- /dev/null +++ b/src/cli/completions/types.ts @@ -0,0 +1,40 @@ +/** + * Completion source types — shared between CLI scanner and frontend consumer. + */ + +/** A single dice expression found in a markdown file */ +export interface DiceCompletion { + /** Display text shown in the dropdown */ + label: string; + /** Dice notation, e.g. "3d6kh1" or "1d20+5" */ + notation: string; + /** Source file for attribution */ + source: string; +} + +/** A single link target (article path, optionally scoped to a heading) */ +export interface LinkCompletion { + /** Route-relative path without .md extension, e.g. "/rules/combat" */ + path: string; + /** Human-readable label */ + label: string; + /** Heading slug anchor (without #), or null for the whole file */ + section: string | null; +} + +/** Top-level payload served at /__COMPLETIONS.json */ +export interface CompletionsPayload { + dice: DiceCompletion[]; + links: LinkCompletion[]; +} + +/** + * A completion source plugin. Add new sources by implementing this + * interface and registering in the barrel. + */ +export interface CompletionSource { + /** Unique key — becomes the top-level key in CompletionsPayload */ + key: string; + /** Scan the content index and return structured completion items */ + scan(index: Record): unknown[]; +}