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:
2026-07-06 16:50:45 +08:00
parent 307a3c8320
commit 8284f5caee
5 changed files with 195 additions and 0 deletions
+32
View File
@@ -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;
}
+38
View File
@@ -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;
},
};
+55
View File
@@ -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;
}
+40
View File
@@ -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[];
}