diff options
Diffstat (limited to 'packages/integrations/mdx/src')
-rw-r--r-- | packages/integrations/mdx/src/index.ts | 33 | ||||
-rw-r--r-- | packages/integrations/mdx/src/plugins.ts | 2 | ||||
-rw-r--r-- | packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts | 84 | ||||
-rw-r--r-- | packages/integrations/mdx/src/rehype-collect-headings.ts | 6 | ||||
-rw-r--r-- | packages/integrations/mdx/src/rehype-images-to-component.ts | 10 | ||||
-rw-r--r-- | packages/integrations/mdx/src/server.ts | 73 | ||||
-rw-r--r-- | packages/integrations/mdx/src/utils.ts | 6 | ||||
-rw-r--r-- | packages/integrations/mdx/src/vite-plugin-mdx.ts | 31 |
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..fb6766e5f 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: '@astrojs/mdx/server.js', + }); 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 { |