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', ''],
+ ['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;