diff --git a/README.md b/README.md index 0e10de9f..59f69609 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,10 @@ > If you like this package, please give it a star on [GitHub](https://github.com/lumirlumir/npm-eslint-markdown)!⭐
> Your support helps us improve and maintain the project. -Lint your Markdown with ESLint. Additional rules for use with `@eslint/markdown`.🛠️ +Lint your Markdown with ESLint. + +- Additional rules for use with `@eslint/markdown`.🛠️ +- A blazing fast Markdown parser for ESLint.⚡ ## Documentation @@ -31,6 +34,18 @@ This [`eslint-markdown`](https://github.com/lumirlumir/npm-eslint-markdown#readm So, we **highly recommend** using the `eslint-markdown` plugin alongside ESLint's built-in Markdown support, `@eslint/markdown`. +## Included Packages + +This repository is maintained as a monorepo and includes the following two packages. + +### `eslint-markdown` - [Docs](https://eslint-markdown.lumir.page/docs/packages/eslint-markdown) | [Repository](https://github.com/lumirlumir/npm-eslint-markdown/tree/main/packages/eslint-markdown) | [npm](https://www.npmjs.com/package/eslint-markdown) + +Lint your Markdown with ESLint. Additional rules for use with `@eslint/markdown`.🛠️ + +### `@eslint-markdown/parser` - [Docs](https://eslint-markdown.lumir.page/docs/packages/parser) | [Repository](https://github.com/lumirlumir/npm-eslint-markdown/tree/main/packages/parser) | [npm](https://www.npmjs.com/package/@eslint-markdown/parser) + +A blazing fast Markdown parser for ESLint.⚡ + ## Code of Conduct See [Code of Conduct](https://github.com/lumirlumir/.github/blob/main/CODE_OF_CONDUCT.md#contributor-covenant-code-of-conduct). diff --git a/package-lock.json b/package-lock.json index b423118a..c8e4e13a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -380,6 +380,140 @@ "node": ">=18" } }, + "node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@codecov/bundler-plugin-core": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@codecov/bundler-plugin-core/-/bundler-plugin-core-2.0.1.tgz", @@ -419,6 +553,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -903,6 +1068,10 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint-markdown/parser": { + "resolved": "packages/parser", + "link": true + }, "node_modules/@eslint/config-array": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", @@ -1295,6 +1464,27 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, "node_modules/@next/eslint-plugin-next": { "version": "16.2.10", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz", @@ -1974,6 +2164,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2005,14 +2205,21 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -8588,6 +8795,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" + } + }, + "node_modules/satteri/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -9253,6 +9489,13 @@ "json5": "lib/cli.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -10246,6 +10489,26 @@ "dev": true, "license": "MIT" }, + "packages/parser": { + "name": "@eslint-markdown/parser", + "version": "0.0.0-canary.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.4", + "satteri": "^0.10.5" + }, + "devDependencies": { + "@eslint/markdown": "^7.5.1", + "eslint": "^9.39.5", + "vitest": "^4.1.9" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/lumirlumir" + } + }, "website": { "devDependencies": { "@codecov/bundler-plugin-core": "^2.0.1", diff --git a/package.json b/package.json index cd59e610..2f67488d 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,10 @@ "test:types": "tsc -b", "test:unit": "vitest run", "test:pkg:em": "npm run test -w packages/eslint-markdown", - "build": "npm run build:pkg:em && npm run build:w", + "test:pkg:p": "npm run test -w packages/parser", + "build": "npm run build:pkg:p && npm run build:pkg:em && npm run build:w", "build:pkg:em": "npm run build -w packages/eslint-markdown", + "build:pkg:p": "npm run build -w packages/parser", "build:w": "npm run build -w website", "build:w:vp": "npm run build:vp -w website", "build:w:em": "npm run build:em -w website", diff --git a/packages/parser/package.json b/packages/parser/package.json new file mode 100644 index 00000000..bfc0b800 --- /dev/null +++ b/packages/parser/package.json @@ -0,0 +1,75 @@ +{ + "name": "@eslint-markdown/parser", + "version": "0.0.0-canary.0", + "type": "module", + "sideEffects": false, + "description": "A blazing fast Markdown parser for ESLint.⚡", + "exports": { + ".": { + "types": "./build/index.d.ts", + "default": "./build/index.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "*": [ + "./build/*" + ], + ".": [ + "./build/index.d.ts" + ] + } + }, + "files": [ + "build", + "LICENSE.md", + "README.md" + ], + "keywords": [ + "eslint", + "eslintparser", + "eslint-parser", + "parser", + "markdown", + "mark", + "md", + "commonmark", + "gfm" + ], + "author": "lumir (https://github.com/lumirlumir)", + "funding": "https://github.com/sponsors/lumirlumir", + "license": "MIT", + "homepage": "https://eslint-markdown.lumir.page", + "repository": { + "type": "github", + "url": "git+https://github.com/lumirlumir/npm-eslint-markdown.git", + "directory": "packages/parser" + }, + "bugs": { + "url": "https://github.com/lumirlumir/npm-eslint-markdown/issues" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "scripts": { + "prepublishOnly": "npm run build", + "build": "tsc -b tsconfig.build.json && node ../../scripts/cp.js ../../LICENSE.md LICENSE.md ../../README.md README.md", + "test": "npm run test:types && npm run test:unit", + "test:types": "tsc -b ./tsconfig.json", + "test:unit": "vitest run" + }, + "dependencies": { + "@types/mdast": "^4.0.4", + "satteri": "^0.10.5" + }, + "devDependencies": { + "@eslint/markdown": "^7.5.1", + "eslint": "^9.39.5", + "vitest": "^4.1.9" + } +} diff --git a/packages/parser/src/index.test-d.ts b/packages/parser/src/index.test-d.ts new file mode 100644 index 00000000..fdcd03f3 --- /dev/null +++ b/packages/parser/src/index.test-d.ts @@ -0,0 +1,136 @@ +/** + * @fileoverview Type test for `index.ts`. + */ + +// -------------------------------------------------------------------------------- +// Import +// -------------------------------------------------------------------------------- + +import parser, { + meta, + parse, + type Json, + type JsonData, + type ParseOptions, +} from '@eslint-markdown/parser'; +import type { + Json as ESLintMarkdownJson, + JsonData as ESLintMarkdownJsonData, + MarkdownLanguageOptions, +} from '@eslint/markdown'; +import type { Data, FrontmatterContent, PhrasingContent, Root, RootContent } from 'mdast'; + +// -------------------------------------------------------------------------------- +// Test +// -------------------------------------------------------------------------------- + +// -------------------------------------------------------------------------------- +// #region Json (type) + +({}) as Json satisfies Extract; +({}) as Json satisfies Extract; + +({}) as Json satisfies ESLintMarkdownJson; + +// #endregion Json (type) +// -------------------------------------------------------------------------------- + +// -------------------------------------------------------------------------------- +// #region JsonData (type) + +({}) as JsonData satisfies Data; + +({}) as JsonData satisfies ESLintMarkdownJsonData; + +// #endregion JsonData (type) +// -------------------------------------------------------------------------------- + +// -------------------------------------------------------------------------------- +// #region satteri mdast augmentation (type) + +'toml' satisfies RootContent['type']; +'math' satisfies RootContent['type']; +'inlineMath' satisfies PhrasingContent['type']; + +// #endregion satteri mdast augmentation (type) +// -------------------------------------------------------------------------------- + +// -------------------------------------------------------------------------------- +// #region ParseOptions (type) + +let parseOptions: ParseOptions; + +parseOptions = {}; +parseOptions = { + mode: 'commonmark', +}; +parseOptions = { + mode: 'gfm', +}; +parseOptions = { + frontmatter: false, +}; +parseOptions = { + frontmatter: 'yaml', +}; +parseOptions = { + frontmatter: 'toml', +}; +parseOptions = { + frontmatter: 'json', +}; +parseOptions = { + math: false, +}; +parseOptions = { + math: true, +}; +parseOptions = { + mode: 'gfm', + frontmatter: 'json', + math: true, +}; + +// @ts-expect-error -- `mode` only accepts supported Markdown modes. +parseOptions = { mode: 'mdx' }; +// @ts-expect-error -- `frontmatter` does not accept `true`. +parseOptions = { frontmatter: true }; +// @ts-expect-error -- `frontmatter` only accepts supported front matter formats. +parseOptions = { frontmatter: 'xml' }; +// @ts-expect-error -- `math` must be a boolean. +parseOptions = { math: 'true' }; + +({}) as MarkdownLanguageOptions['frontmatter'] satisfies ParseOptions['frontmatter']; + +// #endregion ParseOptions (type) +// -------------------------------------------------------------------------------- + +// -------------------------------------------------------------------------------- +// #region meta (named export) + +meta.name satisfies string; +meta.name satisfies '@eslint-markdown/parser'; + +meta.version satisfies string; + +// #endregion meta (named export) +// -------------------------------------------------------------------------------- + +// -------------------------------------------------------------------------------- +// #region parse (named export) + +parse satisfies Function; +parse satisfies (text: string, options?: ParseOptions) => Root; + +// #endregion parse (named export) +// -------------------------------------------------------------------------------- + +// -------------------------------------------------------------------------------- +// #region parser (default export) + +parser satisfies object; +parser.meta satisfies typeof meta; +parser.parse satisfies typeof parse; + +// #endregion parser (default export) +// -------------------------------------------------------------------------------- diff --git a/packages/parser/src/index.test.ts b/packages/parser/src/index.test.ts new file mode 100644 index 00000000..74bfc6fc --- /dev/null +++ b/packages/parser/src/index.test.ts @@ -0,0 +1,224 @@ +/** + * @fileoverview Tests for `index.ts`. + */ + +// -------------------------------------------------------------------------------- +// Import +// -------------------------------------------------------------------------------- + +import { assert, describe, it } from 'vitest'; +import parser, { meta, parse } from './index.js'; +import pkg from '../package.json' with { type: 'json' }; + +// -------------------------------------------------------------------------------- +// Test +// -------------------------------------------------------------------------------- + +describe('index', () => { + describe('named export', () => { + describe('meta', () => { + it('should have a `name` property', () => { + assert.strictEqual(typeof meta.name, 'string'); + assert.strictEqual(meta.name, pkg.name); + }); + + it('should have a `version` property', () => { + assert.strictEqual(typeof meta.version, 'string'); + assert.strictEqual(meta.version, pkg.version); + }); + }); + + describe('parse', () => { + it('should be a function', () => { + assert.strictEqual(typeof parse, 'function'); + }); + + it('should parse Markdown as CommonMark by default', () => { + const ast = parse('~hi~'); + + assert(ast.type === 'root'); + assert.strictEqual(ast.children.length, 1); + + const paragraph = ast.children[0]; + + assert(paragraph.type === 'paragraph'); + assert.strictEqual(paragraph.children.length, 1); + + const text = paragraph.children[0]; + + assert(text.type === 'text'); + assert.strictEqual(text.value, '~hi~'); + }); + + it("should parse Markdown as CommonMark when `mode` is set to `'commonmark'`", () => { + const ast = parse('~hi~', { mode: 'commonmark' }); + + assert(ast.type === 'root'); + assert.strictEqual(ast.children.length, 1); + + const paragraph = ast.children[0]; + + assert(paragraph.type === 'paragraph'); + assert.strictEqual(paragraph.children.length, 1); + + const text = paragraph.children[0]; + + assert(text.type === 'text'); + assert.strictEqual(text.value, '~hi~'); + }); + + it("should parse Markdown as GFM when `mode` is set to `'gfm'`", () => { + const ast = parse('~hi~', { mode: 'gfm' }); + + assert(ast.type === 'root'); + assert.strictEqual(ast.children.length, 1); + + const paragraph = ast.children[0]; + + assert(paragraph.type === 'paragraph'); + assert.strictEqual(paragraph.children.length, 1); + + const del = paragraph.children[0]; + + assert(del.type === 'delete'); + assert.strictEqual(del.children.length, 1); + + const text = del.children[0]; + + assert(text.type === 'text'); + assert.strictEqual(text.value, 'hi'); + }); + + it('should not parse frontmatter when `frontmatter` is set to `false`', () => { + const ast = parse('---\ntitle: Example\n---\n\n# Hello', { + frontmatter: false, + }); + + assert(ast.type === 'root'); + + const firstChild = ast.children[0]; + + assert(firstChild.type === 'thematicBreak'); + }); + + it("should parse YAML frontmatter when `frontmatter` is set to `'yaml'`", () => { + const ast = parse('---\ntitle: Example\n---\n\n# Hello', { + frontmatter: 'yaml', + }); + + assert(ast.type === 'root'); + + const frontmatter = ast.children[0]; + + assert(frontmatter.type === 'yaml'); + assert.strictEqual(frontmatter.value, 'title: Example'); + }); + + it("should parse TOML frontmatter when `frontmatter` is set to `'toml'`", () => { + const ast = parse('+++\ntitle = "Example"\n+++\n\n# Hello', { + frontmatter: 'toml', + }); + + assert(ast.type === 'root'); + + const frontmatter = ast.children[0]; + + assert(frontmatter.type === 'toml'); + assert.strictEqual(frontmatter.value, 'title = "Example"'); + }); + + it("should parse JSON frontmatter when `frontmatter` is set to `'json'`", () => { + const ast = parse('---\n{"title": "Example"}\n---\n\n# Hello', { + frontmatter: 'json', + }); + + assert(ast.type === 'root'); + + const frontmatter = ast.children[0]; + + assert(frontmatter.type === 'json'); + assert.strictEqual(frontmatter.value, '{"title": "Example"}'); + }); + + it("should not parse TOML frontmatter when `frontmatter` is set to `'yaml'`", () => { + const ast = parse('+++\ntitle = "Example"\n+++', { + frontmatter: 'yaml', + }); + + assert(ast.type === 'root'); + + const firstChild = ast.children[0]; + + assert(firstChild.type === 'paragraph'); + }); + + it("should not parse YAML frontmatter when `frontmatter` is set to `'toml'`", () => { + const ast = parse('---\ntitle: Example\n---', { + frontmatter: 'toml', + }); + + assert(ast.type === 'root'); + + const firstChild = ast.children[0]; + + assert(firstChild.type === 'thematicBreak'); + }); + + it("should not parse TOML frontmatter when `frontmatter` is set to `'json'`", () => { + const ast = parse('+++\ntitle = "Example"\n+++', { + frontmatter: 'json', + }); + + assert(ast.type === 'root'); + + const firstChild = ast.children[0]; + + assert(firstChild.type === 'paragraph'); + }); + + it("should parse an empty document when `frontmatter` is set to `'json'`", () => { + const ast = parse('', { frontmatter: 'json' }); + + assert(ast.type === 'root'); + assert.strictEqual(ast.children.length, 0); + }); + + it('should parse inline and block math when `math` is set to `true`', () => { + const ast = parse('Inline $x$ and block:\n\n$$\ny = 2\n$$', { math: true }); + + assert(ast.type === 'root'); + + const paragraph = ast.children[0]; + + assert(paragraph.type === 'paragraph'); + assert.strictEqual(paragraph.children.length, 3); + + const inlineMath = paragraph.children[1]; + + assert(inlineMath.type === 'inlineMath'); + assert.strictEqual(inlineMath.value, 'x'); + + const math = ast.children[1]; + + assert(math.type === 'math'); + assert.strictEqual(math.value, 'y = 2'); + }); + }); + }); + + describe('default export', () => { + it('should have a `meta` property', () => { + assert.strictEqual(typeof parser.meta, 'object'); + + assert.strictEqual(typeof parser.meta.name, 'string'); + assert.strictEqual(parser.meta.name, pkg.name); + + assert.strictEqual(typeof parser.meta.version, 'string'); + assert.strictEqual(parser.meta.version, pkg.version); + }); + + it('should have a `parse` property', () => { + assert.strictEqual(typeof parser.parse, 'function'); + }); + }); +}); diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts new file mode 100644 index 00000000..4bdf3148 --- /dev/null +++ b/packages/parser/src/index.ts @@ -0,0 +1,168 @@ +/** + * @fileoverview Entry file for the `@eslint-markdown/parser` package. + * @see https://eslint.org/docs/latest/use/configure/parser + * @see https://eslint.org/docs/latest/extend/custom-parsers + */ + +// -------------------------------------------------------------------------------- +// Reference Directive +// -------------------------------------------------------------------------------- + +/// + +// -------------------------------------------------------------------------------- +// Import +// -------------------------------------------------------------------------------- + +import type { Data, Literal, Root } from 'mdast'; +import { markdownToMdast } from 'satteri'; +import pkg from '../package.json' with { type: 'json' }; + +// -------------------------------------------------------------------------------- +// Typedef +// -------------------------------------------------------------------------------- + +/** + * Markdown JSON. + */ +export interface Json extends Literal { + /** + * Node type of mdast JSON. + */ + type: 'json'; + /** + * Data associated with the mdast JSON. + */ + data?: JsonData | undefined; +} + +/** + * Info associated with mdast JSON nodes by the ecosystem. + */ +export type JsonData = Data; + +/** + * Registers mdast JSON nodes as valid front matter and root content. + */ +declare module 'mdast' { + interface FrontmatterContentMap { + json: Json; + } + + interface RootContentMap { + json: Json; + } +} + +/** + * The options for parsing markdown. + */ +export interface ParseOptions { + /** + * The options for parsing markdown. + * @default 'commonmark' + */ + mode?: 'commonmark' | 'gfm'; + + /** + * The options for parsing frontmatter. + * @default false + */ + frontmatter?: false | 'yaml' | 'toml' | 'json'; + + /** + * The options for parsing math. + * @default false + */ + math?: boolean; +} + +// -------------------------------------------------------------------------------- +// Named Export +// -------------------------------------------------------------------------------- + +/** + * Metadata describing the parser. + */ +export const meta = { + /** + * The name of the parser. + */ + name: '@eslint-markdown/parser', + + /** + * The version of the parser. + */ + version: pkg.version, +} as const; + +/** + * Parse Markdown source text into an mdast syntax tree at blazing-fast speed. + * + * @param text The Markdown source text to parse. + * @param options The parsing options. + * @returns The mdast syntax tree. + * @example + * ```js + * import { parse } from '@eslint-markdown/parser'; + * + * const ast = parse('# Hello, world!', { + * mode: 'commonmark', + * frontmatter: false, + * math: false, + * }); + * ``` + */ +export function parse( + text: string, + { mode = 'commonmark', frontmatter = false, math = false }: ParseOptions = {}, +): Root { + /* + * ESLint removes the BOM before calling the parser, so the BOM does not need to + * be handled here and `startsWith()` is safe to use. + * See: https://github.com/eslint/markdown/blob/v8.0.3/src/language/markdown-language.js#L209-L210 + * + * We can also remove this logic once the following issue is resolved: + * https://github.com/bruits/satteri/issues/194 + */ + const frontmatterEnabled = + (frontmatter === 'toml' && text.startsWith('+++')) || + ((frontmatter === 'yaml' || frontmatter === 'json') && text.startsWith('---')); + + const ast = markdownToMdast(text, { + features: { + gfm: mode === 'gfm', + frontmatter: frontmatterEnabled, + math, + }, + }); + + /* + * `satteri` does not support JSON front matter. Because JSON and YAML front + * matter both use `---` delimiters, we can reuse the YAML front matter handling. + * + * To ensure compatibility with `@eslint/markdown`, convert `yaml` node to a + * `json` node when `frontmatter` is set to `'json'`. + * + * Front matter can only appear at the beginning of a document, so we can safely + * assume that the root node's first child is the front matter node. + */ + if (frontmatter === 'json' && ast.type === 'root' && ast.children[0]?.type === 'yaml') { + ast.children[0] = { + ...ast.children[0], + type: 'json', + }; + } + + // TODO: Remove `as Root` when https://github.com/bruits/satteri/issues/191 is resolved. + return ast as Root; +} + +// -------------------------------------------------------------------------------- +// Default Export +// -------------------------------------------------------------------------------- + +export default { + meta, + parse, +}; diff --git a/packages/parser/tsconfig.build.json b/packages/parser/tsconfig.build.json new file mode 100644 index 00000000..90002812 --- /dev/null +++ b/packages/parser/tsconfig.build.json @@ -0,0 +1,35 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.js", + "src/**/*.mjs", + "src/**/*.cjs", + "src/**/*.jsx", + "src/**/*.ts", + "src/**/*.mts", + "src/**/*.cts", + "src/**/*.tsx" + ], + "exclude": [ + "src/**/*.test.js", + "src/**/*.test.mjs", + "src/**/*.test.cjs", + "src/**/*.test.ts", + "src/**/*.test.mts", + "src/**/*.test.cts", + "src/**/*.test-d.ts", + "src/**/*.test-d.mts", + "src/**/*.test-d.cts", + "src/tests/**/*.js", + "src/tests/**/*.ts", + "**/fixtures/**" + ], + "compilerOptions": { + /* Emit */ + "emitDeclarationOnly": false, + "outDir": "build", + + /* Projects */ + "tsBuildInfoFile": "${configDir}/.tsbuildinfo/tsconfig.build.tsbuildinfo" + } +} diff --git a/packages/parser/tsconfig.json b/packages/parser/tsconfig.json new file mode 100644 index 00000000..decbcf27 --- /dev/null +++ b/packages/parser/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.build.json" + }, + { + "path": "./tsconfig.test-d.json" + }, + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/packages/parser/tsconfig.test-d.json b/packages/parser/tsconfig.test-d.json new file mode 100644 index 00000000..93263822 --- /dev/null +++ b/packages/parser/tsconfig.test-d.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.test-d.ts", "src/**/*.test-d.mts", "src/**/*.test-d.cts"], + "exclude": [], + "references": [ + { + "path": "./tsconfig.build.json" + } + ], + "compilerOptions": { + /* Type Checking */ + "noUnusedLocals": false, // Turning it off since it's a test. + "noUnusedParameters": false, // Turning it off since it's a test. + + /* Emit */ + "noEmit": true, + + /* Projects */ + "tsBuildInfoFile": "${configDir}/.tsbuildinfo/tsconfig.test-d.tsbuildinfo" + } +} diff --git a/packages/parser/tsconfig.test.json b/packages/parser/tsconfig.test.json new file mode 100644 index 00000000..99668bf1 --- /dev/null +++ b/packages/parser/tsconfig.test.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.test.js", + "src/**/*.test.mjs", + "src/**/*.test.cjs", + "src/**/*.test.ts", + "src/**/*.test.mts", + "src/**/*.test.cts" + ], + "exclude": ["**/fixtures/**"], + "references": [ + { + "path": "./tsconfig.build.json" + } + ], + "compilerOptions": { + /* Emit */ + "noEmit": true, + + /* Projects */ + "tsBuildInfoFile": "${configDir}/.tsbuildinfo/tsconfig.test.tsbuildinfo" + } +} diff --git a/packages/parser/vitest.config.js b/packages/parser/vitest.config.js new file mode 100644 index 00000000..87d1c9bc --- /dev/null +++ b/packages/parser/vitest.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.{js,mjs,cjs,ts,mts,cts}'], + + // Vitest's built-in type checking is still experimental, so we intentionally keep it disabled. + // I prefer the native TypeScript type-checking flow and rely on the repo's project references + // for better performance, familiar behavior, and more accurate diagnostics. + typecheck: { + enabled: false, // Set to true if you want to enable type checking during tests. + include: ['src/**/*.test-d.{ts,mts,cts,tsx}'], + }, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index e89979be..c43caf5a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,9 @@ "references": [ { "path": "./packages/eslint-markdown/tsconfig.json" + }, + { + "path": "./packages/parser/tsconfig.json" } ] } diff --git a/website/.vitepress/config.js b/website/.vitepress/config.js index 32731595..aa01bcd9 100644 --- a/website/.vitepress/config.js +++ b/website/.vitepress/config.js @@ -222,6 +222,10 @@ export default defineConfig({ text: 'eslint-markdown', link: 'eslint-markdown', }, + { + text: '@eslint-markdown/parser', + link: 'parser', + }, ], }, diff --git a/website/docs/packages/parser.md b/website/docs/packages/parser.md new file mode 100644 index 00000000..ed986a69 --- /dev/null +++ b/website/docs/packages/parser.md @@ -0,0 +1,64 @@ +--- +description: "Package documentation and configuration options for the `@eslint-markdown/parser` Markdown parser." +--- + +# `@eslint-markdown/parser` + +> A blazing fast Markdown parser for ESLint. + +`@eslint-markdown/parser` parses Markdown source text into an [mdast](https://github.com/syntax-tree/mdast#readme) syntax tree. It supports CommonMark and GitHub Flavored Markdown (GFM), with optional support for YAML, TOML, or JSON front matter and math syntax. + +## Usage + +The default export provides the parser metadata and the `parse` function. The same values are also available as named exports. + +```js +import parser from '@eslint-markdown/parser'; + +const ast = parser.parse('# Hello, world!', { + mode: 'gfm', + frontmatter: 'yaml', + math: true, +}); +``` + +## Configuration + +Pass parser options as the second argument to `parse`: + +```ts +interface ParseOptions { + mode?: 'commonmark' | 'gfm'; + frontmatter?: false | 'yaml' | 'toml' | 'json'; + math?: boolean; +} +``` + +### `mode` + +> Default: `'commonmark'` + +Controls the Markdown dialect. Use `'commonmark'` for [CommonMark](https://commonmark.org/) or `'gfm'` for [GitHub Flavored Markdown](https://github.github.com/gfm/). + +### `frontmatter` + +> Default: `false` + +Controls front matter parsing. Use `false` to disable front matter, `'yaml'` or `'json'` for front matter delimited by `---`, or `'toml'` for front matter delimited by `+++`. + +### `math` + +> Default: `false` + +Enables inline and block math syntax when set to `true`. + +## Exports + +| Name | Description | +| :------------- | :-------------------------------------------------------------------------- | +| `default` | Parser object containing `meta` and `parse`. | +| `meta` | Object containing the package `name` and `version`. | +| `parse` | Function that parses Markdown source text into an mdast `Root` syntax tree. | +| `Json` | TypeScript type for JSON front matter nodes. | +| `JsonData` | TypeScript type for data associated with JSON front matter nodes. | +| `ParseOptions` | TypeScript type for the options accepted by `parse`. |