import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import type { Plugin } from "vite"; /** 虚拟 stub 模块标识。 */ const STUB_ID = "\0timi-web-ssr-stub"; /** 被 stub 的模块说明符。 */ const STUBBED_MODULES = new Set(["timi-web", "timi-web/editor"]); /** * SSR 阶段必须存在的具名导出。 * * 取自 `src` 与 `docs` 中所有 `timi-web` / `timi-web/editor` 值导入的并集, * 是 stub 的保底名单;若构建产物可读,`collectExportNames` 会在此基础上补全全部导出。 */ const REQUIRED_EXPORTS = [ // ---------- timi-web ---------- "VPopup", "Popup", "PopupType", "Copyright", "MarkdownView", "MarkdownEditor", "Toolkit", "WebToolkit", // ---------- timi-web/editor ---------- "findTextMatches", "replaceAllText", "replaceMatchText", "buildSearchRegex", "highlightCode", "resolveCodeEditorKeydown", "resolvePrismLanguage", "useTiptapEditor", "useTiptapView", "headingMeta", "ATTACHMENT_ID_ATTR", "TEMP_FILE_ID_ATTR", "LOCAL_MEDIA_ID_PREFIX", "isLocalMediaId", "normalizeContent", "supportedCodeLanguages", "TABLE_PICKER_SIZE" ]; /** 从构建产物中提取具名导出,让 stub 自动跟上上游变化。 */ function collectExportNames(distFile: string): Set { const names = new Set(REQUIRED_EXPORTS); if (!existsSync(distFile)) { return names; } try { const source = readFileSync(distFile, "utf-8"); for (const match of source.matchAll(/export\s*\{([^}]*)\}/g)) { for (const part of match[1].split(",")) { const bits = part.trim().split(/\s+as\s+/); const name = (bits[1] ?? bits[0]).trim(); if (/^[A-Za-z_$][\w$]*$/.test(name)) { names.add(name); } } } } catch { // 读取失败时退回保底名单,不影响构建。 } return names; } /** 生成 stub 模块源码:每个导出都是可调用、可解构、可当插件安装的空实现。 */ function buildStubSource(names: Set): string { const named = [...names] .filter(name => name !== "default") .map(name => `export const ${name} = stub;`) .join("\n"); return `const stub = new Proxy(function () {}, { get(target, key) { if (key === Symbol.toPrimitive || key === "valueOf" || key === "toString") { return () => ""; } return stub; }, apply() { return stub; }, construct() { return {}; } }); export default stub; ${named} `; } /** * 在 SSR 阶段把 timi-web 替换为空实现。 * * timi-web 的构建产物在模块顶层就访问 `window` 并启动 Prism 自动高亮定时器, * 在 Node 中 import 会直接抛错,导致 VitePress 的 SSR 构建必然失败。 * * 文档站里真正需要 timi-web 的只有演示组件,它们全部通过 `clientOnly` 延迟到浏览器渲染, * SSR 阶段只需要「能 import 且不报错」,因此这里返回一个 Proxy 空实现。 * 客户端构建不受影响,`options.ssr` 为 false 时照常解析到真实模块。 * @param docsDir docs 目录绝对路径,用于定位 timi-web 构建产物 */ export function timiWebSsrStub(docsDir: string): Plugin { const distFile = resolve(docsDir, "node_modules/timi-web/dist/timi-web.mjs"); let stubSource: string | null = null; return { name: "timi-web-ssr-stub", enforce: "pre", resolveId(source, importer, options) { // 只拦截精确的包名,`timi-web/style.css` 等子路径照常交给 Vite 处理。 if (options?.ssr && STUBBED_MODULES.has(source)) { return STUB_ID; } return null; }, load(id) { if (id !== STUB_ID) { return null; } if (stubSource === null) { stubSource = buildStubSource(collectExportNames(distFile)); } return stubSource; } }; }