From 2de03b1f5b35f970558a9eed754eb03b729ef28e Mon Sep 17 00:00:00 2001 From: luojiyin Date: Mon, 26 Oct 2026 00:53:23 +0800 Subject: [PATCH] perf(bundle): isolate MDX parser from default entry Move synchronous MDX parsing to the mdx subpath. Build separate runtime and type bundles for both entries. Measure bundle size, cold load, heap use, and MDX load cost. Keep this change experimental because MDX imports must change. --- README.md | 4 +- __tests__/helpers.ts | 8 +- __tests__/mdx.spec.ts | 6 +- __tests__/source-map/mdx-boundaries.spec.ts | 10 +- __tests__/types/package-exports.mts | 10 +- api-extractor.mdx.json | 14 ++ docs/mdx-entry-experiment.md | 60 ++++++ etc/parser-mdx.api.md | 217 ++++++++++++++++++++ etc/parser.api.md | 12 -- package.json | 22 +- scripts/bench-markdown-versions.mjs | 80 ++++++-- scripts/test-bench-markdown-versions.mjs | 27 +++ scripts/test-mdx3.mjs | 2 +- scripts/test-package.mjs | 20 +- src/index.ts | 5 +- src/mdx-config.ts | 11 + src/mdx.ts | 17 ++ src/parse-md.ts | 12 +- src/parse-mdx.ts | 8 + src/remark-config.ts | 18 +- src/source-map/build-source-map.ts | 17 +- src/source-map/parse-mdx-with-source-map.ts | 9 + 22 files changed, 500 insertions(+), 89 deletions(-) create mode 100644 api-extractor.mdx.json create mode 100644 docs/mdx-entry-experiment.md create mode 100644 etc/parser-mdx.api.md create mode 100644 src/mdx-config.ts create mode 100644 src/mdx.ts create mode 100644 src/parse-mdx.ts create mode 100644 src/source-map/parse-mdx-with-source-map.ts diff --git a/README.md b/README.md index f683176..51fdafa 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,11 @@ const ast = parseMd('你的 Markdown 文本'); ### MDX input Use `parseMdx` for MDX files. It keeps ESM, JSX, and JavaScript expressions in separate nodes. +The MDX entry loads `remark-mdx` when the caller imports `@lint-md/parser/mdx`. +The default entry supports Markdown without loading the MDX parser. ```ts -import { parseMdx, parseMdxWithSourceMap } from '@lint-md/parser'; +import { parseMdx, parseMdxWithSourceMap } from '@lint-md/parser/mdx'; const input = 'export const x = 1\n\nFix text'; const ast = parseMdx(input); diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts index 600cb25..1f058dd 100644 --- a/__tests__/helpers.ts +++ b/__tests__/helpers.ts @@ -1,12 +1,16 @@ const parser = require('../dist/lint-md-parser.cjs'); +const mdxParser = require('../dist/lint-md-parser-mdx.cjs'); export const { parseMd, - parseMdx, revertMdAstNode, stringifyMdAst, parseMdWithSourceMap, - parseMdxWithSourceMap, SourceMapError, SourceMapConsistencyError, SourceMapUnavailableError, } = parser; +export const { + parseMdx, + parseMdxWithSourceMap, + SourceMapUnavailableError: MdxSourceMapUnavailableError, +} = mdxParser; diff --git a/__tests__/mdx.spec.ts b/__tests__/mdx.spec.ts index 545f39c..43823d3 100644 --- a/__tests__/mdx.spec.ts +++ b/__tests__/mdx.spec.ts @@ -2,7 +2,7 @@ import { parseMd, parseMdx, parseMdxWithSourceMap, - SourceMapUnavailableError, + MdxSourceMapUnavailableError, } from './helpers'; const source = [ @@ -79,7 +79,7 @@ describe('MDX parsing', () => { expect(sourceMap.getRaw(jsx)).toBe( 'Fix body', ); - expect(() => sourceMap.getRaw(jsx.attributes[0])).toThrow(SourceMapUnavailableError); + expect(() => sourceMap.getRaw(jsx.attributes[0])).toThrow(MdxSourceMapUnavailableError); }); test('keeps flow JSX and flow expressions outside Markdown text maps', () => { @@ -104,6 +104,6 @@ describe('MDX parsing', () => { 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); + expect(() => sourceMap.getValueSourceIndex(expression)).toThrow(MdxSourceMapUnavailableError); }); }); diff --git a/__tests__/source-map/mdx-boundaries.spec.ts b/__tests__/source-map/mdx-boundaries.spec.ts index 1d3a328..26a103d 100644 --- a/__tests__/source-map/mdx-boundaries.spec.ts +++ b/__tests__/source-map/mdx-boundaries.spec.ts @@ -1,7 +1,7 @@ import { parseMdx, parseMdxWithSourceMap, - SourceMapUnavailableError, + MdxSourceMapUnavailableError, } from '../helpers'; const nested = [ @@ -85,14 +85,14 @@ describe('MDX source-map boundaries', () => { const expression = at(trailing.children, 'mdxTextExpression'); for (const unsupported of [jsx.attributes[0], jsx.attributes[1], expressionAttribute]) { - expect(() => sourceMap.getRaw(unsupported)).toThrow(SourceMapUnavailableError); + expect(() => sourceMap.getRaw(unsupported)).toThrow(MdxSourceMapUnavailableError); expect(() => sourceMap.getValueSourceIndex(unsupported)) - .toThrow(SourceMapUnavailableError); + .toThrow(MdxSourceMapUnavailableError); } expect(() => sourceMap.getValueSourceIndex(expression)) - .toThrow(SourceMapUnavailableError); + .toThrow(MdxSourceMapUnavailableError); expect(() => sourceMap.getSourceRange(expression, 0, 1)) - .toThrow(SourceMapUnavailableError); + .toThrow(MdxSourceMapUnavailableError); const spaces = sourceMap.getSourceRange(text, 2, 4); expect(mixed.slice(spaces.start.offset, spaces.end.offset)).toBe(' '); diff --git a/__tests__/types/package-exports.mts b/__tests__/types/package-exports.mts index e3a852c..b96556b 100644 --- a/__tests__/types/package-exports.mts +++ b/__tests__/types/package-exports.mts @@ -1,8 +1,6 @@ import { parseMd, - parseMdx, parseMdWithSourceMap, - parseMdxWithSourceMap, revertMdAstNode, stringifyMdAst, SourceMapError, @@ -14,14 +12,18 @@ import { type IndentedCodeSourceInfo, type MarkdownCodeNode, type ParsedMarkdownDocument, - type ParsedMdxDocument, - type PositionedMdxRoot, type MarkdownLinkNode, type MarkdownTextNode, type MarkdownValueSourceIndex, type PositionedMarkdownRoot, type PositionedMarkdownNode, } from '@lint-md/parser'; +import { + parseMdx, + parseMdxWithSourceMap, + type ParsedMdxDocument, + type PositionedMdxRoot, +} from '@lint-md/parser/mdx'; const root = parseMd('# ESM'); diff --git a/api-extractor.mdx.json b/api-extractor.mdx.json new file mode 100644 index 0000000..98ea14f --- /dev/null +++ b/api-extractor.mdx.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "./api-extractor.json", + "mainEntryPointFilePath": "/dist/tmp/mdx.d.ts", + "apiReport": { + "enabled": true, + "reportFileName": "parser-mdx.api.md", + "reportFolder": "/etc/" + }, + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "/dist/lint-md-parser-mdx.d.ts" + } +} diff --git a/docs/mdx-entry-experiment.md b/docs/mdx-entry-experiment.md new file mode 100644 index 0000000..bf06541 --- /dev/null +++ b/docs/mdx-entry-experiment.md @@ -0,0 +1,60 @@ +# MDX entry experiment + +This experiment moves the synchronous MDX functions to `@lint-md/parser/mdx`. +The default entry keeps the Markdown functions. +Both entries include their own bundled parser code. + +## Measurement + +The baseline is commit `22b995f`. +The measurements use Node 24.15.0 and nine fresh processes per case. +Each process parses about 16 KiB of input. +The table shows median values. + +| Case | Baseline | Split entry | +| --- | ---: | ---: | +| Default CJS bytes | 484,137 | 194,515 | +| Default CJS gzip bytes | 143,556 | 59,141 | +| Default CJS load time | 14.44 ms | 7.85 ms | +| Default CJS load heap | 2.06 MB | 1.00 MB | +| Default ESM bytes | 483,787 | 194,198 | +| Default ESM gzip bytes | 143,438 | 59,011 | +| Default ESM load time | 16.17 ms | 9.05 ms | +| Default ESM load heap | 2.30 MB | 1.24 MB | +| Cold MDX CJS load time | 14.61 ms | 14.21 ms | +| Cold MDX ESM load time | 15.14 ms | 15.81 ms | +| MDX CJS load after default | 0.08 ms | 12.42 ms | +| MDX CJS load heap after default | 0.01 MB | 1.93 MB | +| MDX ESM load after default | 0.09 ms | 12.69 ms | +| MDX ESM load heap after default | 0.01 MB | 1.88 MB | + +The four bundled JavaScript files total 1,356,173 bytes after the split. +The two baseline files total 967,924 bytes. +This adds 388,249 bytes to the package's uncompressed JavaScript files. + +Use this command to compare the default entries: + +```bash +node scripts/bench-markdown-versions.mjs --baseline /path/to/baseline --current . --runs 9 --bytes 16384 +``` + +Use this command to compare cold MDX loads: + +```bash +node scripts/bench-markdown-versions.mjs --baseline /path/to/baseline --current . --mode mdx --runs 9 --bytes 16384 +``` + +Add `--preload-default` to measure MDX loading after the default entry. +Build both checkouts before running the commands. + +## Decision + +The split cuts default bundle size and load work by about half. +It gives no clear cold MDX gain. +It adds a second entry, a second type bundle, and separate error class instances. +Callers must change MDX imports to `@lint-md/parser/mdx`. +The import change needs a major release or a migration plan. +The split also duplicates bundled code across the two entries. + +Keep this PR as an experiment until consumers review the import change. +Adopt it only if default entry load cost matters more than the added package size and API change. diff --git a/etc/parser-mdx.api.md b/etc/parser-mdx.api.md new file mode 100644 index 0000000..3034f17 --- /dev/null +++ b/etc/parser-mdx.api.md @@ -0,0 +1,217 @@ +## API Report File for "@lint-md/parser" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { BlockContent } from 'mdast'; +import { Code } from 'mdast'; +import type { Content } from 'mdast'; +import { Definition } from 'mdast'; +import type { DefinitionContent } from 'mdast'; +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'; +import { Text as Text_2 } from 'mdast'; + +export { Code } + +// @public +export type CodeSourceInfo = FencedCodeSourceInfo | IndentedCodeSourceInfo; + +// @public +export interface FencedCodeSourceInfo { + infoInsertPoint: ParsedPoint; + kind: 'fenced'; + openingFence: ParsedPosition; +} + +// @public +export interface IndentedCodeSourceInfo { + kind: 'indented'; +} + +export { Link } + +export { ListItem } + +// @public (undocumented) +export type MarkdownCodeNode = Code; + +// @public (undocumented) +export interface MarkdownContainerDirective extends Parent, MarkdownDirectiveFields { + // (undocumented) + children: Array; + // (undocumented) + type: 'containerDirective'; +} + +// @public (undocumented) +export type MarkdownDefinitionNode = Definition; + +// @public (undocumented) +export interface MarkdownDirectiveFields { + // (undocumented) + attributes?: Record | null | undefined; + // (undocumented) + name: string; +} + +// @public (undocumented) +export type MarkdownInlineCodeNode = InlineCode; + +// @public (undocumented) +export interface MarkdownInlineMath extends Literal { + // (undocumented) + type: 'inlineMath'; +} + +// @public (undocumented) +export interface MarkdownLeafDirective extends Parent, MarkdownDirectiveFields { + // (undocumented) + children: PhrasingContent[]; + // (undocumented) + type: 'leafDirective'; +} + +// @public (undocumented) +export type MarkdownLinkNode = Link; + +// @public (undocumented) +export type MarkdownListItemNode = ListItem; + +// @public (undocumented) +export interface MarkdownMath extends Literal { + // (undocumented) + meta?: string | null | undefined; + // (undocumented) + type: 'math'; +} + +// @public (undocumented) +export type MarkdownNode = MarkdownRoot | Content; + +// @public (undocumented) +export type MarkdownRoot = Root; + +// @public +export interface MarkdownSourceMap { + getCodeSourceInfo(node: MarkdownCodeNode): CodeSourceInfo; + getFieldSourceRange(node: MarkdownLinkNode | MarkdownDefinitionNode, field: 'url', valueStart: number, valueEnd: number): ParsedPosition; + getRaw(node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode | MarkdownLinkNode | MarkdownDefinitionNode): string; + getSourceRange(node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, valueStart: number, valueEnd: number): ParsedPosition; + getValueSourceIndex(node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode): MarkdownValueSourceIndex; +} + +// @public (undocumented) +export interface MarkdownTextDirective extends Parent, MarkdownDirectiveFields { + // (undocumented) + children: PhrasingContent[]; + // (undocumented) + type: 'textDirective'; +} + +// @public (undocumented) +export type MarkdownTextNode = Text_2; + +// @public +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 ParsedMdxDocument { + ast: PositionedMdxRoot; + sourceMap: MarkdownSourceMap; +} + +// @public +export interface ParsedPoint { + column: number; + line: number; + offset: number; +} + +// @public +export interface ParsedPosition { + end: ParsedPoint; + start: ParsedPoint; +} + +// @public +export const parseMdx: (mdx: string) => PositionedMdxRoot; + +// @public +export const parseMdxWithSourceMap: (mdx: string) => ParsedMdxDocument; + +// @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 +export type PositionedMdxRoot = Positioned; + +// @public +export class SourceMapConsistencyError extends SourceMapError { + constructor(message?: string); +} + +// @public +export class SourceMapError extends RangeError { + constructor(code: SourceMapErrorCode, message: string); + readonly code: SourceMapErrorCode; +} + +// @public +export type SourceMapErrorCode = 'ERR_SOURCE_MAP_CONSISTENCY' | 'ERR_SOURCE_MAP_UNAVAILABLE'; + +// @public +export class SourceMapUnavailableError extends SourceMapError { + constructor(message?: string); +} + +export { Text_2 as Text } + +// (No @packageDocumentation comment for this package) + +``` diff --git a/etc/parser.api.md b/etc/parser.api.md index 2243fee..05f426d 100644 --- a/etc/parser.api.md +++ b/etc/parser.api.md @@ -153,12 +153,6 @@ export interface ParsedMarkdownDocument { sourceMap: MarkdownSourceMap; } -// @public -export interface ParsedMdxDocument { - ast: PositionedMdxRoot; - sourceMap: MarkdownSourceMap; -} - // @public export interface ParsedPoint { column: number; @@ -178,12 +172,6 @@ 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 extends { children: Array; diff --git a/package.json b/package.json index a7ee7f3..762c1d0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,16 @@ "types": "./dist/lint-md-parser.d.cts", "default": "./dist/lint-md-parser.cjs" } + }, + "./mdx": { + "import": { + "types": "./dist/lint-md-parser-mdx.d.mts", + "default": "./dist/lint-md-parser-mdx.mjs" + }, + "require": { + "types": "./dist/lint-md-parser-mdx.d.cts", + "default": "./dist/lint-md-parser-mdx.cjs" + } } }, "files": [ @@ -36,15 +46,21 @@ "dist/lint-md-parser.d.ts", "dist/lint-md-parser.d.cts", "dist/lint-md-parser.d.mts", + "dist/lint-md-parser-mdx.cjs", + "dist/lint-md-parser-mdx.mjs", + "dist/lint-md-parser-mdx.d.ts", + "dist/lint-md-parser-mdx.d.cts", + "dist/lint-md-parser-mdx.d.mts", "dist/tsdoc-metadata.json" ], "scripts": { "watch": "tsc -w", "build": "rm -rf dist && pnpm run build:dist && pnpm run build:ts-declaration", - "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", + "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 && esbuild src/mdx.ts --bundle --platform=node --format=cjs --outfile=dist/lint-md-parser-mdx.cjs --minify && esbuild src/mdx.ts --bundle --platform=node --format=esm --outfile=dist/lint-md-parser-mdx.mjs --minify", + "build:ts-declaration": "tsc && api-extractor run --local --verbose && api-extractor run --local --verbose --config api-extractor.mdx.json && 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 && cp dist/lint-md-parser-mdx.d.ts dist/lint-md-parser-mdx.d.cts && cp dist/lint-md-parser-mdx.d.ts dist/lint-md-parser-mdx.d.mts && for f in dist/lint-md-parser.d.ts dist/lint-md-parser.d.cts dist/lint-md-parser.d.mts dist/lint-md-parser-mdx.d.ts dist/lint-md-parser-mdx.d.cts dist/lint-md-parser-mdx.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 && pnpm run test:mdx3", + "test": "jest && pnpm run test:types && pnpm run test:package && pnpm run test:mdx3 && pnpm run test:bench-entry", + "test:bench-entry": "node scripts/test-bench-markdown-versions.mjs", "test:mdx3": "node scripts/test-mdx3.mjs", "test:types": "tsc -p __tests__/types/tsconfig.json", "test:package": "publint && node scripts/test-package.mjs", diff --git a/scripts/bench-markdown-versions.mjs b/scripts/bench-markdown-versions.mjs index b88e8a3..ea9c587 100644 --- a/scripts/bench-markdown-versions.mjs +++ b/scripts/bench-markdown-versions.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Compare cold Markdown calls from two built parser packages. +// Compare cold Markdown or MDX calls from two built parser packages. // Run bench-parse.mjs in each checkout for warm parse and source-map costs. import { spawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; @@ -7,27 +7,36 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import fs from 'node:fs'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; +import { gzipSync } from 'node:zlib'; const SCRIPT = fileURLToPath(import.meta.url); const FIXTURE = '# 标题\n\n这是**粗体**和*斜体*混合的段落文字。\n\n- 列表项 [链接](https://example.com)\n\n'; +const MDX_BLOCK = '\n\n中文 **正文** {x}\n\n'; const FORMATS = [ { name: 'cjs', file: 'lint-md-parser.cjs' }, { name: 'esm', file: 'lint-md-parser.mjs' }, ]; -const FUNCTIONS = ['parseMd', 'parseMdWithSourceMap']; +const MODES = { + markdown: ['parseMd', 'parseMdWithSourceMap'], + mdx: ['parseMdx', 'parseMdxWithSourceMap'], +}; function parseArgs() { const args = process.argv.slice(2); - const options = { runs: 7, bytes: 16 * 1024 }; + const options = { runs: 7, bytes: 16 * 1024, mode: 'markdown', preloadDefault: false }; for (let index = 0; index < args.length; index += 1) { const flag = args[index]; if (flag === '--help') { - console.log('Usage: node scripts/bench-markdown-versions.mjs --baseline --current [--runs ] [--bytes ]'); + console.log('Usage: node scripts/bench-markdown-versions.mjs --baseline --current [--runs ] [--bytes ] [--mode markdown|mdx] [--preload-default] [--baseline-entry default|mdx]'); process.exit(0); } - if (!['--baseline', '--current', '--runs', '--bytes'].includes(flag)) + if (flag === '--preload-default') { + options.preloadDefault = true; + continue; + } + if (!['--baseline', '--current', '--runs', '--bytes', '--mode', '--baseline-entry'].includes(flag)) throw new Error(`Unknown option: ${flag}`); - options[flag.slice(2)] = args[++index]; + options[flag === '--baseline-entry' ? 'baselineEntry' : flag.slice(2)] = args[++index]; } options.runs = Number(options.runs); options.bytes = Number(options.bytes); @@ -37,6 +46,11 @@ function parseArgs() { throw new Error('--runs must be a positive integer.'); if (!Number.isSafeInteger(options.bytes) || options.bytes < 1) throw new Error('--bytes must be a positive integer.'); + if (!(options.mode in MODES)) + throw new Error('--mode must be markdown or mdx.'); + options.baselineEntry ??= 'default'; + if (!['default', 'mdx'].includes(options.baselineEntry)) + throw new Error('--baseline-entry must be default or mdx.'); return options; } @@ -47,17 +61,25 @@ function median(values) { } async function runChild() { - const { BENCH_FILE, BENCH_FORMAT, BENCH_FUNCTION, BENCH_BYTES } = process.env; + const { + BENCH_FILE, BENCH_FORMAT, BENCH_FUNCTION, BENCH_BYTES, + BENCH_MODE, BENCH_PRELOAD_FILE, + } = process.env; const targetBytes = Number(BENCH_BYTES); - const fixtureBytes = Buffer.byteLength(FIXTURE); - const input = FIXTURE.repeat(Math.floor(targetBytes / fixtureBytes)) - + 'a'.repeat(targetBytes % fixtureBytes); + const input = BENCH_MODE === 'mdx' + ? 'export const x = 42\n\n' + MDX_BLOCK.repeat( + Math.max(1, Math.ceil(targetBytes / Buffer.byteLength(MDX_BLOCK))), + ) + : FIXTURE.repeat(Math.floor(targetBytes / Buffer.byteLength(FIXTURE))) + + 'a'.repeat(targetBytes % Buffer.byteLength(FIXTURE)); + const load = file => BENCH_FORMAT === 'cjs' + ? createRequire(import.meta.url)(file) + : import(pathToFileURL(file).href); + if (BENCH_PRELOAD_FILE) await load(BENCH_PRELOAD_FILE); global.gc?.(); const before = process.memoryUsage(); const start = performance.now(); - const parser = BENCH_FORMAT === 'cjs' - ? createRequire(import.meta.url)(BENCH_FILE) - : await import(pathToFileURL(BENCH_FILE).href); + const parser = await load(BENCH_FILE); const loadedAt = performance.now(); const afterLoad = process.memoryUsage(); const result = parser[BENCH_FUNCTION](input); @@ -77,8 +99,18 @@ async function runChild() { })}\n`); } -function runSample(root, format, functionName, bytes) { - const file = path.resolve(root, 'dist', format.file); +function entryFile(root, format, entry) { + const name = entry === 'mdx' + ? `lint-md-parser-mdx.${format.name === 'esm' ? 'mjs' : 'cjs'}` + : format.file; + return path.resolve(root, 'dist', name); +} + +function runSample(root, version, format, functionName, options) { + const entry = options.mode === 'mdx' + ? (version === 'baseline' ? options.baselineEntry : 'mdx') + : 'default'; + const file = entryFile(root, format, entry); if (!fs.existsSync(file)) throw new Error(`Build the package first: ${file}`); const child = spawnSync(process.execPath, ['--expose-gc', SCRIPT], { @@ -86,9 +118,12 @@ function runSample(root, format, functionName, bytes) { ...process.env, BENCH_MARKDOWN_CHILD: '1', BENCH_FILE: file, + BENCH_PRELOAD_FILE: options.preloadDefault + ? entryFile(root, format, 'default') : '', BENCH_FORMAT: format.name, BENCH_FUNCTION: functionName, - BENCH_BYTES: String(bytes), + BENCH_BYTES: String(options.bytes), + BENCH_MODE: options.mode, }, encoding: 'utf8', }); @@ -99,15 +134,16 @@ function runSample(root, format, functionName, bytes) { function printResults(options) { const samples = { baseline: {}, current: {} }; + const functions = MODES[options.mode]; for (const format of FORMATS) { - for (const functionName of FUNCTIONS) { + for (const functionName of functions) { const key = `${format.name}/${functionName}`; samples.baseline[key] = []; samples.current[key] = []; for (let index = 0; index < options.runs; index += 1) { const order = index % 2 ? ['current', 'baseline'] : ['baseline', 'current']; for (const version of order) - samples[version][key].push(runSample(options[version], format, functionName, options.bytes)); + samples[version][key].push(runSample(options[version], version, format, functionName, options)); } } } @@ -115,8 +151,12 @@ function printResults(options) { for (const version of ['baseline', 'current']) { summary[version] = {}; for (const format of FORMATS) { - const file = path.resolve(options[version], 'dist', format.file); + const entry = options.mode === 'mdx' + ? (version === 'baseline' ? options.baselineEntry : 'mdx') + : 'default'; + const file = entryFile(options[version], format, entry); summary[version][`${format.name}Bytes`] = fs.statSync(file).size; + summary[version][`${format.name}GzipBytes`] = gzipSync(fs.readFileSync(file)).length; } for (const [key, rows] of Object.entries(samples[version])) { summary[version][key] = {}; @@ -126,6 +166,8 @@ function printResults(options) { } console.log(JSON.stringify({ node: process.version, + mode: options.mode, + preloadDefault: options.preloadDefault, bytes: options.bytes, runs: options.runs, summary, diff --git a/scripts/test-bench-markdown-versions.mjs b/scripts/test-bench-markdown-versions.mjs index 24ffa3f..85ae313 100644 --- a/scripts/test-bench-markdown-versions.mjs +++ b/scripts/test-bench-markdown-versions.mjs @@ -30,3 +30,30 @@ for (const version of ['baseline', 'current']) { } } console.log('Markdown comparison smoke test passed.'); + +const mdxChild = spawnSync(process.execPath, [ + script, + '--baseline', root, + '--current', root, + '--runs', '1', + '--bytes', '1024', + '--mode', 'mdx', + '--baseline-entry', 'mdx', + '--preload-default', +], { encoding: 'utf8' }); + +assert.equal(mdxChild.status, 0, mdxChild.stderr); +const mdxResult = JSON.parse(mdxChild.stdout); +assert.equal(mdxResult.mode, 'mdx'); +assert.equal(mdxResult.preloadDefault, true); +for (const version of ['baseline', 'current']) { + assert.ok(mdxResult.summary[version].cjsGzipBytes > 0); + for (const format of ['cjs', 'esm']) { + for (const name of ['parseMdx', 'parseMdxWithSourceMap']) { + const sample = mdxResult.summary[version][`${format}/${name}`]; + assert.ok(sample.coldTotalMs > 0); + assert.ok(sample.rssBytes > 0); + } + } +} +console.log('MDX comparison smoke test passed.'); diff --git a/scripts/test-mdx3.mjs b/scripts/test-mdx3.mjs index 64a8300..84d4712 100644 --- a/scripts/test-mdx3.mjs +++ b/scripts/test-mdx3.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { compile } from '@mdx-js/mdx'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; -import { parseMdx, parseMdxWithSourceMap } from '../dist/lint-md-parser.mjs'; +import { parseMdx, parseMdxWithSourceMap } from '../dist/lint-md-parser-mdx.mjs'; const source = [ 'export const 用户ID = 42', diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index c7ed12f..a7c0497 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -48,6 +48,12 @@ try { ]); assert.equal(cjsResult, 'root'); + const defaultMdxResult = run('node', [ + '-e', + "const parser = require('@lint-md/parser'); process.stdout.write(String(parser.parseMdx))", + ]); + assert.equal(defaultMdxResult, 'undefined'); + const cjsStringifyResult = run('node', [ '-e', "const parser = require('@lint-md/parser'); process.stdout.write(String(parser.stringifyMdAst === parser.revertMdAstNode))", @@ -64,10 +70,16 @@ try { const mdxResult = run('node', [ '--input-type=module', '-e', - "import { parseMdx, parseMdxWithSourceMap } from '@lint-md/parser'; const input = ''; process.stdout.write([parseMdx(input).children[0].type, parseMdxWithSourceMap(input).ast.children[0].type].join(','))", + "import { parseMdx, parseMdxWithSourceMap } from '@lint-md/parser/mdx'; const input = ''; process.stdout.write([parseMdx(input).children[0].type, parseMdxWithSourceMap(input).ast.children[0].type].join(','))", ]); assert.equal(mdxResult, 'mdxJsxFlowElement,mdxJsxFlowElement'); + const cjsMdxResult = run('node', [ + '-e', + "const parser = require('@lint-md/parser/mdx'); const input = ''; const doc = parser.parseMdxWithSourceMap(input); let code = ''; try { doc.sourceMap.getRaw(doc.ast.children[0].attributes[0]); } catch (error) { code = error.code; } process.stdout.write([parser.parseMdx(input).children[0].type, code].join(','))", + ]); + assert.equal(cjsMdxResult, 'mdxJsxFlowElement,ERR_SOURCE_MAP_UNAVAILABLE'); + const esmStringifyResult = run('node', [ '--input-type=module', '-e', @@ -107,7 +119,8 @@ try { ' MarkdownRoot,', ' MarkdownTextDirective,', "} from '@lint-md/parser';", - "import { parseMd, parseMdx, revertMdAstNode, stringifyMdAst } from '@lint-md/parser';", + "import { parseMd, revertMdAstNode, stringifyMdAst } from '@lint-md/parser';", + "import { parseMdx } from '@lint-md/parser/mdx';", '', 'const markdown: string = revertMdAstNode(parseMd("# ESM"));', 'const same: boolean = stringifyMdAst === revertMdAstNode;', @@ -196,6 +209,7 @@ try { ' MarkdownTextDirective,', "} from '@lint-md/parser';", "import parser = require('@lint-md/parser');", + "import mdx = require('@lint-md/parser/mdx');", '', 'const markdown: string = parser.revertMdAstNode(parser.parseMd("# CJS"));', 'const same: boolean = parser.stringifyMdAst === parser.revertMdAstNode;', @@ -205,6 +219,8 @@ try { 'function assertType(_val: T): void {}', '', 'const root = parser.parseMd("# test");', + 'const mdxRoot = mdx.parseMdx("");', + 'void mdxRoot;', '', 'for (const child of root.children) {', ' if (child.type === "yaml") {', diff --git a/src/index.ts b/src/index.ts index e684477..37528cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -export { parseMd, parseMdx, revertMdAstNode, revertMdAstNode as stringifyMdAst } from './parse-md'; -export { parseMdWithSourceMap, parseMdxWithSourceMap } from './source-map/build-source-map'; +export { parseMd, revertMdAstNode, revertMdAstNode as stringifyMdAst } from './parse-md'; +export { parseMdWithSourceMap } from './source-map/build-source-map'; export { SourceMapError, SourceMapConsistencyError, @@ -15,5 +15,4 @@ export type { MarkdownSourceMap, MarkdownValueSourceIndex, ParsedMarkdownDocument, - ParsedMdxDocument, } from './source-map/types'; diff --git a/src/mdx-config.ts b/src/mdx-config.ts new file mode 100644 index 0000000..ca0aacd --- /dev/null +++ b/src/mdx-config.ts @@ -0,0 +1,11 @@ +import remarkMdx from 'remark-mdx'; +import { createParserProcessor, getProcessorExtensions } from './remark-config'; + +/** Add MDX syntax only when a caller loads the MDX entry. */ +export const createMdxProcessor = (): ReturnType => { + // The MDX 3 test dependency exposes unified 11 types. remark-mdx 2 uses unified 10 at runtime. + return createParserProcessor().use(remarkMdx as any); +}; + +export const getMdxParserExtensions = () => + getProcessorExtensions(createMdxProcessor()); diff --git a/src/mdx.ts b/src/mdx.ts new file mode 100644 index 0000000..4a3962f --- /dev/null +++ b/src/mdx.ts @@ -0,0 +1,17 @@ +export { parseMdx } from './parse-mdx'; +export { parseMdxWithSourceMap } from './source-map/parse-mdx-with-source-map'; +export { + SourceMapError, + SourceMapConsistencyError, + SourceMapUnavailableError, + type SourceMapErrorCode, +} from './source-map/errors'; +export * from './types'; +export type { + CodeSourceInfo, + FencedCodeSourceInfo, + IndentedCodeSourceInfo, + MarkdownSourceMap, + MarkdownValueSourceIndex, + ParsedMdxDocument, +} from './source-map/types'; diff --git a/src/parse-md.ts b/src/parse-md.ts index 0c08fc2..5bcdf8f 100644 --- a/src/parse-md.ts +++ b/src/parse-md.ts @@ -1,8 +1,7 @@ import { createParserProcessor } from './remark-config'; -import type { MarkdownRoot, PositionedMarkdownRoot, PositionedMdxRoot } from './types'; +import type { MarkdownRoot, PositionedMarkdownRoot } from './types'; const depsLink = createParserProcessor(); -const mdxProcessor = createParserProcessor('mdx'); /** * 将 Markdown 解析成 ast @@ -32,15 +31,6 @@ export const parseMd = (md: string): PositionedMarkdownRoot => { return depsLink.parse(md) as PositionedMarkdownRoot; }; -/** - * Parse MDX into positioned Markdown and MDX syntax nodes. - * Use this function for MDX input so JSX and expressions stay distinct. - * - * @public - */ -export const parseMdx = (mdx: string): PositionedMdxRoot => - mdxProcessor.parse(mdx) as PositionedMdxRoot; - /** * 将 ast 解析成 markdown * diff --git a/src/parse-mdx.ts b/src/parse-mdx.ts new file mode 100644 index 0000000..4941756 --- /dev/null +++ b/src/parse-mdx.ts @@ -0,0 +1,8 @@ +import { createMdxProcessor } from './mdx-config'; +import type { PositionedMdxRoot } from './types'; + +const mdxProcessor = createMdxProcessor(); + +/** Parse MDX into positioned Markdown and MDX syntax nodes. @public */ +export const parseMdx = (mdx: string): PositionedMdxRoot => + mdxProcessor.parse(mdx) as PositionedMdxRoot; diff --git a/src/remark-config.ts b/src/remark-config.ts index 3c68b0d..0448ccc 100644 --- a/src/remark-config.ts +++ b/src/remark-config.ts @@ -3,7 +3,6 @@ import remarkGfm from 'remark-gfm'; import remarkDirective from 'remark-directive'; import remarkMath from 'remark-math'; import { remark } from 'remark'; -import remarkMdx from 'remark-mdx'; import { gfmAutolinkLiteralFromMarkdown } from 'mdast-util-gfm-autolink-literal'; /** @@ -87,18 +86,13 @@ const positionSafeGfm = function positionSafeGfm(this: { * Each call returns an independent processor whose own data holds a fresh * clone of the autolink extension — no shared mutable state between calls. */ -export type ParserSyntax = 'markdown' | 'mdx'; - -export const createParserProcessor = (syntax: ParserSyntax = 'markdown'): ReturnType => { - const processor = remark() +export const createParserProcessor = (): ReturnType => + remark() .use(frontmatter) .use(remarkGfm) .use(positionSafeGfm) .use(remarkDirective) .use(remarkMath); - // The MDX 3 test dependency exposes unified 11 types. remark-mdx 2 uses unified 10 at runtime. - return syntax === 'mdx' ? processor.use(remarkMdx as any) : processor; -}; export interface ParserExtensions { micromarkExtensions: unknown[] @@ -112,8 +106,9 @@ export interface ParserExtensions { * kept literal). Reusing this frozen data is what keeps * `parseMdWithSourceMap`'s AST identical to `parseMd`'s. */ -export const getParserExtensions = (syntax: ParserSyntax = 'markdown'): ParserExtensions => { - const frozen = createParserProcessor(syntax); +export const getProcessorExtensions = ( + frozen: ReturnType, +): ParserExtensions => { frozen.freeze(); const data = frozen.data(); return { @@ -121,3 +116,6 @@ export const getParserExtensions = (syntax: ParserSyntax = 'markdown'): ParserEx fromMarkdownExtensions: (data.fromMarkdownExtensions as unknown[]) || [], }; }; + +export const getParserExtensions = (): ParserExtensions => + getProcessorExtensions(createParserProcessor()); diff --git a/src/source-map/build-source-map.ts b/src/source-map/build-source-map.ts index 559cffc..b1ffa46 100644 --- a/src/source-map/build-source-map.ts +++ b/src/source-map/build-source-map.ts @@ -11,7 +11,7 @@ import type { ParsedPosition, PositionedMarkdownRoot, } from '../types'; -import { getParserExtensions } from '../remark-config'; +import { type ParserExtensions, getParserExtensions } from '../remark-config'; import { SourceMapConsistencyError, SourceMapUnavailableError, @@ -24,7 +24,6 @@ import type { MarkdownSourceMapSegment, MarkdownValueSourceIndex, ParsedMarkdownDocument, - ParsedMdxDocument, } from './types'; // Use the same parser extensions as `parseMd`. @@ -33,7 +32,6 @@ import type { // The replacements mirror the matching `mdast-util-from-markdown` AST behavior. // All other tokens use the standard handlers. const markdownExtensions = getParserExtensions(); -const mdxExtensions = getParserExtensions('mdx'); interface RecordingState { /** Markdown source used for parser-confirmed indentation spans. */ @@ -335,9 +333,9 @@ function resolveSegmentRange(options: SegmentRangeOptions): ParsedPosition { * * @public */ -const parseWithSourceMap = ( +export const parseWithSourceMap = ( md: string, - syntax: 'markdown' | 'mdx', + { micromarkExtensions, fromMarkdownExtensions }: ParserExtensions, ): ParsedMarkdownDocument => { const state: RecordingState = { source: md, @@ -350,9 +348,6 @@ const parseWithSourceMap = ( emptyUrlOffsets: new WeakMap(), }; - const { micromarkExtensions, fromMarkdownExtensions } = syntax === 'mdx' - ? mdxExtensions - : markdownExtensions; const tree = fromMarkdown(md, { extensions: micromarkExtensions as any, mdastExtensions: [ @@ -705,8 +700,4 @@ const parseWithSourceMap = ( /** Parse Markdown and map supported values to source positions. @public */ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => - parseWithSourceMap(md, 'markdown'); - -/** Parse MDX and map Markdown values to source positions. @public */ -export const parseMdxWithSourceMap = (mdx: string): ParsedMdxDocument => - parseWithSourceMap(mdx, 'mdx'); + parseWithSourceMap(md, markdownExtensions); diff --git a/src/source-map/parse-mdx-with-source-map.ts b/src/source-map/parse-mdx-with-source-map.ts new file mode 100644 index 0000000..f79d05d --- /dev/null +++ b/src/source-map/parse-mdx-with-source-map.ts @@ -0,0 +1,9 @@ +import { getMdxParserExtensions } from '../mdx-config'; +import { parseWithSourceMap } from './build-source-map'; +import type { ParsedMdxDocument } from './types'; + +const mdxExtensions = getMdxParserExtensions(); + +/** Parse MDX and map Markdown values to source positions. @public */ +export const parseMdxWithSourceMap = (mdx: string): ParsedMdxDocument => + parseWithSourceMap(mdx, mdxExtensions);