diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index b50357e..c72fc30 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -11,6 +11,7 @@ import { scanCompletions, type CompletionsPayload, } from "../completions/index.js"; +import { extractInlineBlocks } from "../inline-blocks.js"; interface ContentIndex { [path: string]: string; @@ -105,7 +106,16 @@ export function scanDirectory(dir: string): ContentIndex { ) { try { const content = readFileSync(fullPath, "utf-8"); - index[normalizedRelPath] = content; + if (entry.endsWith(".md")) { + const stripped = extractInlineBlocks( + content, + normalizedRelPath, + index, + ); + index[normalizedRelPath] = stripped; + } else { + index[normalizedRelPath] = content; + } } catch (e) { console.error(`读取文件失败:${fullPath}`, e); } @@ -295,9 +305,18 @@ export function createContentServer( try { const content = readFileSync(path, "utf-8"); const relPath = "/" + relative(contentDir, path).split(sep).join("/"); - contentIndex[relPath] = content; + if (relPath.endsWith(".md")) { + const stripped = extractInlineBlocks( + content, + relPath, + contentIndex, + ); + contentIndex[relPath] = stripped; + recomputeCompletions(); + } else { + contentIndex[relPath] = content; + } console.log(`[新增] ${relPath}`); - if (relPath.endsWith(".md")) recomputeCompletions(); } catch (e) { console.error(`读取新增文件失败:${path}`, e); } @@ -312,9 +331,18 @@ export function createContentServer( try { const content = readFileSync(path, "utf-8"); const relPath = "/" + relative(contentDir, path).split(sep).join("/"); - contentIndex[relPath] = content; + if (relPath.endsWith(".md")) { + const stripped = extractInlineBlocks( + content, + relPath, + contentIndex, + ); + contentIndex[relPath] = stripped; + recomputeCompletions(); + } else { + contentIndex[relPath] = content; + } console.log(`[更新] ${relPath}`); - if (relPath.endsWith(".md")) recomputeCompletions(); } catch (e) { console.error(`读取更新文件失败:${path}`, e); } diff --git a/src/cli/inline-blocks.ts b/src/cli/inline-blocks.ts new file mode 100644 index 0000000..aa78e8b --- /dev/null +++ b/src/cli/inline-blocks.ts @@ -0,0 +1,68 @@ +import { posix } from "path"; + +/** + * Regex to match a fenced code block with a `file="..."` attribute, e.g.: + * + * ```csv file="card-data.csv" + * name, cost, effect + * Fireball, 1, Blast em + * ``` + * + * ```yaml file="monsters.yaml" + * goblin: + * hp: 7 + * ``` + * + * The language identifier is optional — only `file="..."` matters. + * + * Group 1: the info string after the opening fence (e.g. `csv file="card-data.csv"`) + * Group 2: the code block body + */ +const INLINE_FILE_BLOCK_RE = /^```\w*\s+file="([^"]+)"(?:\s+\w+="[^"]*")*\s*\n([\s\S]*?)```\s*$/gm; + +/** + * Process fenced code blocks that contain a `file="..."` attribute. + * + * For each block like: + * ```lang file="card-data.csv" + * content + * ``` + * + * 1. Strips the block from the returned text (so it doesn't render) + * 2. Injects a synthetic entry into `index` at the resolved file path + * + * @param content - Raw file content + * @param fileRelativePath - Normalized path of the containing file (e.g. "/rules/combat.md") + * @param index - The content index to inject synthetic entries into + * @returns Content with inline blocks removed + */ +export function extractInlineBlocks( + content: string, + fileRelativePath: string, + index: Record, +): string { + const matches: { filename: string; body: string }[] = []; + let match: RegExpExecArray | null; + + INLINE_FILE_BLOCK_RE.lastIndex = 0; + + while ((match = INLINE_FILE_BLOCK_RE.exec(content)) !== null) { + matches.push({ filename: match[1], body: match[2] }); + } + + if (matches.length === 0) return content; + + const stripped = content.replace(INLINE_FILE_BLOCK_RE, ""); + + for (const { filename, body } of matches) { + const fileDir = posix.dirname(fileRelativePath); + const resolvedPath = posix.join(fileDir, filename); + + index[resolvedPath] = body; + console.log( + `[inline] Extracted ${resolvedPath} from ${fileRelativePath}`, + ); + } + + return stripped; +}