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.
This commit is contained in:
parent
307a3c8320
commit
8284f5caee
|
|
@ -6,6 +6,10 @@ import { join, resolve, extname, sep, relative, dirname } from "path";
|
||||||
import { watch } from "chokidar";
|
import { watch } from "chokidar";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { createJournalServer } from "../journal.js";
|
import { createJournalServer } from "../journal.js";
|
||||||
|
import {
|
||||||
|
scanCompletions,
|
||||||
|
type CompletionsPayload,
|
||||||
|
} from "../completions/index.js";
|
||||||
|
|
||||||
interface ContentIndex {
|
interface ContentIndex {
|
||||||
[path: string]: string;
|
[path: string]: string;
|
||||||
|
|
@ -144,6 +148,7 @@ function createRequestHandler(
|
||||||
contentDir: string,
|
contentDir: string,
|
||||||
distDir: string,
|
distDir: string,
|
||||||
getIndex: () => ContentIndex,
|
getIndex: () => ContentIndex,
|
||||||
|
getCompletions: () => CompletionsPayload,
|
||||||
) {
|
) {
|
||||||
return (req: IncomingMessage, res: ServerResponse) => {
|
return (req: IncomingMessage, res: ServerResponse) => {
|
||||||
const url = req.url || "/";
|
const url = req.url || "/";
|
||||||
|
|
@ -155,6 +160,12 @@ function createRequestHandler(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1b. 处理 /__COMPLETIONS.json
|
||||||
|
if (filePath === "/__COMPLETIONS.json") {
|
||||||
|
sendJson(res, getCompletions());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 处理 /static/ 目录(从 dist/web)
|
// 2. 处理 /static/ 目录(从 dist/web)
|
||||||
if (filePath.startsWith("/static/")) {
|
if (filePath.startsWith("/static/")) {
|
||||||
if (tryServeStatic(res, filePath, distDir)) {
|
if (tryServeStatic(res, filePath, distDir)) {
|
||||||
|
|
@ -196,6 +207,10 @@ export interface ContentServer {
|
||||||
* 内容索引
|
* 内容索引
|
||||||
*/
|
*/
|
||||||
index: ContentIndex;
|
index: ContentIndex;
|
||||||
|
/**
|
||||||
|
* 补全数据
|
||||||
|
*/
|
||||||
|
completions: CompletionsPayload;
|
||||||
/**
|
/**
|
||||||
* 关闭服务器
|
* 关闭服务器
|
||||||
*/
|
*/
|
||||||
|
|
@ -212,11 +227,21 @@ export function createContentServer(
|
||||||
host: string = "0.0.0.0",
|
host: string = "0.0.0.0",
|
||||||
): ContentServer {
|
): ContentServer {
|
||||||
let contentIndex: ContentIndex = {};
|
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("扫描内容目录...");
|
console.log("扫描内容目录...");
|
||||||
contentIndex = scanDirectory(contentDir);
|
contentIndex = scanDirectory(contentDir);
|
||||||
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
||||||
|
recomputeCompletions();
|
||||||
|
|
||||||
// 监听文件变化
|
// 监听文件变化
|
||||||
console.log("监听文件变化...");
|
console.log("监听文件变化...");
|
||||||
|
|
@ -238,6 +263,7 @@ export function createContentServer(
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
contentIndex[relPath] = content;
|
contentIndex[relPath] = content;
|
||||||
console.log(`[新增] ${relPath}`);
|
console.log(`[新增] ${relPath}`);
|
||||||
|
if (relPath.endsWith(".md")) recomputeCompletions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取新增文件失败:${path}`, e);
|
console.error(`读取新增文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
|
|
@ -254,6 +280,7 @@ export function createContentServer(
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
contentIndex[relPath] = content;
|
contentIndex[relPath] = content;
|
||||||
console.log(`[更新] ${relPath}`);
|
console.log(`[更新] ${relPath}`);
|
||||||
|
if (relPath.endsWith(".md")) recomputeCompletions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取更新文件失败:${path}`, e);
|
console.error(`读取更新文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +295,7 @@ export function createContentServer(
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
delete contentIndex[relPath];
|
delete contentIndex[relPath];
|
||||||
console.log(`[删除] ${relPath}`);
|
console.log(`[删除] ${relPath}`);
|
||||||
|
if (relPath.endsWith(".md")) recomputeCompletions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -279,6 +307,7 @@ export function createContentServer(
|
||||||
contentDir,
|
contentDir,
|
||||||
distPath,
|
distPath,
|
||||||
() => contentIndex,
|
() => contentIndex,
|
||||||
|
() => completionsIndex,
|
||||||
);
|
);
|
||||||
const server = createServer(handleRequest);
|
const server = createServer(handleRequest);
|
||||||
|
|
||||||
|
|
@ -298,6 +327,7 @@ export function createContentServer(
|
||||||
server,
|
server,
|
||||||
watcher,
|
watcher,
|
||||||
index: contentIndex,
|
index: contentIndex,
|
||||||
|
completions: completionsIndex,
|
||||||
close() {
|
close() {
|
||||||
console.log("关闭内容服务器...");
|
console.log("关闭内容服务器...");
|
||||||
server.close();
|
server.close();
|
||||||
|
|
|
||||||
|
|
@ -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<string, string>,
|
||||||
|
): CompletionsPayload {
|
||||||
|
const payload: Record<string, unknown[]> = {};
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
payload[source.key] = source.scan(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as unknown as CompletionsPayload;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Dice completion source — extracts `<md-dice>` 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 = /<md-dice[^>]*>\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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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<string, string>): unknown[];
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue