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/index.ts33
-rw-r--r--packages/integrations/mdx/src/plugins.ts2
-rw-r--r--packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts84
-rw-r--r--packages/integrations/mdx/src/rehype-collect-headings.ts6
-rw-r--r--packages/integrations/mdx/src/rehype-images-to-component.ts10
-rw-r--r--packages/integrations/mdx/src/server.ts73
-rw-r--r--packages/integrations/mdx/src/utils.ts6
-rw-r--r--packages/integrations/mdx/src/vite-plugin-mdx.ts31
8 files changed, 201 insertions, 44 deletions
diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts
index de29003ff..fd2fab8c8 100644
--- a/packages/integrations/mdx/src/index.ts
+++ b/packages/integrations/mdx/src/index.ts
@@ -8,13 +8,12 @@ import type {
ContentEntryType,
HookParameters,
} from 'astro';
-import astroJSXRenderer from 'astro/jsx/renderer.js';
import type { Options as RemarkRehypeOptions } from 'remark-rehype';
import type { PluggableList } from 'unified';
import type { OptimizeOptions } from './rehype-optimize-static.js';
-import { ignoreStringPlugins, parseFrontmatter } from './utils.js';
+import { ignoreStringPlugins, safeParseFrontmatter } from './utils.js';
import { vitePluginMdxPostprocess } from './vite-plugin-mdx-postprocess.js';
-import { vitePluginMdx } from './vite-plugin-mdx.js';
+import { type VitePluginMdxOptions, vitePluginMdx } from './vite-plugin-mdx.js';
export type MdxOptions = Omit<typeof markdownConfigDefaults, 'remarkPlugins' | 'rehypePlugins'> & {
extendMarkdownConfig: boolean;
@@ -37,14 +36,14 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & {
export function getContainerRenderer(): ContainerRenderer {
return {
name: 'astro:jsx',
- serverEntrypoint: 'astro/jsx/server.js',
+ serverEntrypoint: '@astrojs/mdx/server.js',
};
}
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 = {};
+ let vitePluginMdxOptions: VitePluginMdxOptions = {};
return {
name: '@astrojs/mdx',
@@ -53,17 +52,20 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
const { updateConfig, config, addPageExtension, addContentEntryType, addRenderer } =
params as SetupHookParams;
- addRenderer(astroJSXRenderer);
+ addRenderer({
+ name: 'astro:jsx',
+ serverEntrypoint: new URL('../dist/server.js', import.meta.url),
+ });
addPageExtension('.mdx');
addContentEntryType({
extensions: ['.mdx'],
async getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
- const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
+ const parsed = safeParseFrontmatter(contents, fileURLToPath(fileUrl));
return {
- data: parsed.data,
- body: parsed.content,
- slug: parsed.data.slug,
- rawData: parsed.matter,
+ data: parsed.frontmatter,
+ body: parsed.content.trim(),
+ slug: parsed.frontmatter.slug,
+ rawData: parsed.rawFrontmatter,
};
},
contentModuleTypes: await fs.readFile(
@@ -77,7 +79,7 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
updateConfig({
vite: {
- plugins: [vitePluginMdx(mdxOptions), vitePluginMdxPostprocess(config)],
+ plugins: [vitePluginMdx(vitePluginMdxOptions), vitePluginMdxPostprocess(config)],
},
});
},
@@ -96,10 +98,13 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
});
// Mutate `mdxOptions` so that `vitePluginMdx` can reference the actual options
- Object.assign(mdxOptions, resolvedMdxOptions);
+ Object.assign(vitePluginMdxOptions, {
+ mdxOptions: resolvedMdxOptions,
+ srcDir: config.srcDir,
+ });
// @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 = {};
+ vitePluginMdxOptions = {};
},
},
};
diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts
index 082e8f6fd..77c76243c 100644
--- a/packages/integrations/mdx/src/plugins.ts
+++ b/packages/integrations/mdx/src/plugins.ts
@@ -83,7 +83,7 @@ function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
}
rehypePlugins.push(
- // Render info from `vfile.data.astro.data.frontmatter` as JS
+ // Render info from `vfile.data.astro.frontmatter` as JS
rehypeApplyFrontmatterExport,
// Analyze MDX nodes and attach to `vfile.data.__astroMetadata`
rehypeAnalyzeAstroMetadata,
diff --git a/packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts b/packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts
index 1b981a68e..5880c30b3 100644
--- a/packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts
+++ b/packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts
@@ -1,23 +1,35 @@
-import { InvalidAstroDataError } from '@astrojs/markdown-remark';
-import { safelyGetAstroData } from '@astrojs/markdown-remark/dist/internal.js';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { isFrontmatterValid } from '@astrojs/markdown-remark';
+import type { Root, RootContent } from 'hast';
import type { VFile } from 'vfile';
import { jsToTreeNode } from './utils.js';
+// Passed metadata to help determine adding charset utf8 by default
+declare module 'vfile' {
+ interface DataMap {
+ applyFrontmatterExport?: {
+ srcDir?: URL;
+ };
+ }
+}
+
+const exportConstPartialTrueRe = /export\s+const\s+partial\s*=\s*true/;
+
export function rehypeApplyFrontmatterExport() {
- return function (tree: any, vfile: VFile) {
- const astroData = safelyGetAstroData(vfile.data);
- if (astroData instanceof InvalidAstroDataError)
+ return function (tree: Root, vfile: VFile) {
+ const frontmatter = vfile.data.astro?.frontmatter;
+ if (!frontmatter || !isFrontmatterValid(frontmatter))
throw new Error(
// Copied from Astro core `errors-data`
// TODO: find way to import error data from core
'[MDX] A remark or rehype plugin attempted to inject invalid frontmatter. Ensure "astro.frontmatter" is set to a valid JSON object that is not `null` or `undefined`.',
);
- const { frontmatter } = astroData;
- const exportNodes = [
+ const extraChildren: RootContent[] = [
jsToTreeNode(`export const frontmatter = ${JSON.stringify(frontmatter)};`),
];
if (frontmatter.layout) {
- exportNodes.unshift(
+ extraChildren.unshift(
jsToTreeNode(
// NOTE: Use `__astro_*` import names to prevent conflicts with user code
/** @see 'vite-plugin-markdown' for layout props reference */
@@ -41,7 +53,61 @@ export default function ({ children }) {
};`,
),
);
+ } else if (shouldAddCharset(tree, vfile)) {
+ extraChildren.unshift({
+ type: 'mdxJsxFlowElement',
+ name: 'meta',
+ attributes: [
+ {
+ type: 'mdxJsxAttribute',
+ name: 'charset',
+ value: 'utf-8',
+ },
+ ],
+ children: [],
+ });
}
- tree.children = exportNodes.concat(tree.children);
+ tree.children = extraChildren.concat(tree.children);
};
}
+
+/**
+ * If this is a page (e.g. in src/pages), has no layout frontmatter (handled before calling this function),
+ * has no leading component that looks like a wrapping layout, and `partial` isn't set to true, we default to
+ * adding charset=utf-8 like markdown so that users don't have to worry about it for MDX pages without layouts.
+ */
+function shouldAddCharset(tree: Root, vfile: VFile) {
+ const srcDirUrl = vfile.data.applyFrontmatterExport?.srcDir;
+ if (!srcDirUrl) return false;
+
+ const hasConstPartialTrue = tree.children.some(
+ (node) => node.type === 'mdxjsEsm' && exportConstPartialTrueRe.test(node.value),
+ );
+ if (hasConstPartialTrue) return false;
+
+ // NOTE: the pages directory is a non-configurable Astro behaviour
+ const pagesDir = path.join(fileURLToPath(srcDirUrl), 'pages').replace(/\\/g, '/');
+ // `vfile.path` comes from Vite, which is a normalized path (no backslashes)
+ const filePath = vfile.path;
+ if (!filePath.startsWith(pagesDir)) return false;
+
+ const hasLeadingUnderscoreInPath = filePath
+ .slice(pagesDir.length)
+ .replace(/\\/g, '/')
+ .split('/')
+ .some((part) => part.startsWith('_'));
+ if (hasLeadingUnderscoreInPath) return false;
+
+ // Bail if the first content found is a wrapping layout component
+ for (const child of tree.children) {
+ if (child.type === 'element') break;
+ if (child.type === 'mdxJsxFlowElement') {
+ // If is fragment or lowercase tag name (html tags), skip and assume there's no layout
+ if (child.name == null) break;
+ if (child.name[0] === child.name[0].toLowerCase()) break;
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/packages/integrations/mdx/src/rehype-collect-headings.ts b/packages/integrations/mdx/src/rehype-collect-headings.ts
index fafc59721..a51e8e9f0 100644
--- a/packages/integrations/mdx/src/rehype-collect-headings.ts
+++ b/packages/integrations/mdx/src/rehype-collect-headings.ts
@@ -1,9 +1,9 @@
-import type { MarkdownHeading, MarkdownVFile } from '@astrojs/markdown-remark';
+import type { VFile } from 'vfile';
import { jsToTreeNode } from './utils.js';
export function rehypeInjectHeadingsExport() {
- return function (tree: any, file: MarkdownVFile) {
- const headings: MarkdownHeading[] = file.data.__astroHeadings || [];
+ return function (tree: any, file: VFile) {
+ const headings = file.data.astro?.headings ?? [];
tree.children.unshift(
jsToTreeNode(`export function getHeadings() { return ${JSON.stringify(headings)} }`),
);
diff --git a/packages/integrations/mdx/src/rehype-images-to-component.ts b/packages/integrations/mdx/src/rehype-images-to-component.ts
index 95b500784..da2f25ee5 100644
--- a/packages/integrations/mdx/src/rehype-images-to-component.ts
+++ b/packages/integrations/mdx/src/rehype-images-to-component.ts
@@ -1,8 +1,8 @@
-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 type { VFile } from 'vfile';
import { jsToTreeNode } from './utils.js';
export const ASTRO_IMAGE_ELEMENT = 'astro-image';
@@ -72,18 +72,18 @@ function getImageComponentAttributes(props: Properties): MdxJsxAttribute[] {
}
export function rehypeImageToComponent() {
- return function (tree: Root, file: MarkdownVFile) {
- if (!file.data.imagePaths) return;
+ return function (tree: Root, file: VFile) {
+ if (!file.data.astro?.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;
+ if (!file.data.astro?.imagePaths || node.tagName !== 'img' || !node.properties.src) return;
const src = decodeURI(String(node.properties.src));
- if (!file.data.imagePaths.has(src)) return;
+ if (!file.data.astro.imagePaths?.includes(src)) return;
let importName = importedImages.get(src);
diff --git a/packages/integrations/mdx/src/server.ts b/packages/integrations/mdx/src/server.ts
new file mode 100644
index 000000000..79934eb32
--- /dev/null
+++ b/packages/integrations/mdx/src/server.ts
@@ -0,0 +1,73 @@
+import type { NamedSSRLoadedRendererValue } from 'astro';
+import { AstroError } from 'astro/errors';
+import { AstroJSX, jsx } from 'astro/jsx-runtime';
+import { renderJSX } from 'astro/runtime/server/index.js';
+
+const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
+
+// NOTE: In practice, MDX components are always tagged with `__astro_tag_component__`, so the right renderer
+// is used directly, and this check is not often used to return true.
+export async function check(
+ Component: any,
+ props: any,
+ { default: children = null, ...slotted } = {},
+) {
+ if (typeof Component !== 'function') return false;
+ const slots: Record<string, any> = {};
+ for (const [key, value] of Object.entries(slotted)) {
+ const name = slotName(key);
+ slots[name] = value;
+ }
+ try {
+ const result = await Component({ ...props, ...slots, children });
+ return result[AstroJSX];
+ } catch (e) {
+ throwEnhancedErrorIfMdxComponent(e as Error, Component);
+ }
+ return false;
+}
+
+export async function renderToStaticMarkup(
+ this: any,
+ Component: any,
+ props = {},
+ { default: children = null, ...slotted } = {},
+) {
+ const slots: Record<string, any> = {};
+ for (const [key, value] of Object.entries(slotted)) {
+ const name = slotName(key);
+ slots[name] = value;
+ }
+
+ const { result } = this;
+ try {
+ const html = await renderJSX(result, jsx(Component, { ...props, ...slots, children }));
+ return { html };
+ } catch (e) {
+ throwEnhancedErrorIfMdxComponent(e as Error, Component);
+ throw e;
+ }
+}
+
+function throwEnhancedErrorIfMdxComponent(error: Error, Component: any) {
+ // if the exception is from an mdx component
+ // throw an error
+ if (Component[Symbol.for('mdx-component')]) {
+ // if it's an existing AstroError, we don't need to re-throw, keep the original hint
+ if (AstroError.is(error)) return;
+ // Mimic the fields of the internal `AstroError` class (not from `astro/errors`) to
+ // provide better title and hint for the error overlay
+ (error as any).title = error.name;
+ (error as any).hint =
+ `This issue often occurs when your MDX component encounters runtime errors.`;
+ throw error;
+ }
+}
+
+const renderer: NamedSSRLoadedRendererValue = {
+ name: 'astro:jsx',
+ check,
+ renderToStaticMarkup,
+};
+
+export default renderer;
diff --git a/packages/integrations/mdx/src/utils.ts b/packages/integrations/mdx/src/utils.ts
index ad98abb9e..7dcd4a14c 100644
--- a/packages/integrations/mdx/src/utils.ts
+++ b/packages/integrations/mdx/src/utils.ts
@@ -1,7 +1,7 @@
+import { parseFrontmatter } from '@astrojs/markdown-remark';
import type { Options as AcornOpts } from 'acorn';
import { parse } from 'acorn';
import type { AstroConfig, AstroIntegrationLogger, SSRError } from 'astro';
-import matter from 'gray-matter';
import { bold } from 'kleur/colors';
import type { MdxjsEsm } from 'mdast-util-mdx';
import type { PluggableList } from 'unified';
@@ -48,9 +48,9 @@ export function getFileInfo(id: string, config: AstroConfig): FileInfo {
* Match YAML exception handling from Astro core errors
* @see 'astro/src/core/errors.ts'
*/
-export function parseFrontmatter(code: string, id: string) {
+export function safeParseFrontmatter(code: string, id: string) {
try {
- return matter(code);
+ return parseFrontmatter(code, { frontmatter: 'empty-with-spaces' });
} catch (e: any) {
if (e.name === 'YAMLException') {
const err: SSRError = e;
diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts
index 5a409d40d..869c65d26 100644
--- a/packages/integrations/mdx/src/vite-plugin-mdx.ts
+++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts
@@ -1,13 +1,18 @@
-import { setVfileFrontmatter } from '@astrojs/markdown-remark';
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 { parseFrontmatter } from './utils.js';
+import { safeParseFrontmatter } from './utils.js';
-export function vitePluginMdx(mdxOptions: MdxOptions): Plugin {
+export interface VitePluginMdxOptions {
+ mdxOptions: MdxOptions;
+ srcDir: URL;
+}
+
+// NOTE: Do not destructure `opts` as we're assigning a reference that will be mutated later
+export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin {
let processor: ReturnType<typeof createMdxProcessor> | undefined;
let sourcemapEnabled: boolean;
@@ -39,16 +44,24 @@ export function vitePluginMdx(mdxOptions: MdxOptions): Plugin {
async transform(code, id) {
if (!id.endsWith('.mdx')) return;
- const { data: frontmatter, content: pageContent, matter } = parseFrontmatter(code, id);
- const frontmatterLines = matter ? matter.match(/\n/g)?.join('') + '\n\n' : '';
+ const { frontmatter, content } = safeParseFrontmatter(code, id);
- const vfile = new VFile({ value: frontmatterLines + pageContent, path: id });
- // Ensure `data.astro` is available to all remark plugins
- setVfileFrontmatter(vfile, frontmatter);
+ const vfile = new VFile({
+ value: content,
+ path: id,
+ data: {
+ astro: {
+ frontmatter,
+ },
+ applyFrontmatterExport: {
+ srcDir: opts.srcDir,
+ },
+ },
+ });
// Lazily initialize the MDX processor
if (!processor) {
- processor = createMdxProcessor(mdxOptions, { sourcemap: sourcemapEnabled });
+ processor = createMdxProcessor(opts.mdxOptions, { sourcemap: sourcemapEnabled });
}
try {