diff --git a/README.md b/README.md index be4c8be..40cc217 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__tests__/position.spec.ts b/__tests__/position.spec.ts new file mode 100644 index 0000000..83ab581 --- /dev/null +++ b/__tests__/position.spec.ts @@ -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', '
raw
'], + ['inline html', 'text raw 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 +::: +`)); + }); +}); diff --git a/__tests__/types/package-exports.cts b/__tests__/types/package-exports.cts index fe8c6c0..5ae45fc 100644 --- a/__tests__/types/package-exports.cts +++ b/__tests__/types/package-exports.cts @@ -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; diff --git a/__tests__/types/package-exports.mts b/__tests__/types/package-exports.mts index 9b1fb72..aa76d95 100644 --- a/__tests__/types/package-exports.mts +++ b/__tests__/types/package-exports.mts @@ -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; diff --git a/etc/parser.api.md b/etc/parser.api.md index 270356c..241ccd1 100644 --- a/etc/parser.api.md +++ b/etc/parser.api.md @@ -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 extends { + children: Array; +} ? Omit & { + position: ParsedPosition; + children: Array>; +} : Omit & { + position: ParsedPosition; +}; + +// @public +export type PositionedMarkdownNode = Positioned; + +// @public +export type PositionedMarkdownRoot = Positioned; // @public const revertMdAstNode: (node: MarkdownRoot) => string; diff --git a/src/parse-md.ts b/src/parse-md.ts index 0b24bcd..325a148 100644 --- a/src/parse-md.ts +++ b/src/parse-md.ts @@ -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 @@ -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; }; /** diff --git a/src/types.ts b/src/types.ts index b0f8e2d..a18b397 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 extends { children: Array } + ? Omit & { + position: ParsedPosition + children: Array> + } + : Omit & { + position: ParsedPosition + }; + /** @public */ export interface MarkdownDirectiveFields { name: string @@ -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; + +/** + * Any AST node returned by `parseMd`, with non-optional `position` + * (including `start/end.offset`). + * + * @public + */ +export type PositionedMarkdownNode = Positioned;