From dcd6aba8b3caf1c8b24de6762374a3f2e440d33c Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 2 Jul 2026 19:16:52 +0800 Subject: [PATCH 1/6] feat(types): add Positioned, ParsedPoint, ParsedPosition and positioned aliases --- src/types.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/types.ts b/src/types.ts index b0f8e2d..c2685e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,34 @@ import type { export type { Code, Link, ListItem, Text } from 'mdast'; +/** @public */ +export interface ParsedPoint { + line: number + column: number + offset: number +} + +/** @public */ +export interface ParsedPosition { + start: ParsedPoint + 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 +90,9 @@ export type MarkdownLinkNode = import('mdast').Link; /** @public */ export type MarkdownTextNode = import('mdast').Text; + +/** @public */ +export type PositionedMarkdownRoot = Positioned; + +/** @public */ +export type PositionedMarkdownNode = Positioned; From 7ab1c3c6225306e9d5f51435dd0dfcbb1becf232 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 2 Jul 2026 19:17:01 +0800 Subject: [PATCH 2/6] feat(parse-md): return PositionedMarkdownRoot from parseMd --- src/parse-md.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/parse-md.ts b/src/parse-md.ts index 0b24bcd..b6d7fb7 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,12 @@ 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 => { + return depsLink.parse(md) as PositionedMarkdownRoot; }; /** From 275cadab2f7c18c34c389ceca863b784972f7788 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 2 Jul 2026 19:17:11 +0800 Subject: [PATCH 3/6] test(position): cover position contract across all plugins, update type-test fixtures --- __tests__/position.spec.ts | 67 +++++++++++++++++++++++++++++ __tests__/types/package-exports.cts | 12 +++++- __tests__/types/package-exports.mts | 20 ++++++++- etc/parser.api.md | 36 +++++++++++++++- 4 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 __tests__/position.spec.ts diff --git a/__tests__/position.spec.ts b/__tests__/position.spec.ts new file mode 100644 index 0000000..bd81c0b --- /dev/null +++ b/__tests__/position.spec.ts @@ -0,0 +1,67 @@ +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'); + for (const child of current.children ?? []) { + assertPositioned(child); + } +}; + +describe('parseMd position contract', () => { + test('every node in the parsed tree has start/end with offset (all plugins)', () => { + const root = 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 +::: +`); + + assertPositioned(root); + }); + + test('GFM autolink tokenizer path produces a link with position', () => { + const root = parseMd('visit www.example.com'); + assertPositioned(root); + }); +}); 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..a238be8 100644 --- a/etc/parser.api.md +++ b/etc/parser.api.md @@ -86,8 +86,42 @@ export interface MarkdownTextDirective extends Parent, MarkdownDirectiveFields { // @public (undocumented) export type MarkdownTextNode = Text_2; +// @public (undocumented) +export interface ParsedPoint { + // (undocumented) + column: number; + // (undocumented) + line: number; + // (undocumented) + offset: number; +} + +// @public (undocumented) +export interface ParsedPosition { + // (undocumented) + end: ParsedPoint; + // (undocumented) + start: ParsedPoint; +} + +// @public +export const parseMd: (md: string) => PositionedMarkdownRoot; + // @public -export const parseMd: (md: string) => MarkdownRoot; +export type Positioned = T extends { + children: Array; +} ? Omit & { + position: ParsedPosition; + children: Array>; +} : Omit & { + position: ParsedPosition; +}; + +// @public (undocumented) +export type PositionedMarkdownNode = Positioned; + +// @public (undocumented) +export type PositionedMarkdownRoot = Positioned; // @public const revertMdAstNode: (node: MarkdownRoot) => string; From 098b738b98ef3b18cff55f73dfef494bc854efd9 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 2 Jul 2026 19:31:39 +0800 Subject: [PATCH 4/6] test(position): cover every supported node with parameterized corpus --- __tests__/position.spec.ts | 45 +++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/__tests__/position.spec.ts b/__tests__/position.spec.ts index bd81c0b..83ab581 100644 --- a/__tests__/position.spec.ts +++ b/__tests__/position.spec.ts @@ -17,14 +17,46 @@ const assertPositioned = (node: unknown): void => { 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('every node in the parsed tree has start/end with offset (all plugins)', () => { - const root = parseMd(`--- + 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 --- @@ -55,13 +87,6 @@ $x^2$ :::note directive body ::: -`); - - assertPositioned(root); - }); - - test('GFM autolink tokenizer path produces a link with position', () => { - const root = parseMd('visit www.example.com'); - assertPositioned(root); +`)); }); }); From 843645cd62d8bfacc0a44c3b3f4d4edd65e806d8 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 2 Jul 2026 19:33:36 +0800 Subject: [PATCH 5/6] docs(types,parse-md,readme): document position contract and TSDoc new types --- README.md | 28 ++++++++++++++++++++++++++++ etc/parser.api.md | 13 ++++--------- src/parse-md.ts | 14 ++++++++++++++ src/types.ts | 36 ++++++++++++++++++++++++++++++------ 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index be4c8be..6a29ea2 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,34 @@ 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 +ast.children[0].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/etc/parser.api.md b/etc/parser.api.md index a238be8..241ccd1 100644 --- a/etc/parser.api.md +++ b/etc/parser.api.md @@ -86,21 +86,16 @@ export interface MarkdownTextDirective extends Parent, MarkdownDirectiveFields { // @public (undocumented) export type MarkdownTextNode = Text_2; -// @public (undocumented) +// @public export interface ParsedPoint { - // (undocumented) column: number; - // (undocumented) line: number; - // (undocumented) offset: number; } -// @public (undocumented) +// @public export interface ParsedPosition { - // (undocumented) end: ParsedPoint; - // (undocumented) start: ParsedPoint; } @@ -117,10 +112,10 @@ export type Positioned = T extends { position: ParsedPosition; }; -// @public (undocumented) +// @public export type PositionedMarkdownNode = Positioned; -// @public (undocumented) +// @public export type PositionedMarkdownRoot = Positioned; // @public diff --git a/src/parse-md.ts b/src/parse-md.ts index b6d7fb7..325a148 100644 --- a/src/parse-md.ts +++ b/src/parse-md.ts @@ -37,6 +37,20 @@ const depsLink = remark() * @public */ 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 c2685e6..a18b397 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,22 +10,36 @@ import type { export type { Code, Link, ListItem, Text } from 'mdast'; -/** @public */ +/** + * 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 } -/** @public */ +/** + * 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. + * Recursively rewrites a mdast node so `position` and its nested + * `start/end.offset` are non-optional. * * @public */ @@ -91,8 +105,18 @@ export type MarkdownLinkNode = import('mdast').Link; /** @public */ export type MarkdownTextNode = import('mdast').Text; -/** @public */ +/** + * 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; -/** @public */ +/** + * Any AST node returned by `parseMd`, with non-optional `position` + * (including `start/end.offset`). + * + * @public + */ export type PositionedMarkdownNode = Positioned; From 60f358e4e2667baf3d4e2721c39825391aeac0dd Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 2 Jul 2026 19:41:08 +0800 Subject: [PATCH 6/6] docs(readme): use PositionedMarkdownNode in the position contract example --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a29ea2..40cc217 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ const ast = parseMd('你的 Markdown 文本'); import { parseMd, type PositionedMarkdownNode } from '@lint-md/parser'; const ast = parseMd('# title'); -ast.position.start.offset; // number -ast.children[0].position.end.offset; // number +ast.position.start.offset; // number +const firstNode: PositionedMarkdownNode = ast.children[0]; +firstNode.position.end.offset; // number ``` 契约由三件事保证: