From 0a68122efdc409075184f7b35201e227073b1136 Mon Sep 17 00:00:00 2001 From: hyper Date: Fri, 7 Aug 2026 12:46:08 +0800 Subject: [PATCH] refactor(cli): centralize content registry building Extract buildRegistryFromIndex and normalizePathKey from the CLI serve command and the browser completions module into the shared content-registry module. This ensures both paths produce identical pathIndex/docContent, including the spark-injection pass that was previously only applied in the CLI. --- src/cli/commands/serve.ts | 31 ++++------------- src/cli/content-registry.ts | 50 +++++++++++++++++++++++++++ src/components/journal/completions.ts | 15 ++++---- 3 files changed, 63 insertions(+), 33 deletions(-) diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 05d38dc..5d7c716 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -2,15 +2,15 @@ import type { ServeCommandHandler } from "../types.js"; import { createServer, Server, IncomingMessage, ServerResponse } from "http"; import { readdirSync, statSync, readFileSync, existsSync } from "fs"; import { createReadStream } from "fs"; -import { join, resolve, extname, sep, relative, dirname } from "path"; +import { join, resolve, extname, relative, dirname } from "path"; import { watch } from "chokidar"; import { networkInterfaces } from "os"; import { fileURLToPath } from "url"; import { createJournalServer } from "../journal.js"; import { - scanDoc, + buildRegistryFromIndex, + normalizePathKey, deriveCompletions, - injectSparkDirectives, type ContentRegistry, } from "../content-registry.js"; import type { CompletionsPayload } from "../completions/types.js"; @@ -86,7 +86,7 @@ function getBestIP(): string { * 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容) */ export function buildRegistry(dir: string): ContentRegistry { - const registry: ContentRegistry = { pathIndex: {}, docContent: {} }; + const index: Record = {}; function scan(currentPath: string, relativePath: string) { const entries = readdirSync(currentPath); @@ -96,7 +96,7 @@ export function buildRegistry(dir: string): ContentRegistry { const fullPath = join(currentPath, entry); const relPath = relativePath ? join(relativePath, entry) : entry; - const normalizedRelPath = "/" + relPath.split(sep).join("/"); + const normalizedRelPath = normalizePathKey(relPath); const stats = statSync(fullPath); if (stats.isDirectory()) { @@ -108,14 +108,7 @@ export function buildRegistry(dir: string): ContentRegistry { entry.endsWith(".svg") ) { try { - const content = readFileSync(fullPath, "utf-8"); - if (entry.endsWith(".md")) { - const result = scanDoc(content, normalizedRelPath); - registry.pathIndex[normalizedRelPath] = result.stripped; - registry.docContent[normalizedRelPath] = result.content; - } else { - registry.pathIndex[normalizedRelPath] = content; - } + index[normalizedRelPath] = readFileSync(fullPath, "utf-8"); } catch (e) { console.error(`读取文件失败:${fullPath}`, e); } @@ -125,17 +118,7 @@ export function buildRegistry(dir: string): ContentRegistry { scan(dir, ""); - // ---- Inject data-spark into real-file spark table directives ---- - for (const [relPath, content] of Object.entries(registry.pathIndex)) { - if (!relPath.endsWith(".md")) continue; - registry.pathIndex[relPath] = injectSparkDirectives( - content, - relPath, - registry, - ); - } - - return registry; + return buildRegistryFromIndex(index); } /** diff --git a/src/cli/content-registry.ts b/src/cli/content-registry.ts index 86309da..26e21e1 100644 --- a/src/cli/content-registry.ts +++ b/src/cli/content-registry.ts @@ -63,6 +63,16 @@ export const EMPTY_REGISTRY: ContentRegistry = { docContent: {}, }; +/** + * Normalize a file path into a stable registry key: forward slashes and a + * single leading `/`. Both the CLI filesystem walk and the browser folder + * scan funnel through this so identical files always share a key. + */ +export function normalizePathKey(path: string): string { + const normalized = path.split("\\").join("/"); + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + // --------------------------------------------------------------------------- // Id / hash derivation (single source of truth) // --------------------------------------------------------------------------- @@ -185,6 +195,46 @@ export function scanDoc(content: string, docPath: string): DocScanResult { return { stripped: rewritten, content: contentStore }; } +/** + * Build a `ContentRegistry` from a raw `{ path: content }` index. + * + * Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser + * folder scan (`scanClientSide` in `components/journal/completions.ts`) so + * both modes produce identical `pathIndex`/`docContent` — including the + * `data-spark` injection pass that only the CLI used to run. + * + * Keys are normalized via `normalizePathKey`; `.md` files are run through + * `scanDoc` and the spark-injection pass, other extensions are stored raw. + */ +export function buildRegistryFromIndex( + index: Record, +): ContentRegistry { + const registry: ContentRegistry = { pathIndex: {}, docContent: {} }; + + for (const [rawPath, content] of Object.entries(index)) { + const path = normalizePathKey(rawPath); + if (path.endsWith(".md")) { + const result = scanDoc(content, path); + registry.pathIndex[path] = result.stripped; + registry.docContent[path] = result.content; + } else { + registry.pathIndex[path] = content; + } + } + + // Inject data-spark into real-file spark table directives (idempotent). + for (const [relPath, content] of Object.entries(registry.pathIndex)) { + if (!relPath.endsWith(".md")) continue; + registry.pathIndex[relPath] = injectSparkDirectives( + content, + relPath, + registry, + ); + } + + return registry; +} + // --------------------------------------------------------------------------- // Spark table coercion // --------------------------------------------------------------------------- diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index c0361df..0fbbebf 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -16,7 +16,7 @@ import { setInlineResolver, } from "../../data-loader/file-index"; import { - scanDoc, + buildRegistryFromIndex, deriveCompletions, resolveInlineByPath, type ContentRegistry, @@ -108,19 +108,16 @@ async function tryServerRegistry(): Promise { async function scanClientSide(): Promise { const paths = await getPathsByExtension("md"); - // Build a registry from the in-memory file index, then derive completions - // through the same shared pipeline as the CLI. - const registry: ContentRegistry = { pathIndex: {}, docContent: {} }; - - // First pass: load all .md content into the registry. + // Load all .md content into a raw index, then build the registry through + // the same shared pipeline as the CLI (scanDoc + spark injection). + const index: Record = {}; for (const filePath of paths) { const content = await getIndexedData(filePath); if (!content) continue; - const result = scanDoc(content, filePath); - registry.pathIndex[filePath] = result.stripped; - registry.docContent[filePath] = result.content; + index[filePath] = content; } + const registry = buildRegistryFromIndex(index); activeRegistry = registry; return deriveCompletions(registry); }