From e12f3079ac3556ed3ca29829be87223648f264df Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Mon, 23 Oct 2023 16:33:06 +0200 Subject: [PATCH] Add support for passing `baseUrl` when running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, `baseUrl` was supported at compile time. That meant it wasn’t possible to use `compile` with `outputFormat: 'function-body'` on a server and `run` on a client, and choosing the URL there, which is likely what you want in that setup, to pass `import.meta.url`. Additionally, `import()` expressions using an exression (e.g., `'@mdx-js/' + 'mdx'`) are now supported. If you use `run` or `evaluate`, you *should* pass `baseUrl`, likely as `import.meta.url`. If you don’t, and it is needed (because `export … from`, `import`, or `import.meta.url`), you will get a runtime error. --- packages/mdx/lib/core.js | 8 +- packages/mdx/lib/plugin/recma-document.js | 294 ++++++++++++++++-- packages/mdx/lib/plugin/recma-jsx-rewrite.js | 2 +- .../mdx/lib/util/resolve-evaluate-options.js | 22 +- packages/mdx/readme.md | 28 +- packages/mdx/test/compile.js | 74 ++++- packages/mdx/test/evaluate.js | 68 +++- 7 files changed, 429 insertions(+), 67 deletions(-) diff --git a/packages/mdx/lib/core.js b/packages/mdx/lib/core.js index 06cd1a72f..258a250dc 100644 --- a/packages/mdx/lib/core.js +++ b/packages/mdx/lib/core.js @@ -16,12 +16,8 @@ * Add a source map (object form) as the `map` field on the resulting file * (optional). * @property {URL | string | null | undefined} [baseUrl] - * Resolve `import`s (and `export … from`, and `import.meta.url`) from this - * URL (optional, example: `import.meta.url`); - * this option is useful when code will run in a different place, such as - * when `.mdx` files are in path *a* but compiled to path *b* and imports - * should run relative the path *b*, or when evaluating code, whether in Node - * or a browser. + * Use this URL as `import.meta.url` and resolve `import` and `export … from` + * relative to it (optional, example: `import.meta.url`). * @property {boolean | null | undefined} [development=false] * Whether to add extra info to error messages in generated code and use the * development automatic JSX runtime (`Fragment` and `jsxDEV` from diff --git a/packages/mdx/lib/plugin/recma-document.js b/packages/mdx/lib/plugin/recma-document.js index e4c81a01e..b822f7367 100644 --- a/packages/mdx/lib/plugin/recma-document.js +++ b/packages/mdx/lib/plugin/recma-document.js @@ -1,4 +1,5 @@ /** + * @typedef {import('estree-jsx').CallExpression} CallExpression * @typedef {import('estree-jsx').Directive} Directive * @typedef {import('estree-jsx').ExportAllDeclaration} ExportAllDeclaration * @typedef {import('estree-jsx').ExportDefaultDeclaration} ExportDefaultDeclaration @@ -6,6 +7,7 @@ * @typedef {import('estree-jsx').ExportSpecifier} ExportSpecifier * @typedef {import('estree-jsx').Expression} Expression * @typedef {import('estree-jsx').FunctionDeclaration} FunctionDeclaration + * @typedef {import('estree-jsx').Identifier} Identifier * @typedef {import('estree-jsx').ImportDeclaration} ImportDeclaration * @typedef {import('estree-jsx').ImportDefaultSpecifier} ImportDefaultSpecifier * @typedef {import('estree-jsx').ImportExpression} ImportExpression @@ -36,6 +38,7 @@ import {create} from '../util/estree-util-create.js' import {declarationToExpression} from '../util/estree-util-declaration-to-expression.js' import {isDeclaration} from '../util/estree-util-is-declaration.js' import {specifiersToDeclarations} from '../util/estree-util-specifiers-to-declarations.js' +import {toIdOrMemberExpression} from '../util/estree-util-to-id-or-member-expression.js' /** * Wrap the estree in `MDXContent`. @@ -46,8 +49,8 @@ import {specifiersToDeclarations} from '../util/estree-util-specifiers-to-declar * Transform. */ export function recmaDocument(options) { - const baseUrl_ = options.baseUrl || undefined - const baseUrl = typeof baseUrl_ === 'object' ? baseUrl_.href : baseUrl_ + const baseUrl = options.baseUrl || undefined + const baseHref = typeof baseUrl === 'object' ? baseUrl.href : baseUrl const outputFormat = options.outputFormat || 'program' const pragma = options.pragma === undefined ? 'React.createElement' : options.pragma @@ -321,9 +324,68 @@ export function recmaDocument(options) { tree.body = replacement - if (baseUrl) { + let usesImportMetaUrlVariable = false + let usesResolveDynamicHelper = false + + if (baseHref || outputFormat === 'function-body') { walk(tree, { enter(node) { + if ( + (node.type === 'ExportAllDeclaration' || + node.type === 'ExportNamedDeclaration' || + node.type === 'ImportDeclaration') && + node.source + ) { + // We never hit this branch when generating function bodies, as + // statements are already compiled away into import expressions. + assert(baseHref, 'unexpected missing `baseHref` in branch') + + let value = node.source.value + // The literal source for statements can only be string. + assert(typeof value === 'string', 'expected string source') + + // Resolve a specifier. + // This is the same as `_resolveDynamicMdxSpecifier`, which has to + // be injected to work with expressions at runtime, but as we have + // `baseHref` at compile time here and statements are static + // strings, we can do it now. + try { + // To do: use `URL.canParse` next major. + // eslint-disable-next-line no-new + new URL(value) + // Fine: a full URL. + } catch { + if ( + value.startsWith('/') || + value.startsWith('./') || + value.startsWith('../') + ) { + value = new URL(value, baseHref).href + } else { + // Fine: are bare specifier. + } + } + + /** @type {SimpleLiteral} */ + const replacement = {type: 'Literal', value} + create(node.source, replacement) + node.source = replacement + return + } + + if (node.type === 'ImportExpression') { + usesResolveDynamicHelper = true + /** @type {CallExpression} */ + const replacement = { + type: 'CallExpression', + callee: {type: 'Identifier', name: '_resolveDynamicMdxSpecifier'}, + arguments: [node.source], + optional: false + } + node.source = replacement + return + } + if ( node.type === 'MemberExpression' && 'object' in node && @@ -333,14 +395,38 @@ export function recmaDocument(options) { node.object.property.name === 'meta' && node.property.name === 'url' ) { - /** @type {SimpleLiteral} */ - const replacement = {type: 'Literal', value: baseUrl} + usesImportMetaUrlVariable = true + /** @type {Identifier} */ + const replacement = {type: 'Identifier', name: '_importMetaUrl'} + create(node, replacement) this.replace(replacement) } } }) } + if (usesResolveDynamicHelper) { + if (!baseHref) { + usesImportMetaUrlVariable = true + } + + tree.body.push( + resolveDynamicMdxSpecifier( + baseHref + ? {type: 'Literal', value: baseHref} + : {type: 'Identifier', name: '_importMetaUrl'} + ) + ) + } + + if (usesImportMetaUrlVariable) { + assert( + outputFormat === 'function-body', + 'expected `function-body` when using dynamic url injection' + ) + tree.body.unshift(...createImportMetaUrlVariable()) + } + /** * @param {ExportAllDeclaration | ExportNamedDeclaration} node * Export node. @@ -379,32 +465,6 @@ export function recmaDocument(options) { * Nothing. */ function handleEsm(node) { - // Rewrite the source of the `import` / `export … from`. - // See: - if (baseUrl && node.source) { - let value = String(node.source.value) - - try { - // A full valid URL. - value = String(new URL(value)) - } catch { - // Relative: `/example.js`, `./example.js`, and `../example.js`. - if (/^\.{0,2}\//.test(value)) { - value = String(new URL(value, baseUrl)) - } - // Otherwise, it’s a bare specifiers. - // For example `some-package`, `@some-package`, and - // `some-package/path`. - // These are supported in Node and browsers plan to support them - // with import maps (). - } - - /** @type {Literal} */ - const literal = {type: 'Literal', value} - create(node.source, literal) - node.source = literal - } - /** @type {ModuleDeclaration | Statement | undefined} */ let replace /** @type {Expression} */ @@ -639,3 +699,175 @@ export function recmaDocument(options) { ] } } + +/** + * @param {Expression} importMetaUrl + * @returns {FunctionDeclaration} + */ +function resolveDynamicMdxSpecifier(importMetaUrl) { + return { + type: 'FunctionDeclaration', + id: {type: 'Identifier', name: '_resolveDynamicMdxSpecifier'}, + generator: false, + async: false, + params: [{type: 'Identifier', name: 'd'}], + body: { + type: 'BlockStatement', + body: [ + { + type: 'IfStatement', + test: { + type: 'BinaryExpression', + left: { + type: 'UnaryExpression', + operator: 'typeof', + prefix: true, + argument: {type: 'Identifier', name: 'd'} + }, + operator: '!==', + right: {type: 'Literal', value: 'string'} + }, + consequent: { + type: 'ReturnStatement', + argument: {type: 'Identifier', name: 'd'} + }, + alternate: null + }, + // To do: use `URL.canParse` when widely supported (see commented + // out code below). + { + type: 'TryStatement', + block: { + type: 'BlockStatement', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'NewExpression', + callee: {type: 'Identifier', name: 'URL'}, + arguments: [{type: 'Identifier', name: 'd'}] + } + }, + { + type: 'ReturnStatement', + argument: {type: 'Identifier', name: 'd'} + } + ] + }, + handler: { + type: 'CatchClause', + param: null, + body: {type: 'BlockStatement', body: []} + }, + finalizer: null + }, + // To do: use `URL.canParse` when widely supported. + // { + // type: 'IfStatement', + // test: { + // type: 'CallExpression', + // callee: toIdOrMemberExpression(['URL', 'canParse']), + // arguments: [{type: 'Identifier', name: 'd'}], + // optional: false + // }, + // consequent: { + // type: 'ReturnStatement', + // argument: {type: 'Identifier', name: 'd'} + // }, + // alternate: null + // }, + { + type: 'IfStatement', + test: { + type: 'LogicalExpression', + left: { + type: 'LogicalExpression', + left: { + type: 'CallExpression', + callee: toIdOrMemberExpression(['d', 'startsWith']), + arguments: [{type: 'Literal', value: '/'}], + optional: false + }, + operator: '||', + right: { + type: 'CallExpression', + callee: toIdOrMemberExpression(['d', 'startsWith']), + arguments: [{type: 'Literal', value: './'}], + optional: false + } + }, + operator: '||', + right: { + type: 'CallExpression', + callee: toIdOrMemberExpression(['d', 'startsWith']), + arguments: [{type: 'Literal', value: '../'}], + optional: false + } + }, + consequent: { + type: 'ReturnStatement', + argument: { + type: 'MemberExpression', + object: { + type: 'NewExpression', + callee: {type: 'Identifier', name: 'URL'}, + arguments: [{type: 'Identifier', name: 'd'}, importMetaUrl] + }, + property: {type: 'Identifier', name: 'href'}, + computed: false, + optional: false + } + }, + alternate: null + }, + { + type: 'ReturnStatement', + argument: {type: 'Identifier', name: 'd'} + } + ] + } + } +} + +/** + * @returns {Array} + */ +function createImportMetaUrlVariable() { + return [ + { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + id: {type: 'Identifier', name: '_importMetaUrl'}, + init: toIdOrMemberExpression(['arguments', 0, 'baseUrl']) + } + ], + kind: 'const' + }, + { + type: 'IfStatement', + test: { + type: 'UnaryExpression', + operator: '!', + prefix: true, + argument: {type: 'Identifier', name: '_importMetaUrl'} + }, + consequent: { + type: 'ThrowStatement', + argument: { + type: 'NewExpression', + callee: {type: 'Identifier', name: 'Error'}, + arguments: [ + { + type: 'Literal', + value: + 'Unexpected missing `options.baseUrl` needed to support `export … from`, `import`, or `import.meta.url` when generating `function-body`' + } + ] + } + }, + alternate: null + } + ] +} diff --git a/packages/mdx/lib/plugin/recma-jsx-rewrite.js b/packages/mdx/lib/plugin/recma-jsx-rewrite.js index 818053d56..f945a7d9b 100644 --- a/packages/mdx/lib/plugin/recma-jsx-rewrite.js +++ b/packages/mdx/lib/plugin/recma-jsx-rewrite.js @@ -446,7 +446,7 @@ export function recmaJsxRewrite(options) { createErrorHelper = true - if (development && place !== '1:1-1:1') { + if (development && place) { parameters.push({type: 'Literal', value: place}) } diff --git a/packages/mdx/lib/util/resolve-evaluate-options.js b/packages/mdx/lib/util/resolve-evaluate-options.js index 142cdfe77..febfe1bfe 100644 --- a/packages/mdx/lib/util/resolve-evaluate-options.js +++ b/packages/mdx/lib/util/resolve-evaluate-options.js @@ -10,7 +10,7 @@ * @typedef {EvaluateProcessorOptions & RunOptions} EvaluateOptions * Configuration for `evaluate`. * - * @typedef {Omit } EvaluateProcessorOptions + * @typedef {Omit } EvaluateProcessorOptions * Compile configuration without JSX options for evaluation. * * @typedef RunOptions @@ -23,6 +23,12 @@ * `useMDXComponents` is used when the code is compiled with * `providerImportSource: '#'` (the exact value of this compile option * doesn’t matter). + * @property {URL | string | null | undefined} [baseUrl] + * Use this URL as `import.meta.url` and resolve `import` and `export … from` + * relative to it (optional, example: `import.meta.url`); + * this option can also be given at compile time in `CompileOptions`; + * you should pass this (likely at runtime), as you might get runtime errors + * when using `import.meta.url` / `import` / `export … from ` otherwise. * @property {Fragment} Fragment * Symbol to use for fragments (**required**). * @property {Jsx | null | undefined} [jsx] @@ -52,8 +58,16 @@ * Split options. */ export function resolveEvaluateOptions(options) { - const {Fragment, development, jsx, jsxDEV, jsxs, useMDXComponents, ...rest} = - options || {} + const { + Fragment, + baseUrl, + development, + jsx, + jsxDEV, + jsxs, + useMDXComponents, + ...rest + } = options || {} if (!Fragment) throw new Error('Expected `Fragment` given to `evaluate`') if (development) { @@ -70,6 +84,6 @@ export function resolveEvaluateOptions(options) { outputFormat: 'function-body', providerImportSource: useMDXComponents ? '#' : undefined }, - runtime: {Fragment, jsx, jsxDEV, jsxs, useMDXComponents} + runtime: {Fragment, baseUrl, jsx, jsxDEV, jsxs, useMDXComponents} } } diff --git a/packages/mdx/readme.md b/packages/mdx/readme.md index a04e505bb..88075d1a3 100644 --- a/packages/mdx/readme.md +++ b/packages/mdx/readme.md @@ -382,9 +382,10 @@ type CompileOptions = Omit & { Configuration for `evaluate` (TypeScript type). `EvaluateOptions` is the same as [`CompileOptions`][api-compile-options], -except that the options `jsx`, `jsxImportSource`, `jsxRuntime`, `outputFormat`, -`pragma`, `pragmaFrag`, `pragmaImportSource`, and `providerImportSource` are -not allowed, and that [`RunOptions`][api-run-options] are also used. +except that the options `baseUrl`, `jsx`, `jsxImportSource`, `jsxRuntime`, +`outputFormat`, `pragma`, `pragmaFrag`, `pragmaImportSource`, and +`providerImportSource` are not allowed, and that +[`RunOptions`][api-run-options] are also used. ###### Type @@ -394,6 +395,7 @@ not allowed, and that [`RunOptions`][api-run-options] are also used. */ type EvaluateOptions = Omit< CompileOptions, + | 'baseUrl' // Note that this is also in `RunOptions`. | 'jsx' | 'jsxImportSource' | 'jsxRuntime' @@ -494,12 +496,8 @@ Configuration for `createProcessor` (TypeScript type). * `baseUrl` (`URL` or `string`, optional, example: `import.meta.url`) - — resolve `import`s (and `export … from`, and `import.meta.url`) from this - URL; - this option is useful when code will run in a different place, such as when - `.mdx` files are in path *a* but compiled to path *b* and imports should - run relative the path *b*, or when evaluating code, whether in Node or a - browser + — use this URL as `import.meta.url` and resolve `import` and + `export … from` relative to it
Expand example @@ -517,7 +515,7 @@ Configuration for `createProcessor` (TypeScript type). …now running `node example.js` yields: ```tsx - import {Fragment as _Fragment, jsx as _jsx} from 'react/jsx-runtime' + import {jsx as _jsx} from 'react/jsx-runtime' export {number} from 'https://a.full/data.js' function _createMdxContent(props) { /* … */ } export default function MDXContent(props = {}) { /* … */ } @@ -972,6 +970,12 @@ matter). * `Fragment` ([`Fragment`][api-fragment], **required**) — symbol to use for fragments +* `baseUrl` (`URL` or `string`, optional, example: `import.meta.url`) + — use this URL as `import.meta.url` and resolve `import` and + `export … from` relative to it; + this option can also be given at compile time in `CompileOptions`; + you should pass this (likely at runtime), as you might get runtime errors + when using `import.meta.url` / `import` / `export … from ` otherwise * `jsx` ([`Jsx`][api-jsx], optional) — function to generate an element with static children in production mode * `jsxDEV` ([`JsxDEV`][api-jsx-dev], optional) @@ -1199,10 +1203,6 @@ abide by its terms. [use]: #use -[outputformat]: #optionsoutputformat - -[baseurl]: #optionsbaseurl - [unified]: https://github.com/unifiedjs/unified [processor]: https://github.com/unifiedjs/unified#processor diff --git a/packages/mdx/test/compile.js b/packages/mdx/test/compile.js index 01c96bd64..c7334dc13 100644 --- a/packages/mdx/test/compile.js +++ b/packages/mdx/test/compile.js @@ -18,7 +18,7 @@ import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import {SourceMapGenerator} from 'source-map' import {VFile} from 'vfile' -import {run} from './context/run.js' +import {run, runWhole} from './context/run.js' test('@mdx-js/mdx: compile', async function (t) { await t.test('should throw when a removed option is passed', function () { @@ -1075,6 +1075,78 @@ test('@mdx-js/mdx: compile', async function (t) { await fs.unlink(url) }) + + await t.test( + 'should leave bare specifiers untouched w/ `baseUrl`', + async function () { + const dlv = await import('dlv') + const mod = await runWhole( + await compile('import dlv from "dlv"\nexport {dlv}', { + baseUrl: 'https://example.com' + }) + ) + + assert.equal(mod.dlv, dlv.default) + } + ) + + await t.test( + 'should leave URLs as specifiers untouched w/ `baseUrl`', + async function () { + const mod = await runWhole( + await compile('import fs from "node:fs/promises"\nexport {fs}', { + baseUrl: 'https://example.com' + }) + ) + + assert.equal(mod.fs, fs) + } + ) + + await t.test( + 'should resolve relative specifiers w/ `baseUrl`', + async function () { + // Note: this is run inside `context/`, so it would normally have to be `./data.js`. + // But because we rewrite relative to this file `compile.js`, it’s `./context/data.js`. + const mod = await runWhole( + await compile('import num from "./context/data.js"\nexport {num}', { + baseUrl: import.meta.url + }) + ) + + assert.equal(mod.num, 6.28) + } + ) + + await t.test('should support `baseUrl` as a URL', async function () { + // Same as above but uses a URL. + const mod = await runWhole( + await compile('import num from "./context/data.js"\nexport {num}', { + baseUrl: new URL(import.meta.url) + }) + ) + + assert.equal(mod.num, 6.28) + }) + + await t.test( + 'should support importing dynamic expressions', + async function () { + // Same as above but uses a URL. + const mod = await runWhole( + await compile( + 'export async function get() {\n const mod = await import("./context/data.js");\n return mod.number\n}', + { + baseUrl: new URL(import.meta.url) + } + ) + ) + + const get = mod.get + assert(typeof get === 'function') + assert.equal(await get(), 3.14) + } + ) }) test('@mdx-js/mdx: compile (JSX)', async function (t) { diff --git a/packages/mdx/test/evaluate.js b/packages/mdx/test/evaluate.js index a2ff1ca6f..d2f261df1 100644 --- a/packages/mdx/test/evaluate.js +++ b/packages/mdx/test/evaluate.js @@ -97,6 +97,54 @@ test('@mdx-js/mdx: evaluate', async function (t) { ) }) + await t.test( + 'should throw a runtime error when using `import` w/o `baseUrl`', + async function () { + try { + await evaluate('import "a"', runtime) + assert.fail() + } catch (error) { + const cause = /** @type {Error} */ (error) + assert.match( + String(cause), + /Unexpected missing `options.baseUrl` needed to support/ + ) + } + } + ) + + await t.test( + 'should throw a runtime error when using `export … from` w/o `baseUrl`', + async function () { + try { + await evaluate('export {a} from "b"', runtime) + assert.fail() + } catch (error) { + const cause = /** @type {Error} */ (error) + assert.match( + String(cause), + /Unexpected missing `options.baseUrl` needed to support/ + ) + } + } + ) + + await t.test( + 'should throw a runtime error when using `export … from` w/o `baseUrl`', + async function () { + try { + await evaluate('{import.meta.url}', runtime) + assert.fail() + } catch (error) { + const cause = /** @type {Error} */ (error) + assert.match( + String(cause), + /Unexpected missing `options.baseUrl` needed to support/ + ) + } + } + ) + await t.test( 'should support an `import` of a relative url w/ `baseUrl`', async function () { @@ -130,24 +178,24 @@ test('@mdx-js/mdx: evaluate', async function (t) { ) await t.test( - 'should support an `import` w/o specifiers w/o `baseUrl`', + 'should support an `import` w/o specifiers w/o `baseUrl` (expecting it at runtime)', async function () { - assert.match( - String(await compile('import "a"', {outputFormat: 'function-body'})), - /\nawait import\("a"\);?\n/ + const doc = String( + await compile('import "a"', {outputFormat: 'function-body'}) ) + assert.match(doc, /const _importMetaUrl = arguments\[0]\.baseUrl/) + assert.match(doc, /await import\(_resolveDynamicMdxSpecifier\("a"\)\);/) } ) await t.test( - 'should support an `import` w/ 0 specifiers w/o `baseUrl`', + 'should support an `import` w/ 0 specifiers w/o `baseUrl` (expecting it at runtime)', async function () { - assert.match( - String( - await compile('import {} from "a"', {outputFormat: 'function-body'}) - ), - /\nawait import\("a"\);?\n/ + const doc = String( + await compile('import {} from "a"', {outputFormat: 'function-body'}) ) + assert.match(doc, /const _importMetaUrl = arguments\[0]\.baseUrl/) + assert.match(doc, /await import\(_resolveDynamicMdxSpecifier\("a"\)\);/) } )