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.getSourceRange()` 新增对 fenced 与 indented `code.value` 的源码映射支持,覆盖围栏、info/meta、blockquote/list 容器前缀、缩进剥离、空行以及 CR、LF、CRLF(#67)
- `MarkdownSourceMap.getSourceRange()` 新增对 `inlineCode.value` 的源码映射支持,覆盖多反引号定界符、首尾 padding 规则以及 CR、LF、CRLF;同时新增公开类型 `MarkdownInlineCodeNode`(#66)

### Fixed
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&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` 等)后续版本补充。

## 开发验证

Expand Down
185 changes: 184 additions & 1 deletion __tests__/source-map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('\\(');
Expand Down Expand Up @@ -290,6 +301,177 @@ 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('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('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 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);
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);
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', () => {
Expand Down Expand Up @@ -508,7 +690,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(
Expand Down Expand Up @@ -728,6 +909,8 @@ describe('parseMd vs parseMdWithSourceMap: AST parity corpus', () => {
'`code with &lt; tag` and > quote',
'text [a](<b &amp; c>) end',
'pre\n```js\nconst x = 1 &amp; 2;\n```\npost',
'~~~\r\na\r\n~~~',
' a\n\n b\n',
'&#0;&#128;&#xFDD0; and &amp;amp;',
'A&#x1F600;B',
];
Expand Down
10 changes: 10 additions & 0 deletions __tests__/types/package-exports.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +31,8 @@ void raw;
void inlineCodeRaw;
void range;
void inlineCodeRange;
void codeRange;
void codeRaw;
void consistency;
void asRangeError;
void isSourceMapError;
Expand Down
4 changes: 2 additions & 2 deletions etc/parser.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading