文档发布架构
youteacher_analyze 是一个基于 Next.js App Router 的站点,它的全部职责就是通过一套共享外壳发布三份商业文档——商业计划书(Business Plan)、营销计划(Marketing Plan)、技术手册(Tech Playbook)。这里没有 CMS,也没有为每份文档单写一套布局代码。每份文档不过是一串有序的 React 章节组件,交给同一个 DocumentLayout。
一套外壳,三份文档
根路由只负责转发访客。src/app/page.tsx 只有两行:redirect('/businessplan'),所以 / 永远落到商业计划书这个默认入口。三条真实路由就是 App Router 里的三个目录 businessplan/、marketingplan/、techplaybook/,每个都是一个 page.tsx,返回形状完全一致的东西:一个 <DocumentLayout>,带 currentDoc、title、subtitle 三个 prop,把该文档的章节作为 children 包起来。
三个页面之间唯一不同的,只是 prop 的取值和里面的章节组件清单。商业计划书传 currentDoc="business-plan" 和九个章节(从 Executive Summary 到 Appendix)。营销计划传 currentDoc="marketing-plan" 和十四个章节。技术手册传 currentDoc="tech-playbook" 和五个(PRD、Architecture、Agile Sprints、Quality Assurance、Deployment)。currentDoc 这个 prop 是一个固定的联合类型,取值恰好是这三个字符串字面量。
DocumentLayout 组装了什么
DocumentLayout 是一个 client 组件,它围绕拿到的章节把页面框架搭起来。它按顺序渲染:
- 页面顶部附近一个固定定位的
DocumentSelector,并告诉它当前是哪份文档; - 一个 header 区块,显示文档的
title和subtitle,后面接一个静态的 meta-info 面板(项目概述、团队、日期、状态)——这块信息写死在布局里,不是每份文档单独传进来的; - 一个页面主体,把
TableOfContents侧栏放在content区域旁边,并把章节children落进这个 content 区域。
因为框架完全活在布局里,给某份文档加一章,只需要在那份 page.tsx 里加一行 import 和一行 JSX,别的地方都不用知道。
内容原语:Chapter 与 Section
文档由两个极小的结构组件搭成。Chapter 渲染一个带该章 id、类名 chapter、data-type="chapter" 标记的 <div>,并在 children 上方放一个 <h2> 标题。Section 渲染一个 <section>,带可选的 id 和可选的 <h3> 标题,标题在 children 上方。两者都刻意做得很薄——它们承载的是结构与身份,而不是内容样式。正是这一点让目录无需任何手工维护的索引就能工作(见下)。
目录由 DOM 生成,而非清单
TableOfContents 是最巧的一块。它不维护任何手写的章节列表。挂载后它稍等 DOM 稳定,然后遍历每一个 .chapter 元素,把它的 <h2> 读成章标题、把其中嵌套的 <section> 里的 <h3> 读成子节,再据此渲染出的实际内容构建出目录树。这正是 Chapter 和 Section 要费事发出稳定 id 和标题标签的原因——目录是已渲染文档的一个投影,所以在 page.tsx 里加的一章会自动出现在侧栏里,不存在第二处要同步更新。
它还跑一个 scroll-spy:测量每个目标的绝对偏移,随读者滚动追踪当前所在的小节,高亮对应链接,并在某章(或其某个子节)处于激活态时展开该章。点击链接会带一个固定顶部偏移平滑滚动到目标。滚动处理通过 requestAnimationFrame 做了节流,让这个 spy 保持轻量。
在文档之间切换
DocumentSelector 负责跨文档导航。它持有一份很小的静态清单,把每份文档映射到它的标签、图标和路由路径,标出当前项,选中时调用 Next.js router 把选中的路径 push 进去。于是这三份文档用起来像是同一份出版物的几个标签页,尽管每一份都是独立的 App Router 路由。
为什么是这个形状
这个设计用一个非常小、非常好读的系统,换掉了一个通用内容系统:一份文档就是一串有序的组件,框架是一套共享布局,而导航(无论是文档内的大纲还是跨文档的切换器)都自己推导出来,而不是靠手工维护。代价是内容活在代码里(每一章都是一个 React 组件),而非活在数据里——对三份精心编排的规划文档来说这没问题,也正因如此,这里没有编辑器、没有数据库、没有为每份文档重复的样板代码。