Neoverse-Docs
关于项目

搜索、导航与社区

静态搜索、结果增强、章节筛选、跨文档返回、源码端点、i18n 与 Giscus

主要编写者:
本章 AI 摘要

项目把搜索索引、页面树和源码端点静态生成,再在浏览器中增Chapter 范围筛选、搜索结果增强流水线、搜索聚光灯与跨文档阅读返回。中文搜索使用中英混合分词,并只为标题和小节标题增加拼音检索。Giscus 主题切换通过 useLayoutEffect固定 400ms隐消除闪烁。路由转场独立专题明。

一、静态搜索架构

src/content/search/index.ts 使用 Fumadocs createFromSource() 从内容源生成搜索数据;src/app/api/search/route.ts 只设置 dynamic = 'force-static' 并导出静态 GET。生产构建会把这个 Route Handler 输出为静态端点,不需要请求时扫描 Markdown。

Fumadocs 的页级索引 ID 使用稳定的 docs:<id>:<locale>,导航仍走 URL。全文索引记录保留页面 URL、标题、描述和 structured content;一级 slug 继续作为原始 tag,因此既有 Chapter 筛选直接复用内容路径,不需要维护第二份章节映射。

Search Schema v2 与元数据 Join

src/content/search/schema.ts 在应用层定义 SearchDocument:每条页、描述、小节或正文记录都可表达 contentIdlocale、标题、描述、可选 heading ID / 标题、正文,以及 contentType、Topic、Track 和 Difficulty。这里使用 contentType 而不是 type,避免与 Fumadocs 的 page / heading / text 记录类型混淆。

正文继续只来自 Fumadocs 的 structured content。构建索引时,src/content/search/index.ts 以稳定 contentId + locale 从 Manifest 派生的 Search Metadata Projection 连接 taxonomy;找不到匹配项会使构建失败,而不会悄悄生成缺少 metadata 的记录。正文不进入 Content IR,也不会复制到 metadata Sidecar。

现有静态搜索引擎保持不变。为兼容其固定 Schema,Chapter 原始 tag 外还会写入 track:<id>topic:<id>content-type:<id>difficulty:<id> 等 namespaced tag。静态 /api/search-metadata Sidecar 按稳定页级搜索 ID 输出这些 metadata,供未来的 Search UI 或结果增强消费;当前 Search Dialog 不请求它,也不新增筛选控件。

二、中英混合与拼音检索

默认英文分词无法可靠处理连续中文,简单按字符切分又会损害英文单词和短语。src/content/search/tokenizer.ts 组合了两类行为:

  • 中文片段使用 @orama/tokenizers/mandarin 的词典分词;
  • 英文片段统一小写,保持英文查询大小写不敏感;
  • 中文标题与小节标题额外建立拼音形式;
  • 正文不全量扩展拼音,避免索引膨胀和无关匹配;
  • 英文 locale 继续使用英文语言配置。

命名空间前缀

服务端索引与客户端查询使用不同的命名空间前缀,确保拼音只在需要时被检索:

常量前缀用途
PINYIN_INDEX_PREFIX\uE000服务端仅标记标题与小节标题,生成拼音别名
PINYIN_QUERY_PREFIX\u0000pinyin:浏览器查询时仅搜索带命名空间的拼音别名

拼音生成

getPinyinAliases() 为中文段生成:

  • 全拼形式;
  • 首字母缩写;
  • 有界相邻片段(MAX_PINYIN_SEGMENT_SPAN = 8),避免过长片段产生无关匹配。

addKeyboardUmlautVariant() 处理 v → ü 变体,支持键盘输入拼音变音符号。

服务端在构建索引前收集需要拼音扩展的标题,客户端使用同一混合分词逻辑初始化中文数据库,避免"索引方式"和"查询方式"不一致。

中文搜索把 thresholdtolerance 设为严格值,减少短词和单字被模糊匹配到大量无关页面。英文搜索仍由英文语言配置承担词形与规范化。

三、搜索结果增强

src/features/search/client.tswithEnhancedSearch() 包装原始搜索客户端,按以下流水线处理结果:

Text
原始搜索结果
  → cleanSearchResultContent()      清除拼音索引前缀
  → rankSearchResultGroups()        按 page 分组,组内按 type 优先级排序
  → preferSearchResultAnchors()     每组首位 page 结果继承最相关子节 URL
  → mergePinyinSearchResults()      拼音结果去重合并
  → addSearchSpotlightParams()      为每个结果添加聚光灯参数

分组排序

rankSearchResultGroups()

  • 按 page 分组,组内按 type 优先级(page > heading > text);
  • 组间按是否含 <mark> 高亮排序,高亮命中组优先。

锚点优先

preferSearchResultAnchors():每组首位 page 结果继承最相关子节 URL,让搜索结果直接定位到具体小节。

拼音合并

mergePinyinSearchResults()

  • 拼音结果去重合并;
  • 跳过 text 类型与已见 id
  • 限制 60 条,避免结果膨胀。

聚光灯参数

addSearchSpotlightParams() 为每个结果添加 _searchSpotlight 参数,携带最相关文本片段,用于跳转后的聚光灯定位。

四、搜索弹窗与范围

src/features/search/components/search-dialog.tsx 使用 Fumadocs useDocsSearch 和静态客户端:

  • 中文 locale 使用 createMixedTokenizer(),其他使用 english
  • withEnhancedSearch(searchClient, locale === 'zh') 包装:拼音回退、结果分组排序、章节锚点继承;
  • 章节筛选 Popover:search-dialog__scope-trigger 轻量触发器保持次要层级。

范围选项从当前 locale 的页面树生成:

  • "全部内容"不传 tag
  • 选择某个 Chapter 时,以该根节点的 slug 作为 tag
  • locale 变化时重置不再有效的范围;
  • 结果与范围文案从项目字典读取。

搜索弹窗关闭时,组件会在捕获阶段监听 Fumadocs 的 Ctrl + K / Cmd + K 快捷键。若读者在 #nd-page 正文容器内保留了完整选区,弹窗打开前会把选区规范化为空格分隔的单行查询,并取前 200 个字符(MAX_SELECTED_SEARCH_LENGTH);点击搜索入口、跨出正文的选区和可编辑控件中的选区都保持原有查询行为。

范围菜单只是查询约束,不维护独立内容状态。新增 Chapter 并注册页面树后,它会自然进入搜索范围。

Chapter 仍是作者的线性内容编排;它只用于兼容的搜索范围,不能推导 Topic,也不是 Learn、Explore 或 Reference 的分类来源。Topic、Track、Content Type 和 Difficulty 的查询数据只来自 content/search 的 Projection / Schema 接口,Search Feature 不重新解析 Frontmatter 或 Taxonomy。

五、搜索聚光灯

src/features/search/components/search-spotlight.tsx 实现搜索结果跳转后的短时全局聚光灯:

  • 监听 click(link / button[aria-selected])、keydown Enter、hashchange、popstate;
  • findTextRange():TreeWalker 遍历文本节点,在锚点之后查找目标文本;
  • getRangeRect():合并多行 rects;
  • clampSpotlightRect():12px padding,限制在视口内;
  • centerSpotlightRect():滚动让目标居中;
  • ResizeObserver + MutationObserver 跟踪布局变化;
  • 交互(pointerdown / wheel / touchstart / Escape)后关闭;
  • SPOTLIGHT_DURATION_MS = 3200(reduced motion 1800);
  • removeSpotlightParam():完成后从 URL 清除参数。

src/features/search/spotlight.ts 提供聚光灯参数处理:

  • SEARCH_SPOTLIGHT_PARAM = '_searchSpotlight'
  • getSpotlightScrollDelta():让目标 rect 中心对齐视口中心;
  • cleanSpotlightText():清除 HTML 标签与 markdown 装饰,截断 120 字符;
  • getResultGroupTarget():优先取 <mark> 高亮文本,page 类型向子节借用目标。

六、页面树与导航

content/docs/{locale}/meta.json 和子目录 meta.json 共同形成 Fumadocs 页面树。文档布局使用当前 locale 对应的树,因此中文与英文可以拥有不同覆盖范围,不需要创建空页面维持完全对称。

Fumadocs 页面树提供:

  • 左侧 Chapter、Stage 与页面导航;
  • 当前页面的上一篇 / 下一篇关系;
  • 本页目录和滚动位置;
  • 首页章节入口和搜索范围来源。

分段标题通过 ---标题--- 形式记录在 pages 数组中。页面顺序来自显式配置,而不是依赖文件系统排序。

七、跨文档阅读返回

普通浏览器历史只能回到来源页面,无法稳定找回读者点击链接时的上下文。长文档上方若有图片、代码或延迟布局内容,恢复绝对 scrollY 还可能发生偏移。

DocsReadingReturn 在正文链接被点击时记录:

  • 来源和目标 pathname;
  • 来源页面标题;
  • 链接相对于 data-docs-body 的元素路径;
  • 链接离开时的视口位置;
  • 绝对滚动位置兜底。

到达目标文档后,页面显示"返回阅读位置"操作。返回来源时,组件优先按元素路径找到同一链接,再把它恢复到离开时的屏幕高度;只有元素路径失效才使用绝对位置。

这些元数据保存在 sessionStorage,不会保存或克隆正文。外部链接、新窗口链接、下载链接、同页 Hash 和上一篇 / 下一篇导航保持原生行为。

文档刷新恢复

src/features/reading/restore.ts 处理页面刷新后的滚动恢复:

  • DOCS_REFRESH_RESTORE_BOOTSTRAP 是 hydration 前内联脚本;
  • 仅在 navigation.type === 'reload' 且 sessionStorage 存在有效锚点时运行;
  • 设置 data-nd-reading-restore 属性让客户端恢复滚动位置。

八、文档源码与 GitHub 操作

文档页面根据内容源的 fullPath 推导 GitHub 文件地址,同时按 locale 和 slug 生成 docs-source URL。

src/app/[lang]/docs-source/[...slug]/route.ts 在构建期枚举全部页面,并返回原始 Markdown / MDX 内容。输出路径使用 .md 后缀,避免静态托管中页面目录与同名文件冲突。

DocsPageActions 将"查看源码"和"在 GitHub 打开"放在作者信息同一行。读者发现问题时可以直接定位源文件,不需要手工从页面 URL 猜仓库目录。

九、i18n 边界

src/lib/i18n.ts 是支持语言和默认语言的单一来源。它同时驱动:

  • Fumadocs 内容 loader 的 locale 分桶;
  • generateStaticParams() 的语言参数;
  • Fumadocs UI 的语言上下文;
  • 自定义错误页、加载页和评论语言。

界面翻译分为两层:

  • src/adapters/fumadocs/layout.tsx 扩展 Fumadocs 内置 UI 翻译(包括 Ask AIClose SidebarLayout Tab 等 16.13.x 新增 key);
  • src/dictionaries/ 保存项目自定义文案,并由 TypeScript 接口约束键结构。

界面支持中文与 English 不代表每篇文章已经翻译。页面是否存在由 content/docs/{locale}/ 的实际文件决定,英文页面不使用空壳占位来模拟完整覆盖。

十、社区模块

留言墙和文档评论使用 Giscus 与 GitHub Discussions。DocsCommunity 将评论区放在正文卡片之外,Guestbook 根据 locale 和主题选择 Giscus 语言与样式。

Giscus 配置

  • slugKey 作为讨论标识,中英文页面共享同一讨论串;
  • mapping="specific"inputPosition="top"reactionsEnabled="1"
  • GISCUS_LANG_MAP:zh → zh-CN、en → en;
  • 主题 URL:生产用 jsDelivr CDN,开发用站点 origin 拼接相对路径。

主题切换闪烁消除

Giscus 运行在跨域 iframe 中,主题切换时 iframe 会重新加载主题 CSS,期间出现短暂的未样式化闪烁。项目通过以下方式消除:

  • useLayoutEffect(而非 useEffect)在 React commit 后、paint 前同步设置 data-switching 属性,确保 opacity:0 在浏览器绘制前生效;
  • prevThemeUrl ref 跟踪上次主题,仅在两个有效 URL 之间切换时触发;
  • 固定 400ms 渐隐时长,不等待 resizeHeight 消息(该消息仅在高度变化时回发,主题切换往往不改变高度);
  • CSS 配合:transition: none 立即隐藏,opacity 240ms 渐显回来。

useEffect 会在浏览器 paint 后执行,留下一个绘制窗口让未样式化内容闪现;useLayoutEffectopacity:0 应用提前到 paint 之前,消除了这个窗口。

Giscus 仍然是外部增强:网络不可用或用户没有 GitHub 账号时,文档正文、搜索和导航不受影响。

十一、交互状态放在哪里

状态存储位置生命周期
主题偏好浏览器本地存储跨页面与再次访问
动效偏好浏览器本地存储跨页面与再次访问
侧栏折叠浏览器本地存储跨页面与再次访问
任务完成状态按 pathname 的本地存储跨页面与再次访问
Tabs 偏好浏览器本地存储persist 开启时
阅读返回点sessionStorage当前标签页会话
转场 DOM 克隆内存单次转场

这些状态都不需要账号或数据库,也不会跨设备同步。静态优先并不等于拒绝状态,而是把轻量、私有、无需协作的数据留在浏览器。

十二、关键文件

文件职责
src/app/api/search/route.ts静态搜索索引
src/app/api/search-metadata/route.ts静态 Search metadata Sidecar
src/content/search/index.ts服务端索引构建与静态 GET
src/content/search/schema.tsSearch Document v2 与 structured content Join
src/content/search/facets.tsChapter 兼容与 taxonomy tag / 未来过滤接口
src/content/search/tokenizer.ts中文、英文与拼音混合分词
src/features/search/client.ts搜索结果增强流水线
src/features/search/selection.ts正文选区校验与搜索查询规范化
src/features/search/spotlight.ts聚光灯参数处理
src/features/search/components/search-dialog.tsx查询与 Chapter 范围 UI
src/features/search/components/search-spotlight.tsx搜索结果聚光灯
src/app/[lang]/docs/layout.tsxlocale 页面树与导航布局
src/features/reading/docs-reading-return.tsx跨文档上下文恢复
src/features/reading/restore.ts文档刷新滚动恢复
src/app/[lang]/docs-source/[...slug]/route.tsMarkdown 源码端点
src/adapters/fumadocs/layout.tsxFumadocs UI i18n
src/features/community/components/guestbook.tsxGiscus locale 与主题同步
src/features/community/components/docs-community.tsx文档评论区隔离

搜索聚光灯通过 runtime/navigation 判断转场是否结束,并通过 Fumadocs Adapter 获取正文根节点,不再从根元素的 transition dataset 反推状态。

本页目录

讨论区

欢迎分享你的想法与建议