Foundry Forge Doc NatSpec 围栏代码块字符保留:MDX 安全转义与完整代码围栏的处理
2026/9/16 17:44:56 网站建设 项目流程

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 转义”:

  • <转义为&lt;
  • {转义为&#123;
  • 行首的import/export会被neutralize_esm中和(import前缀替换为&#105;&#109;export前缀替换为&#101;),见 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_fencefence_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 个 ` 替换为 &#96;、>=3 个 ~ 替换为 &#126;,其余字符原样保留 }

该函数处理的是未通过围栏保留判定的文本:被拒绝的、不完整的围栏标记会被转义为 HTML 实体(&#96;/&#126;),实体渲染出来仍是原来的字符外观,但不再具备 Markdown 语法作用,从而避免残缺的围栏标记改变后续描述文本的 Markdown 上下文。这正是“其他文本保持既有 MDX-hazard 转义”的体现。

适用范围边界:哪些字段享受围栏保留

判定链在collect_comments中按 NatSpec 字段类型分流(crates/doc/src/render.rs):

字段处理函数是否保留围栏内</{
@notice/@dev(descriptions)replace_description_links→ 识别围栏 → 区域外转义
@param/@returnreplace_inline_links(直接转义)
@title/@author/@custom:*sanitize_description_prose
@custom:namereplace_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_descriptionsFenceScope.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.mdxcontract.Child.mdx中渲染结果为:

### inspect <i> Example: ```solidity if (value < 1) {

Outside < and {

Parameters

NameTypeDescription
valueuint256Parameter example:
~solidity
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 &lt; and &#123;`,证明转义仍对普通文本生效; 3. **`@param` 表格单元格**:`if (value < 1) {` 在表格中被转义为 `if (value &lt; 1) &#123;`,证明参数描述不享受围栏保留(表格内换行被折叠为 `<br/>`)。 同一测试还对 `contract.Metadata` 验证了**未闭合/残缺围栏与特殊上下文**的兜底行为: ```md **Title:** Metadata &#126;~~ **Author:** Author &#96;``

~~~直接跟在@title文本行后并未构成完整独立围栏,其标记被转义为&#126;~~(渲染回~~~字样但无语法作用);```@author上下文同理被转为&#96;``。而@notice{1+1}之所以原样保留,是因为它落在完整的独立~~~围栏内(后续@custom:note中的~~~~因不构成根级独立围栏,其标记被中和为&#126;~~~)。这组断言直观展示了“完整独立”判定与neutralize_fence_markers兜底的分工。

如何在自己的项目中复现与验证

生成文档

在项目根目录执行:

forge doc

默认输出到根目录下的docs/(由 crates/config/src/doc.rs 的DocConfig控制),生成一个可直接运行的 vocs 站点脚手架与 MDX 页面(页面位于docs/src/pages/src/,命名形如contract.<ContractName>.mdxinterface.<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描述即可观察到表格中的&lt;/&#123;实体。

关键判定条件速查

  • 围栏标记:`~,长度 ≥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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询