diff options
Diffstat (limited to 'packages/integrations/mdx/src')
-rw-r--r-- | packages/integrations/mdx/src/README.md | 39 | ||||
-rw-r--r-- | packages/integrations/mdx/src/index.ts | 29 | ||||
-rw-r--r-- | packages/integrations/mdx/src/plugins.ts | 16 | ||||
-rw-r--r-- | packages/integrations/mdx/src/rehype-images-to-component.ts | 166 | ||||
-rw-r--r-- | packages/integrations/mdx/src/rehype-optimize-static.ts | 251 | ||||
-rw-r--r-- | packages/integrations/mdx/src/remark-images-to-component.ts | 156 | ||||
-rw-r--r-- | packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts | 75 | ||||
-rw-r--r-- | packages/integrations/mdx/src/vite-plugin-mdx.ts | 50 |
8 files changed, 530 insertions, 252 deletions
diff --git a/packages/integrations/mdx/src/README.md b/packages/integrations/mdx/src/README.md index bbbc6075c..3fc991b77 100644 --- a/packages/integrations/mdx/src/README.md +++ b/packages/integrations/mdx/src/README.md @@ -30,12 +30,7 @@ After: ```jsx function _createMdxContent() { - return ( - <> - <h1>My MDX Content</h1> - <pre set:html="<code class=...</code>"></pre> - </> - ); + return <Fragment set:html="<h1>My MDX Content</h1>\n<code class=...</code>" />; } ``` @@ -49,15 +44,20 @@ The next section explains the algorithm, which you can follow along by pairing w ### How it works -Two variables: +The flow can be divided into a "scan phase" and a "mutation phase". The scan phase searches for nodes that can be optimized, and the mutation phase applies the optimization on the `hast` nodes. + +#### Scan phase + +Variables: - `allPossibleElements`: A set of subtree roots where we can add a new `set:html` property with its children as value. - `elementStack`: The stack of elements (that could be subtree roots) while traversing the `hast` (node ancestors). +- `elementMetadatas`: A weak map to store the metadata used only by the mutation phase later. Flow: 1. Walk the `hast` tree. -2. For each `node` we enter, if the `node` is static (`type` is `element` or `mdxJsxFlowElement`), record in `allPossibleElements` and push to `elementStack`. +2. For each `node` we enter, if the `node` is static (`type` is `element` or starts with `mdx`), record in `allPossibleElements` and push to `elementStack`. We also record additional metadata in `elementMetadatas` for the mutation phase later. - Q: Why do we record `mdxJsxFlowElement`, it's MDX? <br> A: Because we're looking for nodes whose children are static. The node itself doesn't need to be static. - Q: Are we sure this is the subtree root node in `allPossibleElements`? <br> @@ -71,8 +71,25 @@ Flow: - Q: Why before step 2's `node` enter handling? <br> A: If we find a non-static `node`, the `node` should still be considered in `allPossibleElements` as its children could be static. 5. Walk done. This leaves us with `allPossibleElements` containing only subtree roots that can be optimized. -6. Add the `set:html` property to the `hast` node, and remove its children. -7. 🎉 The rest of the MDX pipeline will do its thing and generate the desired JSX like above. +6. Proceed to the mutation phase. + +#### Mutation phase + +Inputs: + +- `allPossibleElements` from the scan phase. +- `elementMetadatas` from the scan phase. + +Flow: + +1. Before we mutate the `hast` tree, each element in `allPossibleElements` may have siblings that can be optimized together. Sibling elements are grouped with the `findElementGroups()` function, which returns an array of element groups (new variable `elementGroups`) and mutates `allPossibleElements` to remove elements that are already part of a group. + + - Q: How does `findElementGroups()` work? <br> + A: For each elements in `allPossibleElements` that are non-static, we're able to take the element metadata from `elementMetadatas` and guess the next sibling node. If the next sibling node is static and is an element in `allPossibleElements`, we group them together for optimization. It continues to guess until it hits a non-static node or an element not in `allPossibleElements`, which it'll finalize the group as part of the returned result. + +2. For each elements in `allPossibleElements`, we serailize them as HTML and add it to the `set:html` property of the `hast` node, and remove its children. +3. For each element group in `elementGroups`, we serialize the group children as HTML and add it to a new `<Fragment set:html="..." />` node, and replace the group children with the new `<Fragment />` node. +4. 🎉 The rest of the MDX pipeline will do its thing and generate the desired JSX like above. ### Extra @@ -82,7 +99,7 @@ Astro's MDX implementation supports specifying `export const components` in the #### Further optimizations -In [How it works](#how-it-works) step 4, +In [Scan phase](#scan-phase) step 4, > we remove all the elements in `elementStack` from `allPossibleElements` diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index fc1d92da4..3aaed8787 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -29,6 +29,10 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & { }; export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroIntegration { + // @ts-expect-error Temporarily assign an empty object here, which will be re-assigned by the + // `astro:config:done` hook later. This is so that `vitePluginMdx` can get hold of a reference earlier. + let mdxOptions: MdxOptions = {}; + return { name: '@astrojs/mdx', hooks: { @@ -58,21 +62,30 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI handlePropagation: true, }); + updateConfig({ + vite: { + plugins: [vitePluginMdx(mdxOptions), vitePluginMdxPostprocess(config)], + }, + }); + }, + 'astro:config:done': ({ config }) => { + // We resolve the final MDX options here so that other integrations have a chance to modify + // `config.markdown` before we access it const extendMarkdownConfig = partialMdxOptions.extendMarkdownConfig ?? defaultMdxOptions.extendMarkdownConfig; - const mdxOptions = applyDefaultOptions({ + const resolvedMdxOptions = applyDefaultOptions({ options: partialMdxOptions, defaults: markdownConfigToMdxOptions( extendMarkdownConfig ? config.markdown : markdownConfigDefaults ), }); - updateConfig({ - vite: { - plugins: [vitePluginMdx(config, mdxOptions), vitePluginMdxPostprocess(config)], - }, - }); + // Mutate `mdxOptions` so that `vitePluginMdx` can reference the actual options + Object.assign(mdxOptions, resolvedMdxOptions); + // @ts-expect-error After we assign, we don't need to reference `mdxOptions` in this context anymore. + // Re-assign it so that the garbage can be collected later. + mdxOptions = {}; }, }, }; @@ -81,7 +94,8 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI const defaultMdxOptions = { extendMarkdownConfig: true, recmaPlugins: [], -}; + optimize: false, +} satisfies Partial<MdxOptions>; function markdownConfigToMdxOptions(markdownConfig: typeof markdownConfigDefaults): MdxOptions { return { @@ -90,7 +104,6 @@ function markdownConfigToMdxOptions(markdownConfig: typeof markdownConfigDefault remarkPlugins: ignoreStringPlugins(markdownConfig.remarkPlugins), rehypePlugins: ignoreStringPlugins(markdownConfig.rehypePlugins), remarkRehype: (markdownConfig.remarkRehype as any) ?? {}, - optimize: false, }; } diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts index 99d0c70b2..3978e5325 100644 --- a/packages/integrations/mdx/src/plugins.ts +++ b/packages/integrations/mdx/src/plugins.ts @@ -5,6 +5,7 @@ import { remarkCollectImages, } from '@astrojs/markdown-remark'; import { createProcessor, nodeTypes } from '@mdx-js/mdx'; +import { rehypeAnalyzeAstroMetadata } from 'astro/jsx/rehype.js'; import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; import remarkSmartypants from 'remark-smartypants'; @@ -13,9 +14,9 @@ import type { PluggableList } from 'unified'; import type { MdxOptions } from './index.js'; import { rehypeApplyFrontmatterExport } from './rehype-apply-frontmatter-export.js'; import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js'; +import { rehypeImageToComponent } from './rehype-images-to-component.js'; import rehypeMetaString from './rehype-meta-string.js'; import { rehypeOptimizeStatic } from './rehype-optimize-static.js'; -import { remarkImageToComponent } from './remark-images-to-component.js'; // Skip nonessential plugins during performance benchmark runs const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK); @@ -30,7 +31,6 @@ export function createMdxProcessor(mdxOptions: MdxOptions, extraOptions: MdxProc rehypePlugins: getRehypePlugins(mdxOptions), recmaPlugins: mdxOptions.recmaPlugins, remarkRehypeOptions: mdxOptions.remarkRehype, - jsx: true, jsxImportSource: 'astro', // Note: disable `.md` (and other alternative extensions for markdown files like `.markdown`) support format: 'mdx', @@ -52,7 +52,7 @@ function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList { } } - remarkPlugins.push(...mdxOptions.remarkPlugins, remarkCollectImages, remarkImageToComponent); + remarkPlugins.push(...mdxOptions.remarkPlugins, remarkCollectImages); return remarkPlugins; } @@ -74,7 +74,7 @@ function getRehypePlugins(mdxOptions: MdxOptions): PluggableList { } } - rehypePlugins.push(...mdxOptions.rehypePlugins); + rehypePlugins.push(...mdxOptions.rehypePlugins, rehypeImageToComponent); if (!isPerformanceBenchmark) { // getHeadings() is guaranteed by TS, so this must be included. @@ -82,8 +82,12 @@ function getRehypePlugins(mdxOptions: MdxOptions): PluggableList { rehypePlugins.push(rehypeHeadingIds, rehypeInjectHeadingsExport); } - // computed from `astro.data.frontmatter` in VFile data - rehypePlugins.push(rehypeApplyFrontmatterExport); + rehypePlugins.push( + // Render info from `vfile.data.astro.data.frontmatter` as JS + rehypeApplyFrontmatterExport, + // Analyze MDX nodes and attach to `vfile.data.__astroMetadata` + rehypeAnalyzeAstroMetadata + ); if (mdxOptions.optimize) { // Convert user `optimize` option to compatible `rehypeOptimizeStatic` option diff --git a/packages/integrations/mdx/src/rehype-images-to-component.ts b/packages/integrations/mdx/src/rehype-images-to-component.ts new file mode 100644 index 000000000..6c797fda2 --- /dev/null +++ b/packages/integrations/mdx/src/rehype-images-to-component.ts @@ -0,0 +1,166 @@ +import type { MarkdownVFile } from '@astrojs/markdown-remark'; +import type { Properties, Root } from 'hast'; +import type { MdxJsxAttribute, MdxjsEsm } from 'mdast-util-mdx'; +import type { MdxJsxFlowElementHast } from 'mdast-util-mdx-jsx'; +import { visit } from 'unist-util-visit'; +import { jsToTreeNode } from './utils.js'; + +export const ASTRO_IMAGE_ELEMENT = 'astro-image'; +export const ASTRO_IMAGE_IMPORT = '__AstroImage__'; +export const USES_ASTRO_IMAGE_FLAG = '__usesAstroImage'; + +function createArrayAttribute(name: string, values: (string | number)[]): MdxJsxAttribute { + return { + type: 'mdxJsxAttribute', + name: name, + value: { + type: 'mdxJsxAttributeValueExpression', + value: name, + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'ArrayExpression', + elements: values.map((value) => ({ + type: 'Literal', + value: value, + raw: String(value), + })), + }, + }, + ], + sourceType: 'module', + comments: [], + }, + }, + }, + }; +} + +/** + * Convert the <img /> element properties (except `src`) to MDX JSX attributes. + * + * @param {Properties} props - The element properties + * @returns {MdxJsxAttribute[]} The MDX attributes + */ +function getImageComponentAttributes(props: Properties): MdxJsxAttribute[] { + const attrs: MdxJsxAttribute[] = []; + + for (const [prop, value] of Object.entries(props)) { + if (prop === 'src') continue; + + /* + * <Image /> component expects an array for those attributes but the + * received properties are sanitized as strings. So we need to convert them + * back to an array. + */ + if (prop === 'widths' || prop === 'densities') { + attrs.push(createArrayAttribute(prop, String(value).split(' '))); + } else { + attrs.push({ + name: prop, + type: 'mdxJsxAttribute', + value: String(value), + }); + } + } + + return attrs; +} + +export function rehypeImageToComponent() { + return function (tree: Root, file: MarkdownVFile) { + if (!file.data.imagePaths) return; + + const importsStatements: MdxjsEsm[] = []; + const importedImages = new Map<string, string>(); + + visit(tree, 'element', (node, index, parent) => { + if (!file.data.imagePaths || node.tagName !== 'img' || !node.properties.src) return; + + const src = decodeURI(String(node.properties.src)); + + if (!file.data.imagePaths.has(src)) return; + + let importName = importedImages.get(src); + + if (!importName) { + importName = `__${importedImages.size}_${src.replace(/\W/g, '_')}__`; + + importsStatements.push({ + type: 'mdxjsEsm', + value: '', + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ImportDeclaration', + source: { + type: 'Literal', + value: src, + raw: JSON.stringify(src), + }, + specifiers: [ + { + type: 'ImportDefaultSpecifier', + local: { type: 'Identifier', name: importName }, + }, + ], + }, + ], + }, + }, + }); + importedImages.set(src, importName); + } + + // Build a component that's equivalent to <Image src={importName} {...attributes} /> + const componentElement: MdxJsxFlowElementHast = { + name: ASTRO_IMAGE_ELEMENT, + type: 'mdxJsxFlowElement', + attributes: [ + ...getImageComponentAttributes(node.properties), + { + name: 'src', + type: 'mdxJsxAttribute', + value: { + type: 'mdxJsxAttributeValueExpression', + value: importName, + data: { + estree: { + type: 'Program', + sourceType: 'module', + comments: [], + body: [ + { + type: 'ExpressionStatement', + expression: { type: 'Identifier', name: importName }, + }, + ], + }, + }, + }, + }, + ], + children: [], + }; + + parent!.children.splice(index!, 1, componentElement); + }); + + // Add all the import statements to the top of the file for the images + tree.children.unshift(...importsStatements); + + tree.children.unshift( + jsToTreeNode(`import { Image as ${ASTRO_IMAGE_IMPORT} } from "astro:assets";`) + ); + // Export `__usesAstroImage` to pick up `astro:assets` usage in the module graph. + // @see the '@astrojs/mdx-postprocess' plugin + tree.children.push(jsToTreeNode(`export const ${USES_ASTRO_IMAGE_FLAG} = true`)); + }; +} diff --git a/packages/integrations/mdx/src/rehype-optimize-static.ts b/packages/integrations/mdx/src/rehype-optimize-static.ts index 573af317e..ebedb753e 100644 --- a/packages/integrations/mdx/src/rehype-optimize-static.ts +++ b/packages/integrations/mdx/src/rehype-optimize-static.ts @@ -1,11 +1,26 @@ -import { visit } from 'estree-util-visit'; +import type { RehypePlugin } from '@astrojs/markdown-remark'; +import { SKIP, visit } from 'estree-util-visit'; +import type { Element, RootContent, RootContentMap } from 'hast'; import { toHtml } from 'hast-util-to-html'; +import type { MdxJsxFlowElementHast, MdxJsxTextElementHast } from 'mdast-util-mdx-jsx'; -// accessing untyped hast and mdx types -type Node = any; +// This import includes ambient types for hast to include mdx nodes +import type {} from 'mdast-util-mdx'; + +// Alias as the main hast node +type Node = RootContent; +// Nodes that have the `children` property +type ParentNode = Element | MdxJsxFlowElementHast | MdxJsxTextElementHast; +// Nodes that can have its children optimized as a single HTML string +type OptimizableNode = Element | MdxJsxFlowElementHast | MdxJsxTextElementHast; export interface OptimizeOptions { - customComponentNames?: string[]; + ignoreElementNames?: string[]; +} + +interface ElementMetadata { + parent: ParentNode; + index: number; } const exportConstComponentsRe = /export\s+const\s+components\s*=/; @@ -17,44 +32,57 @@ const exportConstComponentsRe = /export\s+const\s+components\s*=/; * This optimization reduces the JS output as more content are represented as a * string instead, which also reduces the AST size that Rollup holds in memory. */ -export function rehypeOptimizeStatic(options?: OptimizeOptions) { - return (tree: any) => { +export const rehypeOptimizeStatic: RehypePlugin<[OptimizeOptions?]> = (options) => { + return (tree) => { // A set of non-static components to avoid collapsing when walking the tree // as they need to be preserved as JSX to be rendered dynamically. - const customComponentNames = new Set<string>(options?.customComponentNames); + const ignoreElementNames = new Set<string>(options?.ignoreElementNames); // Find `export const components = { ... }` and get it's object's keys to be - // populated into `customComponentNames`. This configuration is used to render + // populated into `ignoreElementNames`. This configuration is used to render // some HTML elements as custom components, and we also want to avoid collapsing them. for (const child of tree.children) { if (child.type === 'mdxjsEsm' && exportConstComponentsRe.test(child.value)) { - // Try to loosely get the object property nodes - const objectPropertyNodes = child.data.estree.body[0]?.declarations?.[0]?.init?.properties; - if (objectPropertyNodes) { - for (const objectPropertyNode of objectPropertyNodes) { - const componentName = objectPropertyNode.key?.name ?? objectPropertyNode.key?.value; - if (componentName) { - customComponentNames.add(componentName); - } + const keys = getExportConstComponentObjectKeys(child); + if (keys) { + for (const key of keys) { + ignoreElementNames.add(key); } } + break; } } // All possible elements that could be the root of a subtree - const allPossibleElements = new Set<Node>(); + const allPossibleElements = new Set<OptimizableNode>(); // The current collapsible element stack while traversing the tree const elementStack: Node[] = []; + // Metadata used by `findElementGroups` later + const elementMetadatas = new WeakMap<OptimizableNode, ElementMetadata>(); + + /** + * A non-static node causes all its parents to be non-optimizable + */ + const isNodeNonStatic = (node: Node) => { + // @ts-expect-error Access `.tagName` naively for perf + return node.type.startsWith('mdx') || ignoreElementNames.has(node.tagName); + }; + + visit(tree as any, { + // @ts-expect-error Force coerce node as hast node + enter(node: Node, key, index, parents: ParentNode[]) { + // `estree-util-visit` may traverse in MDX `attributes`, we don't want that. Only continue + // if it's traversing the root, or the `children` key. + if (key != null && key !== 'children') return SKIP; + + // Mutate `node` as a normal hast element node if it's a plain MDX node, e.g. `<kbd>something</kbd>` + simplifyPlainMdxComponentNode(node, ignoreElementNames); - visit(tree, { - enter(node) { - // @ts-expect-error read tagName naively - const isCustomComponent = node.tagName && customComponentNames.has(node.tagName); - // For nodes that can't be optimized, eliminate all elements in the - // `elementStack` from the `allPossibleElements` set. - if (node.type.startsWith('mdx') || isCustomComponent) { + // For nodes that are not static, eliminate all elements in the `elementStack` from the + // `allPossibleElements` set. + if (isNodeNonStatic(node)) { for (const el of elementStack) { - allPossibleElements.delete(el); + allPossibleElements.delete(el as OptimizableNode); } // Micro-optimization: While this destroys the meaning of an element // stack for this node, things will still work but we won't repeatedly @@ -64,17 +92,25 @@ export function rehypeOptimizeStatic(options?: OptimizeOptions) { } // For possible subtree root nodes, record them in `elementStack` and // `allPossibleElements` to be used in the "leave" hook below. - // @ts-expect-error MDX types for `.type` is not enhanced because MDX isn't used directly - if (node.type === 'element' || node.type === 'mdxJsxFlowElement') { + if (node.type === 'element' || isMdxComponentNode(node)) { elementStack.push(node); allPossibleElements.add(node); + + if (index != null && node.type === 'element') { + // Record metadata for element node to be used for grouping analysis later + elementMetadatas.set(node, { parent: parents[parents.length - 1], index }); + } } }, - leave(node, _, __, parents) { + // @ts-expect-error Force coerce node as hast node + leave(node: Node, key, _, parents: ParentNode[]) { + // `estree-util-visit` may traverse in MDX `attributes`, we don't want that. Only continue + // if it's traversing the root, or the `children` key. + if (key != null && key !== 'children') return SKIP; + // Do the reverse of the if condition above, popping the `elementStack`, // and consolidating `allPossibleElements` as a subtree root. - // @ts-expect-error MDX types for `.type` is not enhanced because MDX isn't used directly - if (node.type === 'element' || node.type === 'mdxJsxFlowElement') { + if (node.type === 'element' || isMdxComponentNode(node)) { elementStack.pop(); // Many possible elements could be part of a subtree, in order to find // the root, we check the parent of the element we're popping. If the @@ -89,10 +125,18 @@ export function rehypeOptimizeStatic(options?: OptimizeOptions) { }, }); + // Within `allPossibleElements`, element nodes are often siblings and instead of setting `set:html` + // on each of the element node, we can create a `<Fragment set:html="...">` element that includes + // all element nodes instead, simplifying the output. + const elementGroups = findElementGroups(allPossibleElements, elementMetadatas, isNodeNonStatic); + // For all possible subtree roots, collapse them into `set:html` and // strip of their children for (const el of allPossibleElements) { - if (el.type === 'mdxJsxFlowElement') { + // Avoid adding empty `set:html` attributes if there's no children + if (el.children.length === 0) continue; + + if (isMdxComponentNode(el)) { el.attributes.push({ type: 'mdxJsxAttribute', name: 'set:html', @@ -103,5 +147,150 @@ export function rehypeOptimizeStatic(options?: OptimizeOptions) { } el.children = []; } + + // For each element group, we create a new `<Fragment />` MDX node with `set:html` of the children + // serialized as HTML. We insert this new fragment, replacing all the group children nodes. + // We iterate in reverse to avoid changing the index of groups of the same parent. + for (let i = elementGroups.length - 1; i >= 0; i--) { + const group = elementGroups[i]; + const fragmentNode: MdxJsxFlowElementHast = { + type: 'mdxJsxFlowElement', + name: 'Fragment', + attributes: [ + { + type: 'mdxJsxAttribute', + name: 'set:html', + value: toHtml(group.children), + }, + ], + children: [], + }; + group.parent.children.splice(group.startIndex, group.children.length, fragmentNode); + } }; +}; + +interface ElementGroup { + parent: ParentNode; + startIndex: number; + children: Node[]; +} + +/** + * Iterate through `allPossibleElements` and find elements that are siblings, and return them. `allPossibleElements` + * will be mutated to exclude these grouped elements. + */ +function findElementGroups( + allPossibleElements: Set<OptimizableNode>, + elementMetadatas: WeakMap<OptimizableNode, ElementMetadata>, + isNodeNonStatic: (node: Node) => boolean +): ElementGroup[] { + const elementGroups: ElementGroup[] = []; + + for (const el of allPossibleElements) { + // Non-static nodes can't be grouped. It can only optimize its static children. + if (isNodeNonStatic(el)) continue; + + // Get the metadata for the element node, this should always exist + const metadata = elementMetadatas.get(el); + if (!metadata) { + throw new Error( + 'Internal MDX error: rehype-optimize-static should have metadata for element node' + ); + } + + // For this element, iterate through the next siblings and add them to this array + // if they are text nodes or elements that are in `allPossibleElements` (optimizable). + // If one of the next siblings don't match the criteria, break the loop as others are no longer siblings. + const groupableElements: Node[] = [el]; + for (let i = metadata.index + 1; i < metadata.parent.children.length; i++) { + const node = metadata.parent.children[i]; + + // If the node is non-static, we can't group it with the current element + if (isNodeNonStatic(node)) break; + + if (node.type === 'element') { + // This node is now (presumably) part of a group, remove it from `allPossibleElements` + const existed = allPossibleElements.delete(node); + // If this node didn't exist in `allPossibleElements`, it's likely that one of its children + // are non-static, hence this node can also not be grouped. So we break out here. + if (!existed) break; + } + + groupableElements.push(node); + } + + // If group elements are more than one, add them to the `elementGroups`. + // Grouping is most effective if there's multiple elements in it. + if (groupableElements.length > 1) { + elementGroups.push({ + parent: metadata.parent, + startIndex: metadata.index, + children: groupableElements, + }); + // The `el` is also now part of a group, remove it from `allPossibleElements` + allPossibleElements.delete(el); + } + } + + return elementGroups; +} + +function isMdxComponentNode(node: Node): node is MdxJsxFlowElementHast | MdxJsxTextElementHast { + return node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement'; +} + +/** + * Get the object keys from `export const components` + * + * @example + * `export const components = { foo, bar: Baz }`, returns `['foo', 'bar']` + */ +function getExportConstComponentObjectKeys(node: RootContentMap['mdxjsEsm']) { + const exportNamedDeclaration = node.data?.estree?.body[0]; + if (exportNamedDeclaration?.type !== 'ExportNamedDeclaration') return; + + const variableDeclaration = exportNamedDeclaration.declaration; + if (variableDeclaration?.type !== 'VariableDeclaration') return; + + const variableInit = variableDeclaration.declarations[0]?.init; + if (variableInit?.type !== 'ObjectExpression') return; + + const keys: string[] = []; + for (const propertyNode of variableInit.properties) { + if (propertyNode.type === 'Property' && propertyNode.key.type === 'Identifier') { + keys.push(propertyNode.key.name); + } + } + return keys; +} + +/** + * Some MDX nodes are simply `<kbd>something</kbd>` which isn't needed to be completely treated + * as an MDX node. This function tries to mutate this node as a simple hast element node if so. + */ +function simplifyPlainMdxComponentNode(node: Node, ignoreElementNames: Set<string>) { + if ( + !isMdxComponentNode(node) || + // Attributes could be dynamic, so bail if so. + node.attributes.length > 0 || + // Fragments are also dynamic + !node.name || + // Ignore if the node name is in the ignore list + ignoreElementNames.has(node.name) || + // If the node name has uppercase characters, it's likely an actual MDX component + node.name.toLowerCase() !== node.name + ) { + return; + } + + // Mutate as hast element node + const newNode = node as unknown as Element; + newNode.type = 'element'; + newNode.tagName = node.name; + newNode.properties = {}; + + // @ts-expect-error Delete mdx-specific properties + node.attributes = undefined; + node.data = undefined; } diff --git a/packages/integrations/mdx/src/remark-images-to-component.ts b/packages/integrations/mdx/src/remark-images-to-component.ts deleted file mode 100644 index 46d04d443..000000000 --- a/packages/integrations/mdx/src/remark-images-to-component.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { MarkdownVFile } from '@astrojs/markdown-remark'; -import type { Image, Parent } from 'mdast'; -import type { MdxJsxAttribute, MdxJsxFlowElement, MdxjsEsm } from 'mdast-util-mdx'; -import { visit } from 'unist-util-visit'; -import { jsToTreeNode } from './utils.js'; - -export const ASTRO_IMAGE_ELEMENT = 'astro-image'; -export const ASTRO_IMAGE_IMPORT = '__AstroImage__'; -export const USES_ASTRO_IMAGE_FLAG = '__usesAstroImage'; - -export function remarkImageToComponent() { - return function (tree: any, file: MarkdownVFile) { - if (!file.data.imagePaths) return; - - const importsStatements: MdxjsEsm[] = []; - const importedImages = new Map<string, string>(); - - visit(tree, 'image', (node: Image, index: number | undefined, parent: Parent | null) => { - // Use the imagePaths set from the remark-collect-images so we don't have to duplicate the logic for - // checking if an image should be imported or not - if (file.data.imagePaths?.has(node.url)) { - let importName = importedImages.get(node.url); - - // If we haven't already imported this image, add an import statement - if (!importName) { - importName = `__${importedImages.size}_${node.url.replace(/\W/g, '_')}__`; - importsStatements.push({ - type: 'mdxjsEsm', - value: '', - data: { - estree: { - type: 'Program', - sourceType: 'module', - body: [ - { - type: 'ImportDeclaration', - source: { - type: 'Literal', - value: node.url, - raw: JSON.stringify(node.url), - }, - specifiers: [ - { - type: 'ImportDefaultSpecifier', - local: { type: 'Identifier', name: importName }, - }, - ], - }, - ], - }, - }, - }); - importedImages.set(node.url, importName); - } - - // Build a component that's equivalent to <Image src={importName} alt={node.alt} title={node.title} /> - const componentElement: MdxJsxFlowElement = { - name: ASTRO_IMAGE_ELEMENT, - type: 'mdxJsxFlowElement', - attributes: [ - { - name: 'src', - type: 'mdxJsxAttribute', - value: { - type: 'mdxJsxAttributeValueExpression', - value: importName, - data: { - estree: { - type: 'Program', - sourceType: 'module', - comments: [], - body: [ - { - type: 'ExpressionStatement', - expression: { type: 'Identifier', name: importName }, - }, - ], - }, - }, - }, - }, - { name: 'alt', type: 'mdxJsxAttribute', value: node.alt || '' }, - ], - children: [], - }; - - if (node.title) { - componentElement.attributes.push({ - type: 'mdxJsxAttribute', - name: 'title', - value: node.title, - }); - } - - if (node.data && node.data.hProperties) { - const createArrayAttribute = (name: string, values: string[]): MdxJsxAttribute => { - return { - type: 'mdxJsxAttribute', - name: name, - value: { - type: 'mdxJsxAttributeValueExpression', - value: name, - data: { - estree: { - type: 'Program', - body: [ - { - type: 'ExpressionStatement', - expression: { - type: 'ArrayExpression', - elements: values.map((value) => ({ - type: 'Literal', - value: value, - raw: String(value), - })), - }, - }, - ], - sourceType: 'module', - comments: [], - }, - }, - }, - }; - }; - // Go through every hProperty and add it as an attribute of the <Image> - Object.entries(node.data.hProperties as Record<string, string | string[]>).forEach( - ([key, value]) => { - if (Array.isArray(value)) { - componentElement.attributes.push(createArrayAttribute(key, value)); - } else { - componentElement.attributes.push({ - name: key, - type: 'mdxJsxAttribute', - value: String(value), - }); - } - } - ); - } - - parent!.children.splice(index!, 1, componentElement); - } - }); - - // Add all the import statements to the top of the file for the images - tree.children.unshift(...importsStatements); - - tree.children.unshift( - jsToTreeNode(`import { Image as ${ASTRO_IMAGE_IMPORT} } from "astro:assets";`) - ); - // Export `__usesAstroImage` to pick up `astro:assets` usage in the module graph. - // @see the '@astrojs/mdx-postprocess' plugin - tree.children.push(jsToTreeNode(`export const ${USES_ASTRO_IMAGE_FLAG} = true`)); - }; -} diff --git a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts index c60504be6..7661c0ecf 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts @@ -5,24 +5,27 @@ import { ASTRO_IMAGE_ELEMENT, ASTRO_IMAGE_IMPORT, USES_ASTRO_IMAGE_FLAG, -} from './remark-images-to-component.js'; +} from './rehype-images-to-component.js'; import { type FileInfo, getFileInfo } from './utils.js'; +const underscoreFragmentImportRegex = /[\s,{]_Fragment[\s,}]/; +const astroTagComponentImportRegex = /[\s,{]__astro_tag_component__[\s,}]/; + // These transforms must happen *after* JSX runtime transformations export function vitePluginMdxPostprocess(astroConfig: AstroConfig): Plugin { return { name: '@astrojs/mdx-postprocess', - transform(code, id) { + transform(code, id, opts) { if (!id.endsWith('.mdx')) return; const fileInfo = getFileInfo(id, astroConfig); const [imports, exports] = parse(code); // Call a series of functions that transform the code - code = injectFragmentImport(code, imports); + code = injectUnderscoreFragmentImport(code, imports); code = injectMetadataExports(code, exports, fileInfo); code = transformContentExport(code, exports); - code = annotateContentExport(code, id); + code = annotateContentExport(code, id, !!opts?.ssr, imports); // The code transformations above are append-only, so the line/column mappings are the same // and we can omit the sourcemap for performance. @@ -31,23 +34,12 @@ export function vitePluginMdxPostprocess(astroConfig: AstroConfig): Plugin { }; } -const fragmentImportRegex = /[\s,{](?:Fragment,|Fragment\s*\})/; - /** - * Inject `Fragment` identifier import if not already present. It should already be injected, - * but check just to be safe. - * - * TODO: Double-check if we no longer need this function. + * Inject `Fragment` identifier import if not already present. */ -function injectFragmentImport(code: string, imports: readonly ImportSpecifier[]) { - const importsFromJSXRuntime = imports - .filter(({ n }) => n === 'astro/jsx-runtime') - .map(({ ss, se }) => code.substring(ss, se)); - const hasFragmentImport = importsFromJSXRuntime.some((statement) => - fragmentImportRegex.test(statement) - ); - if (!hasFragmentImport) { - code = `import { Fragment } from "astro/jsx-runtime"\n` + code; +function injectUnderscoreFragmentImport(code: string, imports: readonly ImportSpecifier[]) { + if (!isSpecifierImported(code, imports, underscoreFragmentImportRegex, 'astro/jsx-runtime')) { + code += `\nimport { Fragment as _Fragment } from 'astro/jsx-runtime';`; } return code; } @@ -81,7 +73,9 @@ function transformContentExport(code: string, exports: readonly ExportSpecifier[ const usesAstroImage = exports.find(({ n }) => n === USES_ASTRO_IMAGE_FLAG); // Generate code for the `components` prop passed to `MDXContent` - let componentsCode = `{ Fragment${hasComponents ? ', ...components' : ''}, ...props.components,`; + let componentsCode = `{ Fragment: _Fragment${ + hasComponents ? ', ...components' : '' + }, ...props.components,`; if (usesAstroImage) { componentsCode += ` ${JSON.stringify(ASTRO_IMAGE_ELEMENT)}: ${ hasComponents ? 'components.img ?? ' : '' @@ -103,7 +97,12 @@ export default Content;`; /** * Add properties to the `Content` export. */ -function annotateContentExport(code: string, id: string) { +function annotateContentExport( + code: string, + id: string, + ssr: boolean, + imports: readonly ImportSpecifier[] +) { // Mark `Content` as MDX component code += `\nContent[Symbol.for('mdx-component')] = true`; // Ensure styles and scripts are injected into a `<head>` when a layout is not applied @@ -111,5 +110,39 @@ function annotateContentExport(code: string, id: string) { // Assign the `moduleId` metadata to `Content` code += `\nContent.moduleId = ${JSON.stringify(id)};`; + // Tag the `Content` export as "astro:jsx" so it's quicker to identify how to render this component + if (ssr) { + if ( + !isSpecifierImported( + code, + imports, + astroTagComponentImportRegex, + 'astro/runtime/server/index.js' + ) + ) { + code += `\nimport { __astro_tag_component__ } from 'astro/runtime/server/index.js';`; + } + code += `\n__astro_tag_component__(Content, 'astro:jsx');`; + } + return code; } + +/** + * Check whether the `specifierRegex` matches for an import of `source` in the `code`. + */ +function isSpecifierImported( + code: string, + imports: readonly ImportSpecifier[], + specifierRegex: RegExp, + source: string +) { + for (const imp of imports) { + if (imp.n !== source) continue; + + const importStatement = code.slice(imp.ss, imp.se); + if (specifierRegex.test(importStatement)) return true; + } + + return false; +} diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts index 6f2ec2cc4..1b966ecd2 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts @@ -1,13 +1,13 @@ -import fs from 'node:fs/promises'; import { setVfileFrontmatter } from '@astrojs/markdown-remark'; -import type { AstroConfig, SSRError } from 'astro'; +import type { SSRError } from 'astro'; +import { getAstroMetadata } from 'astro/jsx/rehype.js'; import { VFile } from 'vfile'; import type { Plugin } from 'vite'; import type { MdxOptions } from './index.js'; import { createMdxProcessor } from './plugins.js'; -import { getFileInfo, parseFrontmatter } from './utils.js'; +import { parseFrontmatter } from './utils.js'; -export function vitePluginMdx(astroConfig: AstroConfig, mdxOptions: MdxOptions): Plugin { +export function vitePluginMdx(mdxOptions: MdxOptions): Plugin { let processor: ReturnType<typeof createMdxProcessor> | undefined; return { @@ -17,21 +17,19 @@ export function vitePluginMdx(astroConfig: AstroConfig, mdxOptions: MdxOptions): processor = undefined; }, configResolved(resolved) { + // `mdxOptions` should be populated at this point, but `astro sync` doesn't call `astro:config:done` :( + // Workaround this for now by skipping here. `astro sync` shouldn't call the `transform()` hook here anyways. + if (Object.keys(mdxOptions).length === 0) return; + processor = createMdxProcessor(mdxOptions, { sourcemap: !!resolved.build.sourcemap, }); - // HACK: move ourselves before Astro's JSX plugin to transform things in the right order + // HACK: Remove the `astro:jsx` plugin if defined as we handle the JSX transformation ourselves const jsxPluginIndex = resolved.plugins.findIndex((p) => p.name === 'astro:jsx'); if (jsxPluginIndex !== -1) { - const myPluginIndex = resolved.plugins.findIndex((p) => p.name === '@mdx-js/rollup'); - if (myPluginIndex !== -1) { - const myPlugin = resolved.plugins[myPluginIndex]; - // @ts-ignore-error ignore readonly annotation - resolved.plugins.splice(myPluginIndex, 1); - // @ts-ignore-error ignore readonly annotation - resolved.plugins.splice(jsxPluginIndex, 0, myPlugin); - } + // @ts-ignore-error ignore readonly annotation + resolved.plugins.splice(jsxPluginIndex, 1); } }, async resolveId(source, importer, options) { @@ -43,13 +41,9 @@ export function vitePluginMdx(astroConfig: AstroConfig, mdxOptions: MdxOptions): }, // Override transform to alter code before MDX compilation // ex. inject layouts - async transform(_, id) { + async transform(code, id) { if (!id.endsWith('.mdx')) return; - // Read code from file manually to prevent Vite from parsing `import.meta.env` expressions - const { fileId } = getFileInfo(id, astroConfig); - const code = await fs.readFile(fileId, 'utf-8'); - const { data: frontmatter, content: pageContent } = parseFrontmatter(code, id); const vfile = new VFile({ value: pageContent, path: id }); @@ -70,13 +64,14 @@ export function vitePluginMdx(astroConfig: AstroConfig, mdxOptions: MdxOptions): return { code: String(compiled.value), map: compiled.map, + meta: getMdxMeta(vfile), }; } catch (e: any) { const err: SSRError = e; // For some reason MDX puts the error location in the error's name, not very useful for us. err.name = 'MDXError'; - err.loc = { file: fileId, line: e.line, column: e.column }; + err.loc = { file: id, line: e.line, column: e.column }; // For another some reason, MDX doesn't include a stack trace. Weird Error.captureStackTrace(err); @@ -86,3 +81,20 @@ export function vitePluginMdx(astroConfig: AstroConfig, mdxOptions: MdxOptions): }, }; } + +function getMdxMeta(vfile: VFile): Record<string, any> { + const astroMetadata = getAstroMetadata(vfile); + if (!astroMetadata) { + throw new Error( + 'Internal MDX error: Astro metadata is not set by rehype-analyze-astro-metadata' + ); + } + return { + astro: astroMetadata, + vite: { + // Setting this vite metadata to `ts` causes Vite to resolve .js + // extensions to .ts files. + lang: 'ts', + }, + }; +} |