PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
CODE READING / 33

Astro 内容与页面路由

内容集合与普通页面

本章目录

本章要回答的问题: 构建准备好的 .generated/docs/*.md,如何成为一个可访问的章节页面?

输入来自 站点构建管线:每篇文件带有 frontmatter、已改写的站内链接和已经展开的源码片段。content.config.ts 把这些文件注册为 Astro 内容集合;[slug].astro 为每个 entry 生成页面并把正文、目录和章节关系交给布局。浏览器最后只接收 HTML,负责链接、片段滚动和原生折叠,不会回读仓库。

1. 内容集合把生成目录交给 Astro

site/src/content.config.ts · L1–L3 · 打开完整源码

import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
export const collections = { docs: defineCollection({ loader: glob({ base: './.generated/docs', pattern: '*.md' }) }) };

defineCollection 和 glob 都在 Astro 构建阶段运行。docs 集合的 loader 以 ./.generated/docs 为根,只匹配 *.md;因此它读的是准备脚本刚写出的页面输入,而不是人工 docs/ 目录,也不是一个运行时 API。

配置没有额外 schema。标题、描述、section 等 frontmatter 字段由 prepare.mjs 写入,页面随后按约定读取。若把集合名从 docs 改成别的名字而页面仍调用 getCollection('docs'),错误会在构建阶段出现;改浏览器 URL 不能补上缺失的集合。

2. 动态文件名在构建时枚举成静态路径

site/src/pages/[slug].astro · L1–L7 · 打开完整源码

---
import { getCollection, render } from 'astro:content';
import Layout from '../layouts/Docs.astro';
import nav from '../../.generated/navigation.json'; import { lessons } from '../lessons/catalog'; import { readingLinks } from '../components/lesson-player/validate.mjs';
export async function getStaticPaths() {
  return (await getCollection('docs')).map(entry=>({params:{slug:entry.id},props:{entry}}));
}

方括号文件名表示 Astro 的动态路由模板,但当前站点是静态输出。getStaticPaths 先读取 docs 集合,再为每个 entry 返回一组 params 和 props:entry.id 成为 URL 中的 slug,完整 entry 随 props 传给当前页面。于是一个生成文件经历这条转换:

.generated/docs/read-runner.md
        → getCollection('docs')
        → params.slug = "read-runner"
        → /read-runner/index.html

这里没有访问数据库,也没有等用户打开 URL 才临时生成页面。每个合法 entry 都在 build 时确定;缺少 entry 的路径由 404 页面接住。

3. 当前 entry 生成正文、目录和前后章节

site/src/pages/[slug].astro · L8–L13 · 打开完整源码

const { entry } = Astro.props;
const { Content, headings } = await render(entry);
const group = nav.filter(p=>p.section===entry.data.section);
const index = group.findIndex(p=>p.slug===entry.id); const audioLessons=lessons.filter(lesson=>readingLinks(lesson.episode).some(link=>link.href.split('#')[0]===`/${entry.id}/`));
const previous = group[index-1], next = group[index+1];
---

页面从 Astro.props 取出 entry,再调用 render(entry)。Content 是可插入模板的正文组件,headings 是 Markdown 标题产生的元数据数组。它们都已经基于生成后的 Markdown 计算,不需要浏览器端 Markdown 解释器。

接下来页面从导航 JSON 中筛出同一 section 的 group,用当前 entry 的 id 找到位置。前一项和后一项用数组下标取得;首尾处越界得到 undefined,模板就可以隐藏相应按钮。另一条并行数据流从饭点课目录取出能链接回当前章节的课程:readingLinks 只比较链接去掉 hash 后的路径,找到匹配项才留下课程。它生成的是入口信息,不会在此处播放音频。

因此,页面构建期同时处理三种关系:正文来自 content entry,章节顺序来自 navigation JSON,配套课程来自 lesson catalog。它们都只是当前 HTML 的输入。

4. 模板把元数据接进布局

site/src/pages/[slug].astro · L14–L18 · 打开完整源码

<Layout title={entry.data.title} description={entry.data.description} headings={headings}>
  <div class="eyebrow">{entry.data.section==='deep'?'DEEP DIVE':entry.data.section==='patterns'?'GO PATTERNS':entry.data.section==='reading'?'CODE READING':entry.data.section==='basics'?'FOUNDATIONS':entry.data.section==='research'?'RESEARCH EXPLORATION':'IMPLEMENTATION'} / {String(entry.data.section==='implementation'?index+1:index).padStart(2,'0')}</div>
  <h1>{entry.data.title}</h1><p class="lede">{entry.data.description}</p>
  <details class="page-jump"><summary>本章目录</summary><nav>{headings.filter(h=>h.depth===2).map(h=><a href={`#${h.slug}`}>{h.text}</a>)}</nav></details>
  {audioLessons.length>0 && <details class="notice" aria-label="对应饭点课"><summary>配套饭点图解 · {audioLessons.length} 节(按需展开)</summary><ul>{audioLessons.map(({episode})=><li><a href={`/listen/${episode.id}/`}>{episode.kind==='flow'?'流程课':'知识小点'} · {episode.title}</a></li>)}</ul></details>}<article class="prose"><Content /></article>

Layout 收到标题、描述和 headings,负责站点外壳;当前页面只补上章节标签、标题和导语。section 决定显示 DEEP DIVE、GO PATTERNS、CODE READING、FOUNDATIONS 或 IMPLEMENTATION,序号由同组位置计算。实现组从 1 开始,其余组把总览保留为 0,最终通过 padStart 显示两位编号。

本章目录只选 depth 为 2 的标题,并用 heading slug 生成 #fragment 链接。Content 被放进 <article class="prose">,所以片段中的真实代码、段落和列表沿着同一个正文插槽输出。

如果当前章节有匹配的饭点课,audioLessons.length > 0 才生成带 notice 样式的原生 details。列表默认折叠,summary 显示课程数量,让读者先进入正文,需要时再展开配套材料。每个课程入口按 episode 类型显示“流程课”或“知识小点”,链接指向 /listen/<id>/。这里复用课程导航,不加载播放器。

site/src/pages/[slug].astro · L19–L23 · 打开完整源码

  <nav class="page-steps" aria-label="章节顺序">
    {previous && <a href={`/${previous.slug}/`}><small>← 上一章</small><strong>{previous.title}</strong></a>}
    {next && <a href={`/${next.slug}/`}><small>下一章 →</small><strong>{next.title}</strong></a>}
  </nav>
</Layout>

章节顺序导航有两个条件链接。previous 存在时生成上一章,next 存在时生成下一章;首章和末章分别只显示一侧。链接使用已经由 prepare.mjs 和 navigation 数据确定的 slug,标题作为可读文本。布局关闭后,本页构建完成。

5. 缺失路径交给固定的 404 页面

site/src/pages/404.astro · L1–L4 · 打开完整源码

---
import Layout from '../layouts/Docs.astro';
---
<Layout title="页面不存在"><div class="eyebrow">404</div><h1>这条路径没有内容。</h1><p class="lede">返回学习目录,或从源码索引重新定位。</p><a class="button" href="/">返回首页 →</a></Layout>

404.astro 没有集合读取和 getStaticPaths。Astro 根据这个特殊文件名生成静态 404 输出,正文固定告诉读者当前路径没有内容,并提供回首页和源码索引重新定位的提示。

它不是服务器端动态回退,也不是权限系统:知道其他静态 URL 的人仍可能请求那些文件。页面里的 noindex 只影响搜索索引,响应头和爬虫规则的职责见 站点配置;最终产物是否完整见 构建后验证。

6. 从页面输入到浏览器行为

可以把整页的实际交接压缩成四步:

  1. prepare.mjs 读取人工 Markdown,补 frontmatter、改写站内链接并展开源码标记。
  2. content.config.ts 用 glob loader 把 .generated/docs 注册成 docs collection。
  3. [slug].astro 枚举路径,渲染 entry,并把 headings、navigation 和课程链接接入模板。
  4. Astro 输出 HTML;浏览器只执行原生 <a>、hash 定位和 <details> 折叠。

因此修改页面文案不会改变 Runner.Run,修改导航顺序也不会改变 runtime 执行顺序;它们只改变下一次构建写出的页面数据。下一步可读 页面外壳 查看这些 props 怎样成为完整 HTML,或读 源码页面 查看源码数据的另一条渲染路径。

读完本章,应能回答:entry.id 怎样决定 URL?为什么标题目录和饭点课链接都在构建期计算?404、noindex 与访问控制分别由谁负责?