From 2019a72f172bbadbda3309b92b27223d3e6e68b1 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 18 Jul 2026 21:37:57 +0800 Subject: [PATCH 1/4] feat: map code block source ranges --- CHANGELOG.md | 1 + README.md | 8 +- __tests__/source-map.spec.ts | 120 ++++++++++++- __tests__/types/package-exports.cts | 10 ++ etc/parser.api.md | 4 +- src/source-map/build-source-map.ts | 256 +++++++++++++++++++++++++++- src/source-map/types.ts | 11 +- 7 files changed, 392 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9682c4c..d880f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `MarkdownSourceMap.getSourceRange()` 新增对 fenced 与 indented `code.value` 的源码映射支持,覆盖围栏、info/meta、缩进剥离、空行以及 CR、LF、CRLF(#67) - `MarkdownSourceMap.getSourceRange()` 新增对 `inlineCode.value` 的源码映射支持,覆盖多反引号定界符、首尾 padding 规则以及 CR、LF、CRLF;同时新增公开类型 `MarkdownInlineCodeNode`(#66) ### Fixed diff --git a/README.md b/README.md index fa11d8e..e5c8bcb 100644 --- a/README.md +++ b/README.md @@ -86,14 +86,14 @@ sourceMap.getRaw(textNode); // 'A&B' ### 契约 -- `getSourceRange(node, valueStart, valueEnd)` 的索引与 JavaScript 字符串下标一致,范围均为半开区间 `[start, end)`;当前支持 `text.value` 与 `inlineCode.value`。 +- `getSourceRange(node, valueStart, valueEnd)` 的索引与 JavaScript 字符串下标一致,范围均为半开区间 `[start, end)`;当前支持 `text.value`、`inlineCode.value` 与 block `code.value`。 - 映射覆盖受支持节点的整个 `value`,segment 之间无空洞、无重叠。 - `getSourceRange(node, 0, node.value.length)` 覆盖该节点 value 的完整原始来源范围。 - 错误分为三条路径,专属错误均继承 `RangeError`(现有 `catch (RangeError)` 不受影响),并带稳定的 `code` 字段;当跨边界传递时(如跨 CJS/ESM 实例、重复安装、worker 边界),只要错误被显式序列化且 `code` 字段被保留,即可用 `code` 而非 `instanceof` 判断(类本身无法保证任意序列化机制一定保留自定义属性): - - `SourceMapConsistencyError`(`ERR_SOURCE_MAP_CONSISTENCY`):已建立映射的 `text` 或 `inlineCode` 节点在解析后被修改——映射只对原始解析值有效,重新赋入相同内容的 `value` 不受影响; - - `SourceMapUnavailableError`(`ERR_SOURCE_MAP_UNAVAILABLE`):节点属于其他文档、由插件生成或在解析后加入、或不是受支持的 `text` / `inlineCode` 节点——不会伪造位置; + - `SourceMapConsistencyError`(`ERR_SOURCE_MAP_CONSISTENCY`):已建立映射的 `text`、`inlineCode` 或 `code` 节点在解析后被修改——映射只对原始解析值有效,重新赋入相同内容的 `value` 不受影响; + - `SourceMapUnavailableError`(`ERR_SOURCE_MAP_UNAVAILABLE`):节点属于其他文档、由插件生成或在解析后加入、或不是受支持的 `text` / `inlineCode` / `code` 节点——不会伪造位置; - 普通 `RangeError`:`valueStart` / `valueEnd` 非法(非有限整数、越界、倒置,或空区间落在原子构造内部)。 -- 当前版本覆盖 `text.value` 与 `inlineCode.value`;其余字段(`code.value`、`link.url` 等)后续版本补充。 +- 当前版本覆盖 `text.value`、`inlineCode.value` 与 `code.value`;其余字段(`link.url` 等)后续版本补充。 ## 开发验证 diff --git a/__tests__/source-map.spec.ts b/__tests__/source-map.spec.ts index 4fe0a31..b4a5486 100644 --- a/__tests__/source-map.spec.ts +++ b/__tests__/source-map.spec.ts @@ -25,6 +25,17 @@ function inlineCodeNodes(root: any): any[] { return out; } +/** Collect every block `code` node in document order. */ +function codeNodes(root: any): any[] { + const out: any[] = []; + (function walk(n: any) { + if (n.type === 'code') + out.push(n); + for (const c of n.children || []) walk(c); + })(root); + return out; +} + describe('parseMdWithSourceMap: text.value → raw source', () => { test('backslash escape \\( maps to a 2-char source span', () => { const { ast, sourceMap } = parseMdWithSourceMap('\\('); @@ -290,6 +301,112 @@ describe('parseMdWithSourceMap: inlineCode.value → raw source', () => { expect(() => sourceMap.getRaw(node)).toThrow(SourceMapConsistencyError); }); }); +describe('parseMdWithSourceMap: code.value → raw source', () => { + function expectPerCodeUnitRanges( + md: string, + node: any, + sourceMap: any, + ): void { + const whole = sourceMap.getSourceRange(node, 0, node.value.length); + let previousStart = whole.start.offset; + let previousEnd = whole.start.offset; + for (let i = 0; i < node.value.length; i++) { + const range = sourceMap.getSourceRange(node, i, i + 1); + expect(range.start.offset).toBeGreaterThanOrEqual(0); + expect(range.end.offset).toBeGreaterThanOrEqual(range.start.offset); + expect(range.end.offset).toBeLessThanOrEqual(md.length); + expect(range.start.offset).toBeGreaterThanOrEqual(whole.start.offset); + expect(range.end.offset).toBeLessThanOrEqual(whole.end.offset); + expect(range.start.offset).toBeGreaterThanOrEqual(previousStart); + expect(range.end.offset).toBeGreaterThanOrEqual(previousEnd); + previousStart = range.start.offset; + previousEnd = range.end.offset; + } + } + + test.each(['\n', '\r', '\r\n'])( + 'maps fenced code with %p line endings', + (lineEnding) => { + const md = `\`\`\`ts meta${lineEnding}a${lineEnding}b${lineEnding}\`\`\``; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe(`a${lineEnding}b`); + expect(sourceMap.getRaw(node)).toBe(md); + const whole = sourceMap.getSourceRange(node, 0, node.value.length); + expect(whole.start.offset).toBe( + md.indexOf('a', md.indexOf(lineEnding) + lineEnding.length), + ); + expect(whole.end.offset).toBe(md.indexOf('b') + 1); + expectPerCodeUnitRanges(md, node, sourceMap); + }, + ); + + test('maps indented code line by line, including a blank line', () => { + const md = ' a\r\n\r\n b\r\n'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe('a\r\n\r\nb'); + expect(sourceMap.getRaw(node)).toBe(' a\r\n\r\n b'); + const whole = sourceMap.getSourceRange(node, 0, node.value.length); + expect(whole.start.offset).toBe(md.indexOf('a')); + expect(whole.end.offset).toBe(md.indexOf('b') + 1); + expectPerCodeUnitRanges(md, node, sourceMap); + }); + + test.each(['\t', ' \t', ' \t', ' \t', '\t\t'])( + 'maps a tab-indented code line with prefix %p', + (indentation) => { + const md = indentation + 'a\n'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + const value = indentation === '\t\t' ? '\ta' : 'a'; + expect(node.value).toBe(value); + const range = sourceMap.getSourceRange(node, 0, node.value.length); + expect(md.slice(range.start.offset, range.end.offset)).toBe(value); + }, + ); + + test('maps tilde-fenced code', () => { + const md = '~~~\r\nvalue\r\n~~~'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe('value'); + expect(sourceMap.getRaw(node)).toBe(md); + expect(sourceMap.getSourceRange(node, 0, node.value.length)).toEqual({ + start: { line: 2, column: 1, offset: 5 }, + end: { line: 2, column: 6, offset: 10 }, + }); + }); + + test('excludes fenced delimiters and their indentation from code ranges', () => { + const md = ' ```\n a\n b\n ```'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe('a\nb'); + expect(sourceMap.getRaw(node)).toBe('```\n a\n b\n ```'); + expect(sourceMap.getSourceRange(node, 0, 1).start.offset).toBe(md.indexOf('a')); + expect(sourceMap.getSourceRange(node, 2, 3).end.offset).toBe(md.indexOf('b') + 1); + }); + + test('maps an empty fenced code value to its content boundary', () => { + const md = '```\n\n```'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe(''); + expect(sourceMap.getRaw(node)).toBe(md); + expect(sourceMap.getSourceRange(node, 0, 0).start.offset).toBe(4); + }); + + test('rejects a code value modified after parsing', () => { + const { ast, sourceMap } = parseMdWithSourceMap('```\nvalue\n```'); + const node = codeNodes(ast)[0]; + node.value = 'changed'; + expect(() => sourceMap.getSourceRange(node, 0, 1)).toThrow( + SourceMapConsistencyError, + ); + expect(() => sourceMap.getRaw(node)).toThrow(SourceMapConsistencyError); + }); +}); describe('parseMdWithSourceMap: contract', () => { test('getSourceRange start..end covers the whole text node value', () => { @@ -508,7 +625,6 @@ describe('parseMdWithSourceMap: contract', () => { }); }); - describe('parseMdWithSourceMap: split and unmapped nodes', () => { test('text nodes split around a www autolink each map to their own raw span', () => { const { ast, sourceMap } = parseMdWithSourceMap( @@ -728,6 +844,8 @@ describe('parseMd vs parseMdWithSourceMap: AST parity corpus', () => { '`code with < tag` and > quote', 'text [a]() end', 'pre\n```js\nconst x = 1 & 2;\n```\npost', + '~~~\r\na\r\n~~~', + ' a\n\n b\n', '�€﷐ and &amp;', 'A😀B', ]; diff --git a/__tests__/types/package-exports.cts b/__tests__/types/package-exports.cts index d274936..a05522c 100644 --- a/__tests__/types/package-exports.cts +++ b/__tests__/types/package-exports.cts @@ -9,12 +9,20 @@ const raw: string = doc.sourceMap.getRaw(doc.ast.children[0]); const inlineCodeRaw: string = doc.sourceMap.getRaw( doc.ast.children[0] as parser.MarkdownInlineCodeNode, ); +const codeRaw: string = doc.sourceMap.getRaw( + doc.ast.children[0] as parser.MarkdownCodeNode, +); const range = doc.sourceMap.getSourceRange(doc.ast.children[0] as parser.MarkdownTextNode, 0, 1); const inlineCodeRange = doc.sourceMap.getSourceRange( doc.ast.children[0] as parser.MarkdownInlineCodeNode, 0, 1, ); +const codeRange = doc.sourceMap.getSourceRange( + doc.ast.children[0] as parser.MarkdownCodeNode, + 0, + 1, +); const consistency = new parser.SourceMapConsistencyError(); const asRangeError: RangeError = new parser.SourceMapUnavailableError(); const isSourceMapError: boolean = consistency instanceof parser.SourceMapError; @@ -23,6 +31,8 @@ void raw; void inlineCodeRaw; void range; void inlineCodeRange; +void codeRange; +void codeRaw; void consistency; void asRangeError; void isSourceMapError; diff --git a/etc/parser.api.md b/etc/parser.api.md index bf03f44..4545a21 100644 --- a/etc/parser.api.md +++ b/etc/parser.api.md @@ -81,8 +81,8 @@ export type MarkdownRoot = Root; // @public export interface MarkdownSourceMap { - getRaw(node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode): string; - getSourceRange(node: MarkdownTextNode | MarkdownInlineCodeNode, valueStart: number, valueEnd: number): ParsedPosition; + getRaw(node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode): string; + getSourceRange(node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, valueStart: number, valueEnd: number): ParsedPosition; } // @public (undocumented) diff --git a/src/source-map/build-source-map.ts b/src/source-map/build-source-map.ts index a2ea72a..72ac877 100644 --- a/src/source-map/build-source-map.ts +++ b/src/source-map/build-source-map.ts @@ -3,6 +3,7 @@ import { decodeNumericCharacterReference } from 'micromark-util-decode-numeric-c import { decodeNamedCharacterReference } from 'decode-named-character-reference'; import type { Root } from 'mdast'; import type { + MarkdownCodeNode, MarkdownInlineCodeNode, MarkdownNode, MarkdownTextNode, @@ -44,6 +45,10 @@ interface RecordingState { segments: WeakMap /** inlineCode node -> value segments (see buildInlineCodeSegments). */ inlineCodeSegments: WeakMap + /** code node -> value segments (see buildCodeSegments). */ + codeSegments: WeakMap + /** code node -> source point for an empty value. */ + emptyCodeOffsets: WeakMap } const REPLACEMENT_CHARACTER = '�'; @@ -376,6 +381,209 @@ function buildInlineCodeSegments( }]; } +interface CodeSegments { + segments: MarkdownSourceMapSegment[] + emptyOffset?: number +} + +interface SourceSpan { + start: number + end: number +} + +function lineEnd(md: string, start: number, limit: number): number { + let offset = start; + while (offset < limit) { + const char = md.charCodeAt(offset); + if (char === 13) + return offset + (md.charCodeAt(offset + 1) === 10 ? 2 : 1); + if (char === 10) + return offset + 1; + offset++; + } + return limit; +} + +function lineStart(md: string, start: number, end: number): number { + let offset = end; + while (offset > start) { + const char = md.charCodeAt(offset - 1); + if (char === 10 || char === 13) + break; + offset--; + } + return offset; +} + +function lineContentEnd(md: string, start: number, end: number): number { + if (end <= start) + return end; + if (md.charCodeAt(end - 1) === 10) { + return end - (md.charCodeAt(end - 2) === 13 ? 2 : 1); + } + return md.charCodeAt(end - 1) === 13 ? end - 1 : end; +} + +function trimTrailingLineEnding(md: string, spans: SourceSpan[]): void { + const last = spans[spans.length - 1]; + if (!last) + return; + if (md.charCodeAt(last.end - 1) === 10) { + last.end -= md.charCodeAt(last.end - 2) === 13 ? 2 : 1; + } + else if (md.charCodeAt(last.end - 1) === 13) { + last.end--; + } + if (last.start === last.end) + spans.pop(); +} + +function segmentsFromSpans( + md: string, + spans: SourceSpan[], + value: string, +): MarkdownSourceMapSegment[] | undefined { + const segments: MarkdownSourceMapSegment[] = []; + let valueOffset = 0; + for (const span of spans) { + if (span.start >= span.end) + continue; + const length = span.end - span.start; + const previous = segments[segments.length - 1]; + if (previous && previous.sourceEnd === span.start && previous.valueEnd === valueOffset) { + previous.sourceEnd = span.end; + previous.valueEnd += length; + } + else { + segments.push({ + valueStart: valueOffset, + valueEnd: valueOffset + length, + sourceStart: span.start, + sourceEnd: span.end, + kind: 'literal', + }); + } + valueOffset += length; + } + return valueOffset === value.length ? segments : undefined; +} + +function buildFencedCodeSegments( + md: string, + node: { value: string; position?: ParsedPosition }, +): CodeSegments | undefined { + const position = node.position; + if (!position) + return undefined; + const start = position.start.offset; + const end = position.end.offset; + const marker = md.charCodeAt(start); + if (marker !== 96 && marker !== 126) + return undefined; + + let fenceLength = 0; + while (md.charCodeAt(start + fenceLength) === marker) fenceLength++; + if (fenceLength < 3) + return undefined; + + const openingLineEnd = lineEnd(md, start, end); + let contentEnd = end; + const closingLineStart = lineStart(md, start, end); + const closing = md.slice(closingLineStart, end); + const closingMatch = /^( {0,3})(`+|~+)[ \t]*$/.exec(closing); + if ( + closingMatch + && closingMatch[2].charCodeAt(0) === marker + && closingMatch[2].length >= fenceLength + ) { + contentEnd = closingLineStart; + } + + const physicalLineStart = lineStart(md, 0, start); + const openingIndent = start - physicalLineStart; + const spans: SourceSpan[] = []; + let offset = openingLineEnd; + while (offset < contentEnd) { + const endOfLine = lineEnd(md, offset, contentEnd); + let contentStart = offset; + let removed = 0; + while (removed < openingIndent && md.charCodeAt(contentStart) === 32) { + contentStart++; + removed++; + } + spans.push({ start: contentStart, end: endOfLine }); + offset = endOfLine; + } + trimTrailingLineEnding(md, spans); + const segments = segmentsFromSpans(md, spans, node.value); + if (!segments) + return undefined; + return { + segments, + emptyOffset: openingLineEnd, + }; +} + +function buildIndentedCodeSegments( + md: string, + node: { value: string; position?: ParsedPosition }, +): CodeSegments | undefined { + const position = node.position; + if (!position) + return undefined; + const start = position.start.offset; + const end = position.end.offset; + const spans: SourceSpan[] = []; + let offset = start; + while (offset < end) { + const endOfLine = lineEnd(md, offset, end); + let contentStart = offset; + let indentation = 0; + while (indentation < 4) { + const char = md.charCodeAt(contentStart); + if (char === 32) { + contentStart++; + indentation++; + } + else if (char === 9) { + contentStart++; + indentation += 4 - (indentation % 4); + } + else { + break; + } + } + // A non-blank line with fewer than four indentation columns needs + // parser-specific virtual-space accounting. Do not fabricate it. + if (indentation < 4 && contentStart < lineContentEnd(md, offset, endOfLine)) + return undefined; + spans.push({ start: contentStart, end: endOfLine }); + offset = endOfLine; + } + trimTrailingLineEnding(md, spans); + const segments = segmentsFromSpans(md, spans, node.value); + if (!segments) + return undefined; + return { + segments, + emptyOffset: start + Math.min(4, end - start), + }; +} + +function buildCodeSegments( + md: string, + node: { value: string; position?: ParsedPosition }, +): CodeSegments | undefined { + const position = node.position; + if (!position) + return undefined; + const start = position.start.offset; + const marker = md.charCodeAt(start); + return marker === 96 || marker === 126 + ? buildFencedCodeSegments(md, node) + : buildIndentedCodeSegments(md, node); +} + /** * Find the segment covering `valueIndex`, or undefined. * @@ -427,6 +635,8 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { fullSpan: false, segments: new WeakMap(), inlineCodeSegments: new WeakMap(), + codeSegments: new WeakMap(), + emptyCodeOffsets: new WeakMap(), }; const tree = fromMarkdown(md, { @@ -452,6 +662,19 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { for (const child of node.children || []) recordInlineCodeSegments(child); })(ast); + (function recordCodeSegments(node: any) { + if (node.type === 'code' && typeof node.value === 'string') { + const mapping = buildCodeSegments(md, node); + if (mapping) { + state.codeSegments.set(node, mapping.segments); + if (mapping.emptyOffset !== undefined) { + state.emptyCodeOffsets.set(node, mapping.emptyOffset); + } + } + } + for (const child of node.children || []) recordCodeSegments(child); + })(ast); + // Record every node that belongs to this document so `getRaw` / // `getSourceRange` can reject foreign nodes instead of silently slicing the // wrong Markdown with a stolen offset. For mapped text nodes, also snapshot @@ -469,7 +692,11 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { const originalOffsets = new WeakMap(); (function register(node: any) { owned.add(node); - if (state.segments.has(node) || state.inlineCodeSegments.has(node)) { + if ( + state.segments.has(node) + || state.inlineCodeSegments.has(node) + || state.codeSegments.has(node) + ) { originalValues.set(node, node.value); } const position = (node as { position?: ParsedPosition }).position; @@ -493,7 +720,9 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { }; const sourceMap: MarkdownSourceMap = { - getRaw(node: MarkdownNode): string { + getRaw( + node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, + ): string { if (!owned.has(node as object)) { throw new SourceMapUnavailableError( 'getRaw: the given node does not belong to this document; pass a ' @@ -509,7 +738,10 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { // positions the text node one code unit earlier). return md.slice(segs[0].sourceStart, segs[segs.length - 1].sourceEnd); } - if (state.inlineCodeSegments.has(node as object)) { + if ( + state.inlineCodeSegments.has(node as object) + || state.codeSegments.has(node as object) + ) { assertUnmodified(node as object); } // Non-mapped nodes: slice with the offsets snapshotted at parse time, so @@ -526,7 +758,7 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { }, getSourceRange( - node: MarkdownTextNode | MarkdownInlineCodeNode, + node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, valueStart: number, valueEnd: number, ): ParsedPosition { @@ -549,12 +781,13 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { ); } const segs = state.segments.get(node as object) - || state.inlineCodeSegments.get(node as object); + || state.inlineCodeSegments.get(node as object) + || state.codeSegments.get(node as object); if (!segs) { throw new SourceMapUnavailableError( 'getSourceRange: no source mapping is available for the given ' + 'node; it was generated, added after parsing, or is not a ' - + 'supported text or inlineCode node', + + 'supported text, inlineCode, or code node', ); } assertUnmodified(node as object); @@ -569,6 +802,17 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { ); } + if (segs.length === 0) { + const emptyOffset = state.emptyCodeOffsets.get(node as object); + if (node.value.length === 0 && valueStart === 0 && valueEnd === 0 && emptyOffset !== undefined) { + const sourcePoint = pointAtOffset(lineStarts, md, emptyOffset); + return { start: sourcePoint, end: sourcePoint }; + } + throw new RangeError( + 'getSourceRange: value range is not fully covered by the source map', + ); + } + // Escapes / character references / normalizations are atomic: the parser // produced them as a single unit, so any value range intersecting such a // segment must map back to that segment's *complete* source span. Only diff --git a/src/source-map/types.ts b/src/source-map/types.ts index b27f264..4afeb9b 100644 --- a/src/source-map/types.ts +++ b/src/source-map/types.ts @@ -1,4 +1,5 @@ import type { + MarkdownCodeNode, MarkdownInlineCodeNode, MarkdownNode, MarkdownTextNode, @@ -84,13 +85,13 @@ export interface MarkdownSourceMap { * @returns The raw Markdown that produced `node`. */ getRaw( - node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode, + node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, ): string /** * Maps a half-open range of a supported node's normalized `value` back to * the corresponding range in the raw Markdown source. Supported nodes are - * `text` and `inlineCode`. + * `text`, `inlineCode`, and block `code`. * * The returned range is monotonically increasing and covers the union of * every source segment spanned by `[valueStart, valueEnd)`. @@ -99,7 +100,7 @@ export interface MarkdownSourceMap { * * - {@link SourceMapUnavailableError} — the node belongs to another * document, was generated or added after parsing, or is not a - * supported `text` or `inlineCode` node. No mapping is fabricated. + * supported `text`, `inlineCode`, or `code` node. No mapping is fabricated. * - {@link SourceMapConsistencyError} — the mapped node has been modified since * parsing; the map only covers the original parsed value. * - `RangeError` — `valueStart` / `valueEnd` are not finite integers, are @@ -112,7 +113,7 @@ export interface MarkdownSourceMap { * @returns The source range covering the requested value slice. */ getSourceRange( - node: MarkdownTextNode | MarkdownInlineCodeNode, + node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, valueStart: number, valueEnd: number, ): ParsedPosition @@ -126,6 +127,6 @@ export interface MarkdownSourceMap { export interface ParsedMarkdownDocument { /** The fully positioned AST, identical to what {@link parseMd} returns. */ ast: import('../types').PositionedMarkdownRoot - /** Sidecar source map for resolving `text` value ranges to raw source. */ + /** Sidecar source map for resolving supported value ranges to raw source. */ sourceMap: MarkdownSourceMap } From fabeec6e232c73cb1b2841df11835547aec99558 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 18 Jul 2026 21:51:35 +0800 Subject: [PATCH 2/4] fix: map code blocks in containers --- CHANGELOG.md | 2 +- __tests__/source-map.spec.ts | 26 ++++++++ src/source-map/build-source-map.ts | 103 ++++++++++++++++++++++++++--- 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d880f95..b05561d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- `MarkdownSourceMap.getSourceRange()` 新增对 fenced 与 indented `code.value` 的源码映射支持,覆盖围栏、info/meta、缩进剥离、空行以及 CR、LF、CRLF(#67) +- `MarkdownSourceMap.getSourceRange()` 新增对 fenced 与 indented `code.value` 的源码映射支持,覆盖围栏、info/meta、blockquote/list 容器前缀、缩进剥离、空行以及 CR、LF、CRLF(#67) - `MarkdownSourceMap.getSourceRange()` 新增对 `inlineCode.value` 的源码映射支持,覆盖多反引号定界符、首尾 padding 规则以及 CR、LF、CRLF;同时新增公开类型 `MarkdownInlineCodeNode`(#66) ### Fixed diff --git a/__tests__/source-map.spec.ts b/__tests__/source-map.spec.ts index b4a5486..d0c6709 100644 --- a/__tests__/source-map.spec.ts +++ b/__tests__/source-map.spec.ts @@ -378,6 +378,32 @@ describe('parseMdWithSourceMap: code.value → raw source', () => { }); }); + test('maps fenced code inside a blockquote', () => { + const md = '> ```js\n> const x = 1\n> ```'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe('const x = 1'); + const range = sourceMap.getSourceRange(node, 0, node.value.length); + expect(md.slice(range.start.offset, range.end.offset)).toContain('const x = 1'); + }); + + test('maps indented code inside a blockquote', () => { + const md = '> indented\n> code'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe('indented\ncode'); + expectPerCodeUnitRanges(md, node, sourceMap); + }); + + test('maps fenced code nested in a list', () => { + const md = '- item\n ```\n value\n ```'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe('value'); + const range = sourceMap.getSourceRange(node, 0, node.value.length); + expect(md.slice(range.start.offset, range.end.offset)).toBe('value'); + }); + test('excludes fenced delimiters and their indentation from code ranges', () => { const md = ' ```\n a\n b\n ```'; const { ast, sourceMap } = parseMdWithSourceMap(md); diff --git a/src/source-map/build-source-map.ts b/src/source-map/build-source-map.ts index 72ac877..f5fcef3 100644 --- a/src/source-map/build-source-map.ts +++ b/src/source-map/build-source-map.ts @@ -424,6 +424,58 @@ function lineContentEnd(md: string, start: number, end: number): number { return md.charCodeAt(end - 1) === 13 ? end - 1 : end; } +function blockQuoteDepth(md: string, start: number, end: number): number { + let depth = 0; + for (let offset = start; offset < end; offset++) { + if (md.charCodeAt(offset) === 62 /* > */) + depth++; + } + return depth; +} + +function skipBlockQuoteMarkers( + md: string, + start: number, + end: number, + depth: number, +): number | undefined { + let offset = start; + for (let index = 0; index < depth; index++) { + while (offset < end && (md.charCodeAt(offset) === 32 || md.charCodeAt(offset) === 9)) { + offset++; + } + if (md.charCodeAt(offset) !== 62) + return undefined; + offset++; + if (md.charCodeAt(offset) === 32) + offset++; + } + return offset; +} + +function fencedIndentation( + md: string, + lineStartOffset: number, + fenceStart: number, + quoteDepth: number, +): number { + if (quoteDepth === 0) + return fenceStart - lineStartOffset; + let offset = fenceStart - 1; + while (offset >= lineStartOffset && md.charCodeAt(offset) !== 62) offset--; + if (offset < lineStartOffset) + return -1; + offset++; + if (md.charCodeAt(offset) === 32) + offset++; + let indentation = 0; + while (offset < fenceStart && md.charCodeAt(offset) === 32) { + offset++; + indentation++; + } + return offset === fenceStart ? indentation : -1; +} + function trimTrailingLineEnding(md: string, spans: SourceSpan[]): void { const last = spans[spans.length - 1]; if (!last) @@ -445,10 +497,12 @@ function segmentsFromSpans( ): MarkdownSourceMapSegment[] | undefined { const segments: MarkdownSourceMapSegment[] = []; let valueOffset = 0; + let sourceValue = ''; for (const span of spans) { if (span.start >= span.end) continue; const length = span.end - span.start; + sourceValue += md.slice(span.start, span.end); const previous = segments[segments.length - 1]; if (previous && previous.sourceEnd === span.start && previous.valueEnd === valueOffset) { previous.sourceEnd = span.end; @@ -465,7 +519,9 @@ function segmentsFromSpans( } valueOffset += length; } - return valueOffset === value.length ? segments : undefined; + return valueOffset === value.length && sourceValue === value + ? segments + : undefined; } function buildFencedCodeSegments( @@ -486,10 +542,29 @@ function buildFencedCodeSegments( if (fenceLength < 3) return undefined; + const physicalLineStart = lineStart(md, 0, start); + const quoteDepth = blockQuoteDepth(md, physicalLineStart, start); + const openingIndent = fencedIndentation(md, physicalLineStart, start, quoteDepth); + if (openingIndent < 0) + return undefined; const openingLineEnd = lineEnd(md, start, end); let contentEnd = end; const closingLineStart = lineStart(md, start, end); - const closing = md.slice(closingLineStart, end); + const closingStart = quoteDepth === 0 + ? closingLineStart + : skipBlockQuoteMarkers(md, closingLineStart, end, quoteDepth); + if (closingStart === undefined) + return undefined; + let closingFenceStart = closingStart; + let removedIndentation = 0; + while ( + removedIndentation < openingIndent + && md.charCodeAt(closingFenceStart) === 32 + ) { + closingFenceStart++; + removedIndentation++; + } + const closing = md.slice(closingFenceStart, end); const closingMatch = /^( {0,3})(`+|~+)[ \t]*$/.exec(closing); if ( closingMatch @@ -499,13 +574,15 @@ function buildFencedCodeSegments( contentEnd = closingLineStart; } - const physicalLineStart = lineStart(md, 0, start); - const openingIndent = start - physicalLineStart; const spans: SourceSpan[] = []; let offset = openingLineEnd; while (offset < contentEnd) { const endOfLine = lineEnd(md, offset, contentEnd); - let contentStart = offset; + let contentStart = quoteDepth === 0 + ? offset + : skipBlockQuoteMarkers(md, offset, endOfLine, quoteDepth); + if (contentStart === undefined) + return undefined; let removed = 0; while (removed < openingIndent && md.charCodeAt(contentStart) === 32) { contentStart++; @@ -533,11 +610,18 @@ function buildIndentedCodeSegments( return undefined; const start = position.start.offset; const end = position.end.offset; + const physicalLineStart = lineStart(md, 0, start); + const quoteDepth = blockQuoteDepth(md, physicalLineStart, start); const spans: SourceSpan[] = []; let offset = start; + let firstLine = true; while (offset < end) { const endOfLine = lineEnd(md, offset, end); - let contentStart = offset; + let contentStart = !firstLine && quoteDepth > 0 + ? skipBlockQuoteMarkers(md, offset, endOfLine, quoteDepth) + : offset; + if (contentStart === undefined) + return undefined; let indentation = 0; while (indentation < 4) { const char = md.charCodeAt(contentStart); @@ -559,6 +643,7 @@ function buildIndentedCodeSegments( return undefined; spans.push({ start: contentStart, end: endOfLine }); offset = endOfLine; + firstLine = false; } trimTrailingLineEnding(md, spans); const segments = segmentsFromSpans(md, spans, node.value); @@ -615,10 +700,10 @@ function findSegmentAt( /** * Parse Markdown and additionally produce a sidecar source map that resolves - * each `text` node's normalized `value` back to the raw Markdown source. + * supported normalized-value fields back to the raw Markdown source. * - * The AST is identical to {@link parseMd}; only `text` nodes carry a mapping - * in the first version. + * The AST is identical to {@link parseMd}. The current version maps + * `text.value`, `inlineCode.value`, and block `code.value`. * * @param md - Markdown text. * @returns The positioned AST plus a source map. From ae082302b9ebe45ce13e70080d8d89541c3c8c9e Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 18 Jul 2026 22:16:54 +0800 Subject: [PATCH 3/4] fix: map indented code in lists --- __tests__/source-map.spec.ts | 28 ++++++++++++++++++++++ src/source-map/build-source-map.ts | 37 +++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/__tests__/source-map.spec.ts b/__tests__/source-map.spec.ts index d0c6709..6d38d1e 100644 --- a/__tests__/source-map.spec.ts +++ b/__tests__/source-map.spec.ts @@ -404,6 +404,34 @@ describe('parseMdWithSourceMap: code.value → raw source', () => { expect(md.slice(range.start.offset, range.end.offset)).toBe('value'); }); + test('maps multi-line indented code inside a list', () => { + const md = '- Foo\n\n bar\n baz'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe('bar\nbaz'); + const whole = sourceMap.getSourceRange(node, 0, node.value.length); + expect(whole.start.offset).toBe(md.indexOf('bar')); + expect(whole.end.offset).toBe(md.indexOf('baz') + 3); + expectPerCodeUnitRanges(md, node, sourceMap); + }); + + test('maps empty fenced code inside a blockquote', () => { + const md = '> ```\n> ```'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe(''); + const point = sourceMap.getSourceRange(node, 0, 0); + expect(point.start.offset).toBe(md.lastIndexOf('```')); + }); + + test('maps empty fenced code inside a list', () => { + const md = '- item\n ```\n ```'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + const point = sourceMap.getSourceRange(node, 0, 0); + expect(point.start.offset).toBe(md.lastIndexOf('```')); + }); + test('excludes fenced delimiters and their indentation from code ranges', () => { const md = ' ```\n a\n b\n ```'; const { ast, sourceMap } = parseMdWithSourceMap(md); diff --git a/src/source-map/build-source-map.ts b/src/source-map/build-source-map.ts index f5fcef3..aaa3f41 100644 --- a/src/source-map/build-source-map.ts +++ b/src/source-map/build-source-map.ts @@ -549,6 +549,7 @@ function buildFencedCodeSegments( return undefined; const openingLineEnd = lineEnd(md, start, end); let contentEnd = end; + let hasClosingFence = false; const closingLineStart = lineStart(md, start, end); const closingStart = quoteDepth === 0 ? closingLineStart @@ -572,6 +573,7 @@ function buildFencedCodeSegments( && closingMatch[2].length >= fenceLength ) { contentEnd = closingLineStart; + hasClosingFence = true; } const spans: SourceSpan[] = []; @@ -591,13 +593,15 @@ function buildFencedCodeSegments( spans.push({ start: contentStart, end: endOfLine }); offset = endOfLine; } + const emptyOffset = spans[0]?.start + ?? (hasClosingFence ? closingFenceStart : openingLineEnd); trimTrailingLineEnding(md, spans); const segments = segmentsFromSpans(md, spans, node.value); if (!segments) return undefined; return { segments, - emptyOffset: openingLineEnd, + emptyOffset, }; } @@ -612,16 +616,37 @@ function buildIndentedCodeSegments( const end = position.end.offset; const physicalLineStart = lineStart(md, 0, start); const quoteDepth = blockQuoteDepth(md, physicalLineStart, start); + const listContinuationIndent = quoteDepth === 0 + ? start - physicalLineStart + : 0; const spans: SourceSpan[] = []; let offset = start; let firstLine = true; while (offset < end) { const endOfLine = lineEnd(md, offset, end); - let contentStart = !firstLine && quoteDepth > 0 - ? skipBlockQuoteMarkers(md, offset, endOfLine, quoteDepth) - : offset; - if (contentStart === undefined) - return undefined; + let contentStart = offset; + if (!firstLine && quoteDepth > 0) { + const afterMarkers = skipBlockQuoteMarkers(md, offset, endOfLine, quoteDepth); + if (afterMarkers === undefined) + return undefined; + contentStart = afterMarkers; + } + else if (!firstLine && listContinuationIndent > 0) { + let removedContinuation = 0; + while ( + removedContinuation < listContinuationIndent + && md.charCodeAt(contentStart) === 32 + ) { + contentStart++; + removedContinuation++; + } + if ( + removedContinuation !== listContinuationIndent + && contentStart < lineContentEnd(md, offset, endOfLine) + ) { + return undefined; + } + } let indentation = 0; while (indentation < 4) { const char = md.charCodeAt(contentStart); From 6ad97c633ba1c22d114fbf2f20db880e7cdc8d81 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 18 Jul 2026 22:26:44 +0800 Subject: [PATCH 4/4] fix: map code blocks in nested containers --- __tests__/source-map.spec.ts | 11 +++++++++++ src/source-map/build-source-map.ts | 11 +++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/__tests__/source-map.spec.ts b/__tests__/source-map.spec.ts index 6d38d1e..2950d0f 100644 --- a/__tests__/source-map.spec.ts +++ b/__tests__/source-map.spec.ts @@ -415,6 +415,17 @@ describe('parseMdWithSourceMap: code.value → raw source', () => { expectPerCodeUnitRanges(md, node, sourceMap); }); + test('maps multi-line indented code inside a blockquote list', () => { + const md = '> - Foo\n>\n> bar\n> baz'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = codeNodes(ast)[0]; + expect(node.value).toBe('bar\nbaz'); + const whole = sourceMap.getSourceRange(node, 0, node.value.length); + expect(whole.start.offset).toBe(md.indexOf('bar')); + expect(whole.end.offset).toBe(md.indexOf('baz') + 3); + expectPerCodeUnitRanges(md, node, sourceMap); + }); + test('maps empty fenced code inside a blockquote', () => { const md = '> ```\n> ```'; const { ast, sourceMap } = parseMdWithSourceMap(md); diff --git a/src/source-map/build-source-map.ts b/src/source-map/build-source-map.ts index aaa3f41..fe5f693 100644 --- a/src/source-map/build-source-map.ts +++ b/src/source-map/build-source-map.ts @@ -616,9 +616,12 @@ function buildIndentedCodeSegments( const end = position.end.offset; const physicalLineStart = lineStart(md, 0, start); const quoteDepth = blockQuoteDepth(md, physicalLineStart, start); - const listContinuationIndent = quoteDepth === 0 - ? start - physicalLineStart - : 0; + const initialContentStart = quoteDepth === 0 + ? physicalLineStart + : skipBlockQuoteMarkers(md, physicalLineStart, start, quoteDepth); + if (initialContentStart === undefined) + return undefined; + const listContinuationIndent = start - initialContentStart; const spans: SourceSpan[] = []; let offset = start; let firstLine = true; @@ -631,7 +634,7 @@ function buildIndentedCodeSegments( return undefined; contentStart = afterMarkers; } - else if (!firstLine && listContinuationIndent > 0) { + if (!firstLine && listContinuationIndent > 0) { let removedContinuation = 0; while ( removedContinuation < listContinuationIndent