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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,35 @@ const ast = parseMd('你的 Markdown 文本');

如需语义更明确的名字,可使用别名 `stringifyMdAst`,功能与 `revertMdAstNode` 完全相同。

## 位置契约

`parseMd` 返回的 AST 节点**总是**带 `position`,且 `position.start` 与
`position.end` 的 `line` / `column` / `offset` 字段都是 `number`(而非 `undefined`)。
这个契约通过 `PositionedMarkdownRoot` / `PositionedMarkdownNode` 在类型层表达:

```ts
import { parseMd, type PositionedMarkdownNode } from '@lint-md/parser';

const ast = parseMd('# title');
ast.position.start.offset; // number
const firstNode: PositionedMarkdownNode = ast.children[0];
firstNode.position.end.offset; // number
```

契约由三件事保证:

- `gfmAutolinkLiteralFromMarkdown.transforms` 被清空,禁用了 GFM autolink
会合成无 position 子节点的后处理路径。
- `src/parse-md.ts` 的 `parseMd` 实现里写明了契约来源注释,并在 `as` 断言处说明
三个保证的来源。
- `__tests__/position.spec.ts` 遍历解析树,对每个节点断言 `position.start` /
`position.end` 的 `line` / `column` / `offset` 全部是 `number`,并校验
`start.offset <= end.offset`。

> **注意**:`MarkdownRoot` / `MarkdownNode` 仍透传 mdast 原生类型(即 `position` 可选),
> 因为这两个类型也用于接收外部构造或 `revertMdAstNode` 反序列化的 AST,运行时未必总有
> position。如果需要"必有 position"约束,使用 `PositionedMarkdownRoot` / `PositionedMarkdownNode`。

## 开发验证

```bash
Expand Down
92 changes: 92 additions & 0 deletions __tests__/position.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { parseMd } from './helpers';

type PositionedNode = {
position?: {
start?: { line?: unknown; column?: unknown; offset?: unknown };
end?: { line?: unknown; column?: unknown; offset?: unknown };
};
children?: unknown[];
};

const assertPositioned = (node: unknown): void => {
const current = node as PositionedNode;
expect(current.position).toBeDefined();
expect(typeof current.position?.start?.line).toBe('number');
expect(typeof current.position?.start?.column).toBe('number');
expect(typeof current.position?.start?.offset).toBe('number');
expect(typeof current.position?.end?.line).toBe('number');
expect(typeof current.position?.end?.column).toBe('number');
expect(typeof current.position?.end?.offset).toBe('number');
expect(current.position!.start!.offset as number)
.toBeLessThanOrEqual(current.position!.end!.offset as number);
for (const child of current.children ?? []) {
assertPositioned(child);
}
};

describe('parseMd position contract', () => {
test.each([
['frontmatter', '---\ntitle: t\n---\n\nbody'],
['heading', '# h1\n\n## h2'],
['paragraph', 'plain text with *emphasis* and **strong**'],
['thematicBreak', '---\n\n***\n\n___'],
['blockquote', '> a\n> > b'],
['ordered list', '1. a\n2. b'],
['unordered list', '- a\n- b'],
['task list', '- [x] done\n- [ ] todo'],
['table', '| a | b |\n|---|---|\n| 1 | 2 |'],
['strikethrough', '~~strike~~'],
['inline code', '`code`'],
['code block', '```ts\nconst a = 1;\n```'],
['hard break', 'line one \nline two'],
['autolink', 'visit www.example.com'],
['explicit link', '[text](https://example.com)'],
['image', '![alt](https://example.com/x.png "title")'],
['reference link', '[foo][bar]\n\n[bar]: https://example.com'],
['definition', '[ref]: https://example.com "title"'],
['html block', '<div>raw</div>'],
['inline html', 'text <span>raw</span> text'],
['inline math', 'inline $x^2$ math'],
['block math', '$$\nx^2\n$$'],
['container directive', ':::note\nbody\n:::'],
['leaf directive', '::warning[content]'],
['text directive', ':smile[emoji]'],
])('"%s" yields a fully positioned tree', (_label, input) => {
assertPositioned(parseMd(input));
});

test('composite fixture (all plugins combined) is fully positioned', () => {
assertPositioned(parseMd(`---
title: t
---

# heading

text with **strong** and [link](https://example.com).

www.example.com

- a
- b

| a | b |
|---|---|
| 1 | 2 |

- [x] done
- [ ] todo

~~strike~~

\`\`\`ts
const x = 1;
\`\`\`

$x^2$

:::note
directive body
:::
`));
});
});
12 changes: 11 additions & 1 deletion __tests__/types/package-exports.cts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import parser = require('@lint-md/parser');

const root = parser.parseMd('# CommonJS');
const markdown: string = parser.revertMdAstNode(root);

const rootOffset: number = root.position.start.offset;
const typedRoot: parser.PositionedMarkdownRoot = root;

const firstNode: parser.PositionedMarkdownNode = root.children[0];
const nodeOffset: number = firstNode.position.end.offset;

const markdown: string = parser.revertMdAstNode(root);
const same: boolean = parser.stringifyMdAst === parser.revertMdAstNode;

void rootOffset;
void typedRoot;
void firstNode;
void nodeOffset;
void markdown;
void same;
20 changes: 18 additions & 2 deletions __tests__/types/package-exports.mts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import { parseMd, revertMdAstNode, stringifyMdAst } from '@lint-md/parser';
import {
parseMd,
revertMdAstNode,
stringifyMdAst,
type PositionedMarkdownRoot,
type PositionedMarkdownNode,
} from '@lint-md/parser';

const root = parseMd('# ESM');
const markdown: string = revertMdAstNode(root);

const rootOffset: number = root.position.start.offset;
const typedRoot: PositionedMarkdownRoot = root;

const firstNode: PositionedMarkdownNode = root.children[0];
const nodeOffset: number = firstNode.position.end.offset;

const markdown: string = revertMdAstNode(root);
const same: boolean = stringifyMdAst === revertMdAstNode;

void rootOffset;
void typedRoot;
void firstNode;
void nodeOffset;
void markdown;
void same;
31 changes: 30 additions & 1 deletion etc/parser.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,36 @@ export interface MarkdownTextDirective extends Parent, MarkdownDirectiveFields {
export type MarkdownTextNode = Text_2;

// @public
export const parseMd: (md: string) => MarkdownRoot;
export interface ParsedPoint {
column: number;
line: number;
offset: number;
}

// @public
export interface ParsedPosition {
end: ParsedPoint;
start: ParsedPoint;
}

// @public
export const parseMd: (md: string) => PositionedMarkdownRoot;

// @public
export type Positioned<T> = T extends {
children: Array<infer Child>;
} ? Omit<T, 'position' | 'children'> & {
position: ParsedPosition;
children: Array<Positioned<Child>>;
} : Omit<T, 'position'> & {
position: ParsedPosition;
};

// @public
export type PositionedMarkdownNode = Positioned<MarkdownNode>;

// @public
export type PositionedMarkdownRoot = Positioned<MarkdownRoot>;

// @public
const revertMdAstNode: (node: MarkdownRoot) => string;
Expand Down
22 changes: 18 additions & 4 deletions src/parse-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import remarkDirective from 'remark-directive';
import remarkMath from 'remark-math';
import { remark } from 'remark';
import { gfmAutolinkLiteralFromMarkdown } from 'mdast-util-gfm-autolink-literal';
import type { MarkdownRoot } from './types';
import type { MarkdownRoot, PositionedMarkdownRoot } from './types';

/**
* Workaround for https://github.com/remarkjs/remark-gfm/issues/16
Expand Down Expand Up @@ -32,12 +32,26 @@ const depsLink = remark()
* 将 Markdown 解析成 ast
*
* @param md - Markdown 文本
* @returns md ast 结构
* @returns 解析后的 AST 根节点,递归带 position(含 start/end.offset)
*
* @public
*/
export const parseMd = (md: string): MarkdownRoot => {
return depsLink.parse(md);
export const parseMd = (md: string): PositionedMarkdownRoot => {
// The `as PositionedMarkdownRoot` cast asserts a parser-level contract:
// the AST returned by `remark().parse()` is fully positioned.
//
// The contract rests on three guarantees:
// 1. `gfmAutolinkLiteralFromMarkdown.transforms = []` above disables
// the GFM autolink post-process, which is the only known path that
// can synthesize children without a `position` (see
// https://github.com/remarkjs/remark-gfm/issues/16).
// 2. The unified processor is module-level and reused across calls,
// so behavior does not drift between invocations.
// 3. `__tests__/position.spec.ts` traverses the parsed tree and
// asserts every node carries a complete `position`. If a future
// plugin or remark upgrade violates the contract, the test fails
// before this cast can silently propagate to downstream code.
return depsLink.parse(md) as PositionedMarkdownRoot;
};

/**
Expand Down
58 changes: 58 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,48 @@ import type {

export type { Code, Link, ListItem, Text } from 'mdast';

/**
* A 1-based line and column with a 0-based character offset from the start of
* the input.
*
* @public
*/
export interface ParsedPoint {
/** 1-based line number */
line: number
/** 1-based column number */
column: number
/** 0-based character offset from the beginning of the input */
offset: number
}

/**
* Start and end positions of a parsed AST node.
*
* @public
*/
export interface ParsedPosition {
/** Position of the first character of the node */
start: ParsedPoint
/** Position of the first character after the node */
end: ParsedPoint
}

/**
* Recursively rewrites a mdast node so `position` and its nested
* `start/end.offset` are non-optional.
*
* @public
*/
export type Positioned<T> = T extends { children: Array<infer Child> }
? Omit<T, 'position' | 'children'> & {
position: ParsedPosition
children: Array<Positioned<Child>>
}
: Omit<T, 'position'> & {
position: ParsedPosition
};

/** @public */
export interface MarkdownDirectiveFields {
name: string
Expand Down Expand Up @@ -62,3 +104,19 @@ export type MarkdownLinkNode = import('mdast').Link;

/** @public */
export type MarkdownTextNode = import('mdast').Text;

/**
* The AST root returned by `parseMd`, with non-optional `position`
* (including `start/end.offset`) on every node in the tree.
*
* @public
*/
export type PositionedMarkdownRoot = Positioned<MarkdownRoot>;

/**
* Any AST node returned by `parseMd`, with non-optional `position`
* (including `start/end.offset`).
*
* @public
*/
export type PositionedMarkdownNode = Positioned<MarkdownNode>;