本章要回答的问题: 文档作者只写一个源码标记,站点怎样在构建时插入真实代码、保留原始行号,并知道哪些源码行还没有进入讲义?
调用者是 prepare.mjs。输入是 Markdown 文本、已经通过发布白名单的 sources Map,以及贯穿整次准备过程的 used Map;输出是带代码围栏和源码页链接的 Markdown,外加覆盖摘要。它不执行 Go、Python 或 C,也不判断中文解释是否正确。
1. 标记是一种受限输入格式
site/scripts/excerpts.mjs · L1–L5 · 打开完整源码
// Extract once, after ordinary Markdown links are rewritten. Never re-parse
// a marker that happens to occur inside the source code being displayed.
const marker = /<!-- source: ([^\n]+):(\d+)-(\d+) -->/g;
const linesOf = code => code.replace(/\n$/, '').split('\n');
脚本先声明处理顺序:普通 Markdown 链接由准备脚本先重写,源码标记随后只展开一次。这样源码片段中即使包含看起来像源码标记的字符串,也不会被当成第二次输入。
marker 的三个捕获组分别是仓库相对文件名、起始行和结束行;全局标志让一篇讲义可以出现多个片段。linesOf 去掉文件末尾一个终止换行再切分。它不删除中间空行,所以“文件结束的换行”不会伪造一行代码,而真实空白行仍保持在片段里。文档行号从 1 开始,数组下标稍后要减 1。
2. 先确认来源和范围,再记账
site/scripts/excerpts.mjs · L6–L15 · 打开完整源码
export function expandExcerpts(body, sources, used) {
if (/<!--\s*source:/.test(body.replace(marker, ''))) throw new Error('Malformed source marker');
return body.replace(marker, (_whole, name, first, last) => {
const source = sources.get(name);
if (!source) throw new Error(`Unlisted teaching source: ${name}`);
const lines = linesOf(source.code), start = Number(first), end = Number(last);
if (start < 1 || end < start || end > lines.length) throw new Error(`Invalid source range: ${name}:${start}-${end} (file has ${lines.length})`);
const seen = used.get(name) || new Set();
for (let n = start; n <= end; n++) seen.add(n);
used.set(name, seen);
expandExcerpts 接收正文、源码 Map 和可变的覆盖 Map。它先移除合法标记,再检查是否仍残留 source: 开头的注释;残留通常表示作者写错了格式,应该让构建失败,而不是默默丢掉。
对每个匹配,脚本从 sources 取对象。这个 Map 是发布白名单的结果,所以陌生路径会报 Unlisted teaching source,不会把 Markdown 提供的任意路径交给 readFileSync。起止数字转换成 Number 后,脚本拒绝从零开始、倒序或超过文件长度的范围。
范围通过后,脚本把每一行加入对应的 Set。Set 去重保证同一行被多个章节引用时只算一次,但不会改变片段的出现顺序。used 记录的是覆盖事实,不是对解释质量的评分。
例如 x.go:5-8 的输入会记录 5、6、7、8,随后再用数组下标 4 到 7 取文本。这正是 1-based 文档行号与 0-based JavaScript 数组之间的边界。
3. 插入真实文本并保留定位信息
site/scripts/excerpts.mjs · L16–L22 · 打开完整源码
const code = lines.slice(start - 1, end).join('\n');
const longest = Math.max(2, ...(code.match(/`+/g) || []).map(s=>s.length));
const fence = '`'.repeat(longest + 1);
const notice = source.redacted && (start === 1 || code.includes('[REDACTED:')) ? '\n> 此文件的私有运行环境规则已在公开展示中隐藏;行号保持对应本机源码。\n' : '';
return `\n**\`${name}\` · L${start}–L${end}** · [打开完整源码](/code/${source.id}/#L${start})\n${notice}\n<div class="code-excerpt" style="--line-start:${start-1}">\n\n${fence}${source.lang}\n${code}\n${fence}\n\n</div>\n`;
});
}
slice(start - 1, end) 使用左闭右开区间,却能包含原始结束行:起点减一,右端直接使用 end。join('\n') 只恢复换行,不重新排版代码,因此显示内容仍来自同一个公开源码对象。
代码围栏长度根据片段中最长的连续反引号动态选择,避免源码里的反引号提前终止 Markdown 围栏。若来源文件被脱敏且当前片段从第一行开始,或片段中出现 [REDACTED:],脚本插入一次说明,提醒读者行号仍对应本机源码,但展示文本不是完整可执行副本。
返回的 Markdown 同时携带:
name与Lstart–Lend,告诉读者当前片段来自哪里;/code/<source.id>/#Lstart,把入口交给完整源码页;style="--line-start:start-1",把原始起点交给 CSS 计数器;- 来源的语言标记和真实代码围栏。
脚本没有再次调用 expandExcerpts,也不会对刚返回的代码重新匹配标记。处理结果接回原正文,所以片段前后的中文解释仍保留。
4. 覆盖检查只检查可显示的代码
site/scripts/excerpts.mjs · L23–L35 · 打开完整源码
// This checks displayed source coverage, not the semantic quality of prose.
export function verifyCoverage(sources, expected, used) {
return [...new Set(expected)].map(name => {
const source = sources.get(name);
if (!source) throw new Error(`Unlisted coverage target: ${name}`);
const lines = linesOf(source.code), seen = used.get(name) || new Set();
const meaningful = lines.map((text,i)=>text.trim() ? i+1 : 0).filter(Boolean);
const missing = meaningful.filter(n=>!seen.has(n));
if (missing.length) throw new Error(`Uncovered source lines: ${name}: ${missing.join(', ')}`);
return {name, lines:lines.length, covered:meaningful.length};
});
}
verifyCoverage 的注释明确划定了边界:它检查 displayed source coverage,不判断语义质量。它对期望文件名去重,确保同一文件不会重复生成报告;目标必须存在于 sources Map,否则配置和发布清单不一致,构建失败。
对每个源文件,脚本把非空行转换为原始行号,空行标成 0 后过滤掉。再从这些 meaningful 行里找出未出现在 used Set 的项;只要有遗漏,就报出文件和行号。通过时返回 {name, lines, covered},由准备脚本汇总到 .generated/reading-coverage.json,首页可以展示数量。
这项检查的保证很具体:讲义至少展示每个清单文件的非空源代码行。它不保证作者解释了每个分支,不保证示例实际运行,也不替人检查源码更新后语义是否仍然对应。
5. 测试把边界变成可观察结果
site/scripts/excerpts.test.mjs · L1–L10 · 打开完整源码
import assert from 'node:assert/strict';
import { expandExcerpts, verifyCoverage } from './excerpts.mjs';
const sources = new Map([['x.go', {code:'package x\n\nvar a = 1\n',lang:'go',id:'x-go'}]]);
const used = new Map();
const rendered = expandExcerpts('解释\n<!-- source: x.go:1-3 -->\n逐行说明', sources, used);
assert.match(rendered, /package x/);
assert.match(rendered, /--line-start:0/);
assert.match(rendered, /\/code\/x-go\/#L1/);
assert.match(rendered, /逐行说明/);
assert.equal(verifyCoverage(sources, ['x.go'], used)[0].covered, 2);
测试先建立一个最小夹具:x.go 有三行,其中第二行为空。expandExcerpts 处理一段带前后文字的讲义后,断言结果包含真实代码、起点为 0 的 CSS 变量、完整源码链接和原有解释;覆盖摘要则必须只计算两个非空行。
这些断言分别观察不同交接点:文本有没有进入输出、原始行号有没有传给样式、导航有没有指向正确源码页、正文有没有被替换掉。测试数据只验证字符串处理,不冒充 Wasm 或 Go runtime 的执行证据。
site/scripts/excerpts.test.mjs · L11–L17 · 打开完整源码
assert.throws(()=>expandExcerpts('<!-- source: secret.env:1-2 -->',sources,new Map()),/Unlisted/);
assert.throws(()=>expandExcerpts('<!-- source: x.go:1-8 -->',sources,new Map()),/range/);
assert.throws(()=>expandExcerpts('<!-- source: x.go:L1-L2 -->',sources,new Map()),/Malformed/);
assert.throws(()=>verifyCoverage(sources,['x.go'],new Map()),/Uncovered/);
const self = new Map([['a.js',{code:'const marker = "<!-- source: not-a-file:1-2 -->";\n',lang:'js',id:'a-js'}]]);
assert.match(expandExcerpts('<!-- source: a.js:1-1 -->',self,new Map()),/not-a-file/);
console.log('Excerpt extraction, line offsets, coverage and publication boundaries passed.');
剩余测试覆盖失败和递归边界:未列出的文件、越界范围、带 L 的错误行号格式以及完全没有片段的目标都必须抛错。最后把一个看似合法的标记放进源码字符串,确认它作为代码文本保留,未发生二次展开;所有断言成功后才打印通过消息。
在 site 目录运行 node scripts/excerpts.test.mjs,可以快速验证这一层的输入校验、行号偏移和覆盖记账。它不替代构建后的 页面与发布边界验证。源码页如何消费这些 id 与行号,见 源码页的行号与函数导航;页面中的视觉计数器见 全局样式。
6. 维护时沿数据流回溯
源码变更后,先确认源码页和片段范围仍落在当前文件内,再修正围绕该片段的解释;随后运行片段测试、准备步骤和最终站点验证。覆盖数字变为“全满”只能说明 used 集合覆盖了非空行,不能说明文本因果、失败路径和所有权交接已经讲清楚。
读完本章,应能回答:为什么提取器只允许白名单路径?start - 1 与 end 如何保留 1-based 行号?为什么同一套源码既能出现在讲义片段,也能出现在独立源码页?