本章要回答的问题: 站点怎样把已批准的源码对象变成源码索引、单文件页面、行号链接和函数目录,同时保证导航不会改掉代码文字?
输入是 prepare.mjs 写出的 .generated/sources.json。其中的 code 已按发布边界处理,id 是稳定 URL 标识,lang 给高亮器,symbols 是声明导航元数据。源码页只展示这些数据,不执行代码,也不从浏览器回读仓库。
1. 索引页把源码集合变成入口
site/src/pages/code/index.astro · L1–L5 · 打开完整源码
---
import Layout from '../../layouts/Docs.astro';
import sources from '../../../.generated/sources.json';
---
<Layout title="源码索引"><div class="eyebrow">SOURCE / NOT A SECOND IMPLEMENTATION</div><h1>直接对照实际源码</h1><p class="lede">构建时从同一仓库读取。先看 Python 和 Future,再按需阅读 C 与初始化。</p><div class="source-list">{sources.map(s=><a href={`/code/${s.id}/`}><code>{s.name}</code><span>{s.code.split('\n').length-1} 行 ↗</span></a>)}</div><p class="notice">不包含凭据、构建机器配置、日志或 Wasm 二进制。</p></Layout>
源码索引页导入共用布局和 sources JSON。sources.map 为每个对象产生一个 /code/<id>/ 链接,文件名用 code 元素保持等宽语义,行数由源码字符串中的换行计算。这里的 id 必须与准备阶段和单文件页使用的值一致;否则索引本身可以渲染,却会指向不存在的静态页面。
索引中的 notice 说明发布边界:不包含凭据、构建机器配置、日志或 Wasm 二进制。这是给读者的可见说明,不是过滤器;实际哪些文件进入 sources 由清单和准备脚本决定,输出是否合规再由 构建后验证 检查。
2. 每个源码对象预先确定一条静态路径
site/src/pages/code/[id].astro · L1–L7 · 打开完整源码
---
import { Code } from 'astro:components';
import Layout from '../../layouts/Docs.astro';
import sources from '../../../.generated/sources.json';
export function getStaticPaths() { return sources.map(source=>({params:{id:source.id},props:{source}})); }
const { source } = Astro.props;
const headings = source.symbols.map(s=>({depth:2,slug:s.id,text:s.label}));
[id].astro 从 Astro 导入 Code 组件和布局,再读取同一份 sources。getStaticPaths 对每个 source 返回 params.id 与 props.source,所以 sources.json 中有多少对象,构建期就枚举多少源码页。
页面把每个 symbol 转为一个 heading 元数据对象:depth 固定为 2,slug 使用 symbol 的稳定 id,text 使用可读 label。这个数组只供布局右侧目录使用,不会重新扫描源码,也不会把函数调用关系误报成目录关系。
一条数据流可以写成:
sources.json[source]
→ getStaticPaths(params.id, props.source)
→ symbols → headings
→ /code/<id>/index.html
3. transformer 只添加导航节点
site/src/pages/code/[id].astro · L8–L14 · 打开完整源码
const transformers = [{ line(node, line) {
node.properties.id = `L${line}`;
node.children.unshift({type:'element',tagName:'a',properties:{href:`#L${line}`,className:['line-number'],'data-line':line,'aria-label':`第 ${line} 行`},children:[]});
for (const s of source.symbols.filter(s=>s.line===line)) {
node.children.unshift({type:'element',tagName:'span',properties:{id:s.id,className:['symbol-anchor'],'aria-hidden':'true'},children:[]});
}
}}];
transformers 给 Code 组件一个 line 钩子。高亮器每产生一行节点,钩子就写入 id="L<number>",使 /code/.../#L12 能直接跳到原始第 12 行。
随后在行节点开头插入行号链接。它的 href 指向当前行 hash,data-line 和 aria-label 保存显示与辅助技术所需的信息;行号节点不属于源码字符。对声明行,脚本再按 source.symbols 筛选匹配项,插入隐藏的 symbol anchor。函数目录和代码行因而共享同一个声明行元数据。
unshift 的作用是把这些定位节点放在高亮文字之前。transformer 修改的是渲染树,不修改 source.code;后续验证会去掉 HTML 标签和实体,再检查可见代码是否仍与输入相同。
4. 单文件模板接入人类阅读入口
site/src/pages/code/[id].astro · L15–L22 · 打开完整源码
---
<Layout title={source.name} headings={headings}>
<div class="eyebrow"><a href="/code/">源码索引</a> / CURRENT IMPLEMENTATION</div>
<h1 class="filename">{source.name}</h1>
<p class="lede">{source.code.split('\n').length-1} 行 · 点击行号可定位;函数链接随声明位置更新,不手填行号。</p>
{source.symbols.length>0 && <details class="function-jump"><summary>跳到函数 / 声明</summary><nav>{source.symbols.map(s=><a href={`#${s.id}`}>{s.label}<small>L{s.line}</small></a>)}</nav></details>}
<div class="source-code numbered"><Code code={source.code.replace(/\n$/,'')} lang={source.lang} theme="github-dark-default" wrap={true} transformers={transformers}/></div>
</Layout>
布局收到 source.name 和 headings 后,可以为右侧目录生成 symbol 链接。正文先给出回索引入口和源码文件名,再显示行数及定位提示。若 symbols 非空,function-jump 折叠区为每个声明生成 #<symbol.id> 链接和原始行号;没有声明时不渲染空目录。
真正的源码交给 Code 时只去掉末尾一个换行。这样不会把文件结尾的终止换行当成额外显示行,同时保留文件内部的空行。lang 决定语法高亮,github-dark-default 决定主题,wrap={true} 只改变长行的视觉换行,不改变源码行的编号。
浏览器点击行号或函数目录后,改变的是 URL hash 和滚动位置。相关 CSS 再用目标选择器高亮行;页面不会通过客户端脚本重新解析源码。
5. symbol 提取器只生成声明目录
site/scripts/symbols.mjs · L1–L8 · 打开完整源码
// Declaration navigation for this small source set; not a semantic call graph.
export function symbolsFor(name, code) {
const found = [], scopes = [];
function add(label, line) {
const id = 'fn-' + label.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
if (found.some(s => s.id === id)) throw new Error(`Duplicate symbol ${name}:${label}`);
found.push({ label, id, line });
}
symbolsFor(name, code) 的职责注释很重要:它生成声明导航,不生成语义调用图。found 按源码出现顺序收集结果,Python 的 scopes 保存嵌套作用域。内部 add 把 label 变成 fn-... id,重复 id 立即抛错,再保存 label、id 和声明行号。
因此 symbol id 是导航协议的一部分:目录需要它做 hash,transformer 需要它做锚点,验证脚本需要它确认落点。它不承诺函数之间的依赖、控制流或类型关系。
6. 按文件后缀识别 Go 与 Python 声明
site/scripts/symbols.mjs · L9–L23 · 打开完整源码
for (const [index, line] of code.split('\n').entries()) {
let m;
if (name.endsWith('.go')) {
m = line.match(/^func\s+(?:\(\w+\s+\*?(\w+)\)\s*)?(\w+)\s*\(/);
if (m) add(m[1] ? `${m[1]}.${m[2]}` : m[2], index + 1);
} else if (name.endsWith('.py')) {
if (!line.trim() || line.trimStart().startsWith('#')) continue;
const indent = line.match(/^\s*/)[0].replaceAll('\t', ' ').length;
while (scopes.length && scopes.at(-1).indent >= indent) scopes.pop();
m = line.match(/^\s*(?:async\s+)?(def|class)\s+(\w+)/);
if (m) {
const label = [...scopes.map(s => s.name), m[2]].join('.');
add(label, index + 1);
scopes.push({ name: m[2], indent });
}
脚本逐行扫描 code.split('\n'),用 index + 1 回到读者使用的一基行号。Go 分支只接受一行形状的 func 定义,并可解析简单指针接收者;有接收者时 label 形如 Type.Method,否则使用函数名。
Python 分支先跳过空行和注释,再按“tab 视为四个空格”计算缩进。当前行缩进离开已有作用域时,从栈顶弹出;匹配到 def、async def 或 class 后,把外层名称与当前名称用点连接,再压入作用域栈。这样类中的方法能显示成 Class.method。
这是面向当前源码集合的启发式导航器,不是 Python/Go 解析器。多行声明、复杂接收者或字符串里的特殊文本不在它的完整语义保证内;源码本身仍会作为文本显示。
7. C 声明和不支持的文件
site/scripts/symbols.mjs · L24–L30 · 打开完整源码
} else if (name.endsWith('.c')) {
m = line.match(/^(?:EXPORT\([^)]+\)\s*)?(?:static\s+)?(?:PyObject\s*\*|int32_t|uint64_t|void\s*\*|void)\s*(\w+)\s*\([^;]*\)\s*\{/);
if (m) add(m[1], index + 1);
}
}
return found;
}
C 分支只匹配受限的函数定义:允许指定的返回类型、可选 EXPORT 或 static、参数区间和同一行的左大括号;带分号的声明不会被当作定义。匹配成功后只记录函数名和行号。
除 .go、.py、.c 外的文件仍可出现在索引中,只是 symbols 为空。源码页不因为没有函数目录而丢掉文件;它仍可以通过行号查看完整文本。
读完本章,应能回答:完整源码页的路径从哪里来?为什么行号和函数锚点不会污染代码文本?symbol 提取器在哪些情况下只能算导航提示而不是语义分析?接下来可看 样式如何高亮定位行 和 布局如何承载目录。