diff --git a/package.json b/package.json index 5d38afac7..ec203754c 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,11 @@ "@dhlx/vitepress-plugin-drawio": "^0.0.10", "@panzoom/panzoom": "^4.6.2", "@types/node": "^25.6.2", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", "gray-matter": "4.0.3", "linkedom": "0.18.13", + "mark.js": "8.11.1", "markdown-it-mathjax3": "^4.3.2", "mermaid": "^10.9.6", "pagedjs": "0.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9fc9056d..0cbf5b2b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,12 +21,21 @@ importers: '@types/node': specifier: ^25.6.2 version: 25.6.2 + '@vueuse/core': + specifier: ^12.4.0 + version: 12.8.2 + '@vueuse/integrations': + specifier: ^12.4.0 + version: 12.8.2(focus-trap@7.8.0) gray-matter: specifier: 4.0.3 version: 4.0.3 linkedom: specifier: 0.18.13 version: 0.18.13 + mark.js: + specifier: 8.11.1 + version: 8.11.1 markdown-it-mathjax3: specifier: ^4.3.2 version: 4.3.2 @@ -817,6 +826,7 @@ packages: '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version algoliasearch@5.52.1: resolution: {integrity: sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==} diff --git a/site/.vitepress/config/index.ts b/site/.vitepress/config/index.ts index a1f17d0cc..42dc5cde9 100644 --- a/site/.vitepress/config/index.ts +++ b/site/.vitepress/config/index.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitepress' import withDrawio from '@dhlx/vitepress-plugin-drawio' import { navEn } from './nav' import { buildSidebar } from './sidebar' -import { sharedThemeConfig, sharedMarkdown, makeSocialLinks } from './shared' +import { sharedThemeConfig, sharedMarkdown, makeSocialLinks, localSearchBoxAlias } from './shared' import { createReadStream, existsSync } from 'node:fs' import { join, normalize } from 'node:path' import { fileURLToPath } from 'node:url' @@ -35,6 +35,9 @@ function serveCodeExamplesInDev() { export default withDrawio(defineConfig({ vite: { plugins: [serveCodeExamplesInDev()], + resolve: { + alias: localSearchBoxAlias, + }, ssr: { // mermaid / @panzoom/panzoom 都只在客户端 onMounted 后动态 import 求值, // SSR 阶段不能也不应打包求值(mermaid 访问 document、panzoom 访问 DOM)。 diff --git a/site/.vitepress/config/shared.ts b/site/.vitepress/config/shared.ts index 9345c678f..d63c50601 100644 --- a/site/.vitepress/config/shared.ts +++ b/site/.vitepress/config/shared.ts @@ -38,6 +38,16 @@ export const sharedMarkdown = { }, } +// 覆盖 vitepress 内置搜索盒:VPLocalSearchBox 只被 VPNavBarSearch 以 +// './VPLocalSearchBox.vue' 这一 specifier 引用,alias 指到我们的覆盖版 +// (索引构建+查询挪进 Web Worker,修 issue #156 的搜索卡顿)。 +// index.ts(dev/单体 build)与 sharedBase(分卷构建)都要用,单一来源防漏改。 +export const localSearchBoxAlias = { + './VPLocalSearchBox.vue': fileURLToPath( + new URL('../theme/components/VPLocalSearchBox.vue', import.meta.url) + ), +} + export const sharedBase = { base: '/Tutorial_AwesomeModernCPP/', cleanUrls: true, @@ -54,6 +64,9 @@ export const sharedBase = { }, vite: { + resolve: { + alias: localSearchBoxAlias, + }, build: { chunkSizeWarningLimit: 5000, }, diff --git a/site/.vitepress/theme/components/VPLocalSearchBox.vue b/site/.vitepress/theme/components/VPLocalSearchBox.vue new file mode 100644 index 000000000..8b17525ce --- /dev/null +++ b/site/.vitepress/theme/components/VPLocalSearchBox.vue @@ -0,0 +1,974 @@ + + + + + + + diff --git a/site/.vitepress/theme/local-search-backend.ts b/site/.vitepress/theme/local-search-backend.ts new file mode 100644 index 000000000..fa2873f64 --- /dev/null +++ b/site/.vitepress/theme/local-search-backend.ts @@ -0,0 +1,130 @@ +// 搜索后端封装(vitepress theme 覆盖件,配套 VPLocalSearchBox.vue)。 +// 优先用 Web Worker 跑索引构建 + 查询;worker 不可用(老浏览器/被 CSP 拦) +// 或 worker 初始化失败时,自动回退到 VitePress 原行为:主线程 MiniSearch。 +// +// 实例是模块级单例:搜索盒每次开关不重建、不丢已解析的索引,重新打开 +// 搜索时不用再付一次解析成本。 +import MiniSearch, { type Options, type SearchResult } from 'minisearch' + +// 与 worker 侧、VPLocalSearchBox 的展示条数一致 +const RESULT_LIMIT = 16 + +export interface SearchBackend { + init(json: string, options: Options): Promise + search(query: string): Promise +} + +let shared: SearchBackend | null = null + +export function getSearchBackend(): SearchBackend { + if (!shared) { + shared = createWorkerBackend() ?? createMainThreadBackend() + } + return shared +} + +function createWorkerBackend(): SearchBackend | null { + // 该模块只会在浏览器挂载的组件里被调用,这里再兜一道 SSR/环境守卫 + if (import.meta.env.SSR || typeof Worker === 'undefined') return null + try { + return new WorkerSearchBackend() + } catch { + return null + } +} + +class WorkerSearchBackend implements SearchBackend { + private worker: Worker + private dead = false + // worker 初始化失败后改走的主线程索引(即 VitePress 原行为) + private fallbackIndex: MiniSearch | null = null + private msgId = 0 + private initWaiters = new Map< + number, + { resolve: () => void; reject: (err: unknown) => void } + >() + private searchWaiters = new Map void>() + + constructor() { + this.worker = new Worker( + new URL('./local-search-worker.ts', import.meta.url), + { type: 'module' } + ) + this.worker.onmessage = (e: MessageEvent) => this.onMessage(e.data) + this.worker.onerror = () => this.drainWaiters() + } + + private onMessage(msg: { + type: 'ready' | 'error' | 'results' + id: number + results?: SearchResult[] + message?: string + }) { + if (msg.type === 'ready' || msg.type === 'error') { + const waiter = this.initWaiters.get(msg.id) + this.initWaiters.delete(msg.id) + if (msg.type === 'ready') waiter?.resolve() + else waiter?.reject(new Error(msg.message)) + } else if (msg.type === 'results') { + const resolve = this.searchWaiters.get(msg.id) + this.searchWaiters.delete(msg.id) + resolve?.(msg.results ?? []) + } + } + + // worker 崩溃/加载失败:在途请求一律收尾,别让组件悬挂 + private drainWaiters() { + this.dead = true + for (const { reject } of this.initWaiters.values()) { + reject(new Error('search worker crashed')) + } + this.initWaiters.clear() + for (const resolve of this.searchWaiters.values()) resolve([]) + this.searchWaiters.clear() + } + + async init(json: string, options: Options): Promise { + // 已经在走主线程回退了,就不再碰 worker + if (this.dead) { + this.fallbackIndex = MiniSearch.loadJSON(json, options) + return + } + const id = ++this.msgId + const ready = new Promise((resolve, reject) => { + this.initWaiters.set(id, { resolve, reject }) + }) + this.worker.postMessage({ type: 'init', id, json, options }) + try { + await ready + this.fallbackIndex = null + } catch { + // worker 初始化失败:同一份 json 落回主线程,行为退回 VitePress 原状 + this.dead = true + this.fallbackIndex = MiniSearch.loadJSON(json, options) + } + } + + async search(query: string): Promise { + if (this.fallbackIndex) { + return this.fallbackIndex.search(query).slice(0, RESULT_LIMIT) + } + if (this.dead) return [] + const id = ++this.msgId + return new Promise((resolve) => { + this.searchWaiters.set(id, resolve) + this.worker.postMessage({ type: 'search', id, query }) + }) + } +} + +function createMainThreadBackend(): SearchBackend { + let index: MiniSearch | null = null + return { + async init(json, options) { + index = MiniSearch.loadJSON(json, options) + }, + async search(query) { + return index ? index.search(query).slice(0, RESULT_LIMIT) : [] + }, + } +} diff --git a/site/.vitepress/theme/local-search-worker.ts b/site/.vitepress/theme/local-search-worker.ts new file mode 100644 index 000000000..c83760b56 --- /dev/null +++ b/site/.vitepress/theme/local-search-worker.ts @@ -0,0 +1,31 @@ +// 本地搜索 worker(vitepress theme 覆盖件,配套 VPLocalSearchBox.vue)。 +// 全站索引是一块 13MB 级的 JSON(zh),原先 JSON.parse + MiniSearch 重建 + +// 每次击键的查询全在主线程,搜索一开页面就冻住(issue #156)。这里把这些 +// 重活全部搬进 worker,主线程只收结果。 +// +// 协议(请求按发送顺序串行处理,响应带 id 原路返回): +// { type: 'init', id, json, options } -> { type: 'ready' | 'error', id } +// { type: 'search', id, query } -> { type: 'results', id, results } +import MiniSearch from 'minisearch' + +// 与 VPLocalSearchBox 的展示条数一致;主线程侧不再重复截断 +const RESULT_LIMIT = 16 + +let index: MiniSearch | null = null + +self.onmessage = (e: MessageEvent) => { + const msg = e.data + if (msg.type === 'init') { + try { + index = MiniSearch.loadJSON(msg.json, msg.options) + self.postMessage({ type: 'ready', id: msg.id }) + } catch (err) { + index = null + self.postMessage({ type: 'error', id: msg.id, message: String(err) }) + } + } else if (msg.type === 'search') { + // init 尚未完成时收到查询:回空结果,主线程有 loading 态兜底 + const results = index ? index.search(msg.query).slice(0, RESULT_LIMIT) : [] + self.postMessage({ type: 'results', id: msg.id, results }) + } +} diff --git a/site/.vitepress/theme/vitepress-deep-modules.d.ts b/site/.vitepress/theme/vitepress-deep-modules.d.ts new file mode 100644 index 000000000..a04adbf2c --- /dev/null +++ b/site/.vitepress/theme/vitepress-deep-modules.d.ts @@ -0,0 +1,44 @@ +// VPLocalSearchBox.vue 覆盖件用到的 vitepress 内部模块的手写最小类型。 +// 这些文件经 vitepress 的 "./dist/*" exports 深导入可达,但包里没有随附 +// .d.ts;类型声明只为编辑器/TS 解析,不参与构建产物。 +declare module '@localSearchIndex' { + const localSearchIndex: Record< + string, + () => Promise<{ default: string }> | undefined + > + export default localSearchIndex +} + +declare module 'vitepress/dist/client/app/utils.js' { + export function pathToFile(path: string): string | null +} + +declare module 'vitepress/dist/client/shared.js' { + export function escapeRegExp(str: string): string +} + +declare module 'vitepress/dist/client/theme-default/support/lru.js' { + export class LRUCache { + constructor(max?: number) + get(key: K): V | undefined + set(key: K, value: V): void + clear(): void + } +} + +declare module 'vitepress/dist/client/theme-default/support/translation.js' { + export function createSearchTranslate( + defaultTranslations: T + ): (key: string) => string +} + +declare module 'mark.js/src/vanilla.js' { + export default class Mark { + constructor(root: HTMLElement) + unmark(options: { done?: () => void }): void + markRegExp( + regex: RegExp, + options: { done?: () => void; acrossElements?: boolean } + ): void + } +}