OI-wiki icon indicating copy to clipboard operation
OI-wiki copied to clipboard

[BUG] remark-lint 的 no-consecutive-blank-lines 规则与 bot 不能兼容

Open HeRaNO opened this issue 7 months ago • 4 comments

请选择:

  • [x] 我已经读过了 F.A.Q.,进行了搜索,但没有得到答案
  • [ ] 我正在着手修复这个问题

我遇到了这样的问题

remark-lint-no-consecutive-blank-lines 要求每个 markdown node 之间的空行数是一样的,但是在 details 框中混合使用多级列表或公式的情况下,似乎这个规则和 bot 的行为是不能兼容的。

有如下文档报此 warning:

  • docs/basic/shell-sort.md:159:33,253:11,273:72
  • docs/contest/common-mistakes.md:544:86
  • docs/ds/wblt.md:137:11
  • docs/lang/csl/bitset.md:390:41
  • docs/math/number-theory/euclidean.md:687:11
  • docs/math/number-theory/euler-totient.md:40:128
  • docs/math/number-theory/lift-the-exponent.md:75:11
  • docs/math/number-theory/lucas.md:143:21
  • docs/math/poly/elementary-func.md:47:11
  • docs/string/sam.md:225:345

我确认这个问题可以这样复现

No response

HeRaNO avatar Sep 14 '25 06:09 HeRaNO

研究了一下这个问题。目前看起来原因是 remark-details 插件在转换 markdown 为 mdast 后,没有处理 list 和 listItem 的 position 属性。

下面是一个例子。对下面的 example.md

## 性质

???+ note "证明"
    -   引理
    
    -   引理
    
        证明:证毕。
    
    -   引理
    
        证明:证毕。
    
    接下来我们证明。

## 多层列表

-   证明

    -   引理
        
        证明:证毕。
    
    -   引理

        证明:证毕。
           
    接下来我们证明。

运行如下代码:

import {remark} from 'remark'
import remarkCopywritingCorrect from 'remark-copywriting-correct'
import remarkPresetLintMarkdownStyleGuide from 'remark-preset-lint-markdown-style-guide'
import remarkLintNoDuplicateHeadingsInSection from 'remark-lint-no-duplicate-headings-in-section'
import remarkLintNoDuplicateHeadings from 'remark-lint-no-duplicate-headings'
import remarkLintListItemSpacing from 'remark-lint-list-item-spacing'
import remarkLintListItemIndent from 'remark-lint-list-item-indent'
import remarkLintCodeBlockStyle from 'remark-lint-code-block-style'
import remarkLintMaximumLineLength from 'remark-lint-maximum-line-length'
import remarkLintOrderedListMarkerValue from 'remark-lint-ordered-list-marker-value'
import remarkAttributes from 'remark-attributes'
import remarkAnchor from 'remark-anchor'
import remarkGfm from 'remark-gfm'
import remarkDetails from 'remark-details'
import remarkMath from 'remark-math'
import remarkMathSpace from 'remark-math-space'
import remarkLintFinalNewline from 'remark-lint-final-newline'
import remarkTabbed from 'remark-tabbed'
import remarkLintNoTabs from 'remark-lint-no-tabs'
import remarkClangFormat from 'remark-clang-format'

import {read, write} from 'to-vfile'
import {reporter} from 'vfile-reporter'
import {inspect} from 'unist-util-inspect'

export const processor = remark()
  .data('settings', {
    emphasis: '*',
    bullet: '-',
    listItemIndent: 'tab'
  })
  .use(remarkCopywritingCorrect)
  .use(remarkPresetLintMarkdownStyleGuide)
  .use(remarkLintNoDuplicateHeadingsInSection)
  .use(remarkLintNoDuplicateHeadings, false)
  .use(remarkLintListItemSpacing, false)
  .use(remarkLintListItemIndent, false)
  .use(remarkLintCodeBlockStyle, false)
  .use(remarkLintMaximumLineLength, false)
  .use(remarkLintOrderedListMarkerValue, 'ordered')
  .use(remarkAttributes)
  .use(remarkAnchor)
  .use(remarkGfm)
  .use(remarkDetails)
  .use(remarkMath)
  .use(remarkMathSpace)
  .use(remarkLintFinalNewline)
  .use(remarkTabbed)
  .use(remarkLintNoTabs)
  .use(remarkClangFormat, {math: false})

const file = await read('example.md')
const tree = processor.parse(file)
console.log(inspect(tree))

会得到如下 mdast:

root[4] (1:1-29:1, 0-233)
├─0 heading[1] (1:1-1:6, 0-5)
│   │ depth: 2
│   └─0 text "性质" (1:4-1:6, 3-5)
├─1 detailsContainer<details>[3] (3:1-15:1, 7-123)
│   │ attributes: {"open":true,"class":"note"}
│   ├─0 detailsContainerSummary<summary>[1] (3:11-3:15, 17-21)
│   │   │ attributes: {}
│   │   └─0 text "\"证明\"" (3:11-3:15, 17-21)
│   ├─1 list[3] (4:5-14:1, 26-110)
│   │   │ ordered: false
│   │   │ start: null
│   │   │ spread: false
│   │   ├─0 listItem[1] (4:5-6:9, 26-46)
│   │   │   │ spread: true
│   │   │   │ checked: null
│   │   │   └─0 paragraph[1] (4:9-4:11, 30-32)
│   │   │       └─0 text "引理" (4:9-4:11, 30-32)
│   │   ├─1 listItem[2] (6:5-10:9, 42-82)
│   │   │   │ spread: true
│   │   │   │ checked: null
│   │   │   ├─0 paragraph[1] (6:9-6:11, 46-48)
│   │   │   │   └─0 text "引理" (6:9-6:11, 46-48)
│   │   │   └─1 paragraph[1] (8:9-8:15, 62-68)
│   │   │       └─0 text "证明:证毕。" (8:9-8:15, 62-68)
│   │   └─2 listItem[2] (10:5-14:1, 78-110)
│   │       │ spread: true
│   │       │ checked: null
│   │       ├─0 paragraph[1] (10:9-10:11, 82-84)
│   │       │   └─0 text "引理" (10:9-10:11, 82-84)
│   │       └─1 paragraph[1] (12:9-12:15, 98-104)
│   │           └─0 text "证明:证毕。" (12:9-12:15, 98-104)
│   └─2 paragraph[1] (14:5-14:13, 114-122)
│       └─0 text "接下来我们证明。" (14:5-14:13, 114-122)
├─2 heading[1] (16:1-16:8, 124-131)
│   │ depth: 2
│   └─0 text "多层列表" (16:4-16:8, 127-131)
└─3 list[1] (18:1-28:13, 133-232)
    │ ordered: false
    │ start: null
    │ spread: false
    └─0 listItem[3] (18:1-28:13, 133-232)
        │ spread: true
        │ checked: null
        ├─0 paragraph[1] (18:5-18:7, 137-139)
        │   └─0 text "证明" (18:5-18:7, 137-139)
        ├─1 list[2] (20:5-27:12, 145-219)
        │   │ ordered: false
        │   │ start: null
        │   │ spread: true
        │   ├─0 listItem[2] (20:5-22:15, 145-175)
        │   │   │ spread: true
        │   │   │ checked: null
        │   │   ├─0 paragraph[1] (20:9-20:11, 149-151)
        │   │   │   └─0 text "引理" (20:9-20:11, 149-151)
        │   │   └─1 paragraph[1] (22:9-22:15, 169-175)
        │   │       └─0 text "证明:证毕。" (22:9-22:15, 169-175)
        │   └─1 listItem[2] (24:5-26:15, 185-207)
        │       │ spread: true
        │       │ checked: null
        │       ├─0 paragraph[1] (24:9-24:11, 189-191)
        │       │   └─0 text "引理" (24:9-24:11, 189-191)
        │       └─1 paragraph[1] (26:9-26:15, 201-207)
        │           └─0 text "证明:证毕。" (26:9-26:15, 201-207)
        └─2 paragraph[1] (28:5-28:13, 224-232)
            └─0 text "接下来我们证明。" (28:5-28:13, 224-232)

no-consecutive-blank-lines 规则主要检查了一系列结点的行号之间的关系:(在 OI wiki 目前导入的 v4.1.2 版本内如此,最新的 v5.0.1 中稍有调整)

  1. 父结点的结束行和第一个子结点的起始行之间间隔不大于 0
  2. 前一个子结点的结束行和后一个子结点的起始行之间间隔不大于 2
  3. 最后一个子结点的结束行和父结点的结束行之间间隔不大于 1

上述 mdast 违反了第三条:

root 29 -- list 28
detailsContainer 15 -- paragraph 14
list 14 -- listItem 14 
listItem 10 -- paragraph 8   <=== here
listItem 14 -- paragraph 12  <=== here
listItem 28 -- paragraph 28
list 27 -- listItem 26
listItem 22 -- paragraph 22
listItem 26 -- paragraph 26

因此,会产生如下报错信息:

example.md
   8:15  warning  Remove 1 line after node  no-consecutive-blank-lines  remark-lint
  12:15  warning  Remove 1 line after node  no-consecutive-blank-lines  remark-lint

⚠ 2 warnings

比较 details 框内列表和下面的多层列表的解析结果可以发现,问题出现在 listitem 和 list 结点的结束位置多了若干行。进一步查看 mircomark 的 events 信息:

... omitted ...
enter {
  type: 'lineEndingBlank',
  start: { line: 13, column: 5, offset: 109, _index: 5, _bufferIndex: -1 },
  end: { line: 14, column: 1, offset: 110, _index: 6, _bufferIndex: -1 }
}
exit {
  type: 'lineEndingBlank',
  start: { line: 13, column: 5, offset: 109, _index: 5, _bufferIndex: -1 },
  end: { line: 14, column: 1, offset: 110, _index: 6, _bufferIndex: -1 }
}
enter {
  type: 'detailsIndent',
  start: { line: 14, column: 1, offset: 110, _index: 25, _bufferIndex: 0 },
  end: { line: 14, column: 5, offset: 114, _index: 25, _bufferIndex: 4 }
}
exit {
  type: 'detailsIndent',
  start: { line: 14, column: 1, offset: 110, _index: 25, _bufferIndex: 0 },
  end: { line: 14, column: 5, offset: 114, _index: 25, _bufferIndex: 4 }
}
exit {
  _container: true,
  type: 'listUnordered',
  start: { line: 4, column: 5, offset: 26, _index: 0, _bufferIndex: 0 },
  end: { line: 14, column: 1, offset: 110, _index: 15, _bufferIndex: -1 }
}
... omitted ...

可以发现结束 listUnordered 事件前,记录了两个 detailsIndent 事件。因为 listItem 结点的结束位置是从下一个列表项的起始位置向前忽略若干事件倒推出来的:(https://github.com/syntax-tree/mdast-util-from-markdown/blob/main/dev/lib/index.js#L370-L401

        if (listItem) {
          let tailIndex = index
          lineIndex = undefined
          while (tailIndex--) {
            const tailEvent = events[tailIndex]
            if (
              tailEvent[1].type === 'lineEnding' ||
              tailEvent[1].type === 'lineEndingBlank'
            ) {
              if (tailEvent[0] === 'exit') continue
              if (lineIndex) {
                events[lineIndex][1].type = 'lineEndingBlank'
                listSpread = true
              }
              tailEvent[1].type = 'lineEnding'
              lineIndex = tailIndex
            } else if (
              tailEvent[1].type === 'linePrefix' ||
              tailEvent[1].type === 'blockQuotePrefix' ||
              tailEvent[1].type === 'blockQuotePrefixWhitespace' ||
              tailEvent[1].type === 'blockQuoteMarker' ||
              tailEvent[1].type === 'listItemIndent'
            ) {
              // Empty
            } else {
              break
            }
          }

但是,跳过的事件列表中没有 remark-details 插件新创建的 detailsIndent 事件,所以,结束位置计算出了问题。

最简单的修正方法可能是在完成上述解析之后,再另加一个 transformer,重新计算一下 details 框内部的 listItem 和 list 的结束位置信息。

GitHub
mdast utility to parse markdown. Contribute to syntax-tree/mdast-util-from-markdown development by creating an account on GitHub.

c-forrest avatar Sep 18 '25 08:09 c-forrest

感谢 Debug,我的理解是一个上游问题?是否应该做一个最小复现反馈给上游

HeRaNO avatar Sep 19 '25 01:09 HeRaNO

感谢 Debug,我的理解是一个上游问题?是否应该做一个最小复现反馈给上游

remark-details 也是 OI Wiki 维护的,严格来说这算上游吗?所以可能得看一下 @Enter-tainer 的意见,要不要修这个 bug。

c-forrest avatar Sep 19 '25 01:09 c-forrest

@c-forrest 我最近没有带宽修哦,如果想修需要自己动手

Enter-tainer avatar Sep 19 '25 01:09 Enter-tainer