├── .prettierignore ├── .npmrc ├── dev ├── index.js ├── index.d.ts └── lib │ └── syntax.js ├── .gitignore ├── .editorconfig ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── tsconfig.json ├── license ├── package.json ├── readme.md └── test └── index.js /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.md 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /dev/index.js: -------------------------------------------------------------------------------- 1 | // Note: more types exposed from `index.d.ts`. 2 | export {mdxjsEsm} from './lib/syntax.js' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | /lib/ 4 | /index.js 5 | *.d.ts 6 | *.log 7 | .DS_Store 8 | yarn.lock 9 | !dev/index.d.ts 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | name: bb 2 | on: 3 | issues: 4 | types: [opened, reopened, edited, closed, labeled, unlabeled] 5 | pull_request_target: 6 | types: [opened, reopened, edited, closed, labeled, unlabeled] 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: unifiedjs/beep-boop-beta@main 12 | with: 13 | repo-token: ${{secrets.GITHUB_TOKEN}} 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "exactOptionalPropertyTypes": true, 8 | "lib": ["es2022"], 9 | "module": "node16", 10 | // Glob error :'( 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "target": "es2022" 14 | }, 15 | "exclude": ["coverage/", "lib/", "node_modules/", "index.js"], 16 | "include": ["**/*.js", "dev/index.d.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | - pull_request 4 | - push 5 | jobs: 6 | main: 7 | name: ${{matrix.node}} 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: ${{matrix.node}} 14 | - run: npm install 15 | - run: npm test 16 | - uses: codecov/codecov-action@v4 17 | strategy: 18 | matrix: 19 | node: 20 | - lts/hydrogen 21 | - node 22 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2020 Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /dev/index.d.ts: -------------------------------------------------------------------------------- 1 | import type {Program} from 'estree' 2 | import type {Acorn, AcornOptions} from 'micromark-util-events-to-acorn' 3 | 4 | export {mdxjsEsm} from './lib/syntax.js' 5 | 6 | /** 7 | * Configuration (required). 8 | */ 9 | export interface Options { 10 | /** 11 | * Acorn parser to use (required). 12 | */ 13 | acorn: Acorn 14 | /** 15 | * Configuration for acorn (default: `{ecmaVersion: 2024, locations: true, 16 | * sourceType: 'module'}`); all fields except `locations` can be set. 17 | */ 18 | acornOptions?: AcornOptions | null | undefined 19 | /** 20 | * Whether to add `estree` fields to tokens with results from acorn 21 | * (default: `false`). 22 | */ 23 | addResult?: boolean | null | undefined 24 | } 25 | 26 | /** 27 | * Augment types. 28 | */ 29 | declare module 'micromark-util-types' { 30 | /** 31 | * Parse context. 32 | */ 33 | interface ParseContext { 34 | definedModuleSpecifiers?: Array 35 | } 36 | 37 | /** 38 | * Token. 39 | */ 40 | interface Token { 41 | estree?: Program 42 | } 43 | 44 | /** 45 | * Token types. 46 | */ 47 | interface TokenTypeMap { 48 | mdxjsEsm: 'mdxjsEsm' 49 | mdxjsEsmData: 'mdxjsEsmData' 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "micromark-extension-mdxjs-esm", 3 | "version": "3.0.0", 4 | "description": "micromark extension to support MDX JS import/exports", 5 | "license": "MIT", 6 | "keywords": [ 7 | "micromark", 8 | "micromark-extension", 9 | "mdx", 10 | "mdxjs", 11 | "import", 12 | "export", 13 | "js", 14 | "javascript", 15 | "es", 16 | "ecmascript", 17 | "markdown", 18 | "unified" 19 | ], 20 | "repository": "micromark/micromark-extension-mdxjs-esm", 21 | "bugs": "https://github.com/micromark/micromark-extension-mdxjs-esm/issues", 22 | "funding": { 23 | "type": "opencollective", 24 | "url": "https://opencollective.com/unified" 25 | }, 26 | "author": "Titus Wormer (https://wooorm.com)", 27 | "contributors": [ 28 | "Titus Wormer (https://wooorm.com)" 29 | ], 30 | "sideEffects": false, 31 | "type": "module", 32 | "exports": { 33 | "development": "./dev/index.js", 34 | "default": "./index.js" 35 | }, 36 | "files": [ 37 | "dev/", 38 | "lib/", 39 | "index.d.ts", 40 | "index.js" 41 | ], 42 | "dependencies": { 43 | "@types/estree": "^1.0.0", 44 | "devlop": "^1.0.0", 45 | "micromark-core-commonmark": "^2.0.0", 46 | "micromark-util-character": "^2.0.0", 47 | "micromark-util-events-to-acorn": "^2.0.0", 48 | "micromark-util-symbol": "^2.0.0", 49 | "micromark-util-types": "^2.0.0", 50 | "unist-util-position-from-estree": "^2.0.0", 51 | "vfile-message": "^4.0.0" 52 | }, 53 | "devDependencies": { 54 | "@types/acorn": "^4.0.0", 55 | "@types/node": "^22.0.0", 56 | "acorn": "^8.0.0", 57 | "acorn-jsx": "^5.0.0", 58 | "c8": "^10.0.0", 59 | "micromark": "^4.0.0", 60 | "micromark-build": "^2.0.0", 61 | "prettier": "^3.0.0", 62 | "remark-cli": "^12.0.0", 63 | "remark-preset-wooorm": "^10.0.0", 64 | "type-coverage": "^2.0.0", 65 | "typescript": "^5.0.0", 66 | "xo": "^0.59.0" 67 | }, 68 | "scripts": { 69 | "prepack": "npm run build && npm run format", 70 | "build": "tsc --build --clean && tsc --build && type-coverage && micromark-build", 71 | "format": "remark . -qfo && prettier . -w --log-level warn && xo --fix", 72 | "test-api-prod": "node --conditions production test/index.js", 73 | "test-api-dev": "node --conditions development test/index.js", 74 | "test-api": "npm run test-api-dev && npm run test-api-prod", 75 | "test-coverage": "c8 --100 --reporter lcov npm run test-api", 76 | "test": "npm run build && npm run format && npm run test-coverage" 77 | }, 78 | "prettier": { 79 | "bracketSpacing": false, 80 | "semi": false, 81 | "singleQuote": true, 82 | "tabWidth": 2, 83 | "trailingComma": "none", 84 | "useTabs": false 85 | }, 86 | "remarkConfig": { 87 | "plugins": [ 88 | "remark-preset-wooorm" 89 | ] 90 | }, 91 | "typeCoverage": { 92 | "atLeast": 100, 93 | "detail": true, 94 | "ignoreCatch": true, 95 | "strict": true 96 | }, 97 | "xo": { 98 | "prettier": true, 99 | "rules": { 100 | "logical-assignment-operators": "off", 101 | "n/file-extension-in-import": "off", 102 | "unicorn/no-this-assignment": "off" 103 | }, 104 | "overrides": [ 105 | { 106 | "files": [ 107 | "**/*.d.ts" 108 | ], 109 | "rules": { 110 | "@typescript-eslint/array-type": [ 111 | "error", 112 | { 113 | "default": "generic" 114 | } 115 | ], 116 | "@typescript-eslint/ban-types": [ 117 | "error", 118 | { 119 | "extendDefaults": true 120 | } 121 | ], 122 | "@typescript-eslint/consistent-type-definitions": [ 123 | "error", 124 | "interface" 125 | ] 126 | } 127 | }, 128 | { 129 | "files": "test/**/*.js", 130 | "rules": { 131 | "no-await-in-loop": "off" 132 | } 133 | } 134 | ] 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /dev/lib/syntax.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Extension, State, TokenizeContext, Tokenizer} from 'micromark-util-types' 3 | * @import {Options} from 'micromark-extension-mdxjs-esm' 4 | */ 5 | 6 | import {ok as assert} from 'devlop' 7 | import {blankLine} from 'micromark-core-commonmark' 8 | import {asciiAlpha, markdownLineEnding} from 'micromark-util-character' 9 | import {eventsToAcorn} from 'micromark-util-events-to-acorn' 10 | import {codes, types} from 'micromark-util-symbol' 11 | import {positionFromEstree} from 'unist-util-position-from-estree' 12 | import {VFileMessage} from 'vfile-message' 13 | 14 | const blankLineBefore = {tokenize: tokenizeNextBlank, partial: true} 15 | 16 | const trouble = 'https://github.com/micromark/micromark-extension-mdxjs-esm' 17 | 18 | const allowedAcornTypes = new Set([ 19 | 'ExportAllDeclaration', 20 | 'ExportDefaultDeclaration', 21 | 'ExportNamedDeclaration', 22 | 'ImportDeclaration' 23 | ]) 24 | 25 | /** 26 | * Create an extension for `micromark` to enable MDX ESM syntax. 27 | * 28 | * @param {Options} options 29 | * Configuration (required). 30 | * @returns {Extension} 31 | * Extension for `micromark` that can be passed in `extensions` to enable MDX 32 | * ESM syntax. 33 | */ 34 | export function mdxjsEsm(options) { 35 | const exportImportConstruct = {tokenize: tokenizeExportImport, concrete: true} 36 | 37 | if (!options || !options.acorn || !options.acorn.parse) { 38 | throw new Error('Expected an `acorn` instance passed in as `options.acorn`') 39 | } 40 | 41 | const acorn = options.acorn 42 | const acornOptions = Object.assign( 43 | {ecmaVersion: 2024, sourceType: 'module'}, 44 | options.acornOptions, 45 | {locations: true} 46 | ) 47 | 48 | return { 49 | flow: { 50 | [codes.lowercaseE]: exportImportConstruct, 51 | [codes.lowercaseI]: exportImportConstruct 52 | } 53 | } 54 | 55 | /** 56 | * @this {TokenizeContext} 57 | * @type {Tokenizer} 58 | */ 59 | function tokenizeExportImport(effects, ok, nok) { 60 | const self = this 61 | const definedModuleSpecifiers = 62 | self.parser.definedModuleSpecifiers || 63 | (self.parser.definedModuleSpecifiers = []) 64 | const eventStart = this.events.length + 1 // Add the main `mdxjsEsm` token 65 | let buffer = '' 66 | 67 | return self.interrupt ? nok : start 68 | 69 | /** 70 | * Start of MDX ESM. 71 | * 72 | * ```markdown 73 | * > | import a from 'b' 74 | * ^ 75 | * ``` 76 | * 77 | * @type {State} 78 | */ 79 | function start(code) { 80 | assert( 81 | code === codes.lowercaseE || code === codes.lowercaseI, 82 | 'expected `e` or `i`' 83 | ) 84 | 85 | // Only at the start of a line, not at whitespace or in a container. 86 | if (self.now().column > 1) return nok(code) 87 | 88 | effects.enter('mdxjsEsm') 89 | effects.enter('mdxjsEsmData') 90 | effects.consume(code) 91 | // eslint-disable-next-line unicorn/prefer-code-point 92 | buffer += String.fromCharCode(code) 93 | return word 94 | } 95 | 96 | /** 97 | * In keyword. 98 | * 99 | * ```markdown 100 | * > | import a from 'b' 101 | * ^^^^^^ 102 | * ``` 103 | * 104 | * @type {State} 105 | */ 106 | function word(code) { 107 | if (asciiAlpha(code)) { 108 | effects.consume(code) 109 | // @ts-expect-error: definitely a number. 110 | // eslint-disable-next-line unicorn/prefer-code-point 111 | buffer += String.fromCharCode(code) 112 | return word 113 | } 114 | 115 | if ( 116 | (buffer === 'import' || buffer === 'export') && 117 | code === codes.space 118 | ) { 119 | effects.consume(code) 120 | return inside 121 | } 122 | 123 | return nok(code) 124 | } 125 | 126 | /** 127 | * In data. 128 | * 129 | * ```markdown 130 | * > | import a from 'b' 131 | * ^ 132 | * ``` 133 | * 134 | * @type {State} 135 | */ 136 | function inside(code) { 137 | if (code === codes.eof || markdownLineEnding(code)) { 138 | effects.exit('mdxjsEsmData') 139 | return lineStart(code) 140 | } 141 | 142 | effects.consume(code) 143 | return inside 144 | } 145 | 146 | /** 147 | * At line ending. 148 | * 149 | * ```markdown 150 | * > | import a from 'b' 151 | * ^ 152 | * | export {a} 153 | * ``` 154 | * 155 | * @type {State} 156 | */ 157 | function lineStart(code) { 158 | if (code === codes.eof) { 159 | return atEnd(code) 160 | } 161 | 162 | if (markdownLineEnding(code)) { 163 | return effects.check(blankLineBefore, atEnd, continuationStart)(code) 164 | } 165 | 166 | effects.enter('mdxjsEsmData') 167 | return inside(code) 168 | } 169 | 170 | /** 171 | * At line ending that continues. 172 | * 173 | * ```markdown 174 | * > | import a from 'b' 175 | * ^ 176 | * | export {a} 177 | * ``` 178 | * 179 | * @type {State} 180 | */ 181 | function continuationStart(code) { 182 | assert(markdownLineEnding(code)) 183 | effects.enter(types.lineEnding) 184 | effects.consume(code) 185 | effects.exit(types.lineEnding) 186 | return lineStart 187 | } 188 | 189 | /** 190 | * At end of line (blank or eof). 191 | * 192 | * ```markdown 193 | * > | import a from 'b' 194 | * ^ 195 | * ``` 196 | * 197 | * @type {State} 198 | */ 199 | function atEnd(code) { 200 | const result = eventsToAcorn(self.events.slice(eventStart), { 201 | acorn, 202 | acornOptions, 203 | tokenTypes: ['mdxjsEsmData'], 204 | prefix: 205 | definedModuleSpecifiers.length > 0 206 | ? 'var ' + definedModuleSpecifiers.join(',') + '\n' 207 | : '' 208 | }) 209 | 210 | if (result.error) { 211 | // There’s an error, which could be solved with more content, and there 212 | // is more content. 213 | if (code !== codes.eof && result.swallow) { 214 | return continuationStart(code) 215 | } 216 | 217 | const error = new VFileMessage( 218 | 'Could not parse import/exports with acorn', 219 | { 220 | cause: result.error, 221 | place: { 222 | line: result.error.loc.line, 223 | column: result.error.loc.column + 1, 224 | offset: result.error.pos 225 | }, 226 | ruleId: 'acorn', 227 | source: 'micromark-extension-mdxjs-esm' 228 | } 229 | ) 230 | error.url = trouble + '#could-not-parse-importexports-with-acorn' 231 | throw error 232 | } 233 | 234 | assert(result.estree, 'expected `estree` to be defined') 235 | 236 | // Remove the `VariableDeclaration`. 237 | if (definedModuleSpecifiers.length > 0) { 238 | const declaration = result.estree.body.shift() 239 | assert(declaration) 240 | assert(declaration.type === 'VariableDeclaration') 241 | } 242 | 243 | let index = -1 244 | 245 | while (++index < result.estree.body.length) { 246 | const node = result.estree.body[index] 247 | 248 | if (!allowedAcornTypes.has(node.type)) { 249 | const error = new VFileMessage( 250 | 'Unexpected `' + 251 | node.type + 252 | '` in code: only import/exports are supported', 253 | { 254 | place: positionFromEstree(node), 255 | ruleId: 'non-esm', 256 | source: 'micromark-extension-mdxjs-esm' 257 | } 258 | ) 259 | error.url = 260 | trouble + 261 | '#unexpected-type-in-code-only-importexports-are-supported' 262 | throw error 263 | } 264 | 265 | // Otherwise, when we’re not interrupting (hacky, because `interrupt` is 266 | // used to parse containers and “sniff” if this is ESM), collect all the 267 | // local values that are imported. 268 | if (node.type === 'ImportDeclaration' && !self.interrupt) { 269 | let index = -1 270 | 271 | while (++index < node.specifiers.length) { 272 | const specifier = node.specifiers[index] 273 | definedModuleSpecifiers.push(specifier.local.name) 274 | } 275 | } 276 | } 277 | 278 | Object.assign( 279 | effects.exit('mdxjsEsm'), 280 | options.addResult ? {estree: result.estree} : undefined 281 | ) 282 | 283 | return ok(code) 284 | } 285 | } 286 | } 287 | 288 | /** @type {Tokenizer} */ 289 | function tokenizeNextBlank(effects, ok, nok) { 290 | return start 291 | 292 | /** 293 | * @type {State} 294 | */ 295 | function start(code) { 296 | assert(markdownLineEnding(code)) 297 | effects.enter(types.lineEndingBlank) 298 | effects.consume(code) 299 | effects.exit(types.lineEndingBlank) 300 | return effects.attempt(blankLine, ok, nok) 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # micromark-extension-mdxjs-esm 2 | 3 | [![Build][build-badge]][build] 4 | [![Coverage][coverage-badge]][coverage] 5 | [![Downloads][downloads-badge]][downloads] 6 | [![Size][size-badge]][size] 7 | [![Sponsors][sponsors-badge]][collective] 8 | [![Backers][backers-badge]][collective] 9 | [![Chat][chat-badge]][chat] 10 | 11 | [micromark][] extension to support [MDX][mdxjs] ESM (`import x from 'y'`). 12 | 13 | ## Contents 14 | 15 | * [What is this?](#what-is-this) 16 | * [When to use this](#when-to-use-this) 17 | * [Install](#install) 18 | * [Use](#use) 19 | * [API](#api) 20 | * [`mdxjsEsm(options)`](#mdxjsesmoptions) 21 | * [`Options`](#options) 22 | * [Authoring](#authoring) 23 | * [Syntax](#syntax) 24 | * [Errors](#errors) 25 | * [Could not parse import/exports with acorn](#could-not-parse-importexports-with-acorn) 26 | * [Unexpected `$type` in code: only import/exports are supported](#unexpected-type-in-code-only-importexports-are-supported) 27 | * [Tokens](#tokens) 28 | * [Types](#types) 29 | * [Compatibility](#compatibility) 30 | * [Security](#security) 31 | * [Related](#related) 32 | * [Contribute](#contribute) 33 | * [License](#license) 34 | 35 | ## What is this? 36 | 37 | This package contains an extension that adds support for the ESM syntax enabled 38 | by [MDX][mdxjs] to [`micromark`][micromark]. 39 | These extensions are used inside MDX. 40 | It matches how imports and exports work in JavaScript through acorn. 41 | 42 | This package is aware of JavaScript syntax. 43 | 44 | ## When to use this 45 | 46 | This project is useful when you want to support ESM in markdown. 47 | 48 | You can use this extension when you are working with [`micromark`][micromark]. 49 | To support all MDX features, use 50 | [`micromark-extension-mdxjs`][micromark-extension-mdxjs] instead. 51 | 52 | When you need a syntax tree, combine this package with 53 | [`mdast-util-mdxjs-esm`][mdast-util-mdxjs-esm]. 54 | 55 | All these packages are used in [`remark-mdx`][remark-mdx], which focusses on 56 | making it easier to transform content by abstracting these internals away. 57 | 58 | When you are using [`mdx-js/mdx`][mdxjs], all of this is already included. 59 | 60 | ## Install 61 | 62 | This package is [ESM only][esm]. 63 | In Node.js (version 16+), install with [npm][]: 64 | 65 | ```sh 66 | npm install micromark-extension-mdxjs-esm 67 | ``` 68 | 69 | In Deno with [`esm.sh`][esmsh]: 70 | 71 | ```js 72 | import {mdxjsEsm} from 'https://esm.sh/micromark-extension-mdxjs-esm@2' 73 | ``` 74 | 75 | In browsers with [`esm.sh`][esmsh]: 76 | 77 | ```html 78 | 81 | ``` 82 | 83 | ## Use 84 | 85 | ```js 86 | import {Parser} from 'acorn' 87 | import acornJsx from 'acorn-jsx' 88 | import {micromark} from 'micromark' 89 | import {mdxjsEsm} from 'micromark-extension-mdxjs-esm' 90 | 91 | const acorn = Parser.extend(acornJsx()) 92 | 93 | const output = micromark('import a from "b"\n\n# c', { 94 | extensions: [mdxjsEsm({acorn})] 95 | }) 96 | 97 | console.log(output) 98 | ``` 99 | 100 | Yields: 101 | 102 | ```html 103 |

c

104 | ``` 105 | 106 | …which is useless: go to a syntax tree with 107 | [`mdast-util-from-markdown`][mdast-util-from-markdown] and 108 | [`mdast-util-mdxjs-esm`][mdast-util-mdxjs-esm] instead. 109 | 110 | ## API 111 | 112 | This package exports the identifier [`mdxjsEsm`][api-mdxjs-esm]. 113 | There is no default export. 114 | 115 | The export map supports the [`development` condition][development]. 116 | Run `node --conditions development module.js` to get instrumented dev code. 117 | Without this condition, production code is loaded. 118 | 119 | ### `mdxjsEsm(options)` 120 | 121 | Create an extension for `micromark` to enable MDX ESM syntax. 122 | 123 | ###### Parameters 124 | 125 | * `options` ([`Options`][api-options], required) 126 | — configuration 127 | 128 | ###### Returns 129 | 130 | Extension for `micromark` that can be passed in `extensions` to enable MDX 131 | ESM syntax ([`Extension`][micromark-extension]). 132 | 133 | ### `Options` 134 | 135 | Configuration (TypeScript type). 136 | 137 | ###### Fields 138 | 139 | * `acorn` ([`Acorn`][acorn], required) 140 | — acorn parser to use 141 | * `acornOptions` ([`AcornOptions`][acorn-options], default: 142 | `{ecmaVersion: 2024, locations: true, sourceType: 'module'}`) 143 | — configuration for acorn; all fields except `locations` can be set 144 | * `addResult` (`boolean`, default: `false`) 145 | — whether to add `estree` fields to tokens with results from acorn 146 | 147 | ## Authoring 148 | 149 | When authoring markdown with ESM, make sure to follow export and import 150 | statements with blank lines before more markdown. 151 | 152 | All valid imports and exports are supported, depending on what the given acorn 153 | instance and configuration supports. 154 | 155 | When the lowercase strings `export` or `import` are found, followed by a space, 156 | we expect JavaScript. 157 | Otherwise, like normal in markdown, we exit and it’ll end up as a paragraph. 158 | We continue parsing until we find a blank line. 159 | At that point, we parse with acorn: it if parses, we found our block. 160 | Otherwise, if parsing failed at the last character, we assume it’s a blank line 161 | in code: we continue on until the next blank line and try again. 162 | Otherwise, the acorn error is thrown. 163 | 164 | Some examples of valid export and import statements: 165 | 166 | ```mdx 167 | import a from 'b' 168 | import * as a from 'b' 169 | import {a} from 'b' 170 | import {a as b} from 'c' 171 | import a, {b as c} from 'd' 172 | import a, * as b from 'c' 173 | import 'a' 174 | 175 | export var a = '' 176 | export const a = '' 177 | export let a = '' 178 | export var a, b 179 | export var a = 'a', b = 'b' 180 | export function a() {} 181 | export class a {} 182 | export var {a} = {} 183 | export var {a: b} = {} 184 | export var [a] = [] 185 | export default a = 1 186 | export default function a() {} 187 | export default class a {} 188 | export * from 'a' 189 | export * as a from 'b' 190 | export {a} from 'b' 191 | export {a as b} from 'c' 192 | export {default} from 'b' 193 | export {default as a, b} from 'c' 194 | 195 | {/* Blank lines are supported in expressions: */} 196 | 197 | export function a() { 198 | 199 | return 'b' 200 | 201 | } 202 | ``` 203 | 204 | ```mdx-invalid 205 | {/* A blank line must be used after import/exports: this is incorrect! */} 206 | 207 | import a from 'b' 208 | ## Hello, world! 209 | ``` 210 | 211 | ## Syntax 212 | 213 | ESM forms with the following BNF: 214 | 215 | ```abnf 216 | ; Restriction: the entire construct must be valid JavaScript. 217 | mdxEsm ::= word " " *line *(eol *line) 218 | 219 | word ::= "e" "x" "p" "o" "r" "t" | "i" "m" "p" "o" "r" "t" 220 | ``` 221 | 222 | This construct must be followed by a blank line or eof (end of file). 223 | 224 | ## Errors 225 | 226 | ### Could not parse import/exports with acorn 227 | 228 | This error occurs if acorn crashes (source: `micromark-extension-mdxjs-esm`, 229 | rule id: `acorn`). 230 | For example: 231 | 232 | ```mdx-invalid 233 | import 1/1 234 | ``` 235 | 236 | ### Unexpected `$type` in code: only import/exports are supported 237 | 238 | This error occurs when a non-ESM construct is found (source: 239 | `micromark-extension-mdxjs-esm`, rule id: `non-esm`). 240 | For example: 241 | 242 | ```mdx-invalid 243 | export var a = 1 244 | var b 245 | ``` 246 | 247 | ## Tokens 248 | 249 | An `mdxjsEsm` token is used to reflect the block of import/exports in markdown. 250 | 251 | It includes: 252 | 253 | * `lineEnding` for the `\r`, `\n`, and `\r\n` 254 | * `lineEndingBlank` for the same characters but when after potential 255 | whitespace and another line ending 256 | * `whitespace` for markdown spaces and tabs in blank lines 257 | * `mdxjsEsmData` for any character in a line of `mdxjsEsm` 258 | 259 | ## Types 260 | 261 | This package is fully typed with [TypeScript][]. 262 | It exports the additional type [`Options`][api-options]. 263 | 264 | ## Compatibility 265 | 266 | Projects maintained by the unified collective are compatible with maintained 267 | versions of Node.js. 268 | 269 | When we cut a new major release, we drop support for unmaintained versions of 270 | Node. 271 | This means we try to keep the current release line, 272 | `micromark-extension-mdxjs-esm@^2`, compatible with Node.js 16. 273 | 274 | This package works with `micromark` version `3` and later. 275 | 276 | ## Security 277 | 278 | This package is safe. 279 | 280 | ## Related 281 | 282 | * [`micromark-extension-mdxjs`][micromark-extension-mdxjs] 283 | — support all MDX syntax 284 | * [`mdast-util-mdxjs-esm`][mdast-util-mdxjs-esm] 285 | — support MDX ESM in mdast 286 | * [`remark-mdx`][remark-mdx] 287 | — support all MDX syntax in remark 288 | 289 | ## Contribute 290 | 291 | See [`contributing.md` in `micromark/.github`][contributing] for ways to get 292 | started. 293 | See [`support.md`][support] for ways to get help. 294 | 295 | This project has a [code of conduct][coc]. 296 | By interacting with this repository, organization, or community you agree to 297 | abide by its terms. 298 | 299 | ## License 300 | 301 | [MIT][license] © [Titus Wormer][author] 302 | 303 | 304 | 305 | [build-badge]: https://github.com/micromark/micromark-extension-mdxjs-esm/workflows/main/badge.svg 306 | 307 | [build]: https://github.com/micromark/micromark-extension-mdxjs-esm/actions 308 | 309 | [coverage-badge]: https://img.shields.io/codecov/c/github/micromark/micromark-extension-mdxjs-esm.svg 310 | 311 | [coverage]: https://codecov.io/github/micromark/micromark-extension-mdxjs-esm 312 | 313 | [downloads-badge]: https://img.shields.io/npm/dm/micromark-extension-mdxjs-esm.svg 314 | 315 | [downloads]: https://www.npmjs.com/package/micromark-extension-mdxjs-esm 316 | 317 | [size-badge]: https://img.shields.io/badge/dynamic/json?label=minzipped%20size&query=$.size.compressedSize&url=https://deno.bundlejs.com/?q=micromark-extension-mdxjs-esm 318 | 319 | [size]: https://bundlejs.com/?q=micromark-extension-mdxjs-esm 320 | 321 | [sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg 322 | 323 | [backers-badge]: https://opencollective.com/unified/backers/badge.svg 324 | 325 | [collective]: https://opencollective.com/unified 326 | 327 | [chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg 328 | 329 | [chat]: https://github.com/micromark/micromark/discussions 330 | 331 | [npm]: https://docs.npmjs.com/cli/install 332 | 333 | [esmsh]: https://esm.sh 334 | 335 | [license]: license 336 | 337 | [author]: https://wooorm.com 338 | 339 | [contributing]: https://github.com/micromark/.github/blob/main/contributing.md 340 | 341 | [support]: https://github.com/micromark/.github/blob/main/support.md 342 | 343 | [coc]: https://github.com/micromark/.github/blob/main/code-of-conduct.md 344 | 345 | [esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 346 | 347 | [typescript]: https://www.typescriptlang.org 348 | 349 | [development]: https://nodejs.org/api/packages.html#packages_resolving_user_conditions 350 | 351 | [micromark]: https://github.com/micromark/micromark 352 | 353 | [micromark-extension]: https://github.com/micromark/micromark#syntaxextension 354 | 355 | [micromark-extension-mdxjs]: https://github.com/micromark/micromark-extension-mdxjs 356 | 357 | [mdast-util-mdxjs-esm]: https://github.com/syntax-tree/mdast-util-mdxjs-esm 358 | 359 | [mdast-util-from-markdown]: https://github.com/syntax-tree/mdast-util-from-markdown 360 | 361 | [remark-mdx]: https://mdxjs.com/packages/remark-mdx/ 362 | 363 | [mdxjs]: https://mdxjs.com 364 | 365 | [acorn]: https://github.com/acornjs/acorn 366 | 367 | [acorn-options]: https://github.com/acornjs/acorn/blob/96c721dbf89d0ccc3a8c7f39e69ef2a6a3c04dfa/acorn/dist/acorn.d.ts#L16 368 | 369 | [api-mdxjs-esm]: #mdxjsesmoptions 370 | 371 | [api-options]: #options 372 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {HtmlExtension} from 'micromark-util-types' 3 | */ 4 | 5 | import assert from 'node:assert/strict' 6 | import test from 'node:test' 7 | import {Parser} from 'acorn' 8 | import acornJsx from 'acorn-jsx' 9 | import {micromark} from 'micromark' 10 | import {mdxjsEsm} from 'micromark-extension-mdxjs-esm' 11 | 12 | const acorn = Parser.extend(acornJsx()) 13 | 14 | /** @type {HtmlExtension} */ 15 | const html = { 16 | enter: { 17 | mdxjsEsm() { 18 | this.buffer() 19 | } 20 | }, 21 | exit: { 22 | mdxjsEsm() { 23 | this.resume() 24 | this.setData('slurpOneLineEnding', true) 25 | } 26 | } 27 | } 28 | 29 | test('mdxjsEsm', async function (t) { 30 | await t.test('should expose the public api', async function () { 31 | assert.deepEqual( 32 | Object.keys(await import('micromark-extension-mdxjs-esm')).sort(), 33 | ['mdxjsEsm'] 34 | ) 35 | }) 36 | 37 | await t.test('should throw if `acorn` is not passed in', async function () { 38 | assert.throws(function () { 39 | micromark('import a from "b"\n\nc', { 40 | // @ts-expect-error: check if this throws a runtime error. 41 | extensions: [mdxjsEsm()], 42 | htmlExtensions: [html] 43 | }) 44 | }, /Expected an `acorn` instance passed in as `options\.acorn`/) 45 | }) 46 | 47 | await t.test('should support an import', async function () { 48 | assert.equal( 49 | micromark('import a from "b"\n\nc', { 50 | extensions: [mdxjsEsm({acorn})], 51 | htmlExtensions: [html] 52 | }), 53 | '

c

' 54 | ) 55 | }) 56 | 57 | await t.test('should support an export', async function () { 58 | assert.equal( 59 | micromark('export default a\n\nb', { 60 | extensions: [mdxjsEsm({acorn})], 61 | htmlExtensions: [html] 62 | }), 63 | '

b

' 64 | ) 65 | }) 66 | 67 | await t.test( 68 | 'should not support other keywords (`impossible`)', 69 | async function () { 70 | assert.equal( 71 | micromark('impossible', { 72 | extensions: [mdxjsEsm({acorn})], 73 | htmlExtensions: [html] 74 | }), 75 | '

impossible

' 76 | ) 77 | } 78 | ) 79 | 80 | await t.test( 81 | 'should not support other keywords (`exporting`)', 82 | async function () { 83 | assert.equal( 84 | micromark('exporting', { 85 | extensions: [mdxjsEsm({acorn})], 86 | htmlExtensions: [html] 87 | }), 88 | '

exporting

' 89 | ) 90 | } 91 | ) 92 | 93 | await t.test( 94 | 'should not support a non-whitespace after the keyword', 95 | async function () { 96 | assert.equal( 97 | micromark('import.', { 98 | extensions: [mdxjsEsm({acorn})], 99 | htmlExtensions: [html] 100 | }), 101 | '

import.

' 102 | ) 103 | } 104 | ) 105 | 106 | await t.test( 107 | 'should not support a non-whitespace after the keyword (import-as-a-function)', 108 | async function () { 109 | assert.equal( 110 | micromark('import("a")', { 111 | extensions: [mdxjsEsm({acorn})], 112 | htmlExtensions: [html] 113 | }), 114 | '

import("a")

' 115 | ) 116 | } 117 | ) 118 | 119 | await t.test('should not support an indent', async function () { 120 | assert.equal( 121 | micromark(' import a from "b"\n export default c', { 122 | extensions: [mdxjsEsm({acorn})], 123 | htmlExtensions: [html] 124 | }), 125 | '

import a from "b"\nexport default c

' 126 | ) 127 | }) 128 | 129 | await t.test('should not support keywords in containers', async function () { 130 | assert.equal( 131 | micromark('- import a from "b"\n> export default c', { 132 | extensions: [mdxjsEsm({acorn})], 133 | htmlExtensions: [html] 134 | }), 135 | '
    \n
  • import a from "b"
  • \n
\n
\n

export default c

\n
' 136 | ) 137 | }) 138 | 139 | await t.test( 140 | 'should support imports and exports in the same “block”', 141 | async function () { 142 | assert.equal( 143 | micromark('import a from "b"\nexport default c', { 144 | extensions: [mdxjsEsm({acorn})], 145 | htmlExtensions: [html] 146 | }), 147 | '' 148 | ) 149 | } 150 | ) 151 | 152 | await t.test( 153 | 'should support imports and exports in separate “blocks”', 154 | async function () { 155 | assert.equal( 156 | micromark('import a from "b"\n\nexport default c', { 157 | extensions: [mdxjsEsm({acorn})], 158 | htmlExtensions: [html] 159 | }), 160 | '' 161 | ) 162 | } 163 | ) 164 | 165 | await t.test( 166 | 'should support imports and exports in between other constructs', 167 | async function () { 168 | assert.equal( 169 | micromark('a\n\nimport a from "b"\n\nb\n\nexport default c', { 170 | extensions: [mdxjsEsm({acorn})], 171 | htmlExtensions: [html] 172 | }), 173 | '

a

\n

b

\n' 174 | ) 175 | } 176 | ) 177 | 178 | await t.test( 179 | 'should not support import/exports when interrupting paragraphs', 180 | async function () { 181 | assert.equal( 182 | micromark('a\nimport a from "b"\n\nb\nexport default c', { 183 | extensions: [mdxjsEsm({acorn})], 184 | htmlExtensions: [html] 185 | }), 186 | '

a\nimport a from "b"

\n

b\nexport default c

' 187 | ) 188 | } 189 | ) 190 | 191 | await t.test('should crash on invalid import/exports (1)', async function () { 192 | assert.throws(function () { 193 | micromark('import a', {extensions: [mdxjsEsm({acorn})]}) 194 | }, /Could not parse import\/exports with acorn/) 195 | }) 196 | 197 | await t.test('should crash on invalid import/exports (2)', async function () { 198 | assert.throws(function () { 199 | micromark('import 1/1', {extensions: [mdxjsEsm({acorn})]}) 200 | }, /Could not parse import\/exports with acorn/) 201 | }) 202 | 203 | await t.test( 204 | 'should support line endings in import/exports', 205 | async function () { 206 | assert.equal( 207 | micromark('export {\n a\n} from "b"\n\nc', { 208 | extensions: [mdxjsEsm({acorn})], 209 | htmlExtensions: [html] 210 | }), 211 | '

c

' 212 | ) 213 | } 214 | ) 215 | 216 | await t.test( 217 | 'should support blank lines in import/exports', 218 | async function () { 219 | assert.equal( 220 | micromark('export {\n\n a\n\n} from "b"\n\nc', { 221 | extensions: [mdxjsEsm({acorn})], 222 | htmlExtensions: [html] 223 | }), 224 | '

c

' 225 | ) 226 | } 227 | ) 228 | 229 | await t.test( 230 | 'should crash on markdown after import/export w/o blank line', 231 | async function () { 232 | assert.throws(function () { 233 | micromark('import a from "b"\n*md*?', { 234 | extensions: [mdxjsEsm({acorn})] 235 | }) 236 | }, /Could not parse import\/exports with acorn/) 237 | } 238 | ) 239 | 240 | await t.test('should support comments in “blocks”', async function () { 241 | assert.equal( 242 | micromark('export var a = 1\n// b\n/* c */\n\nd', { 243 | extensions: [mdxjsEsm({acorn})], 244 | htmlExtensions: [html] 245 | }), 246 | '

d

' 247 | ) 248 | }) 249 | 250 | await t.test( 251 | 'should crash on other declarations in “blocks”', 252 | async function () { 253 | assert.throws(function () { 254 | micromark('export var a = 1\nvar b\n\nc', { 255 | extensions: [mdxjsEsm({acorn})] 256 | }) 257 | }, /Unexpected `VariableDeclaration` in code: only import\/exports are supported/) 258 | } 259 | ) 260 | 261 | await t.test( 262 | 'should crash on import-as-a-function with a space `import (x)`', 263 | async function () { 264 | assert.throws(function () { 265 | micromark('import ("a")\n\nb', { 266 | extensions: [mdxjsEsm({acorn})] 267 | }) 268 | }, /Unexpected `ExpressionStatement` in code: only import\/exports are supported/) 269 | } 270 | ) 271 | 272 | await t.test( 273 | 'should support a reexport from another import', 274 | async function () { 275 | assert.equal( 276 | micromark('import a from "b"\nexport {a}\n\nc', { 277 | extensions: [mdxjsEsm({acorn})], 278 | htmlExtensions: [html] 279 | }), 280 | '

c

' 281 | ) 282 | } 283 | ) 284 | 285 | await t.test( 286 | 'should support a reexport from another import w/ semicolons', 287 | async function () { 288 | assert.equal( 289 | micromark('import a from "b";\nexport {a};\n\nc', { 290 | extensions: [mdxjsEsm({acorn})], 291 | htmlExtensions: [html] 292 | }), 293 | '

c

' 294 | ) 295 | } 296 | ) 297 | 298 | await t.test( 299 | 'should support a reexport default from another import', 300 | async function () { 301 | assert.equal( 302 | micromark('import a from "b"\nexport {a as default}\n\nc', { 303 | extensions: [mdxjsEsm({acorn})], 304 | htmlExtensions: [html] 305 | }), 306 | '

c

' 307 | ) 308 | } 309 | ) 310 | 311 | await t.test( 312 | 'should support JSX if an `acorn` instance supporting it is passed in', 313 | async function () { 314 | assert.equal( 315 | micromark('export var a = () => \n\nc', { 316 | extensions: [mdxjsEsm({acorn})], 317 | htmlExtensions: [html] 318 | }), 319 | '

c

' 320 | ) 321 | } 322 | ) 323 | 324 | await t.test('should support EOF after EOL', async function () { 325 | assert.throws(function () { 326 | micromark('export {a}\n', { 327 | extensions: [mdxjsEsm({acorn})], 328 | htmlExtensions: [html] 329 | }) 330 | }, /Could not parse import\/exports with acorn/) 331 | }) 332 | 333 | await t.test('should support `acornOptions` (1)', async function () { 334 | assert.throws(function () { 335 | micromark('export var a = () => {}\n\nb', { 336 | extensions: [mdxjsEsm({acorn, acornOptions: {ecmaVersion: 5}})], 337 | htmlExtensions: [html] 338 | }) 339 | }, /Could not parse import\/exports with acorn/) 340 | }) 341 | 342 | await t.test('should support `acornOptions` (2)', async function () { 343 | assert.equal( 344 | micromark('export var a = () => {}\n\nb', { 345 | extensions: [mdxjsEsm({acorn, acornOptions: {ecmaVersion: 6}})], 346 | htmlExtensions: [html] 347 | }), 348 | '

b

' 349 | ) 350 | }) 351 | 352 | await t.test( 353 | 'should support a reexport from another esm block (1)', 354 | async function () { 355 | assert.equal( 356 | micromark('import a from "b"\n\nexport {a}\n\nc', { 357 | extensions: [mdxjsEsm({acorn})], 358 | htmlExtensions: [html] 359 | }), 360 | '

c

' 361 | ) 362 | } 363 | ) 364 | 365 | await t.test( 366 | 'should support a reexport from another esm block (2)', 367 | async function () { 368 | assert.equal( 369 | micromark('import a from "b"\n\nexport {a}\n\n# c', { 370 | extensions: [mdxjsEsm({acorn})], 371 | htmlExtensions: [html] 372 | }), 373 | '

c

' 374 | ) 375 | } 376 | ) 377 | 378 | await t.test('should support `addResult`', async function () { 379 | /** @type {HtmlExtension} */ 380 | assert.equal( 381 | micromark('export var a = () => {}\n\nb', { 382 | extensions: [mdxjsEsm({acorn, addResult: true})], 383 | htmlExtensions: [ 384 | { 385 | enter: { 386 | mdxjsEsm(token) { 387 | assert.ok( 388 | 'estree' in token, 389 | '`addResult` should add `estree` to `mdxjsEsm`' 390 | ) 391 | assert.equal( 392 | token.estree.type, 393 | 'Program', 394 | '`addResult` should add a program' 395 | ) 396 | assert(html.enter) 397 | assert(html.enter.mdxjsEsm) 398 | return html.enter.mdxjsEsm.call(this, token) 399 | } 400 | }, 401 | exit: { 402 | mdxjsEsm(token) { 403 | assert(html.exit) 404 | assert(html.exit.mdxjsEsm) 405 | return html.exit.mdxjsEsm.call(this, token) 406 | } 407 | } 408 | } 409 | ] 410 | }), 411 | '

b

' 412 | ) 413 | }) 414 | }) 415 | 416 | test('mdxjsEsm (import)', async function (t) { 417 | const data = { 418 | default: 'import a from "b"', 419 | whole: 'import * as a from "b"', 420 | destructuring: 'import {a} from "b"', 421 | 'destructuring and rename': 'import {a as b} from "c"', 422 | 'default and destructuring': 'import a, {b as c} from "d"', 423 | 'default and whole': 'import a, * as b from "c"', 424 | 'side-effects': 'import "a"' 425 | } 426 | 427 | for (const [key, value] of Object.entries(data)) { 428 | await t.test(key, async function () { 429 | assert.equal( 430 | micromark(value, { 431 | extensions: [mdxjsEsm({acorn})], 432 | htmlExtensions: [html] 433 | }), 434 | '' 435 | ) 436 | }) 437 | } 438 | }) 439 | 440 | test('mdxjsEsm (export)', async function (t) { 441 | const data = { 442 | var: 'export var a = ""', 443 | const: 'export const a = ""', 444 | let: 'export let a = ""', 445 | multiple: 'export var a, b', 446 | 'multiple w/ assignment': 'export var a = "a", b = "b"', 447 | function: 'export function a() {}', 448 | class: 'export class a {}', 449 | destructuring: 'export var {a} = {}', 450 | 'rename destructuring': 'export var {a: b} = {}', 451 | 'array destructuring': 'export var [a] = []', 452 | default: 'export default a = 1', 453 | 'default function': 'export default function a() {}', 454 | 'default class': 'export default class a {}', 455 | aggregate: 'export * from "a"', 456 | 'whole reexport': 'export * as a from "b"', 457 | 'reexport destructuring': 'export {a} from "b"', 458 | 'reexport destructuring w rename': 'export {a as b} from "c"', 459 | 'reexport as a default whole': 'export {default} from "b"', 460 | 'reexport default and non-default': 'export {default as a, b} from "c"' 461 | } 462 | 463 | for (const [key, value] of Object.entries(data)) { 464 | await t.test(key, async function () { 465 | assert.equal( 466 | micromark(value, { 467 | extensions: [mdxjsEsm({acorn})], 468 | htmlExtensions: [html] 469 | }), 470 | '' 471 | ) 472 | }) 473 | } 474 | }) 475 | --------------------------------------------------------------------------------