From bcc566e05e706954257d1a16ed14f45fa383ed72 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 18 Jul 2026 22:50:11 +0800 Subject: [PATCH 1/4] feat: map URL field source ranges --- CHANGELOG.md | 1 + README.md | 4 +- __tests__/source-map.spec.ts | 45 ++++++ __tests__/types/package-exports.cts | 7 + etc/parser.api.md | 7 +- src/source-map/build-source-map.ts | 225 +++++++++++++++++++++++++++- src/source-map/types.ts | 17 ++- src/types.ts | 3 + 8 files changed, 304 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b05561d..80cebde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- 新增 `MarkdownSourceMap.getFieldSourceRange(node, 'url', ...)`,为 `link.url` 与 `definition.url` 提供字段型源码映射,覆盖 `<…>` wrapper、转义与实体(#68) - `MarkdownSourceMap.getSourceRange()` 新增对 fenced 与 indented `code.value` 的源码映射支持,覆盖围栏、info/meta、blockquote/list 容器前缀、缩进剥离、空行以及 CR、LF、CRLF(#67) - `MarkdownSourceMap.getSourceRange()` 新增对 `inlineCode.value` 的源码映射支持,覆盖多反引号定界符、首尾 padding 规则以及 CR、LF、CRLF;同时新增公开类型 `MarkdownInlineCodeNode`(#66) diff --git a/README.md b/README.md index e5c8bcb..fac026f 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` 与 block `code.value`。 +- `getSourceRange(node, valueStart, valueEnd)` 的索引与 JavaScript 字符串下标一致,范围均为半开区间 `[start, end)`;当前支持 `text.value`、`inlineCode.value` 与 block `code.value`。`getFieldSourceRange(node, 'url', valueStart, valueEnd)` 用于 link / definition 的 `url` 字段。 - 映射覆盖受支持节点的整个 `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` 或 `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` 与 `definition.url`;其余字段后续版本补充。 ## 开发验证 diff --git a/__tests__/source-map.spec.ts b/__tests__/source-map.spec.ts index 2950d0f..7eb3a60 100644 --- a/__tests__/source-map.spec.ts +++ b/__tests__/source-map.spec.ts @@ -36,6 +36,15 @@ function codeNodes(root: any): any[] { return out; } +function nodesOfType(root: any, type: string): any[] { + const out: any[] = []; + (function walk(n: any) { + if (n.type === type) 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('\\('); @@ -473,6 +482,42 @@ describe('parseMdWithSourceMap: code.value → raw source', () => { }); }); +describe('parseMdWithSourceMap: URL fields → raw source', () => { + test('maps an inline link URL without its angle-bracket wrapper', () => { + const md = '[label]()'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, 'link')[0]; + expect(node.url).toBe('https://x.test/a'); + expect(sourceMap.getRaw(node)).toBe(md); + const range = sourceMap.getFieldSourceRange(node, 'url', 0, node.url.length); + expect(md.slice(range.start.offset, range.end.offset)).toBe('https://x.test/a'); + }); + + test('maps URL escapes and entities as atomic source ranges', () => { + const md = '[label](a\\(b\\)&c)'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, 'link')[0]; + expect(node.url).toBe('a(b)&c'); + expect(md.slice( + sourceMap.getFieldSourceRange(node, 'url', 1, 2).start.offset, + sourceMap.getFieldSourceRange(node, 'url', 1, 2).end.offset, + )).toBe('\\('); + expect(md.slice( + sourceMap.getFieldSourceRange(node, 'url', 4, 5).start.offset, + sourceMap.getFieldSourceRange(node, 'url', 4, 5).end.offset, + )).toBe('&'); + }); + + test('maps a definition URL without its title', () => { + const md = '[id]: "title"'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, 'definition')[0]; + expect(node.url).toBe('a&b'); + const range = sourceMap.getFieldSourceRange(node, 'url', 0, node.url.length); + expect(md.slice(range.start.offset, range.end.offset)).toBe('a&b'); + }); +}); + describe('parseMdWithSourceMap: contract', () => { test('getSourceRange start..end covers the whole text node value', () => { const { ast, sourceMap } = parseMdWithSourceMap('a & b'); diff --git a/__tests__/types/package-exports.cts b/__tests__/types/package-exports.cts index a05522c..80a7b92 100644 --- a/__tests__/types/package-exports.cts +++ b/__tests__/types/package-exports.cts @@ -23,6 +23,12 @@ const codeRange = doc.sourceMap.getSourceRange( 0, 1, ); +const urlRange = doc.sourceMap.getFieldSourceRange( + doc.ast.children[0] as parser.MarkdownLinkNode, + 'url', + 0, + 1, +); const consistency = new parser.SourceMapConsistencyError(); const asRangeError: RangeError = new parser.SourceMapUnavailableError(); const isSourceMapError: boolean = consistency instanceof parser.SourceMapError; @@ -32,6 +38,7 @@ void inlineCodeRaw; void range; void inlineCodeRange; void codeRange; +void urlRange; void codeRaw; void consistency; void asRangeError; diff --git a/etc/parser.api.md b/etc/parser.api.md index 4545a21..3241f72 100644 --- a/etc/parser.api.md +++ b/etc/parser.api.md @@ -7,6 +7,7 @@ import type { BlockContent } from 'mdast'; import { Code } from 'mdast'; import type { Content } from 'mdast'; +import { Definition } from 'mdast'; import type { DefinitionContent } from 'mdast'; import { InlineCode } from 'mdast'; import { Link } from 'mdast'; @@ -34,6 +35,9 @@ export interface MarkdownContainerDirective extends Parent, MarkdownDirectiveFie type: 'containerDirective'; } +// @public (undocumented) +export type MarkdownDefinitionNode = Definition; + // @public (undocumented) export interface MarkdownDirectiveFields { // (undocumented) @@ -81,7 +85,8 @@ export type MarkdownRoot = Root; // @public export interface MarkdownSourceMap { - getRaw(node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode): string; + getFieldSourceRange(node: MarkdownLinkNode | MarkdownDefinitionNode, field: 'url', valueStart: number, valueEnd: number): ParsedPosition; + getRaw(node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode | MarkdownLinkNode | MarkdownDefinitionNode): string; getSourceRange(node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, valueStart: number, valueEnd: number): ParsedPosition; } diff --git a/src/source-map/build-source-map.ts b/src/source-map/build-source-map.ts index fe5f693..7f85292 100644 --- a/src/source-map/build-source-map.ts +++ b/src/source-map/build-source-map.ts @@ -4,7 +4,9 @@ import { decodeNamedCharacterReference } from 'decode-named-character-reference' import type { Root } from 'mdast'; import type { MarkdownCodeNode, + MarkdownDefinitionNode, MarkdownInlineCodeNode, + MarkdownLinkNode, MarkdownNode, MarkdownTextNode, ParsedPoint, @@ -49,6 +51,8 @@ interface RecordingState { codeSegments: WeakMap /** code node -> source point for an empty value. */ emptyCodeOffsets: WeakMap + /** link / definition node -> normalized URL segments. */ + urlSegments: WeakMap } const REPLACEMENT_CHARACTER = '�'; @@ -381,6 +385,142 @@ function buildInlineCodeSegments( }]; } +function isEscapableUrlCharacter(char: number): boolean { + return (char >= 33 && char <= 47) + || (char >= 58 && char <= 64) + || (char >= 91 && char <= 96) + || (char >= 123 && char <= 126); +} + +function urlDestinationBounds( + md: string, + node: { type: string; position?: ParsedPosition }, +): SourceSpan | undefined { + const position = node.position; + if (!position) + return undefined; + const start = position.start.offset; + const end = position.end.offset; + let offset = start; + + if (node.type === 'link') { + let brackets = 0; + for (; offset < end; offset++) { + const char = md.charCodeAt(offset); + if (char === 92) { + offset++; + } + else if (char === 91) { + brackets++; + } + else if (char === 93 && --brackets === 0 && md.charCodeAt(offset + 1) === 40) { + offset += 2; + break; + } + } + } + else if (node.type === 'definition') { + while (offset < end && md.charCodeAt(offset) !== 58) offset++; + offset++; + while (md.charCodeAt(offset) === 32 || md.charCodeAt(offset) === 9) offset++; + } + else { + return undefined; + } + + if (offset >= end) + return undefined; + if (md.charCodeAt(offset) === 60) { + const destinationStart = ++offset; + while (offset < end && md.charCodeAt(offset) !== 62) { + if (md.charCodeAt(offset) === 92) + offset++; + offset++; + } + return offset < end ? { start: destinationStart, end: offset } : undefined; + } + + const destinationStart = offset; + let parentheses = 0; + while (offset < end) { + const char = md.charCodeAt(offset); + if (char === 92) { + offset += 2; + continue; + } + if (char === 40) + parentheses++; + else if (char === 41) { + if (parentheses === 0) + break; + parentheses--; + } + else if (char === 32 || char === 9 || char === 10 || char === 13) { + break; + } + offset++; + } + return destinationStart < offset ? { start: destinationStart, end: offset } : undefined; +} + +function buildUrlSegments( + md: string, + node: { type: string; url: string; position?: ParsedPosition }, +): MarkdownSourceMapSegment[] | undefined { + const bounds = urlDestinationBounds(md, node); + if (!bounds) + return undefined; + const segments: MarkdownSourceMapSegment[] = []; + let value = ''; + let valueOffset = 0; + const add = (sourceStart: number, sourceEnd: number, output: string, kind: MarkdownSourceMapSegment['kind']) => { + segments.push({ + valueStart: valueOffset, + valueEnd: valueOffset + output.length, + sourceStart, + sourceEnd, + kind, + }); + value += output; + valueOffset += output.length; + }; + + for (let offset = bounds.start; offset < bounds.end;) { + const char = md.charCodeAt(offset); + if (char === 92 && offset + 1 < bounds.end && isEscapableUrlCharacter(md.charCodeAt(offset + 1))) { + add(offset, offset + 2, md[offset + 1], 'escape'); + offset += 2; + continue; + } + if (char === 38) { + const semi = md.indexOf(';', offset + 1); + if (semi >= 0 && semi < bounds.end) { + const body = md.slice(offset + 1, semi); + let decoded: string | false; + if (body.startsWith('#')) { + const numeric = body.slice(1); + const radix = numeric.startsWith('x') || numeric.startsWith('X') ? 16 : 10; + decoded = decodeNumericCharacterReference( + radix === 16 ? numeric.slice(1) : numeric, + radix, + ); + } + else { + decoded = decodeNamedCharacterReference(body); + } + if (decoded !== false) { + add(offset, semi + 1, decoded, 'character-reference'); + offset = semi + 1; + continue; + } + } + } + add(offset, offset + 1, md[offset], 'literal'); + offset++; + } + return value === node.url && segments.length > 0 ? segments : undefined; +} + interface CodeSegments { segments: MarkdownSourceMapSegment[] emptyOffset?: number @@ -750,6 +890,7 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { inlineCodeSegments: new WeakMap(), codeSegments: new WeakMap(), emptyCodeOffsets: new WeakMap(), + urlSegments: new WeakMap(), }; const tree = fromMarkdown(md, { @@ -788,6 +929,18 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { for (const child of node.children || []) recordCodeSegments(child); })(ast); + (function recordUrlSegments(node: any) { + if ( + (node.type === 'link' || node.type === 'definition') + && typeof node.url === 'string' + ) { + const segments = buildUrlSegments(md, node); + if (segments) + state.urlSegments.set(node, segments); + } + for (const child of node.children || []) recordUrlSegments(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 @@ -809,6 +962,7 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { state.segments.has(node) || state.inlineCodeSegments.has(node) || state.codeSegments.has(node) + || state.urlSegments.has(node) ) { originalValues.set(node, node.value); } @@ -834,7 +988,8 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { const sourceMap: MarkdownSourceMap = { getRaw( - node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, + node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode + | MarkdownLinkNode | MarkdownDefinitionNode, ): string { if (!owned.has(node as object)) { throw new SourceMapUnavailableError( @@ -854,6 +1009,7 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { if ( state.inlineCodeSegments.has(node as object) || state.codeSegments.has(node as object) + || state.urlSegments.has(node as object) ) { assertUnmodified(node as object); } @@ -1007,6 +1163,73 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { end: pointAtOffset(lineStarts, md, endOffset), }; }, + + getFieldSourceRange( + node: MarkdownLinkNode | MarkdownDefinitionNode, + field: 'url', + valueStart: number, + valueEnd: number, + ): ParsedPosition { + if (!owned.has(node as object)) { + throw new SourceMapUnavailableError( + 'getFieldSourceRange: the given node does not belong to this document', + ); + } + if (field !== 'url') { + throw new SourceMapUnavailableError( + `getFieldSourceRange: no source mapping is available for field ${field}`, + ); + } + if (!Number.isInteger(valueStart) || !Number.isInteger(valueEnd)) { + throw new RangeError( + 'getFieldSourceRange: valueStart and valueEnd must be finite integers', + ); + } + const segs = state.urlSegments.get(node as object); + if (!segs) { + throw new SourceMapUnavailableError( + 'getFieldSourceRange: no URL source mapping is available for the given node', + ); + } + assertUnmodified(node as object); + if (valueStart < 0 || valueEnd > node.url.length || valueStart > valueEnd) { + throw new RangeError( + `getFieldSourceRange: value range [${valueStart}, ${valueEnd}) is out of bounds`, + ); + } + const sourceOffsetAt = (valueIndex: number, pastUnit: boolean): number => { + const seg = findSegmentAt(segs, valueIndex); + if (!seg) { + if (valueIndex === node.url.length) + return segs[segs.length - 1].sourceEnd; + throw new RangeError('getFieldSourceRange: range is not fully mapped'); + } + if (seg.kind !== 'literal') + return pastUnit ? seg.sourceEnd : seg.sourceStart; + return seg.sourceStart + (pastUnit ? valueIndex + 1 : valueIndex) - seg.valueStart; + }; + if (valueStart === valueEnd) { + const pointRange = (offset: number): ParsedPosition => { + const sourcePoint = pointAtOffset(lineStarts, md, offset); + return { start: sourcePoint, end: sourcePoint }; + }; + if (valueStart === 0) + return pointRange(segs[0].sourceStart); + if (valueStart === node.url.length) + return pointRange(segs[segs.length - 1].sourceEnd); + const seg = findSegmentAt(segs, valueStart); + if (seg && valueStart === seg.valueStart) + return pointRange(seg.sourceStart); + if (seg?.kind === 'literal') { + return pointRange(seg.sourceStart + valueStart - seg.valueStart); + } + throw new RangeError('getFieldSourceRange: empty range falls inside an atomic construct'); + } + return { + start: pointAtOffset(lineStarts, md, sourceOffsetAt(valueStart, false)), + end: pointAtOffset(lineStarts, md, sourceOffsetAt(valueEnd - 1, true)), + }; + }, }; return { ast, sourceMap }; diff --git a/src/source-map/types.ts b/src/source-map/types.ts index 4afeb9b..3441395 100644 --- a/src/source-map/types.ts +++ b/src/source-map/types.ts @@ -1,6 +1,8 @@ import type { MarkdownCodeNode, + MarkdownDefinitionNode, MarkdownInlineCodeNode, + MarkdownLinkNode, MarkdownNode, MarkdownTextNode, ParsedPosition, @@ -85,7 +87,8 @@ export interface MarkdownSourceMap { * @returns The raw Markdown that produced `node`. */ getRaw( - node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, + node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode + | MarkdownLinkNode | MarkdownDefinitionNode, ): string /** @@ -117,6 +120,18 @@ export interface MarkdownSourceMap { valueStart: number, valueEnd: number, ): ParsedPosition + + /** + * Maps a half-open range of a named normalized field back to the raw + * Markdown source. The current field is `url`, supported by `link` and + * `definition` nodes. + */ + getFieldSourceRange( + node: MarkdownLinkNode | MarkdownDefinitionNode, + field: 'url', + valueStart: number, + valueEnd: number, + ): ParsedPosition } /** diff --git a/src/types.ts b/src/types.ts index e36aeae..211b3f7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,9 @@ export type MarkdownListItemNode = import('mdast').ListItem; /** @public */ export type MarkdownLinkNode = import('mdast').Link; +/** @public */ +export type MarkdownDefinitionNode = import('mdast').Definition; + /** @public */ export type MarkdownTextNode = import('mdast').Text; From 2ed41a824b6bf3ecdd4f14b03fbe2d8b24d5d9e2 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 18 Jul 2026 23:08:54 +0800 Subject: [PATCH 2/4] fix: harden URL field source maps --- README.md | 2 +- __tests__/source-map.spec.ts | 51 +++++++++++ src/source-map/build-source-map.ts | 134 ++++++++++++++++++++++------- src/source-map/types.ts | 18 ++-- 4 files changed, 168 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index fac026f..dc6e799 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ sourceMap.getRaw(textNode); // 'A&B' - 映射覆盖受支持节点的整个 `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` 或 `code` 节点在解析后被修改——映射只对原始解析值有效,重新赋入相同内容的 `value` 不受影响; + - `SourceMapConsistencyError`(`ERR_SOURCE_MAP_CONSISTENCY`):已建立映射的 `text`、`inlineCode`、`code` 节点的 `value`,或 `link`、`definition` 节点的 `url` 在解析后被修改——映射只对原始解析字段有效,重新赋入相同内容不受影响; - `SourceMapUnavailableError`(`ERR_SOURCE_MAP_UNAVAILABLE`):节点属于其他文档、由插件生成或在解析后加入、或不是受支持的 `text` / `inlineCode` / `code` 节点——不会伪造位置; - 普通 `RangeError`:`valueStart` / `valueEnd` 非法(非有限整数、越界、倒置,或空区间落在原子构造内部)。 - 当前版本覆盖 `text.value`、`inlineCode.value`、`code.value`、`link.url` 与 `definition.url`;其余字段后续版本补充。 diff --git a/__tests__/source-map.spec.ts b/__tests__/source-map.spec.ts index 7eb3a60..f4f83c7 100644 --- a/__tests__/source-map.spec.ts +++ b/__tests__/source-map.spec.ts @@ -516,6 +516,57 @@ describe('parseMdWithSourceMap: URL fields → raw source', () => { const range = sourceMap.getFieldSourceRange(node, 'url', 0, node.url.length); expect(md.slice(range.start.offset, range.end.offset)).toBe('a&b'); }); + + test('maps destinations after inline-link whitespace and a line ending', () => { + const md = '[link]( /uri\n "title" )'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, 'link')[0]; + expect(node.url).toBe('/uri'); + const range = sourceMap.getFieldSourceRange(node, 'url', 0, node.url.length); + expect(md.slice(range.start.offset, range.end.offset)).toBe('/uri'); + }); + + test.each([ + '[foo]:\n/url', + '[foo]:\n \n "title"', + ])('maps a definition destination after a line ending: %p', (md) => { + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, 'definition')[0]; + expect(node.url).toBe(md.includes('my-url') ? 'my-url' : '/url'); + const range = sourceMap.getFieldSourceRange(node, 'url', 0, node.url.length); + expect(md.slice(range.start.offset, range.end.offset)).toBe(node.url); + }); + + test.each([ + ['[link]()', 7], + ['[link](<>)', 8], + ['[foo]: <>', 8], + ])('maps an empty URL to its content boundary: %p', (md, offset) => { + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, md.startsWith('[foo]') ? 'definition' : 'link')[0]; + expect(node.url).toBe(''); + const range = sourceMap.getFieldSourceRange(node, 'url', 0, 0); + expect(range.start.offset).toBe(offset); + expect(range.end.offset).toBe(offset); + }); + + test('maps a definition whose label contains a colon', () => { + const md = '[a:b]: /url'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, 'definition')[0]; + expect(node.url).toBe('/url'); + const range = sourceMap.getFieldSourceRange(node, 'url', 0, node.url.length); + expect(md.slice(range.start.offset, range.end.offset)).toBe('/url'); + }); + + test('rejects a link URL modified after parsing', () => { + const { ast, sourceMap } = parseMdWithSourceMap('[x](/old)'); + const node = nodesOfType(ast, 'link')[0]; + node.url = '/changed'; + expect(() => sourceMap.getFieldSourceRange(node, 'url', 0, 1)) + .toThrow(SourceMapConsistencyError); + expect(() => sourceMap.getRaw(node)).toThrow(SourceMapConsistencyError); + }); }); describe('parseMdWithSourceMap: contract', () => { diff --git a/src/source-map/build-source-map.ts b/src/source-map/build-source-map.ts index 7f85292..87963d4 100644 --- a/src/source-map/build-source-map.ts +++ b/src/source-map/build-source-map.ts @@ -53,6 +53,8 @@ interface RecordingState { emptyCodeOffsets: WeakMap /** link / definition node -> normalized URL segments. */ urlSegments: WeakMap + /** link / definition node -> source point for an empty URL. */ + emptyUrlOffsets: WeakMap } const REPLACEMENT_CHARACTER = '�'; @@ -392,6 +394,45 @@ function isEscapableUrlCharacter(char: number): boolean { || (char >= 123 && char <= 126); } +function skipUrlWhitespace(md: string, offset: number, end: number): number { + while (offset < end) { + const char = md.charCodeAt(offset); + if (char !== 32 && char !== 9 && char !== 10 && char !== 13) + break; + offset++; + } + return offset; +} + +function labelEnd(md: string, start: number, end: number): number | undefined { + if (md.charCodeAt(start) !== 91) + return undefined; + let depth = 0; + for (let offset = start; offset < end; offset++) { + const char = md.charCodeAt(offset); + if (char === 92) { + offset++; + continue; + } + // A `]` inside a code span is label text, not the end of the label. + if (char === 96) { + let runEnd = offset; + while (md.charCodeAt(runEnd) === 96) runEnd++; + const run = md.slice(offset, runEnd); + const close = md.indexOf(run, runEnd); + if (close >= 0 && close < end) { + offset = close + run.length - 1; + continue; + } + } + if (char === 91) + depth++; + else if (char === 93 && --depth === 0) + return offset; + } + return undefined; +} + function urlDestinationBounds( md: string, node: { type: string; position?: ParsedPosition }, @@ -401,35 +442,26 @@ function urlDestinationBounds( return undefined; const start = position.start.offset; const end = position.end.offset; - let offset = start; + let offset: number; if (node.type === 'link') { - let brackets = 0; - for (; offset < end; offset++) { - const char = md.charCodeAt(offset); - if (char === 92) { - offset++; - } - else if (char === 91) { - brackets++; - } - else if (char === 93 && --brackets === 0 && md.charCodeAt(offset + 1) === 40) { - offset += 2; - break; - } - } + const endOfLabel = labelEnd(md, start, end); + if (endOfLabel === undefined || md.charCodeAt(endOfLabel + 1) !== 40) + return undefined; + offset = skipUrlWhitespace(md, endOfLabel + 2, end); } else if (node.type === 'definition') { - while (offset < end && md.charCodeAt(offset) !== 58) offset++; - offset++; - while (md.charCodeAt(offset) === 32 || md.charCodeAt(offset) === 9) offset++; + const endOfLabel = labelEnd(md, start, end); + if (endOfLabel === undefined || md.charCodeAt(endOfLabel + 1) !== 58) + return undefined; + offset = skipUrlWhitespace(md, endOfLabel + 2, end); } else { return undefined; } if (offset >= end) - return undefined; + return { start: offset, end: offset }; if (md.charCodeAt(offset) === 60) { const destinationStart = ++offset; while (offset < end && md.charCodeAt(offset) !== 62) { @@ -460,16 +492,26 @@ function urlDestinationBounds( } offset++; } - return destinationStart < offset ? { start: destinationStart, end: offset } : undefined; + return { start: destinationStart, end: offset }; +} + +interface UrlSegments { + segments: MarkdownSourceMapSegment[] + emptyOffset?: number } function buildUrlSegments( md: string, node: { type: string; url: string; position?: ParsedPosition }, -): MarkdownSourceMapSegment[] | undefined { +): UrlSegments | undefined { const bounds = urlDestinationBounds(md, node); if (!bounds) return undefined; + if (bounds.start === bounds.end) { + return node.url === '' + ? { segments: [], emptyOffset: bounds.start } + : undefined; + } const segments: MarkdownSourceMapSegment[] = []; let value = ''; let valueOffset = 0; @@ -484,12 +526,19 @@ function buildUrlSegments( value += output; valueOffset += output.length; }; + let literalStart = bounds.start; + const flushLiteral = (end: number): void => { + if (literalStart < end) + add(literalStart, end, md.slice(literalStart, end), 'literal'); + }; for (let offset = bounds.start; offset < bounds.end;) { const char = md.charCodeAt(offset); if (char === 92 && offset + 1 < bounds.end && isEscapableUrlCharacter(md.charCodeAt(offset + 1))) { + flushLiteral(offset); add(offset, offset + 2, md[offset + 1], 'escape'); offset += 2; + literalStart = offset; continue; } if (char === 38) { @@ -509,16 +558,18 @@ function buildUrlSegments( decoded = decodeNamedCharacterReference(body); } if (decoded !== false) { + flushLiteral(offset); add(offset, semi + 1, decoded, 'character-reference'); offset = semi + 1; + literalStart = offset; continue; } } } - add(offset, offset + 1, md[offset], 'literal'); offset++; } - return value === node.url && segments.length > 0 ? segments : undefined; + flushLiteral(bounds.end); + return value === node.url ? { segments } : undefined; } interface CodeSegments { @@ -871,7 +922,8 @@ function findSegmentAt( * supported normalized-value fields back to the raw Markdown source. * * The AST is identical to {@link parseMd}. The current version maps - * `text.value`, `inlineCode.value`, and block `code.value`. + * `text.value`, `inlineCode.value`, block `code.value`, and the `url` field + * of `link` and `definition` nodes. * * @param md - Markdown text. * @returns The positioned AST plus a source map. @@ -891,6 +943,7 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { codeSegments: new WeakMap(), emptyCodeOffsets: new WeakMap(), urlSegments: new WeakMap(), + emptyUrlOffsets: new WeakMap(), }; const tree = fromMarkdown(md, { @@ -935,8 +988,11 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { && typeof node.url === 'string' ) { const segments = buildUrlSegments(md, node); - if (segments) - state.urlSegments.set(node, segments); + if (segments) { + state.urlSegments.set(node, segments.segments); + if (segments.emptyOffset !== undefined) + state.emptyUrlOffsets.set(node, segments.emptyOffset); + } } for (const child of node.children || []) recordUrlSegments(child); })(ast); @@ -955,6 +1011,7 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { // than slice with the stolen offset. const owned = new WeakSet(); const originalValues = new WeakMap(); + const originalUrls = new WeakMap(); const originalOffsets = new WeakMap(); (function register(node: any) { owned.add(node); @@ -962,10 +1019,11 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { state.segments.has(node) || state.inlineCodeSegments.has(node) || state.codeSegments.has(node) - || state.urlSegments.has(node) ) { originalValues.set(node, node.value); } + if (state.urlSegments.has(node)) + originalUrls.set(node, node.url); const position = (node as { position?: ParsedPosition }).position; if (position && position.start && position.end) { originalOffsets.set(node, [position.start.offset, position.end.offset]); @@ -986,6 +1044,16 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { } }; + const assertUrlUnmodified = (node: object): void => { + const original = originalUrls.get(node); + if (original !== undefined && (node as { url?: string }).url !== original) { + throw new SourceMapConsistencyError( + 'the mapped url field has been modified since parsing; the source ' + + 'map only covers the original parsed URL', + ); + } + }; + const sourceMap: MarkdownSourceMap = { getRaw( node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode @@ -1009,10 +1077,11 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { if ( state.inlineCodeSegments.has(node as object) || state.codeSegments.has(node as object) - || state.urlSegments.has(node as object) ) { assertUnmodified(node as object); } + if (state.urlSegments.has(node as object)) + assertUrlUnmodified(node as object); // Non-mapped nodes: slice with the offsets snapshotted at parse time, so // post-parse mutation of `node.position` can't make `getRaw` return the // wrong source. A node with no snapshot never had a real source position. @@ -1191,7 +1260,7 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { 'getFieldSourceRange: no URL source mapping is available for the given node', ); } - assertUnmodified(node as object); + assertUrlUnmodified(node as object); if (valueStart < 0 || valueEnd > node.url.length || valueStart > valueEnd) { throw new RangeError( `getFieldSourceRange: value range [${valueStart}, ${valueEnd}) is out of bounds`, @@ -1213,6 +1282,13 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { const sourcePoint = pointAtOffset(lineStarts, md, offset); return { start: sourcePoint, end: sourcePoint }; }; + if (segs.length === 0) { + const emptyOffset = state.emptyUrlOffsets.get(node as object); + if (node.url.length === 0 && valueStart === 0 && emptyOffset !== undefined) { + return pointRange(emptyOffset); + } + throw new RangeError('getFieldSourceRange: range is not fully mapped'); + } if (valueStart === 0) return pointRange(segs[0].sourceStart); if (valueStart === node.url.length) diff --git a/src/source-map/types.ts b/src/source-map/types.ts index 3441395..61749b4 100644 --- a/src/source-map/types.ts +++ b/src/source-map/types.ts @@ -57,8 +57,8 @@ export interface MarkdownSourceMapSegment { /** * Sidecar source map produced alongside a parse. Maps supported value nodes to - * compressed segments that reconstruct their `value` from the raw Markdown - * source. + * compressed segments that reconstruct supported normalized fields from the + * raw Markdown source. * * @public */ @@ -78,10 +78,10 @@ export interface MarkdownSourceMap { * - {@link SourceMapUnavailableError} — the node belongs to another * document, or had no source position at parse time (e.g. it was * generated by a plugin or added after parsing). No mapping is fabricated. - * - {@link SourceMapConsistencyError} — the mapped node's `value` has - * been modified since parsing; the map only covers the original value. - * (Only mapped value nodes are subject to this check; `position` mutation - * never triggers it because offsets are read from the parse-time snapshot.) + * - {@link SourceMapConsistencyError} — a mapped `value` or `url` field has + * been modified since parsing; the map only covers the original field value. + * (Only mapped fields are subject to this check; `position` mutation never + * triggers it because offsets are read from the parse-time snapshot.) * * @param node - A node from the document this map was built for. * @returns The raw Markdown that produced `node`. @@ -124,7 +124,11 @@ export interface MarkdownSourceMap { /** * Maps a half-open range of a named normalized field back to the raw * Markdown source. The current field is `url`, supported by `link` and - * `definition` nodes. + * `definition` nodes. URL escapes and character references are atomic: a + * range intersecting one returns that construct's complete source span. + * + * Failure modes match `getSourceRange()`; modifying the mapped `url` field + * throws {@link SourceMapConsistencyError}. */ getFieldSourceRange( node: MarkdownLinkNode | MarkdownDefinitionNode, From bff27c1b8915ce3813f10c7ec858b890ba319a02 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 18 Jul 2026 23:30:22 +0800 Subject: [PATCH 3/4] fix: record URL destination tokens --- __tests__/source-map.spec.ts | 30 +++++ src/source-map/build-source-map.ts | 175 +++++++++++------------------ 2 files changed, 98 insertions(+), 107 deletions(-) diff --git a/__tests__/source-map.spec.ts b/__tests__/source-map.spec.ts index f4f83c7..3353572 100644 --- a/__tests__/source-map.spec.ts +++ b/__tests__/source-map.spec.ts @@ -559,6 +559,36 @@ describe('parseMdWithSourceMap: URL fields → raw source', () => { expect(md.slice(range.start.offset, range.end.offset)).toBe('/url'); }); + test('does not confuse a resource-like sequence inside raw HTML', () => { + const md = '[x](same)'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, 'link')[0]; + expect(node.url).toBe('same'); + const range = sourceMap.getFieldSourceRange(node, 'url', 0, node.url.length); + expect(range.start.offset).toBe(md.lastIndexOf('(same)') + 1); + }); + + test('does not confuse a nested image destination with the outer link', () => { + const md = '[![x]()](same)'; + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, 'link')[0]; + expect(node.url).toBe('same'); + const range = sourceMap.getFieldSourceRange(node, 'url', 0, node.url.length); + expect(range.start.offset).toBe(md.lastIndexOf('(same)') + 1); + }); + + test.each([ + ['link', '> [x](\n> \\>\n> )'], + ['definition', '> [x]:\n> \\>'], + ])('excludes blockquote markers from a cross-line %s URL', (type, md) => { + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, type)[0]; + expect(node.url).toBe('>'); + const range = sourceMap.getFieldSourceRange(node, 'url', 0, 1); + expect(range.start.offset).toBe(md.lastIndexOf('\\>')); + expect(md.slice(range.start.offset, range.end.offset)).toBe('\\>'); + }); + test('rejects a link URL modified after parsing', () => { const { ast, sourceMap } = parseMdWithSourceMap('[x](/old)'); const node = nodesOfType(ast, 'link')[0]; diff --git a/src/source-map/build-source-map.ts b/src/source-map/build-source-map.ts index 87963d4..7bfdb44 100644 --- a/src/source-map/build-source-map.ts +++ b/src/source-map/build-source-map.ts @@ -55,6 +55,8 @@ interface RecordingState { urlSegments: WeakMap /** link / definition node -> source point for an empty URL. */ emptyUrlOffsets: WeakMap + /** link / definition node -> parser-confirmed destination content span. */ + urlSourceSpans: WeakMap } const REPLACEMENT_CHARACTER = '�'; @@ -73,6 +75,8 @@ interface CompileContext { getData: (key: string) => unknown setData: (key: string, value?: unknown) => void sliceSerialize: (token: any) => string + buffer: () => void + resume: () => string } /** @@ -87,7 +91,9 @@ interface CompileContext { * * - token event handler names (enter/exit): `data`, `characterEscape` / * `characterEscapeValue`, `characterReference` / `characterReferenceValue`, - * `lineEnding`, `autolinkProtocol`, `autolinkEmail`. + * `lineEnding`, `autolinkProtocol`, `autolinkEmail`, + * `resourceDestinationString`, `definitionDestinationString`, their literal + * wrappers, and `resource`. * - compile-context fields on `this` ({@link CompileContext}): `stack` (the AST * build stack), `config.canContainEols` (whether a line ending is merged into * text), `getData` / `setData` for the keys `characterReferenceType`, @@ -219,6 +225,55 @@ function recordingExtension(state: RecordingState) { node.url = `mailto:${this.sliceSerialize(token)}`; }; + const onenterUrlDestination = function (this: CompileContext) { + this.buffer(); + }; + + const onexitUrlDestination = function (this: CompileContext, token: any) { + const url = this.resume(); + const node = this.stack[this.stack.length - 1]; + node.url = url; + if (node.type === 'link' || node.type === 'definition') { + state.urlSourceSpans.set(node, { + start: token.start.offset, + end: token.end.offset, + }); + } + }; + + const onexitDestinationLiteral = function (this: CompileContext, token: any) { + const node = this.stack[this.stack.length - 1]; + if ( + (node.type === 'link' || node.type === 'definition') + && node.url === '' + && !state.urlSourceSpans.has(node) + ) { + state.urlSourceSpans.set(node, { + start: token.start.offset + 1, + end: token.end.offset - 1, + }); + } + }; + + // The parser emits no destination token for `[label]()`. The confirmed + // resource token still gives us the accurate point immediately before its + // closing `)`. Preserve the standard handler's `inReference` cleanup too. + const onexitresource = function (this: CompileContext, token: any) { + this.setData('inReference'); + const node = this.stack[this.stack.length - 1]; + if ( + node.type === 'link' + && node.url === '' + && !state.urlSourceSpans.has(node) + ) { + const emptyOffset = token.end.offset - 1; + state.urlSourceSpans.set(node, { + start: emptyOffset, + end: emptyOffset, + }); + } + }; + return { enter: { data: onenterdata, @@ -226,6 +281,8 @@ function recordingExtension(state: RecordingState) { characterReference: onenterConstruct, autolinkProtocol: onenterdata, autolinkEmail: onenterdata, + definitionDestinationString: onenterUrlDestination, + resourceDestinationString: onenterUrlDestination, }, exit: { data(this: CompileContext, token: any) { @@ -241,6 +298,11 @@ function recordingExtension(state: RecordingState) { lineEnding: onexitlineending, autolinkProtocol: onexitautolinkprotocol, autolinkEmail: onexitautolinkemail, + definitionDestinationString: onexitUrlDestination, + definitionDestinationLiteral: onexitDestinationLiteral, + resourceDestinationString: onexitUrlDestination, + resourceDestinationLiteral: onexitDestinationLiteral, + resource: onexitresource, }, }; } @@ -394,107 +456,6 @@ function isEscapableUrlCharacter(char: number): boolean { || (char >= 123 && char <= 126); } -function skipUrlWhitespace(md: string, offset: number, end: number): number { - while (offset < end) { - const char = md.charCodeAt(offset); - if (char !== 32 && char !== 9 && char !== 10 && char !== 13) - break; - offset++; - } - return offset; -} - -function labelEnd(md: string, start: number, end: number): number | undefined { - if (md.charCodeAt(start) !== 91) - return undefined; - let depth = 0; - for (let offset = start; offset < end; offset++) { - const char = md.charCodeAt(offset); - if (char === 92) { - offset++; - continue; - } - // A `]` inside a code span is label text, not the end of the label. - if (char === 96) { - let runEnd = offset; - while (md.charCodeAt(runEnd) === 96) runEnd++; - const run = md.slice(offset, runEnd); - const close = md.indexOf(run, runEnd); - if (close >= 0 && close < end) { - offset = close + run.length - 1; - continue; - } - } - if (char === 91) - depth++; - else if (char === 93 && --depth === 0) - return offset; - } - return undefined; -} - -function urlDestinationBounds( - md: string, - node: { type: string; position?: ParsedPosition }, -): SourceSpan | undefined { - const position = node.position; - if (!position) - return undefined; - const start = position.start.offset; - const end = position.end.offset; - let offset: number; - - if (node.type === 'link') { - const endOfLabel = labelEnd(md, start, end); - if (endOfLabel === undefined || md.charCodeAt(endOfLabel + 1) !== 40) - return undefined; - offset = skipUrlWhitespace(md, endOfLabel + 2, end); - } - else if (node.type === 'definition') { - const endOfLabel = labelEnd(md, start, end); - if (endOfLabel === undefined || md.charCodeAt(endOfLabel + 1) !== 58) - return undefined; - offset = skipUrlWhitespace(md, endOfLabel + 2, end); - } - else { - return undefined; - } - - if (offset >= end) - return { start: offset, end: offset }; - if (md.charCodeAt(offset) === 60) { - const destinationStart = ++offset; - while (offset < end && md.charCodeAt(offset) !== 62) { - if (md.charCodeAt(offset) === 92) - offset++; - offset++; - } - return offset < end ? { start: destinationStart, end: offset } : undefined; - } - - const destinationStart = offset; - let parentheses = 0; - while (offset < end) { - const char = md.charCodeAt(offset); - if (char === 92) { - offset += 2; - continue; - } - if (char === 40) - parentheses++; - else if (char === 41) { - if (parentheses === 0) - break; - parentheses--; - } - else if (char === 32 || char === 9 || char === 10 || char === 13) { - break; - } - offset++; - } - return { start: destinationStart, end: offset }; -} - interface UrlSegments { segments: MarkdownSourceMapSegment[] emptyOffset?: number @@ -502,11 +463,9 @@ interface UrlSegments { function buildUrlSegments( md: string, - node: { type: string; url: string; position?: ParsedPosition }, + node: { url: string }, + bounds: SourceSpan, ): UrlSegments | undefined { - const bounds = urlDestinationBounds(md, node); - if (!bounds) - return undefined; if (bounds.start === bounds.end) { return node.url === '' ? { segments: [], emptyOffset: bounds.start } @@ -944,6 +903,7 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { emptyCodeOffsets: new WeakMap(), urlSegments: new WeakMap(), emptyUrlOffsets: new WeakMap(), + urlSourceSpans: new WeakMap(), }; const tree = fromMarkdown(md, { @@ -987,7 +947,8 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { (node.type === 'link' || node.type === 'definition') && typeof node.url === 'string' ) { - const segments = buildUrlSegments(md, node); + const bounds = state.urlSourceSpans.get(node); + const segments = bounds ? buildUrlSegments(md, node, bounds) : undefined; if (segments) { state.urlSegments.set(node, segments.segments); if (segments.emptyOffset !== undefined) From a8ab735430f9c1e3719577592a81302fe5086859 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 18 Jul 2026 23:43:09 +0800 Subject: [PATCH 4/4] docs: clarify URL source map coverage --- README.md | 6 +++--- __tests__/source-map.spec.ts | 10 ++++++++++ __tests__/types/package-exports.mts | 8 ++++++++ src/source-map/types.ts | 7 ++++--- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dc6e799..97888fb 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` 与 block `code.value`。`getFieldSourceRange(node, 'url', valueStart, valueEnd)` 用于 link / definition 的 `url` 字段。 +- `getSourceRange(node, valueStart, valueEnd)` 的索引与 JavaScript 字符串下标一致,范围均为半开区间 `[start, end)`;当前支持 `text.value`、`inlineCode.value` 与 block `code.value`。`getFieldSourceRange(node, 'url', valueStart, valueEnd)` 当前支持 inline resource link 与 definition 的 destination;autolink 和 GFM autolink literal 暂不包含。 - 映射覆盖受支持节点的整个 `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`、`code` 节点的 `value`,或 `link`、`definition` 节点的 `url` 在解析后被修改——映射只对原始解析字段有效,重新赋入相同内容不受影响; - - `SourceMapUnavailableError`(`ERR_SOURCE_MAP_UNAVAILABLE`):节点属于其他文档、由插件生成或在解析后加入、或不是受支持的 `text` / `inlineCode` / `code` 节点——不会伪造位置; + - `SourceMapUnavailableError`(`ERR_SOURCE_MAP_UNAVAILABLE`):节点属于其他文档、由插件生成或在解析后加入、或不是受支持的节点或字段——不会伪造位置; - 普通 `RangeError`:`valueStart` / `valueEnd` 非法(非有限整数、越界、倒置,或空区间落在原子构造内部)。 -- 当前版本覆盖 `text.value`、`inlineCode.value`、`code.value`、`link.url` 与 `definition.url`;其余字段后续版本补充。 +- 当前版本覆盖 `text.value`、`inlineCode.value`、`code.value`、inline resource link 的 `url` 与 definition 的 `url`;其余字段后续版本补充。 ## 开发验证 diff --git a/__tests__/source-map.spec.ts b/__tests__/source-map.spec.ts index 3353572..00cad34 100644 --- a/__tests__/source-map.spec.ts +++ b/__tests__/source-map.spec.ts @@ -493,6 +493,16 @@ describe('parseMdWithSourceMap: URL fields → raw source', () => { expect(md.slice(range.start.offset, range.end.offset)).toBe('https://x.test/a'); }); + test.each([ + '', + 'www.example.com', + ])('does not yet map URL fields for autolinks: %p', (md) => { + const { ast, sourceMap } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, 'link')[0]; + expect(() => sourceMap.getFieldSourceRange(node, 'url', 0, 1)) + .toThrow(SourceMapUnavailableError); + }); + test('maps URL escapes and entities as atomic source ranges', () => { const md = '[label](a\\(b\\)&c)'; const { ast, sourceMap } = parseMdWithSourceMap(md); diff --git a/__tests__/types/package-exports.mts b/__tests__/types/package-exports.mts index 44faffa..747f424 100644 --- a/__tests__/types/package-exports.mts +++ b/__tests__/types/package-exports.mts @@ -8,6 +8,7 @@ import { SourceMapUnavailableError, type SourceMapErrorCode, type ParsedMarkdownDocument, + type MarkdownLinkNode, type PositionedMarkdownRoot, type PositionedMarkdownNode, } from '@lint-md/parser'; @@ -24,6 +25,12 @@ const markdown: string = revertMdAstNode(root); const same: boolean = stringifyMdAst === revertMdAstNode; const doc: ParsedMarkdownDocument = parseMdWithSourceMap('# ESM'); +const urlRange = doc.sourceMap.getFieldSourceRange( + doc.ast.children[0] as MarkdownLinkNode, + 'url', + 0, + 1, +); // @ts-expect-error segment implementation details are intentionally internal. type HiddenSegment = import('@lint-md/parser').MarkdownSourceMapSegment; @@ -43,6 +50,7 @@ void nodeOffset; void markdown; void same; void doc; +void urlRange; void consistency; void unavailable; void asRangeError; diff --git a/src/source-map/types.ts b/src/source-map/types.ts index 61749b4..45582bb 100644 --- a/src/source-map/types.ts +++ b/src/source-map/types.ts @@ -123,9 +123,10 @@ export interface MarkdownSourceMap { /** * Maps a half-open range of a named normalized field back to the raw - * Markdown source. The current field is `url`, supported by `link` and - * `definition` nodes. URL escapes and character references are atomic: a - * range intersecting one returns that construct's complete source span. + * Markdown source. The current field is `url`, supported by inline resource + * `link` nodes and `definition` nodes. Autolinks and GFM autolink literals + * are not currently mapped. URL escapes and character references are atomic: + * a range intersecting one returns that construct's complete source span. * * Failure modes match `getSourceRange()`; modifying the mapped `url` field * throws {@link SourceMapConsistencyError}.