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..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`。 +- `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` 不受影响; - - `SourceMapUnavailableError`(`ERR_SOURCE_MAP_UNAVAILABLE`):节点属于其他文档、由插件生成或在解析后加入、或不是受支持的 `text` / `inlineCode` / `code` 节点——不会伪造位置; + - `SourceMapConsistencyError`(`ERR_SOURCE_MAP_CONSISTENCY`):已建立映射的 `text`、`inlineCode`、`code` 节点的 `value`,或 `link`、`definition` 节点的 `url` 在解析后被修改——映射只对原始解析字段有效,重新赋入相同内容不受影响; + - `SourceMapUnavailableError`(`ERR_SOURCE_MAP_UNAVAILABLE`):节点属于其他文档、由插件生成或在解析后加入、或不是受支持的节点或字段——不会伪造位置; - 普通 `RangeError`:`valueStart` / `valueEnd` 非法(非有限整数、越界、倒置,或空区间落在原子构造内部)。 -- 当前版本覆盖 `text.value`、`inlineCode.value` 与 `code.value`;其余字段(`link.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 2950d0f..00cad34 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,133 @@ 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.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); + 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'); + }); + + 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('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]; + node.url = '/changed'; + expect(() => sourceMap.getFieldSourceRange(node, 'url', 0, 1)) + .toThrow(SourceMapConsistencyError); + expect(() => sourceMap.getRaw(node)).toThrow(SourceMapConsistencyError); + }); +}); + 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/__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/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..7bfdb44 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,12 @@ interface RecordingState { codeSegments: WeakMap /** code node -> source point for an empty value. */ emptyCodeOffsets: WeakMap + /** link / definition node -> normalized URL segments. */ + 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 = '�'; @@ -67,6 +75,8 @@ interface CompileContext { getData: (key: string) => unknown setData: (key: string, value?: unknown) => void sliceSerialize: (token: any) => string + buffer: () => void + resume: () => string } /** @@ -81,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`, @@ -213,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, @@ -220,6 +281,8 @@ function recordingExtension(state: RecordingState) { characterReference: onenterConstruct, autolinkProtocol: onenterdata, autolinkEmail: onenterdata, + definitionDestinationString: onenterUrlDestination, + resourceDestinationString: onenterUrlDestination, }, exit: { data(this: CompileContext, token: any) { @@ -235,6 +298,11 @@ function recordingExtension(state: RecordingState) { lineEnding: onexitlineending, autolinkProtocol: onexitautolinkprotocol, autolinkEmail: onexitautolinkemail, + definitionDestinationString: onexitUrlDestination, + definitionDestinationLiteral: onexitDestinationLiteral, + resourceDestinationString: onexitUrlDestination, + resourceDestinationLiteral: onexitDestinationLiteral, + resource: onexitresource, }, }; } @@ -381,6 +449,88 @@ function buildInlineCodeSegments( }]; } +function isEscapableUrlCharacter(char: number): boolean { + return (char >= 33 && char <= 47) + || (char >= 58 && char <= 64) + || (char >= 91 && char <= 96) + || (char >= 123 && char <= 126); +} + +interface UrlSegments { + segments: MarkdownSourceMapSegment[] + emptyOffset?: number +} + +function buildUrlSegments( + md: string, + node: { url: string }, + bounds: SourceSpan, +): UrlSegments | undefined { + if (bounds.start === bounds.end) { + return node.url === '' + ? { segments: [], emptyOffset: bounds.start } + : 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; + }; + 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) { + 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) { + flushLiteral(offset); + add(offset, semi + 1, decoded, 'character-reference'); + offset = semi + 1; + literalStart = offset; + continue; + } + } + } + offset++; + } + flushLiteral(bounds.end); + return value === node.url ? { segments } : undefined; +} + interface CodeSegments { segments: MarkdownSourceMapSegment[] emptyOffset?: number @@ -731,7 +881,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. @@ -750,6 +901,9 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { inlineCodeSegments: new WeakMap(), codeSegments: new WeakMap(), emptyCodeOffsets: new WeakMap(), + urlSegments: new WeakMap(), + emptyUrlOffsets: new WeakMap(), + urlSourceSpans: new WeakMap(), }; const tree = fromMarkdown(md, { @@ -788,6 +942,22 @@ 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 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) + state.emptyUrlOffsets.set(node, segments.emptyOffset); + } + } + 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 @@ -802,6 +972,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); @@ -812,6 +983,8 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { ) { 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]); @@ -832,9 +1005,20 @@ 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, + node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode + | MarkdownLinkNode | MarkdownDefinitionNode, ): string { if (!owned.has(node as object)) { throw new SourceMapUnavailableError( @@ -857,6 +1041,8 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { ) { 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. @@ -1007,6 +1193,80 @@ 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', + ); + } + 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`, + ); + } + 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 (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) + 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..45582bb 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, @@ -55,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 */ @@ -76,16 +78,17 @@ 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`. */ getRaw( - node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, + node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode + | MarkdownLinkNode | MarkdownDefinitionNode, ): string /** @@ -117,6 +120,23 @@ 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 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}. + */ + 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;