Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,14 @@ sourceMap.getRaw(textNode); // 'A&amp;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`;其余字段后续版本补充

## 开发验证

Expand Down
136 changes: 136 additions & 0 deletions __tests__/source-map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('\\(');
Expand Down Expand Up @@ -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](<https://x.test/a>)';
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([
'<https://example.com>',
'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\\)&amp;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('&amp;');
});

test('maps a definition URL without its title', () => {
const md = '[id]: <a&amp;b> "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&amp;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 <my-url>\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 = '[<span title="](same)">x</span>](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](<a](same)b>)](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 &amp; b');
Expand Down
7 changes: 7 additions & 0 deletions __tests__/types/package-exports.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +38,7 @@ void inlineCodeRaw;
void range;
void inlineCodeRange;
void codeRange;
void urlRange;
void codeRaw;
void consistency;
void asRangeError;
Expand Down
8 changes: 8 additions & 0 deletions __tests__/types/package-exports.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SourceMapUnavailableError,
type SourceMapErrorCode,
type ParsedMarkdownDocument,
type MarkdownLinkNode,
type PositionedMarkdownRoot,
type PositionedMarkdownNode,
} from '@lint-md/parser';
Expand All @@ -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;
Expand All @@ -43,6 +50,7 @@ void nodeOffset;
void markdown;
void same;
void doc;
void urlRange;
void consistency;
void unavailable;
void asRangeError;
Expand Down
7 changes: 6 additions & 1 deletion etc/parser.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,6 +35,9 @@ export interface MarkdownContainerDirective extends Parent, MarkdownDirectiveFie
type: 'containerDirective';
}

// @public (undocumented)
export type MarkdownDefinitionNode = Definition;

// @public (undocumented)
export interface MarkdownDirectiveFields {
// (undocumented)
Expand Down Expand Up @@ -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;
}

Expand Down
Loading