summaryrefslogtreecommitdiff
path: root/packages/integrations/mdx/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/integrations/mdx/src')
-rw-r--r--packages/integrations/mdx/src/README.md39
-rw-r--r--packages/integrations/mdx/src/index.ts29
-rw-r--r--packages/integrations/mdx/src/plugins.ts16
-rw-r--r--packages/integrations/mdx/src/rehype-images-to-component.ts166
-rw-r--r--packages/integrations/mdx/src/rehype-optimize-static.ts251
-rw-r--r--packages/integrations/mdx/src/remark-images-to-component.ts156
-rw-r--r--packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts75
-rw-r--r--packages/integrations/mdx/src/vite-plugin-mdx.ts50
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',
+ },
+ };
+}