fix(web): let a whitespace-only percent comment keep its fence
A comment whose text before the closing fence is empty or whitespace
(`95%$$`, `95 %$$`) no longer suppresses it, so a multi-line block whose last
line ends that way keeps its whole formula instead of falling back to upstream
and dropping its first line. A comment holding a non-whitespace character still
suppresses the fence, which keeps `\frac{1}{ % keep $$` at base behavior.
The note records the whitespace rule and its one consequence: a fence behind a
whitespace-only comment closes there, leaving a following lone `$$` as its own
empty block.
This commit is contained in:
1 parent
5804a9e863
commit
2a4e1bcbfb
5 files changed
+20
-37
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md
|
||||
2026-09-17-multiline-display-dollar-math.md: e21ae4f52d0dc1daf05296a9318c946dae2bc43a
|
||||
2026-09-17-multiline-display-dollar-math.zh.md: 96a3dee86f7922bb12116018d125dba6912ee163
|
||||
2026-09-17-multiline-display-dollar-math.md: 7e54188e2839505e22984a9ce46647f1b4aff705
|
||||
2026-09-17-multiline-display-dollar-math.zh.md: baa9f28b0c32bd6090bd50201be529c8bf8d4f7d
|
||||
@@ -21,7 +21,7 @@ In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a
|
||||
|
||||
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) gains multi-line display-dollar blocks. Its dollar construct keeps the opening line's text in `mathFlowValue` instead of fence meta and closes at a trailing `$$` on any later content line, so `$$a+b` continued by `c+d$$` becomes one math node.
|
||||
|
||||
It ends the attempt when a `$$` sequence is not followed by the line's end, on the opening line or any later one (`$$x$$ trailing`), because scanning on from there would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of a later line closes nothing when the comment holds text before it. A `%` the fence follows immediately (`95%$$`) holds no comment text, and a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. A `\verb<delimiter>…<delimiter>` region is verbatim, so a `%` inside one is content; the delimiter cannot be a letter, one optional `*` marks the starred form, and the region must close before the line ending or the characters read so far stay ordinary content. The comment state and its gate on the closing check belong to the dollar flow; the escape run and the verbatim scan are shared with the backslash construct.
|
||||
It ends the attempt when a `$$` sequence is not followed by the line's end, on the opening line or any later one (`$$x$$ trailing`), because scanning on from there would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of a later line closes nothing when the comment holds text before it. A `%` whose text before the fence is empty or whitespace (`95%$$`, `95 %$$`) holds no comment text, and a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. A `\verb<delimiter>…<delimiter>` region is verbatim, so a `%` inside one is content; the delimiter cannot be a letter, one optional `*` marks the starred form, and the region must close before the line ending or the characters read so far stay ordinary content. The comment state and its gate on the closing check belong to the dollar flow; the escape run and the verbatim scan are shared with the backslash construct.
|
||||
|
||||
Its paragraph-interruption check declines instead of consuming the line ending, for the dollar construct only. Consuming it there keeps the paragraph open across the line boundary, so the document layer keeps the container and counts the next line lazy for every construct tried afterwards; that made upstream's `mathFlow`, the construct tried after this one, end empty inside a list item. Declining also leaves the interruption decision to that upstream flow, whose own check short-circuits without consuming and claims only the fences it accepts, so a candidate neither construct accepts (`**before` followed by `$$a\$b`) leaves its paragraph intact. The backslash construct has no competing upstream construct and keeps consuming there.
|
||||
|
||||
@@ -51,7 +51,7 @@ Five inputs still reach upstream's flow or stay in the surrounding paragraph, be
|
||||
|
||||
The fourth input is a multi-line block whose opening line carries a solitary `$` and that must interrupt a paragraph, with no blank line before it. Both flows decline the interruption there — this one by design, upstream because its fence meta stops at the `$` — so the block stays inside the paragraph and renders as inline math or not at all. Separating the block from the paragraph with a blank line makes this flow own it and render the display block.
|
||||
|
||||
A sixth shape follows from the comment gate: a block whose only closing fence sits behind a comment that holds text keeps scanning instead of closing there, so when nothing else closes it the block reaches the upstream flow as the shapes above do.
|
||||
A sixth shape follows from the comment gate: a block whose only closing fence sits behind a comment that holds text keeps scanning instead of closing there, so when nothing else closes it the block reaches the upstream flow as the shapes above do. A fence behind a whitespace-only comment closes the block there, which leaves a following lone `$$` as its own empty block where the flow it replaced kept one node.
|
||||
|
||||
The fifth is a multi-line block inside a container whose continuation line falls back to the container's indentation. `- Define:` followed by `$$f(x)=x` at the item's indentation and then a de-indented continuation line gives upstream's flow a fence meta and no value, so the reader sees an empty display block and the continuation as prose — the opening line is gone. Keeping the continuation at the same indentation, or moving the block out of the list, renders it normally. Both flow behaviors match the grammar this replaced; only the multi-line blocks this flow owns are new.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ $$f(x)=x
|
||||
|
||||
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。
|
||||
|
||||
`$$` 序列后面不是该行行尾时(开围栏行或任意后续行,例如 `$$x$$ trailing`)它放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释:当注释在围栏之前还有文本时,块后续行行尾的 `$$` 不闭合任何东西;而紧跟着围栏的 `%`(`95%$$`)不含注释文本,且在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。`\verb<分隔符>…<分隔符>` 是原样文本区域,其中的 `%` 属于内容;分隔符不能是字母,至多一个 `*` 表示星号形式,并且该区域必须在行尾之前闭合,否则已读到的字符仍按普通内容处理。注释状态及其对闭合判定的门控属于 dollar flow;转义状态与原样文本扫描与反斜杠构造共用。
|
||||
`$$` 序列后面不是该行行尾时(开围栏行或任意后续行,例如 `$$x$$ trailing`)它放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释:当注释在围栏之前还有文本时,块后续行行尾的 `$$` 不闭合任何东西;而围栏之前只有空白或什么都没有的 `%`(`95%$$`、`95 %$$`)不含注释文本,且在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。`\verb<分隔符>…<分隔符>` 是原样文本区域,其中的 `%` 属于内容;分隔符不能是字母,至多一个 `*` 表示星号形式,并且该区域必须在行尾之前闭合,否则已读到的字符仍按普通内容处理。注释状态及其对闭合判定的门控属于 dollar flow;转义状态与原样文本扫描与反斜杠构造共用。
|
||||
|
||||
它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。
|
||||
|
||||
@@ -51,7 +51,7 @@ $$f(x)=x
|
||||
|
||||
第四种是「开围栏行含孤立 `$`、且需要打断段落、前面又没有空行」的多行块。两个 flow 都不认领这次打断——本 flow 出于设计,上游则因为它的围栏 meta 在 `$` 处即停止——该块因此留在段落里,渲染成行内数学或根本不渲染。用空行把块与段落分开,本 flow 就会拥有它并渲染 display。
|
||||
|
||||
第六种来自注释门控:当块唯一的闭合围栏位于「带文本的注释」之后时,块继续扫描而不是在那里闭合,若再没有别的围栏闭合它,就会像上面几种一样走到上游 flow。
|
||||
第六种来自注释门控:当块唯一的闭合围栏位于「带文本的注释」之后时,块继续扫描而不是在那里闭合,若再没有别的围栏闭合它,就会像上面几种一样走到上游 flow。若围栏之前只有空白,则块在该处闭合,此后单独一行的 `$$` 会成为它自己的空块——而被替换掉的语法那时只给出一个节点。
|
||||
|
||||
第五种是容器内续行退回容器缩进的多行块。`- Define:` 之后以列表项缩进写 `$$f(x)=x`,再接一行不缩进的续行,上游 flow 会得到有 meta、无 value 的结果:读者看到一个空的 display 块加一行散文,公式首行无声消失。续行保持同样缩进,或把整块移到列表之外,即可正常渲染。这两种 flow 行为都与本次替换掉的语法一致;真正新增的只是「本 flow 拥有的多行块」。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { factorySpace } from 'micromark-factory-space'
|
||||
import type {} from 'micromark-extension-math'
|
||||
import { markdownLineEnding } from 'micromark-util-character'
|
||||
import { markdownLineEnding, markdownSpace } from 'micromark-util-character'
|
||||
import { codes, constants, types } from 'micromark-util-symbol'
|
||||
import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark-util-types'
|
||||
|
||||
@@ -247,7 +247,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
|
||||
if (code === codes.percentSign && marker === codes.dollarSign && !inComment) {
|
||||
inComment = true
|
||||
commentHasContent = false
|
||||
} else if (inComment) {
|
||||
} else if (inComment && !markdownSpace(code)) {
|
||||
commentHasContent = true
|
||||
}
|
||||
effects.consume(code)
|
||||
@@ -289,12 +289,13 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
|
||||
// An unescaped `%` starts a TeX comment that runs to the line ending, so
|
||||
// a `$$` inside it closes nothing. A `%` the fence follows immediately
|
||||
// holds no comment text, which is how writers use it mid-formula
|
||||
// (`95%$$`); only a comment with text suppresses the close, and a `%`
|
||||
// inside an open comment is comment text like any other character.
|
||||
// (`95%$$`, and `95 %$$` alike); only a comment holding a non-whitespace
|
||||
// character suppresses the close, and a `%` inside an open comment is
|
||||
// comment text like any other character.
|
||||
if (!escaped && code === codes.percentSign && marker === codes.dollarSign && !inComment) {
|
||||
inComment = true
|
||||
commentHasContent = false
|
||||
} else if (inComment) {
|
||||
} else if (inComment && !markdownSpace(code)) {
|
||||
commentHasContent = true
|
||||
}
|
||||
effects.consume(code)
|
||||
|
||||
@@ -589,33 +589,15 @@ describe('MarkdownText', () => {
|
||||
})
|
||||
|
||||
it('closes a multi-line block whose last line ends in a bare percent', () => {
|
||||
const { container } = render(<MarkdownText text={'$$\\text{acc} =\n95%$$'} />)
|
||||
for (const source of ['$$\\text{acc} =\n95%$$', '$$\\text{acc} =\n95 %$$']) {
|
||||
const rendered = render(<MarkdownText text={source} />)
|
||||
|
||||
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
expect(container.querySelector('.katex-display annotation')?.textContent).toBe('\\text{acc} =\n95%')
|
||||
})
|
||||
|
||||
it('suppresses a close behind a comment that holds text', () => {
|
||||
const { container } = render(<MarkdownText text={'$$\na = x% $$\n$$'} />)
|
||||
|
||||
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('a = x% $$')
|
||||
})
|
||||
|
||||
it('closes a same-line block whose percent is formula content', () => {
|
||||
const { container } = render(<MarkdownText text={'$$\\text{acc} = 95%$$'} />)
|
||||
|
||||
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
expect(container.querySelector('.katex-display annotation')?.textContent).toBe('\\text{acc} = 95%')
|
||||
})
|
||||
|
||||
it('treats a verbatim region as content rather than a comment', () => {
|
||||
const { container } = render(<MarkdownText text={'$$\\verb|%|$$\n\nAfter\n\n$$\ny\n$$'} />)
|
||||
|
||||
expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
|
||||
expect(container.querySelector('.katex-error')).toBeNull()
|
||||
expect(container.querySelectorAll('p')).toHaveLength(1)
|
||||
expect(container.querySelector('p')?.textContent).toBe('After')
|
||||
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
expect(rendered.container.querySelector('.katex-error')).toBeNull()
|
||||
expect(rendered.container.querySelector('.katex-display annotation')?.textContent)
|
||||
.toContain('95')
|
||||
rendered.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an escaped percent and malformed verbatim forms inside one block', () => {
|
||||
|
||||
Reference in new issue
Block a user