</SCRIPT> 没有结束 HTML 块
主线位置:块主线的叶子块。CommonMark 把 HTML 块分成七类,第 1 类是以
<pre、<script、<style、<textarea开头的块,七类的开始和结束条件见叶子块的「HTML 块:七类」,那一页已经指出了这处与规范的出入。本页讲它在文档里的样子、怎么绕开、怎么查。
flowchart TB
start["script 开头"] --> tag["get_html_end_tag"]
tag --> want["只找小写 /script"]
want --> line{"这一行含它"}
line -->|是| close["块结束"]
line -->|否| more["下一行也是 HTML"]
more --> line
more --> eof["文档末尾才结束"]
eof --> raw["标题 强调都原样输出"]
实验 crate:exp-pulldown-cmark/pitb-probe 的 html-block-end(0.13.5),pitb-probe134 配 0.13.4,输出逐字节相同。M2 Max、macOS 27、rustc 1.99.0,输出在 out/html-block-end.txt。
现象
从老网页里复制了一段脚本,结束标签是大写:
<script>
let a = 1;
</SCRIPT>
# 标题
正文 *强调*。
事件流:
0..60 Start(HtmlBlock)
0..9 Html(Borrowed("<script>\n"))
9..20 Html(Borrowed("let a = 1;\n"))
20..30 Html(Borrowed("</SCRIPT>\n"))
30..31 Html(Borrowed("\n"))
31..40 Html(Borrowed("# 标题\n"))
40..41 Html(Borrowed("\n"))
41..60 Html(Borrowed("正文 *强调*。\n"))
0..60 End(HtmlBlock)
整份文档是一个 HTML 块。push_html 把它原样写出:
<script>
let a = 1;
</SCRIPT>
# 标题
正文 *强调*。
浏览器里 HTML 标签不分大小写,</SCRIPT> 照样结束了脚本,于是页面上出现一行字面的 # 标题 和 正文 *强调*。,标题和强调都没了。空行也挡不住:第 1 类 HTML 块不在空行处结束。
结束标签和开始标签不同名时也一样。<pre> 开头、</script> 那一行之后的内容同样全进了 HTML 块:
=== other-end: "<pre>\nconsole.log(1)\n</script>\n\n# 标题\n"
--- html
<pre>
console.log(1)
</script>
# 标题
原因
CommonMark 0.31.2 第 4.6 节对第 1 类的结束条件是「line contains an end tag </pre>, </script>, </style>, or </textarea> (case-insensitive; it need not match the start tag)」:四个结束标签任一个都行,不分大小写。0.13.5 的实现窄得多。
块开头时,get_html_end_tag 不分大小写地认开始标签,但只返回与它同名的那一个小写结束串:
static BEGIN_TAGS: &[&[u8]; 4] = &[b"pre", b"style", b"script", b"textarea"];
// ...
for (beg_tag, end_tag) in BEGIN_TAGS
.iter()
.zip(["</pre>", "</style>", "</script>", "</textarea>"].iter())
{
// ...
if !text_bytes[..tag_len].eq_ignore_ascii_case(beg_tag) {
continue;
}
之后 parse_html_block_type_1_to_5 每读一行,用 str::contains 找这个串:
if self.text[line_start_ix..ix].contains(html_end_tag) {
end_ix = ix;
break;
}
contains 区分大小写,也只找一个串。所以:
| 写法 | 0.13.5 | 规范 |
|---|---|---|
<script> … </script> | 结束 | 结束 |
<SCRIPT> … </script> | 结束(开始标签不分大小写) | 结束 |
<script> … </SCRIPT> | 不结束 | 结束 |
<pre> … </script> | 不结束 | 结束 |
不结束时,循环只在两种情况下停:容器不再匹配(比如块在引用块里,引用块结束了),或者到了文档末尾。第 2 到 5 类(注释、处理指令、声明、CDATA)的结束串是 -->、?>、>、]]>,没有字母,不受影响。
main 上已修,没有发布。 PR #1163 有两个修正提交:0b7c9f2「Match HTML block end tags case-insensitively」和 a8fd0bd「End an HTML block at any of the four type 1 end tags」,后一个的说明举的就是 <script> 配 </TEXTAREA> 的例子,「the block went on to the end of the document; cmark and commonmark.js end it at </TEXTAREA>」。同一个 PR 还改成用 memchr 在每个 < 处一次试四个结束标签。这些提交不在任何 tag 里,0.13.5 没有摘这个 PR;0.13.4 的行为与 0.13.5 相同。
修法
结束标签写成小写,并与开始标签同名。 开始标签的大小写无所谓:
<SCRIPT>
let a = 1;
</script>
# 标题
正文 *强调*。
<SCRIPT>
let a = 1;
</script>
<h1>标题</h1>
<p>正文 <em>强调</em>。</p>
块在 </script> 那一行结束,后面的标题和强调恢复正常。
处理不受你控制的 Markdown: 在解析前把这四种结束标签规范成小写(例如对 </SCRIPT>、</Pre> 这类写法做不分大小写的替换),并检查结束标签是否与开始标签同名。这只是绕过 0.13.5 的出入;main 上的修复发布后可以去掉。这一处不涉及安全边界:pulldown-cmark 本来就原样输出 HTML(见原始 HTML 原样输出那一页),这里的后果是排版坏掉。
怎么检查
遍历 Start(HtmlBlock):块的源文以这四种开始标签之一开头,块里却找不到对应的小写结束标签,它就是一路吞到了容器或文档的末尾。pitb-probe 里的 check_html_blocks:
if let Event::Start(Tag::HtmlBlock) = ev {
let block = &src[r.clone()];
let head = block.trim_start().to_ascii_lowercase();
for tag in ["pre", "script", "style", "textarea"] {
let open = format!("<{tag}");
let next = head.as_bytes().get(open.len()).copied();
let starts = head.starts_with(&open) && matches!(next, None | Some(b'>' | b' ' | b'\t' | b'\n'));
if starts && !block.contains(&format!("</{tag}>")) {
// report line, tag, how many lines were swallowed
}
}
}
check: line 1: <script> block never closed by </script>, swallowed 7 lines up to byte 60
check: 1 problem(s)
改好的文档报 check: 0 problem(s)。
小结
- 第 1 类 HTML 块(
<pre、<script、<style、<textarea开头)在 0.13.5 里只认与开始标签同名的小写结束标签;</SCRIPT>或另外三种结束标签都不结束块,空行也不行。 - 块不结束就一直延续到容器或文档末尾,后面的 Markdown 全部原样输出成 HTML,没有报错。
- 修法:结束标签写小写、与开始标签同名;外来内容先规范化。main 上的 PR #1163 已按规范修好,尚未进入任何发布版本。
- 检查:找以这四种标签开头、却不含对应小写结束标签的
HtmlBlock。