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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@ import { parseMd } from '@lint-md/parser';
const ast = parseMd('你的 Markdown 文本');
```

### MDX input

Use `parseMdx` for MDX files. It keeps ESM, JSX, and JavaScript expressions in separate nodes.

```ts
import { parseMdx, parseMdxWithSourceMap } from '@lint-md/parser';

const input = 'export const x = 1\n\n<Card title="Keep spaces">Fix text</Card>';
const ast = parseMdx(input);
const { sourceMap } = parseMdxWithSourceMap(input);
```

The parser maps Markdown `text`, `inlineCode`, `code`, and link URL fields.
It does not map JSX attribute values or JavaScript expression values.
JSX attribute nodes have no source position in the MDX AST.
Use the JSX element position to read its full source with `sourceMap.getRaw(element)`.
Apply fixes only to mapped Markdown fields. Keep ESM, JSX attributes, and expressions unchanged.

### 序列化说明

`revertMdAstNode` 会将 AST 序列化为 Markdown,但 **不保证恢复原始文本**。
Expand Down Expand Up @@ -106,6 +124,7 @@ sourceMap.getRaw(textNode); // 'A&amp;B'
```bash
pnpm run build
pnpm run test:package
pnpm run test:mdx3
```

`test:package` 会打包并安装实际 tarball,然后验证 CommonJS、ESModule 和 TypeScript 类型入口。
Expand Down
2 changes: 2 additions & 0 deletions __tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
const parser = require('../dist/lint-md-parser.cjs');
export const {
parseMd,
parseMdx,
revertMdAstNode,
stringifyMdAst,
parseMdWithSourceMap,
parseMdxWithSourceMap,
SourceMapError,
SourceMapConsistencyError,
SourceMapUnavailableError,
Expand Down
109 changes: 109 additions & 0 deletions __tests__/mdx.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
parseMd,
parseMdx,
parseMdxWithSourceMap,
SourceMapUnavailableError,
} from './helpers';

const source = [
"import Card from './Card'",
"export const label = 'Keep spaces'",
'',
'# Fix heading',
'',
'<Card title="Keep spaces" count={1 + 2}>Fix body</Card>',
'',
'Fix prose {label} here.',
].join('\n');

function visit(node: any, callback: (node: any) => void): void {
callback(node);
for (const child of node.children || []) visit(child, callback);
}

describe('MDX parsing', () => {
test('keeps MDX syntax distinct from Markdown text', () => {
const ast = parseMdx(source);
const types: string[] = [];
visit(ast, node => types.push(node.type));
expect(types).toEqual(expect.arrayContaining([
'mdxjsEsm',
'mdxJsxTextElement',
'mdxTextExpression',
'heading',
'text',
]));

const jsx = ast.children.find((node: any) => node.type === 'paragraph') as any;
const element = jsx.children[0];
expect(element.attributes[0].value).toBe('Keep spaces');
expect(element.attributes[1].value.value).toBe('1 + 2');
expect(element.children[0].value).toBe('Fix body');
expect(parseMd('# Plain Markdown').children[0].type).toBe('heading');
});

test('maps Markdown text while preserving code and JSX attributes', () => {
const { ast, sourceMap } = parseMdxWithSourceMap(source);
expect(JSON.parse(JSON.stringify(ast))).toEqual(
JSON.parse(JSON.stringify(parseMdx(source))),
);

const edits: Array<{ start: number; end: number; replacement: string }> = [];
visit(ast, node => {
if (node.type !== 'text') return;
const index = node.value.indexOf(' ');
if (index < 0) return;
const range = sourceMap.getSourceRange(node, index, index + 2);
expect(source.slice(range.start.offset, range.end.offset)).toBe(' ');
edits.push({ start: range.start.offset, end: range.end.offset, replacement: ' ' });
});

expect(edits).toHaveLength(3);
let fixed = source;
for (const edit of edits.sort((a, b) => b.start - a.start)) {
fixed = fixed.slice(0, edit.start) + edit.replacement + fixed.slice(edit.end);
}
expect(fixed).toContain("export const label = 'Keep spaces'");
expect(fixed).toContain('<Card title="Keep spaces" count={1 + 2}>Fix body</Card>');
expect(fixed).toContain('# Fix heading');
expect(fixed).toContain('Fix prose {label} here.');
});

test('uses source spans for MDX nodes and rejects unsupported attributes', () => {
const { ast, sourceMap } = parseMdxWithSourceMap(source);
const esm = ast.children[0] as any;
expect(sourceMap.getRaw(esm)).toBe(
"import Card from './Card'\nexport const label = 'Keep spaces'",
);
const jsx = (ast.children.find((node: any) => node.type === 'paragraph') as any).children[0];
expect(sourceMap.getRaw(jsx)).toBe(
'<Card title="Keep spaces" count={1 + 2}>Fix body</Card>',
);
expect(() => sourceMap.getRaw(jsx.attributes[0])).toThrow(SourceMapUnavailableError);
});

test('keeps flow JSX and flow expressions outside Markdown text maps', () => {
const input = [
'export const value = "A B"',
'',
'<Card title="A B" {...props}>',
'Fix body',
'</Card>',
'',
'{value + 1}',
].join('\n');
const { ast, sourceMap } = parseMdxWithSourceMap(input);
const flow = ast.children.find((node: any) => node.type === 'mdxJsxFlowElement') as any;
const expression = ast.children.find((node: any) => node.type === 'mdxFlowExpression') as any;
expect(flow).toBeDefined();
expect(expression).toBeDefined();
expect(sourceMap.getRaw(flow)).toBe('<Card title="A B" {...props}>\nFix body\n</Card>');
expect(sourceMap.getRaw(expression)).toBe('{value + 1}');

const textNodes: any[] = [];
visit(ast, node => { if (node.type === 'text') textNodes.push(node); });
expect(textNodes.map(node => node.value)).toEqual(['Fix body']);
expect(sourceMap.getRaw(textNodes[0])).toBe('Fix body');
expect(() => sourceMap.getValueSourceIndex(expression)).toThrow(SourceMapUnavailableError);
});
});
8 changes: 8 additions & 0 deletions __tests__/types/package-exports.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
parseMd,
parseMdx,
parseMdWithSourceMap,
parseMdxWithSourceMap,
revertMdAstNode,
stringifyMdAst,
SourceMapError,
Expand All @@ -12,6 +14,8 @@ import {
type IndentedCodeSourceInfo,
type MarkdownCodeNode,
type ParsedMarkdownDocument,
type ParsedMdxDocument,
type PositionedMdxRoot,
type MarkdownLinkNode,
type MarkdownTextNode,
type MarkdownValueSourceIndex,
Expand All @@ -23,6 +27,8 @@ const root = parseMd('# ESM');

const rootOffset: number = root.position.start.offset;
const typedRoot: PositionedMarkdownRoot = root;
const mdxRoot: PositionedMdxRoot = parseMdx('<Card title="Keep spaces" />');
const mdxDoc: ParsedMdxDocument = parseMdxWithSourceMap('{1 + 2}');

const firstNode: PositionedMarkdownNode = root.children[0];
const nodeOffset: number = firstNode.position.end.offset;
Expand Down Expand Up @@ -74,6 +80,8 @@ const isSourceMapError: boolean = unavailable instanceof SourceMapError;

void rootOffset;
void typedRoot;
void mdxRoot;
void mdxDoc;
void firstNode;
void nodeOffset;
void markdown;
Expand Down
39 changes: 39 additions & 0 deletions etc/parser.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import { InlineCode } from 'mdast';
import { Link } from 'mdast';
import { ListItem } from 'mdast';
import type { Literal } from 'mdast';
import type { MdxFlowExpression } from 'mdast-util-mdx';
import type { MdxjsEsm } from 'mdast-util-mdx';
import type { MdxJsxAttribute } from 'mdast-util-mdx';
import type { MdxJsxAttributeValueExpression } from 'mdast-util-mdx';
import type { MdxJsxExpressionAttribute } from 'mdast-util-mdx';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
import type { MdxJsxTextElement } from 'mdast-util-mdx';
import type { MdxTextExpression } from 'mdast-util-mdx';
import type { Parent } from 'mdast';
import type { PhrasingContent } from 'mdast';
import type { Root } from 'mdast';
Expand Down Expand Up @@ -123,12 +131,34 @@ export interface MarkdownValueSourceIndex {
sourceOffsetAt(valueIndex: number): number;
}

export { MdxFlowExpression }

export { MdxjsEsm }

export { MdxJsxAttribute }

export { MdxJsxAttributeValueExpression }

export { MdxJsxExpressionAttribute }

export { MdxJsxFlowElement }

export { MdxJsxTextElement }

export { MdxTextExpression }

// @public
export interface ParsedMarkdownDocument {
ast: PositionedMarkdownRoot;
sourceMap: MarkdownSourceMap;
}

// @public
export interface ParsedMdxDocument {
ast: PositionedMdxRoot;
sourceMap: MarkdownSourceMap;
}

// @public
export interface ParsedPoint {
column: number;
Expand All @@ -148,6 +178,12 @@ export const parseMd: (md: string) => PositionedMarkdownRoot;
// @public
export const parseMdWithSourceMap: (md: string) => ParsedMarkdownDocument;

// @public
export const parseMdx: (mdx: string) => PositionedMdxRoot;

// @public
export const parseMdxWithSourceMap: (mdx: string) => ParsedMdxDocument;

// @public
export type Positioned<T> = T extends {
children: Array<infer Child>;
Expand All @@ -164,6 +200,9 @@ export type PositionedMarkdownNode = Positioned<MarkdownNode>;
// @public
export type PositionedMarkdownRoot = Positioned<MarkdownRoot>;

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

// @public
const revertMdAstNode: (node: MarkdownRoot) => string;
export { revertMdAstNode }
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"build:dist": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/lint-md-parser.cjs --minify && esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/lint-md-parser.mjs --minify",
"build:ts-declaration": "tsc && api-extractor run --local --verbose && cp dist/lint-md-parser.d.ts dist/lint-md-parser.d.cts && cp dist/lint-md-parser.d.ts dist/lint-md-parser.d.mts && for f in dist/lint-md-parser.d.ts dist/lint-md-parser.d.cts dist/lint-md-parser.d.mts; do cat scripts/mdast-augment.d.ts >> \"$f\"; done",
"lint": "eslint --ext js,jsx,ts,tsx ./src",
"test": "jest && pnpm run test:types && pnpm run test:package",
"test": "jest && pnpm run test:types && pnpm run test:package && pnpm run test:mdx3",
"test:mdx3": "node scripts/test-mdx3.mjs",
"test:types": "tsc -p __tests__/types/tsconfig.json",
"test:package": "publint && node scripts/test-package.mjs",
"prebench:source-map": "pnpm run build",
Expand All @@ -57,6 +58,7 @@
},
"devDependencies": {
"@attachments/eslint-config": "^0.3.3",
"@mdx-js/mdx": "3.1.1",
"@microsoft/api-extractor": "^7.23.2",
"@types/jest": "^29.5.0",
"@types/node": "^22.0.0",
Expand All @@ -76,10 +78,12 @@
"remark-frontmatter": "^4.0.1",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"remark-mdx": "2.3.0",
"ts-jest": "^29.4.0",
"typescript": "^6.0.3"
},
"dependencies": {
"@types/mdast": "^3.0.15"
"@types/mdast": "^3.0.15",
"mdast-util-mdx": "2.0.1"
}
}
Loading
Loading