Skip to content

source map: expose code block structure info (fenced vs indented, openingFence, infoInsertPoint) #141

Closed

Description

@luojiyin1987

背景

@lint-md/core 的 no-empty-code-lang 需要回答一个问题:一个 code 节点是不是 fenced code。

mdast 的 code 节点不带这个信息:

export interface Code extends Literal {
  type: 'code'
  lang?: string | null | undefined
  meta?: string | null | undefined
}

于是 core 只能回到原始 Markdown 自己推断。lint-md/lint-md#310 里的实现:

const isFencedCodeStart = (text: string, offset: number): boolean => {
  const character = text[offset];
  return character === '`' || character === '~';
};

它依赖的事实是:parser 把 fenced code 的 position.start 定位到围栏字符,把 indented code 的 position.start 定位到行首缩进。

这段代码能工作,但它把一个 parser 已经知道的事实复制到了 consumer 一侧。

parser 已经知道这个事实

src/source-map/recording-extension.ts 里两条路径是完全分开的:

onentercodefenced   / onexitcodefenced    -> FencedCodeRecording
onentercodeindented / onexitcodeindented   -> CodeValueRecording

两条路径都构造同一个节点:

this.enter({ type: 'code', lang: null, meta: null, value: '' }, token);

结构信息在节点上被抹平,之后只有 sidecar 的 recording 还留着它。

而且 FencedCodeRecording 已经记录了 core 需要的大部分东西:

interface FencedCodeRecording extends CodeValueRecording {
  emptyOffset: number
  sawOpeningLineEnding: boolean
  sawContentAfterOpening: boolean
  sawClosingFence: boolean
  openingIndent: number
  closingFenceStart?: number
}

emptyOffset 在 opening 行结尾处算出,已经很接近「没有 lang 时应该插在哪里」。它现在写进 state.emptyCodeOffsets(WeakMap<object, number>),但只在 src/source-map/build-source-map.ts 内部用于空范围的映射查询,MarkdownSourceMap 没有暴露。而且只有 onexitcodefenced 会写它,indented 路径不写。

公开 API 目前有 getValueSourceIndex / getRaw / getSourceRange / getFieldSourceRange,全部是「已有的 value 或 field 对应源码哪里」。没有「这个节点的源码结构是什么」这一类查询。

建议方向

不要往 mdast AST 上加字段(例如 node.fenced = true)。

parser 现在保持 parseMd() 和 parseMdWithSourceMap().ast 的 AST parity,MarkdownCodeNode 又直接来自 mdast 的 Code。往 AST 上塞 parser 私有字段,会让标准 mdast AST 承担 lint-md 的私有语义。放在 sidecar MarkdownSourceMap 上更符合现有架构。

建议一次做成一个小的 discriminated union,而不是一个布尔标志:

interface FencedCodeSourceInfo {
  kind: 'fenced'
  openingFence: ParsedPosition
  infoInsertPoint: ParsedPoint
}

interface IndentedCodeSourceInfo {
  kind: 'indented'
}

type CodeSourceInfo = FencedCodeSourceInfo | IndentedCodeSourceInfo

interface MarkdownSourceMap {
  getCodeSourceInfo(node: MarkdownCodeNode): CodeSourceInfo
}

core 侧就能变成:

const info = sourceMap.getCodeSourceInfo(node);

if (node.lang || info.kind !== 'fenced') {
  return;
}

fixer.insertTextAt(info.infoInsertPoint.offset, 'plain');

比 isFencedCodeStart(text, start) 加 findOpeningLineEnd(text, start) 高一层语义,并且能删掉 core 侧这两个 helper。

为什么不是只加 fenced: boolean

加了布尔值之后,下一条规则会立刻需要更多信息:

  • opening fence 在哪里
  • lang 在源码哪里
  • meta 在源码哪里
  • 没有 lang 时应该插在哪里

于是还要继续加 API。一次给出一个小的结构信息 union,比逐个补布尔标志更省事。

lang / meta mapping 属于第二阶段

getFieldSourceRange(node, 'lang' | 'meta', ...) 也值得做,但语义不同:

  • getFieldSourceRange 回答:已经存在的 lang / meta 对应源码哪里
  • 本 issue 要回答:lang 不存在时应该插在哪里

所以只加 field range 不够。infoInsertPoint(或等价的 opening fence / opening line metadata)是这个 issue 的核心价值。

范围控制

第一阶段只做 getCodeSourceInfo,包含 kind、openingFence、infoInsertPoint。

不要一次公开 opening fence、closing fence、lang、meta、marker character、marker length 全部内容,那样 API 很容易过度设计。等真的有规则需要精确修改已有的 lang 或 meta,再做第二阶段。

相关

同一条原则:一个事实只有 parser 能可靠知道,就不要让 downstream 从原始 Markdown 再推断一次。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions