Foundry Forge Doc NatSpec 围栏代码块字符保留:MDX 安全转义与完整代码围栏的处理
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
forge doc在将 Solidity NatSpec(@notice/@dev)渲染为 MDX 页面时,需要对{、<等 MDX 敏感字符做 HTML 实体转义,以防被误解析为 JSX/ESM 表达式。本指南讲解 Foundry 最新引入的“完整独立围栏代码块(```/~~~)内容原样保留”机制:什么条件下转义会被豁免、其他文本为何仍保持原有转义、以及如何通过源码与测试验证该行为。读完你可以准确预测任意 NatSpec 描述在生成的docs页面中的渲染结果。
背景:为什么 NatSpec 中的<和{需要被转义
forge doc生成的页面是 vocs 顶部注释 “Rendering:solarAST -> vocs MDX”)。MDX 是 Markdown 与 JSX 的混合语法,因此两条规则必须被满足:
- 文本中裸露的
{会被当作 JSX/表达式插值起始符解析; - 文本中裸露的
<会被当作 JSX 标签起始符解析。
如果 NatSpec 描述里恰好写了if (value < 1) {这样的 Solidity 片段,而这些字符不做处理直接输出,轻则页面渲染出错,重则被当作可执行表达式(MDX 中 ESM 语句import/export位于行首时会被执行)。Foundry 因此在渲染管线中对描述性文本统一施加“MDX-hazard 转义”:
<转义为<;{转义为{;- 行首的
import/export会被neutralize_esm中和(import前缀替换为im,export前缀替换为e),见 crates/doc/src/render.rs。
本次变更:完整独立围栏代码块豁免转义
本仓库的 changelog 记录(.changelog/forge-doc-natspec-fenced-characters.md)描述了该 patch 的核心行为:
Preserve
<and{inside complete, standalone fenced code blocks (```/~~~) in NatSpec notice and developer descriptions when renderingforge docpages. Other text keeps the existing MDX-hazard escaping.
即:在渲染forge doc页面时,NatSpec 的@notice与@dev描述中,凡是落在完整(complete)且独立(standalone)的围栏代码块(反引号```或波浪线~~~)内部的内容,<与{会原样保留;而其余文本继续沿用既有的 MDX 危险字符转义。
注意其适用范围是notice 与 dev 描述(descriptions),并非所有 NatSpec 字段。从源码看,@param/@return等字段最终被渲染进表格单元格(table cell),表格单元格本身无法容纳块级 Markdown,因此它们仍走replace_inline_links的直接转义路径,不享受围栏保留(详见下文“适用范围边界”)。
源码实现剖析
核心实现位于 crates/doc/src/render.rs 的描述净化链中,围绕“先识别围栏区域,再只对区域外文本转义”的思路展开。
识别完整围栏:fenced_description_regions
/// Complete fenced code blocks that are direct children of the description document. Restricting /// preservation to root-level blocks keeps list, quote, table, and unclosed-fence behavior on the /// conservative escaping path. fn fenced_description_regions(text: &str) -> Vec<Range<usize>> { let Ok(Node::Root(root)) = to_mdast(text, &ParseOptions::gfm()) else { return Vec::new(); }; root.children .iter() .filter_map(|node| { let Node::Code(_) = node else { return None }; let position = node.position()?; let range = position.start.offset..position.end.offset; is_complete_fence(&text[range.clone()]).then_some(range) }) .collect() }要点:
- 使用 GFM 解析器将描述文本解析为 mdast 语法树,只取根节点(
Root)的直接子节点,即“standalone”的含义——围栏必须是描述文档顶层、独立的代码块,而不是嵌在列表、引用、表格里的代码块; - 只保留类型为
Node::Code的节点,即围栏代码块(```与~~~)而非行内代码; - 再经
is_complete_fence二次校验围栏闭合完整。
闭合性校验:is_complete_fence与fence_marker
fn is_complete_fence(text: &str) -> bool { let mut lines = logical_lines(text); let Some((_, first)) = lines.next() else { return false }; let Some((marker, length)) = fence_marker(first) else { return false }; let mut last = None; for (_, line) in lines { last = Some(line); } let Some(last) = last else { return false }; let indent = last.len() - last.trim_start_matches(' ').len(); if indent > 3 { return false; } let last = &last[indent..]; let closing_length = last.chars().take_while(|&ch| ch == marker).count(); closing_length >= length && last[closing_length..].trim().is_empty() }规则细节:
- 首行必须是合法围栏起始行:缩进不超过 3 个空格,且以连续 ≥3 个
`或~开头(见fence_marker); - 末行必须是合法闭合行:缩进同样不超过 3 个空格,闭合标记长度≥起始标记长度,且闭合标记之后只能有空白;
- 因此未闭合的围栏不会获得豁免,仍整体走转义路径——这是“complete”的严格要求。
只转义区域外文本:replace_description_links
fn replace_description_links(text, name_to_page, current_page, local) -> String { let regions = fenced_description_regions(text); let mut out = String::with_capacity(text.len()); let mut rendered_regions = Vec::with_capacity(regions.len()); let mut copied = 0; for region in regions { out.push_str(&sanitize_description_prose(&text[copied..region.start], ...)); let start = out.len(); out.push_str(&text[region.clone()]); // 原样复制围栏内容,不做转义 rendered_regions.push(start..out.len()); copied = region.end; } out.push_str(&sanitize_description_prose(&text[copied..], ...)); let mdx_regions = code_regions(&out, &ParseOptions::mdx()); if rendered_regions.iter().all(|region| mdx_regions.contains(region)) { out } else { sanitize_description_prose(text, ...) // 兜底:整体回退到全量转义 } }- 围栏之外的文本逐段交给
sanitize_description_prose(内含replace_inline_links+neutralize_fence_markers)做原有转义; - 围栏内部内容则原样拼接,不经过任何转义;
- 最后用
code_regions以 MDX 解析选项再校验一遍:只有当被保留的每个区域在最终输出里确实仍是代码区域时,才接受这份输出;否则整体回退到全量转义。这一步保证了“保留围栏内容”不会破坏 MDX 语义——若 MDX 认为这些内容不再是代码(例如围栏在拼接过程中失效),宁可保守转义。
兜底机制:neutralize_fence_markers
/// Keep rejected or incomplete fence markers from changing the Markdown context of subsequent /// descriptions. Entities render as the original marker characters without acting as syntax. fn neutralize_fence_markers(text: &str) -> String { // 将连续 >=3 个 ` 替换为 `、>=3 个 ~ 替换为 ~,其余字符原样保留 }该函数处理的是未通过围栏保留判定的文本:被拒绝的、不完整的围栏标记会被转义为 HTML 实体(`/~),实体渲染出来仍是原来的字符外观,但不再具备 Markdown 语法作用,从而避免残缺的围栏标记改变后续描述文本的 Markdown 上下文。这正是“其他文本保持既有 MDX-hazard 转义”的体现。
适用范围边界:哪些字段享受围栏保留
判定链在collect_comments中按 NatSpec 字段类型分流(crates/doc/src/render.rs):
| 字段 | 处理函数 | 是否保留围栏内</{ |
|---|---|---|
@notice/@dev(descriptions) | replace_description_links→ 识别围栏 → 区域外转义 | 是 |
@param/@return | replace_inline_links(直接转义) | 否 |
@title/@author/@custom:* | sanitize_description_prose | 否 |
@custom:name | replace_inline_links | 否 |
其中@param/@return之所以不做围栏保留,是因为它们最终进入write_param_table/write_getter_table渲染的表格单元格(crates/doc/src/render.rs),单元格必须是单行逻辑内容且不能包含块级 Markdown(换行会被替换为<br/>,见escape_table_cell)。因此描述中多行围栏在参数表格里不可能以块级代码块形式呈现,保留围栏没有意义。
@inheritdoc继承而来的描述同样走完整转义路径:继承文本来自依赖库的 NatSpec,可能包含任意内容,必须被当作不可信文本处理。
端到端验证:测试用例与渲染结果对照
仓库集成测试 crates/forge/tests/cli/doc.rs 中的natspec_fences_are_limited_to_standalone_descriptions用FenceScope.sol覆盖了全部边界。构造的 Solidity 源文件如下:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; interface IFenced { /// @dev Example: /// ```solidity /** * if (value < 1) { * ``` * Outside < and { */ /// @param value Parameter example: /// ~~~solidity /// if (value < 1) { /// ~~~ function inspect(uint256 value) external; } contract Child is IFenced { /// @inheritdoc IFenced function inspect(uint256 value) external override {} }运行forge doc后,测试断言生成的interface.IFenced.mdx与contract.Child.mdx中渲染结果为:
### inspect <i> Example: ```solidity if (value < 1) {Outside < and {
Parameters
| Name | Type | Description |
|---|---|---|
| value | uint256 | Parameter example: ~ if (value < 1) { |
从断言可读出三条行为: 1. **`@dev` 中的完整围栏**:`if (value < 1) {` 原样出现在 ` ```solidity ` 围栏内,`<` 与 `{` 未被转义(注意源文件中该围栏由 `///` 行与 `/** */` 块注释拼接而成,验证了“续行合并后再做围栏识别”的设计,见 [crates/doc/src/render.rs](https://link.gitcode.com/i/08a2f60100efb7d863f78efa16537549#L665-L693)); 2. **围栏外文本**:`Outside < and {` 被转义为 `Outside < and {`,证明转义仍对普通文本生效; 3. **`@param` 表格单元格**:`if (value < 1) {` 在表格中被转义为 `if (value < 1) {`,证明参数描述不享受围栏保留(表格内换行被折叠为 `<br/>`)。 同一测试还对 `contract.Metadata` 验证了**未闭合/残缺围栏与特殊上下文**的兜底行为: ```md **Title:** Metadata ~~~ **Author:** Author ```~~~直接跟在@title文本行后并未构成完整独立围栏,其标记被转义为~~~(渲染回~~~字样但无语法作用);```在@author上下文同理被转为```。而@notice中{1+1}之所以原样保留,是因为它落在完整的独立~~~围栏内(后续@custom:note中的~~~~因不构成根级独立围栏,其标记被中和为~~~~)。这组断言直观展示了“完整独立”判定与neutralize_fence_markers兜底的分工。
如何在自己的项目中复现与验证
生成文档
在项目根目录执行:
forge doc默认输出到根目录下的docs/(由 crates/config/src/doc.rs 的DocConfig控制),生成一个可直接运行的 vocs 站点脚手架与 MDX 页面(页面位于docs/src/pages/src/,命名形如contract.<ContractName>.mdx、interface.<InterfaceName>.mdx)。可用--out <PATH>指定输出目录。forge doc的参数解析与入口见 crates/forge/src/cmd/doc.rs,构建流程(源码收集、过滤 ignore globs、渲染统计)见 crates/doc/src/builder.rs。
编写触发保留行为的 NatSpec
/// @notice 用法示例(围栏内 `<` 与 `{` 会原样保留): /// ```solidity /// require(amount < max, "too big"); /// if (flag) { revert(); } /// ``` /// 围栏外文本里的 `<` 与 `{` 仍会被转义。 function withdraw(uint256 amount, bool flag) external;若想验证参数描述仍走转义路径,将同样的围栏放进@param描述即可观察到表格中的</{实体。
关键判定条件速查
- 围栏标记:
`或~,长度 ≥3; - 必须是根级独立代码块(不嵌在列表、引用、表格中),mdast 根节点的直接
Code子节点; - 必须闭合完整:末行闭合标记长度 ≥ 起始标记长度,且闭合后只有空白;未闭合围栏走转义路径;
- 只有
@notice/@dev描述享受该豁免,@param/@return/@title/@author/@custom:*及@inheritdoc继承内容一律转义; - 保留结果会经 MDX 解析二次校验,若保留后不再被识别为代码区域,则整体回退为全量转义。
小结
本次 patch 在不削弱 MDX 安全性的前提下,让forge doc输出的文档更贴近开发者书写的 NatSpec 原貌:完整、独立的围栏代码块(```/~~~)内的<、{等危险字符被原样保留,使代码示例可读、可复制;而列表/引用中的代码、未闭合围栏、参数表格单元格以及继承文本仍走保守的 MDX-hazard 转义。理解这条“识别 → 豁免 → 二次校验 → 兜底回退”的渲染链,就能准确预判自己项目文档页面的最终输出。
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考