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);