diff --git a/.changeset/blue-geese-visit.md b/.changeset/blue-geese-visit.md new file mode 100644 index 000000000000..408386d046c3 --- /dev/null +++ b/.changeset/blue-geese-visit.md @@ -0,0 +1,5 @@ +--- +"@astrojs/mdx": patch +--- + +Simplifies plain MDX components as hast element nodes to further improve HTML string inlining for the `optimize` option diff --git a/.changeset/chilly-items-help.md b/.changeset/chilly-items-help.md new file mode 100644 index 000000000000..7e868474e32c --- /dev/null +++ b/.changeset/chilly-items-help.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Improves the error message when failed to render MDX components diff --git a/.changeset/fresh-masks-agree.md b/.changeset/fresh-masks-agree.md new file mode 100644 index 000000000000..08fc812c8841 --- /dev/null +++ b/.changeset/fresh-masks-agree.md @@ -0,0 +1,5 @@ +--- +"@astrojs/mdx": major +--- + +Refactors the MDX transformation to rely only on the unified pipeline. Babel and esbuild transformations are removed, which should result in faster build times. The refactor requires using Astro v4.8.0 but no other changes are necessary. diff --git a/.changeset/friendly-plants-leave.md b/.changeset/friendly-plants-leave.md new file mode 100644 index 000000000000..c972fa42c4db --- /dev/null +++ b/.changeset/friendly-plants-leave.md @@ -0,0 +1,5 @@ +--- +"astro": minor +--- + +Exports `astro/jsx/rehype.js` with utilities to generate an Astro metadata object diff --git a/.changeset/grumpy-pillows-develop.md b/.changeset/grumpy-pillows-develop.md new file mode 100644 index 000000000000..bba2a6fdcc97 --- /dev/null +++ b/.changeset/grumpy-pillows-develop.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Refactors internal handling of styles and scripts for content collections to improve build performance diff --git a/.changeset/large-glasses-jam.md b/.changeset/large-glasses-jam.md new file mode 100644 index 000000000000..885471d82fba --- /dev/null +++ b/.changeset/large-glasses-jam.md @@ -0,0 +1,5 @@ +--- +"@astrojs/mdx": patch +--- + +Allows Vite plugins to transform `.mdx` files before the MDX plugin transforms it diff --git a/.changeset/metal-crabs-applaud.md b/.changeset/metal-crabs-applaud.md new file mode 100644 index 000000000000..c7cb3a26a7f6 --- /dev/null +++ b/.changeset/metal-crabs-applaud.md @@ -0,0 +1,5 @@ +--- +"astro": minor +--- + +Adds a new radio checkbox component to the dev toolbar UI library (`astro-dev-toolbar-radio-checkbox`) diff --git a/.changeset/pink-ligers-share.md b/.changeset/pink-ligers-share.md new file mode 100644 index 000000000000..e7923350fb5b --- /dev/null +++ b/.changeset/pink-ligers-share.md @@ -0,0 +1,49 @@ +--- +"astro": minor +--- + +Adds experimental rewriting in Astro with a new `rewrite()` function and the middleware `next()` function. + +The feature is available via an experimental flag in `astro.config.mjs`: + +```js +export default defineConfig({ + experimental: { + rewriting: true + } +}) +``` + +When enabled, you can use `rewrite()` to **render** another page without changing the URL of the browser in Astro pages and endpoints. + +```astro +--- +// src/pages/dashboard.astro +if (!Astro.props.allowed) { + return Astro.rewrite("/") +} +--- +``` + +```js +// src/pages/api.js +export function GET(ctx) { + if (!ctx.locals.allowed) { + return ctx.rewrite("/") + } +} +``` + +The middleware `next()` function now accepts a parameter with the same type as the `rewrite()` function. For example, with `next("/")`, you can call the next middleware function with a new `Request`. + +```js +// src/middleware.js +export function onRequest(ctx, next) { + if (!ctx.cookies.get("allowed")) { + return next("/") // new signature + } + return next(); +} +``` + +> **NOTE**: please [read the RFC](https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md) to understand the current expectations of the new APIs. diff --git a/.changeset/short-phones-breathe.md b/.changeset/short-phones-breathe.md new file mode 100644 index 000000000000..d27d015afbec --- /dev/null +++ b/.changeset/short-phones-breathe.md @@ -0,0 +1,5 @@ +--- +"@astrojs/react": patch +--- + +Updates package to support React 19 beta diff --git a/.changeset/slimy-cobras-end.md b/.changeset/slimy-cobras-end.md new file mode 100644 index 000000000000..58f22ac07c12 --- /dev/null +++ b/.changeset/slimy-cobras-end.md @@ -0,0 +1,7 @@ +--- +"@astrojs/mdx": major +--- + +Allows integrations after the MDX integration to update `markdown.remarkPlugins` and `markdown.rehypePlugins`, and have the plugins work in MDX too. + +If your integration relies on Astro's previous behavior that prevents integrations from adding remark/rehype plugins for MDX, you will now need to configure `@astrojs/mdx` with `extendMarkdownConfig: false` and explicitly specify any `remarkPlugins` and `rehypePlugins` options instead. diff --git a/.changeset/small-oranges-report.md b/.changeset/small-oranges-report.md new file mode 100644 index 000000000000..8d0906e0530b --- /dev/null +++ b/.changeset/small-oranges-report.md @@ -0,0 +1,5 @@ +--- +"@astrojs/mdx": major +--- + +Renames the `optimize.customComponentNames` option to `optimize.ignoreElementNames` to better reflect its usecase. Its behaviour is not changed and should continue to work as before. diff --git a/.changeset/smart-rats-mate.md b/.changeset/smart-rats-mate.md new file mode 100644 index 000000000000..b779a86c8a5b --- /dev/null +++ b/.changeset/smart-rats-mate.md @@ -0,0 +1,5 @@ +--- +"@astrojs/mdx": patch +--- + +Updates the `optimize` option to group static sibling nodes as a ``. This reduces the number of AST nodes and simplifies runtime rendering of MDX pages. diff --git a/.changeset/sweet-goats-own.md b/.changeset/sweet-goats-own.md new file mode 100644 index 000000000000..6689246c33b3 --- /dev/null +++ b/.changeset/sweet-goats-own.md @@ -0,0 +1,5 @@ +--- +"@astrojs/mdx": major +--- + +Replaces the internal `remark-images-to-component` plugin with `rehype-images-to-component` to let users use additional rehype plugins for images diff --git a/.changeset/tame-avocados-relax.md b/.changeset/tame-avocados-relax.md new file mode 100644 index 000000000000..9b6a36881c03 --- /dev/null +++ b/.changeset/tame-avocados-relax.md @@ -0,0 +1,5 @@ +--- +"@astrojs/mdx": patch +--- + +Tags the MDX component export for quicker component checks while rendering diff --git a/.changeset/twelve-dolphins-roll.md b/.changeset/twelve-dolphins-roll.md new file mode 100644 index 000000000000..baf944281d70 --- /dev/null +++ b/.changeset/twelve-dolphins-roll.md @@ -0,0 +1,5 @@ +--- +"astro": minor +--- + +Adds a new `buttonBorderRadius` property to the `astro-dev-toolbar-button` component for the dev toolbar component library. This property can be useful to make a fully rounded button with an icon in the center. diff --git a/.changeset/violet-snails-call.md b/.changeset/violet-snails-call.md new file mode 100644 index 000000000000..b7f06a7b9321 --- /dev/null +++ b/.changeset/violet-snails-call.md @@ -0,0 +1,5 @@ +--- +"@astrojs/mdx": patch +--- + +Fixes `export const components` keys detection for the `optimize` option diff --git a/.changeset/young-chicken-exercise.md b/.changeset/young-chicken-exercise.md new file mode 100644 index 000000000000..04b7417bbe21 --- /dev/null +++ b/.changeset/young-chicken-exercise.md @@ -0,0 +1,5 @@ +--- +"@astrojs/mdx": patch +--- + +Improves `optimize` handling for MDX components with attributes and inline MDX components diff --git a/packages/astro/package.json b/packages/astro/package.json index 6c3bcfeddbf3..572d5a9863f8 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -209,6 +209,8 @@ "astro-scripts": "workspace:*", "cheerio": "1.0.0-rc.12", "eol": "^0.9.1", + "mdast-util-mdx": "^3.0.0", + "mdast-util-mdx-jsx": "^3.1.2", "memfs": "^4.9.1", "node-mocks-http": "^1.14.1", "parse-srcset": "^1.0.2", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 0cff203cf227..cb39ec785ded 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -33,6 +33,7 @@ import type { DevToolbarCard, DevToolbarHighlight, DevToolbarIcon, + DevToolbarRadioCheckbox, DevToolbarSelect, DevToolbarToggle, DevToolbarTooltip, @@ -250,6 +251,19 @@ export interface AstroGlobal< * [Astro reference](https://docs.astro.build/en/guides/server-side-rendering/) */ redirect: AstroSharedContext['redirect']; + /** + * It rewrites to another page. As opposed to redirects, the URL won't change, and Astro will render the HTML emitted + * by the rewritten URL passed as argument. + * + * ## Example + * + * ```js + * if (pageIsNotEnabled) { + * return Astro.rewrite('/fallback-page') + * } + * ``` + */ + rewrite: AstroSharedContext['rewrite']; /** * The element allows a component to reference itself recursively. * @@ -1641,7 +1655,7 @@ export interface AstroUserConfig { domains?: Record; }; - /** ⚠️ WARNING: SUBJECT TO CHANGE */ + /** ! WARNING: SUBJECT TO CHANGE */ db?: Config.Database; /** @@ -1922,6 +1936,62 @@ export interface AstroUserConfig { origin?: boolean; }; }; + + /** + * @docs + * @name experimental.rewriting + * @type {boolean} + * @default `false` + * @version 4.8.0 + * @description + * + * Enables a routing feature for rewriting requests in Astro pages, endpoints and Astro middleware, giving you programmatic control over your routes. + * + * ```js + * { + * experimental: { + * rewriting: true, + * }, + * } + * ``` + * + * Use `Astro.rewrite` in your `.astro` files to reroute to a different page: + * + * ```astro "rewrite" + * --- + * // src/pages/dashboard.astro + * if (!Astro.props.allowed) { + * return Astro.rewrite("/") + * } + * --- + * ``` + * + * Use `context.rewrite` in your endpoint files to reroute to a different page: + * + * ```js + * // src/pages/api.js + * export function GET(ctx) { + * if (!ctx.locals.allowed) { + * return ctx.rewrite("/") + * } + * } + * ``` + * + * Use `next("/")` in your middleware file to reroute to a different page, and then call the next middleware function: + * + * ```js + * // src/middleware.js + * export function onRequest(ctx, next) { + * if (!ctx.cookies.get("allowed")) { + * return next("/") // new signature + * } + * return next(); + * } + * ``` + * + * For a complete overview, and to give feedback on this experimental API, see the [Rerouting RFC](https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md). + */ + rewriting: boolean; }; } @@ -2491,6 +2561,20 @@ interface AstroSharedContext< */ redirect(path: string, status?: ValidRedirectStatus): Response; + /** + * It rewrites to another page. As opposed to redirects, the URL won't change, and Astro will render the HTML emitted + * by the rerouted URL passed as argument. + * + * ## Example + * + * ```js + * if (pageIsNotEnabled) { + * return Astro.rewrite('/fallback-page') + * } + * ``` + */ + rewrite(rewritePayload: RewritePayload): Promise; + /** * Object accessed via Astro middleware */ @@ -2605,6 +2689,21 @@ export interface APIContext< */ redirect: AstroSharedContext['redirect']; + /** + * It reroutes to another page. As opposed to redirects, the URL won't change, and Astro will render the HTML emitted + * by the rerouted URL passed as argument. + * + * ## Example + * + * ```ts + * // src/pages/secret.ts + * export function GET(ctx) { + * return ctx.rewrite(new URL("../"), ctx.url); + * } + * ``` + */ + rewrite: AstroSharedContext['rewrite']; + /** * An object that middlewares can use to store extra information related to the request. * @@ -2799,7 +2898,9 @@ export interface AstroIntegration { }; } -export type MiddlewareNext = () => Promise; +export type RewritePayload = string | URL | Request; + +export type MiddlewareNext = (rewritePayload?: RewritePayload) => Promise; export type MiddlewareHandler = ( context: APIContext, next: MiddlewareNext @@ -3087,6 +3188,7 @@ declare global { 'astro-dev-toolbar-icon': DevToolbarIcon; 'astro-dev-toolbar-card': DevToolbarCard; 'astro-dev-toolbar-select': DevToolbarSelect; + 'astro-dev-toolbar-radio-checkbox': DevToolbarRadioCheckbox; // Deprecated names // TODO: Remove in Astro 5.0 diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts index 591cad3c70f6..d3228270a6dc 100644 --- a/packages/astro/src/content/vite-plugin-content-assets.ts +++ b/packages/astro/src/content/vite-plugin-content-assets.ts @@ -3,8 +3,7 @@ import { pathToFileURL } from 'node:url'; import type { Plugin, Rollup } from 'vite'; import type { AstroSettings, SSRElement } from '../@types/astro.js'; import { getAssetsPrefix } from '../assets/utils/getAssetsPrefix.js'; -import { getParentModuleInfos, moduleIsTopLevelPage } from '../core/build/graph.js'; -import { type BuildInternals, getPageDataByViteID } from '../core/build/internal.js'; +import type { BuildInternals } from '../core/build/internal.js'; import type { AstroBuildPlugin } from '../core/build/plugin.js'; import type { StaticBuildOptions } from '../core/build/types.js'; import type { ModuleLoader } from '../core/module-loader/loader.js'; @@ -163,49 +162,25 @@ export function astroConfigBuildPlugin( chunk.type === 'chunk' && (chunk.code.includes(LINKS_PLACEHOLDER) || chunk.code.includes(SCRIPTS_PLACEHOLDER)) ) { - let entryStyles = new Set(); - let entryLinks = new Set(); - let entryScripts = new Set(); + const entryStyles = new Set(); + const entryLinks = new Set(); + const entryScripts = new Set(); - if (options.settings.config.experimental.contentCollectionCache) { - // TODO: hoisted scripts are still handled on the pageData rather than the asset propagation point - for (const id of chunk.moduleIds) { - const _entryCss = internals.propagatedStylesMap.get(id); - const _entryScripts = internals.propagatedScriptsMap.get(id); - if (_entryCss) { - for (const value of _entryCss) { - if (value.type === 'inline') entryStyles.add(value.content); - if (value.type === 'external') entryLinks.add(value.src); - } - } - if (_entryScripts) { - for (const value of _entryScripts) { - entryScripts.add(value); - } + for (const id of chunk.moduleIds) { + const _entryCss = internals.propagatedStylesMap.get(id); + const _entryScripts = internals.propagatedScriptsMap.get(id); + if (_entryCss) { + // TODO: Separating styles and links this way is not ideal. The `entryCss` list is order-sensitive + // and splitting them into two sets causes the order to be lost, because styles are rendered after + // links. Refactor this away in the future. + for (const value of _entryCss) { + if (value.type === 'inline') entryStyles.add(value.content); + if (value.type === 'external') entryLinks.add(value.src); } } - } else { - for (const id of Object.keys(chunk.modules)) { - for (const pageInfo of getParentModuleInfos(id, ssrPluginContext!)) { - if (moduleIsTopLevelPage(pageInfo)) { - const pageViteID = pageInfo.id; - const pageData = getPageDataByViteID(internals, pageViteID); - if (!pageData) continue; - - const _entryCss = pageData.propagatedStyles?.get(id); - const _entryScripts = pageData.propagatedScripts?.get(id); - if (_entryCss) { - for (const value of _entryCss) { - if (value.type === 'inline') entryStyles.add(value.content); - if (value.type === 'external') entryLinks.add(value.src); - } - } - if (_entryScripts) { - for (const value of _entryScripts) { - entryScripts.add(value); - } - } - } + if (_entryScripts) { + for (const value of _entryScripts) { + entryScripts.add(value); } } } diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 116151610e1c..1ba5d9479833 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -1,13 +1,6 @@ -import type { - ComponentInstance, - ManifestData, - RouteData, - SSRManifest, -} from '../../@types/astro.js'; +import type { ManifestData, RouteData, SSRManifest } from '../../@types/astro.js'; import { normalizeTheLocale } from '../../i18n/index.js'; -import type { SinglePageBuiltModule } from '../build/types.js'; import { - DEFAULT_404_COMPONENT, REROUTABLE_STATUS_CODES, REROUTE_DIRECTIVE_HEADER, clientAddressSymbol, @@ -26,7 +19,6 @@ import { prependForwardSlash, removeTrailingForwardSlash, } from '../path.js'; -import { RedirectSinglePageBuiltModule } from '../redirects/index.js'; import { RenderContext } from '../render-context.js'; import { createAssetLink } from '../render/ssr-element.js'; import { ensure404Route } from '../routing/astro-designed-error-pages.js'; @@ -96,7 +88,7 @@ export class App { routes: manifest.routes.map((route) => route.routeData), }); this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base); - this.#pipeline = this.#createPipeline(streaming); + this.#pipeline = this.#createPipeline(this.#manifestData, streaming); this.#adapterLogger = new AstroIntegrationLogger( this.#logger.options, this.#manifest.adapterName @@ -110,10 +102,11 @@ export class App { /** * Creates a pipeline by reading the stored manifest * + * @param manifestData * @param streaming * @private */ - #createPipeline(streaming = false) { + #createPipeline(manifestData: ManifestData, streaming = false) { if (this.#manifest.checkOrigin) { this.#manifest.middleware = sequence( createOriginCheckMiddleware(), @@ -121,7 +114,7 @@ export class App { ); } - return AppPipeline.create({ + return AppPipeline.create(manifestData, { logger: this.#logger, manifest: this.#manifest, mode: 'production', @@ -309,7 +302,7 @@ export class App { } const pathname = this.#getPathnameFromRequest(request); const defaultStatus = this.#getDefaultStatusCode(routeData, pathname); - const mod = await this.#getModuleForRoute(routeData); + const mod = await this.#pipeline.getModuleForRoute(routeData); let response; try { @@ -405,7 +398,7 @@ export class App { return this.#mergeResponses(response, originalResponse, override); } - const mod = await this.#getModuleForRoute(errorRouteData); + const mod = await this.#pipeline.getModuleForRoute(errorRouteData); try { const renderContext = RenderContext.create({ locals, @@ -493,35 +486,4 @@ export class App { if (route.endsWith('/500')) return 500; return 200; } - - async #getModuleForRoute(route: RouteData): Promise { - if (route.component === DEFAULT_404_COMPONENT) { - return { - page: async () => - ({ default: () => new Response(null, { status: 404 }) }) as ComponentInstance, - renderers: [], - }; - } - if (route.type === 'redirect') { - return RedirectSinglePageBuiltModule; - } else { - if (this.#manifest.pageMap) { - const importComponentInstance = this.#manifest.pageMap.get(route.component); - if (!importComponentInstance) { - throw new Error( - `Unexpectedly unable to find a component instance for route ${route.route}` - ); - } - const pageModule = await importComponentInstance(); - return pageModule; - } else if (this.#manifest.pageModule) { - const importComponentInstance = this.#manifest.pageModule; - return importComponentInstance; - } else { - throw new Error( - "Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error, please file an issue." - ); - } - } - } } diff --git a/packages/astro/src/core/app/pipeline.ts b/packages/astro/src/core/app/pipeline.ts index b1c615a1eb36..f62dc84ed983 100644 --- a/packages/astro/src/core/app/pipeline.ts +++ b/packages/astro/src/core/app/pipeline.ts @@ -1,21 +1,46 @@ -import type { RouteData, SSRElement, SSRResult } from '../../@types/astro.js'; +import type { + ComponentInstance, + ManifestData, + RewritePayload, + RouteData, + SSRElement, + SSRResult, +} from '../../@types/astro.js'; import { Pipeline } from '../base-pipeline.js'; +import type { SinglePageBuiltModule } from '../build/types.js'; +import { DEFAULT_404_COMPONENT } from '../constants.js'; +import { RedirectSinglePageBuiltModule } from '../redirects/component.js'; import { createModuleScriptElement, createStylesheetElementSet } from '../render/ssr-element.js'; export class AppPipeline extends Pipeline { - static create({ - logger, - manifest, - mode, - renderers, - resolve, - serverLike, - streaming, - }: Pick< - AppPipeline, - 'logger' | 'manifest' | 'mode' | 'renderers' | 'resolve' | 'serverLike' | 'streaming' - >) { - return new AppPipeline(logger, manifest, mode, renderers, resolve, serverLike, streaming); + #manifestData: ManifestData | undefined; + + static create( + manifestData: ManifestData, + { + logger, + manifest, + mode, + renderers, + resolve, + serverLike, + streaming, + }: Pick< + AppPipeline, + 'logger' | 'manifest' | 'mode' | 'renderers' | 'resolve' | 'serverLike' | 'streaming' + > + ) { + const pipeline = new AppPipeline( + logger, + manifest, + mode, + renderers, + resolve, + serverLike, + streaming + ); + pipeline.#manifestData = manifestData; + return pipeline; } headElements(routeData: RouteData): Pick { @@ -41,4 +66,64 @@ export class AppPipeline extends Pipeline { } componentMetadata() {} + async getComponentByRoute(routeData: RouteData): Promise { + const module = await this.getModuleForRoute(routeData); + return module.page(); + } + + async tryRewrite(payload: RewritePayload): Promise<[RouteData, ComponentInstance]> { + let foundRoute; + + for (const route of this.#manifestData!.routes) { + if (payload instanceof URL) { + if (route.pattern.test(payload.pathname)) { + foundRoute = route; + break; + } + } else if (payload instanceof Request) { + const url = new URL(payload.url); + if (route.pattern.test(url.pathname)) { + foundRoute = route; + break; + } + } else if (route.pattern.test(decodeURI(payload))) { + foundRoute = route; + break; + } + } + + if (foundRoute) { + const componentInstance = await this.getComponentByRoute(foundRoute); + return [foundRoute, componentInstance]; + } + throw new Error('Route not found'); + } + + async getModuleForRoute(route: RouteData): Promise { + if (route.component === DEFAULT_404_COMPONENT) { + return { + page: async () => + ({ default: () => new Response(null, { status: 404 }) }) as ComponentInstance, + renderers: [], + }; + } + if (route.type === 'redirect') { + return RedirectSinglePageBuiltModule; + } else { + if (this.manifest.pageMap) { + const importComponentInstance = this.manifest.pageMap.get(route.component); + if (!importComponentInstance) { + throw new Error( + `Unexpectedly unable to find a component instance for route ${route.route}` + ); + } + return await importComponentInstance(); + } else if (this.manifest.pageModule) { + return this.manifest.pageModule; + } + throw new Error( + "Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error, please file an issue." + ); + } + } } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index fd56c6f1068f..30134252ef9d 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -65,6 +65,8 @@ export type SSRManifest = { i18n: SSRManifestI18n | undefined; middleware: MiddlewareHandler; checkOrigin: boolean; + // TODO: remove once the experimental flag is removed + rewritingEnabled: boolean; }; export type SSRManifestI18n = { diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index 832823db35fa..11cff7c809f5 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -1,5 +1,7 @@ import type { + ComponentInstance, MiddlewareHandler, + RewritePayload, RouteData, RuntimeMode, SSRLoadedRenderer, @@ -59,6 +61,23 @@ export abstract class Pipeline { abstract headElements(routeData: RouteData): Promise | HeadElements; abstract componentMetadata(routeData: RouteData): Promise | void; + + /** + * It attempts to retrieve the `RouteData` that matches the input `url`, and the component that belongs to the `RouteData`. + * + * ## Errors + * + * - if not `RouteData` is found + * + * @param {RewritePayload} rewritePayload + */ + abstract tryRewrite(rewritePayload: RewritePayload): Promise<[RouteData, ComponentInstance]>; + + /** + * Tells the pipeline how to retrieve a component give a `RouteData` + * @param routeData + */ + abstract getComponentByRoute(routeData: RouteData): Promise; } // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index ffe799f6e7e4..355d551eaa6c 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -35,24 +35,14 @@ import { getOutputDirectory } from '../../prerender/utils.js'; import type { SSRManifestI18n } from '../app/types.js'; import { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; -import { routeIsFallback } from '../redirects/helpers.js'; -import { - RedirectSinglePageBuiltModule, - getRedirectLocationOrThrow, - routeIsRedirect, -} from '../redirects/index.js'; +import { getRedirectLocationOrThrow, routeIsRedirect } from '../redirects/index.js'; import { RenderContext } from '../render-context.js'; import { callGetStaticPaths } from '../render/route-cache.js'; import { createRequest } from '../request.js'; import { matchRoute } from '../routing/match.js'; import { getOutputFilename, isServerLikeOutput } from '../util.js'; import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js'; -import { - cssOrder, - getEntryFilePathFromComponentPath, - getPageDataByComponent, - mergeInlineCss, -} from './internal.js'; +import { cssOrder, getPageDataByComponent, mergeInlineCss } from './internal.js'; import { BuildPipeline } from './pipeline.js'; import type { PageBuildData, @@ -66,46 +56,6 @@ function createEntryURL(filePath: string, outFolder: URL) { return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); } -async function getEntryForRedirectRoute( - route: RouteData, - internals: BuildInternals, - outFolder: URL -): Promise { - if (route.type !== 'redirect') { - throw new Error(`Expected a redirect route.`); - } - if (route.redirectRoute) { - const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); - if (filePath) { - const url = createEntryURL(filePath, outFolder); - const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); - return ssrEntryPage; - } - } - - return RedirectSinglePageBuiltModule; -} - -async function getEntryForFallbackRoute( - route: RouteData, - internals: BuildInternals, - outFolder: URL -): Promise { - if (route.type !== 'fallback') { - throw new Error(`Expected a redirect route.`); - } - if (route.redirectRoute) { - const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); - if (filePath) { - const url = createEntryURL(filePath, outFolder); - const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); - return ssrEntryPage; - } - } - - return RedirectSinglePageBuiltModule; -} - // Gives back a facadeId that is relative to the root. // ie, src/pages/index.astro instead of /Users/name..../src/pages/index.astro export function rootRelativeFacadeId(facadeId: string, settings: AstroSettings): string { @@ -185,14 +135,15 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil }); } - const ssrEntryURLPage = createEntryURL(filePath, outFolder); - const ssrEntryPage = await import(ssrEntryURLPage.toString()); + const ssrEntryPage = await pipeline.retrieveSsrEntry(pageData.route, filePath); if (options.settings.adapter?.adapterFeatures?.functionPerRoute) { // forcing to use undefined, so we fail in an expected way if the module is not even there. + // @ts-expect-error When building for `functionPerRoute`, the module exports a `pageModule` function instead const ssrEntry = ssrEntryPage?.pageModule; if (ssrEntry) { await generatePage(pageData, ssrEntry, builtPaths, pipeline); } else { + const ssrEntryURLPage = createEntryURL(filePath, outFolder); throw new Error( `Unable to find the manifest for the module ${ssrEntryURLPage.toString()}. This is unexpected and likely a bug in Astro, please report.` ); @@ -205,18 +156,8 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil } } else { for (const [pageData, filePath] of pagesToGenerate) { - if (routeIsRedirect(pageData.route)) { - const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); - await generatePage(pageData, entry, builtPaths, pipeline); - } else if (routeIsFallback(pageData.route)) { - const entry = await getEntryForFallbackRoute(pageData.route, internals, outFolder); - await generatePage(pageData, entry, builtPaths, pipeline); - } else { - const ssrEntryURLPage = createEntryURL(filePath, outFolder); - const entry: SinglePageBuiltModule = await import(ssrEntryURLPage.toString()); - - await generatePage(pageData, entry, builtPaths, pipeline); - } + const entry = await pipeline.retrieveSsrEntry(pageData.route, filePath); + await generatePage(pageData, entry, builtPaths, pipeline); } } logger.info( @@ -232,12 +173,12 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil .map((x) => x.transforms.size) .reduce((a, b) => a + b, 0); const cpuCount = os.cpus().length; - const assetsCreationpipeline = await prepareAssetsGenerationEnv(pipeline, totalCount); + const assetsCreationPipeline = await prepareAssetsGenerationEnv(pipeline, totalCount); const queue = new PQueue({ concurrency: Math.max(cpuCount, 1) }); const assetsTimer = performance.now(); for (const [originalPath, transforms] of staticImageList) { - await generateImagesForPath(originalPath, transforms, assetsCreationpipeline, queue); + await generateImagesForPath(originalPath, transforms, assetsCreationPipeline, queue); } await queue.onIdle(); @@ -615,6 +556,7 @@ function createBuildManifest( i18n: i18nManifest, buildFormat: settings.config.build.format, middleware, + rewritingEnabled: settings.config.experimental.rewriting, checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false, }; } diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index a7ff537dc8a4..a2c74271f496 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -25,8 +25,6 @@ export interface BuildInternals { hoistedScriptIdToHoistedMap: Map>; // A mapping of hoisted script ids back to the pages which reference it hoistedScriptIdToPagesMap: Map>; - // A mapping of hoisted script ids back to the content which reference it - hoistedScriptIdToContentMap: Map>; /** * Used by the `directRenderScript` option. If script is inlined, its id and @@ -93,7 +91,15 @@ export interface BuildInternals { cachedClientEntries: string[]; cacheManifestUsed: boolean; + /** + * Map of propagated module ids (usually something like `/Users/...blog.mdx?astroPropagatedAssets`) + * to a set of stylesheets that it uses. + */ propagatedStylesMap: Map>; + /** + * Map of propagated module ids (usually something like `/Users/...blog.mdx?astroPropagatedAssets`) + * to a set of hoisted scripts that it uses. + */ propagatedScriptsMap: Map>; // A list of all static files created during the build. Used for SSR. @@ -125,7 +131,6 @@ export function createBuildInternals(): BuildInternals { cssModuleToChunkIdMap: new Map(), hoistedScriptIdToHoistedMap, hoistedScriptIdToPagesMap, - hoistedScriptIdToContentMap: new Map(), inlinedScripts: new Map(), entrySpecifierToBundleMap: new Map(), pageToBundleMap: new Map(), diff --git a/packages/astro/src/core/build/page-data.ts b/packages/astro/src/core/build/page-data.ts index ce9e60622ec4..a151bae2c6f3 100644 --- a/packages/astro/src/core/build/page-data.ts +++ b/packages/astro/src/core/build/page-data.ts @@ -53,8 +53,6 @@ export async function collectPagesData( route, moduleSpecifier: '', styles: [], - propagatedStyles: new Map(), - propagatedScripts: new Map(), hoistedScript: undefined, hasSharedModules: false, }; @@ -78,8 +76,6 @@ export async function collectPagesData( route, moduleSpecifier: '', styles: [], - propagatedStyles: new Map(), - propagatedScripts: new Map(), hoistedScript: undefined, hasSharedModules: false, }; diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index a78c8eaf893c..daae6940e046 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -1,8 +1,17 @@ -import type { RouteData, SSRLoadedRenderer, SSRResult } from '../../@types/astro.js'; +import type { + ComponentInstance, + RewritePayload, + RouteData, + SSRLoadedRenderer, + SSRResult, +} from '../../@types/astro.js'; import { getOutputDirectory } from '../../prerender/utils.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import type { SSRManifest } from '../app/types.js'; +import { RouteNotFound } from '../errors/errors-data.js'; +import { AstroError } from '../errors/index.js'; import { routeIsFallback, routeIsRedirect } from '../redirects/helpers.js'; +import { RedirectSinglePageBuiltModule } from '../redirects/index.js'; import { Pipeline } from '../render/index.js'; import { createAssetLink, @@ -10,23 +19,44 @@ import { createStylesheetElementSet, } from '../render/ssr-element.js'; import { isServerLikeOutput } from '../util.js'; +import { getOutDirWithinCwd } from './common.js'; import { type BuildInternals, cssOrder, + getEntryFilePathFromComponentPath, getPageDataByComponent, mergeInlineCss, } from './internal.js'; import { ASTRO_PAGE_MODULE_ID, ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; import { RESOLVED_SPLIT_MODULE_ID } from './plugins/plugin-ssr.js'; -import { getVirtualModulePageNameFromPath } from './plugins/util.js'; -import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; -import type { PageBuildData, StaticBuildOptions } from './types.js'; +import { + ASTRO_PAGE_EXTENSION_POST_PATTERN, + getVirtualModulePageNameFromPath, +} from './plugins/util.js'; +import type { PageBuildData, SinglePageBuiltModule, StaticBuildOptions } from './types.js'; import { i18nHasFallback } from './util.js'; /** * The build pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files. */ export class BuildPipeline extends Pipeline { + #componentsInterner: WeakMap = new WeakMap< + RouteData, + SinglePageBuiltModule + >(); + /** + * This cache is needed to map a single `RouteData` to its file path. + * @private + */ + #routesByFilePath: WeakMap = new WeakMap(); + + get outFolder() { + const ssr = isServerLikeOutput(this.settings.config); + return ssr + ? this.settings.config.build.server + : getOutDirWithinCwd(this.settings.config.outDir); + } + private constructor( readonly internals: BuildInternals, readonly manifest: SSRManifest, @@ -225,6 +255,113 @@ export class BuildPipeline extends Pipeline { } } + for (const [buildData, filePath] of pages.entries()) { + this.#routesByFilePath.set(buildData.route, filePath); + } + return pages; } + + async getComponentByRoute(routeData: RouteData): Promise { + if (this.#componentsInterner.has(routeData)) { + // SAFETY: checked before + const entry = this.#componentsInterner.get(routeData)!; + return await entry.page(); + } else { + // SAFETY: the pipeline calls `retrieveRoutesToGenerate`, which is in charge to fill the cache. + const filePath = this.#routesByFilePath.get(routeData)!; + const module = await this.retrieveSsrEntry(routeData, filePath); + return module.page(); + } + } + + async tryRewrite(payload: RewritePayload): Promise<[RouteData, ComponentInstance]> { + let foundRoute: RouteData | undefined; + // options.manifest is the actual type that contains the information + for (const route of this.options.manifest.routes) { + if (payload instanceof URL) { + if (route.pattern.test(payload.pathname)) { + foundRoute = route; + break; + } + } else if (payload instanceof Request) { + const url = new URL(payload.url); + if (route.pattern.test(url.pathname)) { + foundRoute = route; + break; + } + } else if (route.pattern.test(decodeURI(payload))) { + foundRoute = route; + break; + } + } + if (foundRoute) { + const componentInstance = await this.getComponentByRoute(foundRoute); + return [foundRoute, componentInstance]; + } else { + throw new AstroError(RouteNotFound); + } + } + + async retrieveSsrEntry(route: RouteData, filePath: string): Promise { + if (this.#componentsInterner.has(route)) { + // SAFETY: it is checked inside the if + return this.#componentsInterner.get(route)!; + } + let entry; + if (routeIsRedirect(route)) { + entry = await this.#getEntryForRedirectRoute(route, this.internals, this.outFolder); + } else if (routeIsFallback(route)) { + entry = await this.#getEntryForFallbackRoute(route, this.internals, this.outFolder); + } else { + const ssrEntryURLPage = createEntryURL(filePath, this.outFolder); + entry = await import(ssrEntryURLPage.toString()); + } + this.#componentsInterner.set(route, entry); + return entry; + } + + async #getEntryForFallbackRoute( + route: RouteData, + internals: BuildInternals, + outFolder: URL + ): Promise { + if (route.type !== 'fallback') { + throw new Error(`Expected a redirect route.`); + } + if (route.redirectRoute) { + const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); + if (filePath) { + const url = createEntryURL(filePath, outFolder); + const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); + return ssrEntryPage; + } + } + + return RedirectSinglePageBuiltModule; + } + + async #getEntryForRedirectRoute( + route: RouteData, + internals: BuildInternals, + outFolder: URL + ): Promise { + if (route.type !== 'redirect') { + throw new Error(`Expected a redirect route.`); + } + if (route.redirectRoute) { + const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); + if (filePath) { + const url = createEntryURL(filePath, outFolder); + const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); + return ssrEntryPage; + } + } + + return RedirectSinglePageBuiltModule; + } +} + +function createEntryURL(filePath: string, outFolder: URL) { + return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); } diff --git a/packages/astro/src/core/build/plugins/plugin-analyzer.ts b/packages/astro/src/core/build/plugins/plugin-analyzer.ts index 5bc0c53e04ea..06ba6fe0025c 100644 --- a/packages/astro/src/core/build/plugins/plugin-analyzer.ts +++ b/packages/astro/src/core/build/plugins/plugin-analyzer.ts @@ -6,7 +6,6 @@ import type { AstroBuildPlugin } from '../plugin.js'; import { PROPAGATED_ASSET_FLAG } from '../../../content/consts.js'; import { prependForwardSlash } from '../../../core/path.js'; -import { isContentCollectionsCacheEnabled } from '../../../core/util.js'; import { getParentModuleInfos, getTopLevelPageModuleInfos, @@ -32,9 +31,7 @@ export function vitePluginAnalyzer( const pageScripts = new Map< string, { - type: 'page' | 'content'; hoistedSet: Set; - propagatedMapByImporter: Map>; } >(); @@ -53,48 +50,12 @@ export function vitePluginAnalyzer( if (hoistedScripts.size) { for (const parentInfo of getParentModuleInfos(from, this, isPropagatedAsset)) { if (isPropagatedAsset(parentInfo.id)) { - if (isContentCollectionsCacheEnabled(options.settings.config)) { - if (!pageScripts.has(parentInfo.id)) { - pageScripts.set(parentInfo.id, { - type: 'content', - hoistedSet: new Set(), - propagatedMapByImporter: new Map(), - }); - } - const propagaters = pageScripts.get(parentInfo.id)!.propagatedMapByImporter; - for (const hid of hoistedScripts) { - if (!propagaters.has(parentInfo.id)) { - propagaters.set(parentInfo.id, new Set()); - } - propagaters.get(parentInfo.id)!.add(hid); - } - } else { - for (const nestedParentInfo of getParentModuleInfos(from, this)) { - if (moduleIsTopLevelPage(nestedParentInfo)) { - for (const hid of hoistedScripts) { - if (!pageScripts.has(nestedParentInfo.id)) { - pageScripts.set(nestedParentInfo.id, { - type: 'page', - hoistedSet: new Set(), - propagatedMapByImporter: new Map(), - }); - } - const entry = pageScripts.get(nestedParentInfo.id)!; - if (!entry.propagatedMapByImporter.has(parentInfo.id)) { - entry.propagatedMapByImporter.set(parentInfo.id, new Set()); - } - entry.propagatedMapByImporter.get(parentInfo.id)!.add(hid); - } - } - } - } + internals.propagatedScriptsMap.set(parentInfo.id, hoistedScripts); } else if (moduleIsTopLevelPage(parentInfo)) { for (const hid of hoistedScripts) { if (!pageScripts.has(parentInfo.id)) { pageScripts.set(parentInfo.id, { - type: 'page', hoistedSet: new Set(), - propagatedMapByImporter: new Map(), }); } pageScripts.get(parentInfo.id)?.hoistedSet.add(hid); @@ -105,21 +66,20 @@ export function vitePluginAnalyzer( }, finalize() { - for (const [pageId, { hoistedSet, propagatedMapByImporter, type }] of pageScripts) { - let astroModuleId: string; - if (type === 'page') { - const pageData = getPageDataByViteID(internals, pageId); - if (!pageData) { - continue; - } - const { component } = pageData; - astroModuleId = prependForwardSlash(component); - - // Keep track of the importers - pageData.propagatedScripts = propagatedMapByImporter; - } else { - astroModuleId = pageId; + // Add propagated scripts to client build, + // but DON'T add to pages -> hoisted script map. + for (const propagatedScripts of internals.propagatedScriptsMap.values()) { + for (const propagatedScript of propagatedScripts) { + internals.discoveredScripts.add(propagatedScript); } + } + + for (const [pageId, { hoistedSet }] of pageScripts) { + const pageData = getPageDataByViteID(internals, pageId); + if (!pageData) continue; + + const { component } = pageData; + const astroModuleId = prependForwardSlash(component); const uniqueHoistedId = JSON.stringify(Array.from(hoistedSet).sort()); let moduleId: string; @@ -134,32 +94,13 @@ export function vitePluginAnalyzer( } internals.discoveredScripts.add(moduleId); - // Add propagated scripts to client build, - // but DON'T add to pages -> hoisted script map. - for (const propagatedScripts of propagatedMapByImporter.values()) { - for (const propagatedScript of propagatedScripts) { - internals.discoveredScripts.add(propagatedScript); - } - } - - if (type === 'page') { - // Make sure to track that this page uses this set of hoisted scripts - if (internals.hoistedScriptIdToPagesMap.has(moduleId)) { - const pages = internals.hoistedScriptIdToPagesMap.get(moduleId); - pages!.add(astroModuleId); - } else { - internals.hoistedScriptIdToPagesMap.set(moduleId, new Set([astroModuleId])); - internals.hoistedScriptIdToHoistedMap.set(moduleId, hoistedSet); - } + // Make sure to track that this page uses this set of hoisted scripts + if (internals.hoistedScriptIdToPagesMap.has(moduleId)) { + const pages = internals.hoistedScriptIdToPagesMap.get(moduleId); + pages!.add(astroModuleId); } else { - // For content collections save to hoistedScriptIdToContentMap instead - if (internals.hoistedScriptIdToContentMap.has(moduleId)) { - const contentModules = internals.hoistedScriptIdToContentMap.get(moduleId); - contentModules!.add(astroModuleId); - } else { - internals.hoistedScriptIdToContentMap.set(moduleId, new Set([astroModuleId])); - internals.hoistedScriptIdToHoistedMap.set(moduleId, hoistedSet); - } + internals.hoistedScriptIdToPagesMap.set(moduleId, new Set([astroModuleId])); + internals.hoistedScriptIdToHoistedMap.set(moduleId, hoistedSet); } } }, diff --git a/packages/astro/src/core/build/plugins/plugin-content.ts b/packages/astro/src/core/build/plugins/plugin-content.ts index 7210dd4f184b..b6843e52b351 100644 --- a/packages/astro/src/core/build/plugins/plugin-content.ts +++ b/packages/astro/src/core/build/plugins/plugin-content.ts @@ -171,6 +171,7 @@ function vitePluginContent( outputOptions(outputOptions) { const rootPath = normalizePath(fileURLToPath(opts.settings.config.root)); const srcPath = normalizePath(fileURLToPath(opts.settings.config.srcDir)); + const entryCache = new Map(); extendManualChunks(outputOptions, { before(id, meta) { if (id.startsWith(srcPath) && id.slice(srcPath.length).startsWith('content')) { @@ -186,7 +187,11 @@ function vitePluginContent( return resultId; } const [srcRelativePath, flag] = id.replace(rootPath, '/').split('?'); - const collectionEntry = findEntryFromSrcRelativePath(lookupMap, srcRelativePath); + const collectionEntry = findEntryFromSrcRelativePath( + lookupMap, + srcRelativePath, + entryCache + ); if (collectionEntry) { let suffix = '.mjs'; if (flag === PROPAGATED_ASSET_FLAG) { @@ -273,8 +278,11 @@ function vitePluginContent( }; } -const entryCache = new Map(); -function findEntryFromSrcRelativePath(lookupMap: ContentLookupMap, srcRelativePath: string) { +function findEntryFromSrcRelativePath( + lookupMap: ContentLookupMap, + srcRelativePath: string, + entryCache: Map +) { let value = entryCache.get(srcRelativePath); if (value) return value; for (const collection of Object.values(lookupMap)) { diff --git a/packages/astro/src/core/build/plugins/plugin-css.ts b/packages/astro/src/core/build/plugins/plugin-css.ts index e0dce339f769..c50951e0b081 100644 --- a/packages/astro/src/core/build/plugins/plugin-css.ts +++ b/packages/astro/src/core/build/plugins/plugin-css.ts @@ -5,7 +5,6 @@ import type { BuildInternals } from '../internal.js'; import type { AstroBuildPlugin, BuildTarget } from '../plugin.js'; import type { PageBuildData, StaticBuildOptions, StylesheetAsset } from '../types.js'; -import { RESOLVED_VIRTUAL_MODULE_ID as ASTRO_CONTENT_VIRTUAL_MODULE_ID } from '../../../content/consts.js'; import { hasAssetPropagationFlag } from '../../../content/index.js'; import type { AstroPluginCssMetadata } from '../../../vite-plugin-astro/index.js'; import * as assetName from '../css-asset-name.js'; @@ -63,11 +62,8 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { // stylesheet filenames are kept in here until "post", when they are rendered and ready to be inlined const pagesToCss: Record> = {}; - const pagesToPropagatedCss: Record>> = {}; - - const isContentCollectionCache = - options.buildOptions.settings.config.output === 'static' && - options.buildOptions.settings.config.experimental.contentCollectionCache; + // Map of module Ids (usually something like `/Users/...blog.mdx?astroPropagatedAssets`) to its imported CSS + const moduleIdToPropagatedCss: Record> = {}; const cssBuildPlugin: VitePlugin = { name: 'astro:rollup-plugin-build-css', @@ -141,20 +137,9 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { const parentModuleInfos = getParentExtendedModuleInfos(id, this, hasAssetPropagationFlag); for (const { info: pageInfo, depth, order } of parentModuleInfos) { if (hasAssetPropagationFlag(pageInfo.id)) { - const walkId = isContentCollectionCache ? ASTRO_CONTENT_VIRTUAL_MODULE_ID : id; - for (const parentInfo of getParentModuleInfos(walkId, this)) { - if (moduleIsTopLevelPage(parentInfo) === false) continue; - - const pageViteID = parentInfo.id; - const pageData = getPageDataByViteID(internals, pageViteID); - if (pageData === undefined) continue; - - for (const css of meta.importedCss) { - const propagatedStyles = (pagesToPropagatedCss[pageData.moduleSpecifier] ??= {}); - const existingCss = (propagatedStyles[pageInfo.id] ??= new Set()); - - existingCss.add(css); - } + const propagatedCss = (moduleIdToPropagatedCss[pageInfo.id] ??= new Set()); + for (const css of meta.importedCss) { + propagatedCss.add(css); } } else if (moduleIsTopLevelPage(pageInfo)) { const pageViteID = pageInfo.id; @@ -251,41 +236,30 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { ? { type: 'inline', content: stylesheet.source } : { type: 'external', src: stylesheet.fileName }; - const pages = Array.from(eachPageData(internals)); let sheetAddedToPage = false; - pages.forEach((pageData) => { + // Apply `pagesToCss` information to the respective `pageData.styles` + for (const pageData of eachPageData(internals)) { const orderingInfo = pagesToCss[pageData.moduleSpecifier]?.[stylesheet.fileName]; if (orderingInfo !== undefined) { pageData.styles.push({ ...orderingInfo, sheet }); sheetAddedToPage = true; - return; } + } - const propagatedPaths = pagesToPropagatedCss[pageData.moduleSpecifier]; - if (propagatedPaths === undefined) return; - Object.entries(propagatedPaths).forEach(([pageInfoId, css]) => { - // return early if sheet does not need to be propagated - if (css.has(stylesheet.fileName) !== true) return; - - // return early if the stylesheet needing propagation has already been included - if (pageData.styles.some((s) => s.sheet === sheet)) return; - - let propagatedStyles: Set; - if (isContentCollectionCache) { - propagatedStyles = - internals.propagatedStylesMap.get(pageInfoId) ?? - internals.propagatedStylesMap.set(pageInfoId, new Set()).get(pageInfoId)!; - } else { - propagatedStyles = - pageData.propagatedStyles.get(pageInfoId) ?? - pageData.propagatedStyles.set(pageInfoId, new Set()).get(pageInfoId)!; - } - - propagatedStyles.add(sheet); - sheetAddedToPage = true; - }); - }); + // Apply `moduleIdToPropagatedCss` information to `internals.propagatedStylesMap`. + // NOTE: It's pretty much a copy over to `internals.propagatedStylesMap` as it should be + // completely empty. The whole propagation handling could be better refactored in the future. + for (const moduleId in moduleIdToPropagatedCss) { + if (!moduleIdToPropagatedCss[moduleId].has(stylesheet.fileName)) continue; + let propagatedStyles = internals.propagatedStylesMap.get(moduleId); + if (!propagatedStyles) { + propagatedStyles = new Set(); + internals.propagatedStylesMap.set(moduleId, propagatedStyles); + } + propagatedStyles.add(sheet); + sheetAddedToPage = true; + } if (toBeInlined && sheetAddedToPage) { // CSS is already added to all used pages, we can delete it from the bundle diff --git a/packages/astro/src/core/build/plugins/plugin-hoisted-scripts.ts b/packages/astro/src/core/build/plugins/plugin-hoisted-scripts.ts index 80bfa6a6e964..2ed3c7fa746d 100644 --- a/packages/astro/src/core/build/plugins/plugin-hoisted-scripts.ts +++ b/packages/astro/src/core/build/plugins/plugin-hoisted-scripts.ts @@ -1,6 +1,6 @@ import type { BuildOptions, Plugin as VitePlugin } from 'vite'; import type { AstroSettings } from '../../../@types/astro.js'; -import { isContentCollectionsCacheEnabled, viteID } from '../../util.js'; +import { viteID } from '../../util.js'; import type { BuildInternals } from '../internal.js'; import { getPageDataByViteID } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; @@ -72,42 +72,23 @@ export function vitePluginHoistedScripts( output.dynamicImports.length === 0 && shouldInlineAsset(output.code, output.fileName, assetsInlineLimit); let removeFromBundle = false; - const facadeId = output.facadeModuleId!; - - // Pages - if (internals.hoistedScriptIdToPagesMap.has(facadeId)) { - const pages = internals.hoistedScriptIdToPagesMap.get(facadeId)!; - for (const pathname of pages) { - const vid = viteID(new URL('.' + pathname, settings.config.root)); - - const pageInfo = getPageDataByViteID(internals, vid); - if (pageInfo) { - if (canBeInlined) { - pageInfo.hoistedScript = { - type: 'inline', - value: output.code, - }; - removeFromBundle = true; - } else { - pageInfo.hoistedScript = { - type: 'external', - value: id, - }; - } - } - } - } - // Content collection entries - else { - const contentModules = internals.hoistedScriptIdToContentMap.get(facadeId)!; - for (const contentId of contentModules) { - if (isContentCollectionsCacheEnabled(settings.config)) { - const scripts = - internals.propagatedScriptsMap.get(contentId) ?? - internals.propagatedScriptsMap.set(contentId, new Set()).get(contentId)!; - - scripts.add(facadeId); + const pages = internals.hoistedScriptIdToPagesMap.get(facadeId)!; + for (const pathname of pages) { + const vid = viteID(new URL('.' + pathname, settings.config.root)); + const pageInfo = getPageDataByViteID(internals, vid); + if (pageInfo) { + if (canBeInlined) { + pageInfo.hoistedScript = { + type: 'inline', + value: output.code, + }; + removeFromBundle = true; + } else { + pageInfo.hoistedScript = { + type: 'external', + value: id, + }; } } } diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 498ccdbb544b..5bb6ddab038a 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -277,5 +277,6 @@ function buildManifest( i18n: i18nManifest, buildFormat: settings.config.build.format, checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false, + rewritingEnabled: settings.config.experimental.rewriting, }; } diff --git a/packages/astro/src/core/build/types.ts b/packages/astro/src/core/build/types.ts index 9b418f7bc56d..4b502c353c5a 100644 --- a/packages/astro/src/core/build/types.ts +++ b/packages/astro/src/core/build/types.ts @@ -26,8 +26,6 @@ export interface PageBuildData { component: ComponentPath; route: RouteData; moduleSpecifier: string; - propagatedStyles: Map>; - propagatedScripts: Map>; hoistedScript: HoistedScriptAsset | undefined; styles: Array<{ depth: number; order: number; sheet: StylesheetAsset }>; hasSharedModules: boolean; diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 303846f7608f..0fd4c58e66fd 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -87,6 +87,7 @@ const ASTRO_CONFIG_DEFAULTS = { globalRoutePriority: false, i18nDomains: false, security: {}, + rewriting: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -525,6 +526,7 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.security), i18nDomains: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.i18nDomains), + rewriting: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.rewriting), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.` diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 180160064ab1..7ebc3a3831a1 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1483,6 +1483,18 @@ export const UnsupportedConfigTransformError = { hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue', } satisfies ErrorData; +/** + * @docs + * @description + * + * Astro couldn't find a route matching the one provided by the user + */ +export const RouteNotFound = { + name: 'RouteNotFound', + title: 'Route not found.', + message: `Astro could find a route that matches the one you requested.`, +} satisfies ErrorData; + // Generic catch-all - Only use this in extreme cases, like if there was a cosmic ray bit flip. export const UnknownError = { name: 'UnknownError', title: 'Unknown Error.' } satisfies ErrorData; diff --git a/packages/astro/src/core/middleware/callMiddleware.ts b/packages/astro/src/core/middleware/callMiddleware.ts index 0133c13d032d..b92e0f3cb19b 100644 --- a/packages/astro/src/core/middleware/callMiddleware.ts +++ b/packages/astro/src/core/middleware/callMiddleware.ts @@ -1,5 +1,11 @@ -import type { APIContext, MiddlewareHandler, MiddlewareNext } from '../../@types/astro.js'; +import type { + APIContext, + MiddlewareHandler, + MiddlewareNext, + RewritePayload, +} from '../../@types/astro.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; +import type { Logger } from '../logger/core.js'; /** * Utility function that is in charge of calling the middleware. @@ -38,13 +44,28 @@ import { AstroError, AstroErrorData } from '../errors/index.js'; export async function callMiddleware( onRequest: MiddlewareHandler, apiContext: APIContext, - responseFunction: () => Promise | Response + responseFunction: ( + apiContext: APIContext, + rewritePayload?: RewritePayload + ) => Promise | Response, + // TODO: remove these two arguments once rerouting goes out of experimental + enableRerouting: boolean, + logger: Logger ): Promise { let nextCalled = false; let responseFunctionPromise: Promise | Response | undefined = undefined; - const next: MiddlewareNext = async () => { + const next: MiddlewareNext = async (payload) => { nextCalled = true; - responseFunctionPromise = responseFunction(); + if (enableRerouting) { + responseFunctionPromise = responseFunction(apiContext, payload); + } else { + logger.warn( + 'router', + 'The rewrite API is experimental. To use this feature, add the `rewriting` flag to the `experimental` object in your Astro config.' + ); + responseFunctionPromise = responseFunction(apiContext); + } + // We need to pass the APIContext pass to `callMiddleware` because it can be mutated across middleware functions return responseFunctionPromise; }; diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index cb9304bffbe1..358cf31fe6f3 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -1,17 +1,14 @@ -import type { APIContext, MiddlewareHandler, Params } from '../../@types/astro.js'; +import type { APIContext, MiddlewareHandler, Params, RewritePayload } from '../../@types/astro.js'; import { computeCurrentLocale, computePreferredLocale, computePreferredLocaleList, } from '../../i18n/utils.js'; -import { ASTRO_VERSION } from '../constants.js'; +import { ASTRO_VERSION, clientAddressSymbol, clientLocalsSymbol } from '../constants.js'; import { AstroCookies } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { sequence } from './sequence.js'; -const clientAddressSymbol = Symbol.for('astro.clientAddress'); -const clientLocalsSymbol = Symbol.for('astro.locals'); - function defineMiddleware(fn: MiddlewareHandler) { return fn; } @@ -49,6 +46,12 @@ function createContext({ const url = new URL(request.url); const route = url.pathname; + // TODO verify that this function works in an edge middleware environment + const reroute = (_reroutePayload: RewritePayload) => { + // return dummy response + return Promise.resolve(new Response(null)); + }; + return { cookies: new AstroCookies(request), request, @@ -56,6 +59,7 @@ function createContext({ site: undefined, generator: `Astro v${ASTRO_VERSION}`, props: {}, + rewrite: reroute, redirect(path, status) { return new Response(null, { status: status || 302, diff --git a/packages/astro/src/core/middleware/sequence.ts b/packages/astro/src/core/middleware/sequence.ts index 9a68963945ec..8b2c2b49c572 100644 --- a/packages/astro/src/core/middleware/sequence.ts +++ b/packages/astro/src/core/middleware/sequence.ts @@ -1,4 +1,5 @@ -import type { APIContext, MiddlewareHandler } from '../../@types/astro.js'; +import type { APIContext, MiddlewareHandler, RewritePayload } from '../../@types/astro.js'; +import { AstroCookies } from '../cookies/cookies.js'; import { defineMiddleware } from './index.js'; // From SvelteKit: https://github.com/sveltejs/kit/blob/master/packages/kit/src/exports/hooks/sequence.js @@ -10,13 +11,16 @@ export function sequence(...handlers: MiddlewareHandler[]): MiddlewareHandler { const filtered = handlers.filter((h) => !!h); const length = filtered.length; if (!length) { - const handler: MiddlewareHandler = defineMiddleware((context, next) => { + return defineMiddleware((_context, next) => { return next(); }); - return handler; } return defineMiddleware((context, next) => { + /** + * This variable is used to carry the rerouting payload across middleware functions. + */ + let carriedPayload: RewritePayload | undefined = undefined; return applyHandle(0, context); function applyHandle(i: number, handleContext: APIContext) { @@ -24,11 +28,28 @@ export function sequence(...handlers: MiddlewareHandler[]): MiddlewareHandler { // @ts-expect-error // SAFETY: Usually `next` always returns something in user land, but in `sequence` we are actually // doing a loop over all the `next` functions, and eventually we call the last `next` that returns the `Response`. - const result = handle(handleContext, async () => { + const result = handle(handleContext, async (payload: RewritePayload) => { if (i < length - 1) { + if (payload) { + let newRequest; + if (payload instanceof Request) { + newRequest = payload; + } else if (payload instanceof URL) { + newRequest = new Request(payload, handleContext.request); + } else { + newRequest = new Request( + new URL(payload, handleContext.url.origin), + handleContext.request + ); + } + carriedPayload = payload; + handleContext.request = newRequest; + handleContext.url = new URL(newRequest.url); + handleContext.cookies = new AstroCookies(newRequest); + } return applyHandle(i + 1, handleContext); } else { - return next(); + return next(payload ?? carriedPayload); } }); return result; diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 5cfc8ef2ede3..279745ac19e4 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -4,6 +4,8 @@ import type { AstroGlobalPartial, ComponentInstance, MiddlewareHandler, + MiddlewareNext, + RewritePayload, RouteData, SSRResult, } from '../@types/astro.js'; @@ -39,14 +41,23 @@ export class RenderContext { public locals: App.Locals, readonly middleware: MiddlewareHandler, readonly pathname: string, - readonly request: Request, - readonly routeData: RouteData, + public request: Request, + public routeData: RouteData, public status: number, - readonly cookies = new AstroCookies(request), - readonly params = getParams(routeData, pathname), - readonly url = new URL(request.url) + protected cookies = new AstroCookies(request), + public params = getParams(routeData, pathname), + protected url = new URL(request.url) ) {} + /** + * A flag that tells the render content if the rewriting was triggered + */ + isRewriting = false; + /** + * A safety net in case of loops + */ + counter = 0; + static create({ locals = {}, middleware, @@ -56,7 +67,7 @@ export class RenderContext { routeData, status = 200, }: Pick & - Partial>) { + Partial>): RenderContext { return new RenderContext( pipeline, locals, @@ -80,11 +91,11 @@ export class RenderContext { * - fallback */ async render(componentInstance: ComponentInstance | undefined): Promise { - const { cookies, middleware, pathname, pipeline, routeData } = this; + const { cookies, middleware, pathname, pipeline } = this; const { logger, routeCache, serverLike, streaming } = pipeline; const props = await getProps({ mod: componentInstance, - routeData, + routeData: this.routeData, routeCache, pathname, logger, @@ -92,10 +103,40 @@ export class RenderContext { }); const apiContext = this.createAPIContext(props); - const lastNext = async () => { - switch (routeData.type) { + this.counter++; + if (this.counter === 4) { + return new Response('Loop Detected', { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/508 + status: 508, + statusText: + 'Astro detected a loop where you tried to call the rewriting logic more than four times.', + }); + } + const lastNext = async (ctx: APIContext, payload?: RewritePayload) => { + if (payload) { + if (this.pipeline.manifest.rewritingEnabled) { + try { + const [routeData, component] = await pipeline.tryRewrite(payload); + this.routeData = routeData; + componentInstance = component; + } catch (e) { + return new Response('Not found', { + status: 404, + statusText: 'Not found', + }); + } finally { + this.isRewriting = true; + } + } else { + this.pipeline.logger.warn( + 'router', + 'The rewrite API is experimental. To use this feature, add the `rewriting` flag to the `experimental` object in your Astro config.' + ); + } + } + switch (this.routeData.type) { case 'endpoint': - return renderEndpoint(componentInstance as any, apiContext, serverLike, logger); + return renderEndpoint(componentInstance as any, ctx, serverLike, logger); case 'redirect': return renderRedirect(this); case 'page': { @@ -108,7 +149,7 @@ export class RenderContext { props, {}, streaming, - routeData + this.routeData ); } catch (e) { // If there is an error in the page's frontmatter or instantiation of the RenderTemplate fails midway, @@ -119,7 +160,11 @@ export class RenderContext { // Signal to the i18n middleware to maybe act on this response response.headers.set(ROUTE_TYPE_HEADER, 'page'); // Signal to the error-page-rerouting infra to let this response pass through to avoid loops - if (routeData.route === '/404' || routeData.route === '/500') { + if ( + this.routeData.route === '/404' || + this.routeData.route === '/500' || + this.isRewriting + ) { response.headers.set(REROUTE_DIRECTIVE_HEADER, 'no'); } return response; @@ -130,7 +175,13 @@ export class RenderContext { } }; - const response = await callMiddleware(middleware, apiContext, lastNext); + const response = await callMiddleware( + middleware, + apiContext, + lastNext, + this.pipeline.manifest.rewritingEnabled, + this.pipeline.logger + ); if (response.headers.get(ROUTE_TYPE_HEADER)) { response.headers.delete(ROUTE_TYPE_HEADER); } @@ -143,10 +194,38 @@ export class RenderContext { createAPIContext(props: APIContext['props']): APIContext { const renderContext = this; - const { cookies, params, pipeline, request, url } = this; + const { cookies, params, pipeline, url } = this; const generator = `Astro v${ASTRO_VERSION}`; const redirect = (path: string, status = 302) => new Response(null, { status, headers: { Location: path } }); + + const rewrite = async (reroutePayload: RewritePayload) => { + pipeline.logger.debug('router', 'Called rewriting to:', reroutePayload); + try { + const [routeData, component] = await pipeline.tryRewrite(reroutePayload); + this.routeData = routeData; + if (reroutePayload instanceof Request) { + this.request = reroutePayload; + } else { + this.request = new Request( + new URL(routeData.pathname ?? routeData.route, this.url.origin), + this.request + ); + } + this.url = new URL(this.request.url); + this.cookies = new AstroCookies(this.request); + this.params = getParams(routeData, url.toString()); + this.isRewriting = true; + return await this.render(component); + } catch (e) { + pipeline.logger.debug('router', 'Rewrite failed.', e); + return new Response('Not found', { + status: 404, + statusText: 'Not found', + }); + } + }; + return { cookies, get clientAddress() { @@ -167,7 +246,7 @@ export class RenderContext { renderContext.locals = val; // we also put it on the original Request object, // where the adapter might be expecting to read it after the response. - Reflect.set(request, clientLocalsSymbol, val); + Reflect.set(this.request, clientLocalsSymbol, val); } }, params, @@ -179,7 +258,8 @@ export class RenderContext { }, props, redirect, - request, + rewrite, + request: this.request, site: pipeline.site, url, }; @@ -294,11 +374,11 @@ export class RenderContext { astroStaticPartial: AstroGlobalPartial ): Omit { const renderContext = this; - const { cookies, locals, params, pipeline, request, url } = this; + const { cookies, locals, params, pipeline, url } = this; const { response } = result; const redirect = (path: string, status = 302) => { // If the response is already sent, error as we cannot proceed with the redirect. - if ((request as any)[responseSentSymbol]) { + if ((this.request as any)[responseSentSymbol]) { throw new AstroError({ ...AstroErrorData.ResponseSentError, }); @@ -306,6 +386,33 @@ export class RenderContext { return new Response(null, { status, headers: { Location: path } }); }; + const rewrite = async (reroutePayload: RewritePayload) => { + try { + pipeline.logger.debug('router', 'Calling rewrite: ', reroutePayload); + const [routeData, component] = await pipeline.tryRewrite(reroutePayload); + this.routeData = routeData; + if (reroutePayload instanceof Request) { + this.request = reroutePayload; + } else { + this.request = new Request( + new URL(routeData.pathname ?? routeData.route, this.url.origin), + this.request + ); + } + this.url = new URL(this.request.url); + this.cookies = new AstroCookies(this.request); + this.params = getParams(routeData, url.toString()); + this.isRewriting = true; + return await this.render(component); + } catch (e) { + pipeline.logger.debug('router', 'Rerouting failed, returning a 404.', e); + return new Response('Not found', { + status: 404, + statusText: 'Not found', + }); + } + }; + return { generator: astroStaticPartial.generator, glob: astroStaticPartial.glob, @@ -325,7 +432,8 @@ export class RenderContext { }, locals, redirect, - request, + rewrite, + request: this.request, response, site: pipeline.site, url, diff --git a/packages/astro/src/jsx/babel.ts b/packages/astro/src/jsx/babel.ts index e8f9da87e2e1..d5fc0ccd30b0 100644 --- a/packages/astro/src/jsx/babel.ts +++ b/packages/astro/src/jsx/babel.ts @@ -134,6 +134,9 @@ function addClientOnlyMetadata( } } +/** + * @deprecated This plugin is no longer used. Remove in Astro 5.0 + */ export default function astroJSX(): PluginObj { return { visitor: { diff --git a/packages/astro/src/jsx/rehype.ts b/packages/astro/src/jsx/rehype.ts new file mode 100644 index 000000000000..40a8359cbe5c --- /dev/null +++ b/packages/astro/src/jsx/rehype.ts @@ -0,0 +1,320 @@ +import type { RehypePlugin } from '@astrojs/markdown-remark'; +import type { RootContent } from 'hast'; +import type { + MdxJsxAttribute, + MdxJsxFlowElementHast, + MdxJsxTextElementHast, +} from 'mdast-util-mdx-jsx'; +import { visit } from 'unist-util-visit'; +import type { VFile } from 'vfile'; +import { AstroError } from '../core/errors/errors.js'; +import { AstroErrorData } from '../core/errors/index.js'; +import { resolvePath } from '../core/util.js'; +import type { PluginMetadata } from '../vite-plugin-astro/types.js'; + +// This import includes ambient types for hast to include mdx nodes +import type {} from 'mdast-util-mdx'; + +const ClientOnlyPlaceholder = 'astro-client-only'; + +export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => { + return (tree, file) => { + // Initial metadata for this MDX file, it will be mutated as we traverse the tree + const metadata: PluginMetadata['astro'] = { + clientOnlyComponents: [], + hydratedComponents: [], + scripts: [], + containsHead: false, + propagation: 'none', + pageOptions: {}, + }; + + // Parse imports in this file. This is used to match components with their import source + const imports = parseImports(tree.children); + + visit(tree, (node) => { + if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return; + + const tagName = node.name; + if (!tagName || !isComponent(tagName) || !hasClientDirective(node)) return; + + // From this point onwards, `node` is confirmed to be an island component + + // Match this component with its import source + const matchedImport = findMatchingImport(tagName, imports); + if (!matchedImport) { + throw new AstroError({ + ...AstroErrorData.NoMatchingImport, + message: AstroErrorData.NoMatchingImport.message(node.name!), + }); + } + + // If this is an Astro component, that means the `client:` directive is misused as it doesn't + // work on Astro components as it's server-side only. Warn the user about this. + if (matchedImport.path.endsWith('.astro')) { + const clientAttribute = node.attributes.find( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name.startsWith('client:') + ) as MdxJsxAttribute | undefined; + if (clientAttribute) { + // eslint-disable-next-line + console.warn( + `You are attempting to render <${node.name!} ${ + clientAttribute.name + } />, but ${node.name!} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.` + ); + } + } + + const resolvedPath = resolvePath(matchedImport.path, file.path); + + if (hasClientOnlyDirective(node)) { + // Add this component to the metadata + metadata.clientOnlyComponents.push({ + exportName: matchedImport.name, + specifier: tagName, + resolvedPath, + }); + // Mutate node with additional island attributes + addClientOnlyMetadata(node, matchedImport, resolvedPath); + } else { + // Add this component to the metadata + metadata.hydratedComponents.push({ + exportName: '*', + specifier: tagName, + resolvedPath, + }); + // Mutate node with additional island attributes + addClientMetadata(node, matchedImport, resolvedPath); + } + }); + + // Attach final metadata here, which can later be retrieved by `getAstroMetadata` + file.data.__astroMetadata = metadata; + }; +}; + +export function getAstroMetadata(file: VFile) { + return file.data.__astroMetadata as PluginMetadata['astro'] | undefined; +} + +type ImportSpecifier = { local: string; imported: string }; + +/** + * ``` + * import Foo from './Foo.jsx' + * import { Bar } from './Bar.jsx' + * import { Baz as Wiz } from './Bar.jsx' + * import * as Waz from './BaWazz.jsx' + * + * // => Map { + * // "./Foo.jsx" => Set { { local: "Foo", imported: "default" } }, + * // "./Bar.jsx" => Set { + * // { local: "Bar", imported: "Bar" } + * // { local: "Wiz", imported: "Baz" }, + * // }, + * // "./Waz.jsx" => Set { { local: "Waz", imported: "*" } }, + * // } + * ``` + */ +function parseImports(children: RootContent[]) { + // Map of import source to its imported specifiers + const imports = new Map>(); + + for (const child of children) { + if (child.type !== 'mdxjsEsm') continue; + + const body = child.data?.estree?.body; + if (!body) continue; + + for (const ast of body) { + if (ast.type !== 'ImportDeclaration') continue; + + const source = ast.source.value as string; + const specs: ImportSpecifier[] = ast.specifiers.map((spec) => { + switch (spec.type) { + case 'ImportDefaultSpecifier': + return { local: spec.local.name, imported: 'default' }; + case 'ImportNamespaceSpecifier': + return { local: spec.local.name, imported: '*' }; + case 'ImportSpecifier': + return { local: spec.local.name, imported: spec.imported.name }; + default: + throw new Error('Unknown import declaration specifier: ' + spec); + } + }); + + // Get specifiers set from source or initialize a new one + let specSet = imports.get(source); + if (!specSet) { + specSet = new Set(); + imports.set(source, specSet); + } + + for (const spec of specs) { + specSet.add(spec); + } + } + } + + return imports; +} + +function isComponent(tagName: string) { + return ( + (tagName[0] && tagName[0].toLowerCase() !== tagName[0]) || + tagName.includes('.') || + /[^a-zA-Z]/.test(tagName[0]) + ); +} + +function hasClientDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) { + return node.attributes.some( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name.startsWith('client:') + ); +} + +function hasClientOnlyDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) { + return node.attributes.some( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'client:only' + ); +} + +type MatchedImport = { name: string; path: string }; + +/** + * ``` + * import Button from './Button.jsx' + * + + + + diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/pages/index.astro b/packages/astro/test/fixtures/css-inline-stylesheets-3/src/pages/index.astro new file mode 100644 index 000000000000..2aecfb0f2eb7 --- /dev/null +++ b/packages/astro/test/fixtures/css-inline-stylesheets-3/src/pages/index.astro @@ -0,0 +1,17 @@ +--- +import { getEntryBySlug } from 'astro:content'; +import Button from '../components/Button.astro'; + +const entry = await getEntryBySlug('en', 'endeavour'); +const { Content } = await entry.render(); +--- + +
+

Welcome to Astro

+ + +
diff --git a/packages/astro/test/fixtures/middleware-virtual/astro.config.mjs b/packages/astro/test/fixtures/middleware-virtual/astro.config.mjs new file mode 100644 index 000000000000..bc095ecddb69 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-virtual/astro.config.mjs @@ -0,0 +1,3 @@ +import { defineConfig } from "astro/config"; + +export default defineConfig({}) diff --git a/packages/astro/test/fixtures/middleware-virtual/package.json b/packages/astro/test/fixtures/middleware-virtual/package.json new file mode 100644 index 000000000000..7cfbeb721047 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-virtual/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/middleware-virtual", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/middleware-virtual/src/middleware.js b/packages/astro/test/fixtures/middleware-virtual/src/middleware.js new file mode 100644 index 000000000000..55004a00cfdb --- /dev/null +++ b/packages/astro/test/fixtures/middleware-virtual/src/middleware.js @@ -0,0 +1,6 @@ +import { defineMiddleware } from 'astro:middleware'; + +export const onRequest = defineMiddleware(async (context, next) => { + console.log('[MIDDLEWARE] in ' + context.url.toString()); + return next(); +}); diff --git a/packages/astro/test/fixtures/middleware-virtual/src/pages/index.astro b/packages/astro/test/fixtures/middleware-virtual/src/pages/index.astro new file mode 100644 index 000000000000..9bd31f5fde27 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-virtual/src/pages/index.astro @@ -0,0 +1,13 @@ +--- +const data = Astro.locals; +--- + + + + Index + + + +Index + + diff --git a/packages/astro/test/fixtures/reroute/astro.config.mjs b/packages/astro/test/fixtures/reroute/astro.config.mjs new file mode 100644 index 000000000000..af13ef19b477 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/astro.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + experimental: { + rewriting: true + }, + site: "https://example.com" +}); diff --git a/packages/astro/test/fixtures/reroute/package.json b/packages/astro/test/fixtures/reroute/package.json new file mode 100644 index 000000000000..ed64e57a97e0 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/reroute", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/reroute/src/middleware.js b/packages/astro/test/fixtures/reroute/src/middleware.js new file mode 100644 index 000000000000..4d7c2a7956c8 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/middleware.js @@ -0,0 +1,33 @@ +import { sequence } from 'astro:middleware'; + +let contextReroute = false; + +export const first = async (context, next) => { + if (context.url.pathname.includes('/auth')) { + } + + return next(); +}; + +export const second = async (context, next) => { + if (context.url.pathname.includes('/auth')) { + if (context.url.pathname.includes('/auth/dashboard')) { + contextReroute = true; + return await context.rewrite('/'); + } + if (context.url.pathname.includes('/auth/base')) { + return await next('/'); + } + } + return next(); +}; + +export const third = async (context, next) => { + // just making sure that we are testing the change in context coming from `next()` + if (context.url.pathname.startsWith('/') && contextReroute === false) { + context.locals.auth = 'Third function called'; + } + return next(); +}; + +export const onRequest = sequence(first, second, third); diff --git a/packages/astro/test/fixtures/reroute/src/pages/auth/base.astro b/packages/astro/test/fixtures/reroute/src/pages/auth/base.astro new file mode 100644 index 000000000000..be31dfb14141 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/auth/base.astro @@ -0,0 +1,10 @@ +--- +--- + + + Base + + +

Base

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/auth/dashboard.astro b/packages/astro/test/fixtures/reroute/src/pages/auth/dashboard.astro new file mode 100644 index 000000000000..bfa006aa01a7 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/auth/dashboard.astro @@ -0,0 +1,10 @@ +--- +--- + + + Dashboard + + +

Dashboard

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/auth/settings.astro b/packages/astro/test/fixtures/reroute/src/pages/auth/settings.astro new file mode 100644 index 000000000000..9eee5fe95149 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/auth/settings.astro @@ -0,0 +1,10 @@ +--- +--- + + + Settings + + +

Settings

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/blog/hello/index.astro b/packages/astro/test/fixtures/reroute/src/pages/blog/hello/index.astro new file mode 100644 index 000000000000..8c38e518a7b7 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/blog/hello/index.astro @@ -0,0 +1,11 @@ +--- +return Astro.rewrite(new URL("../../", Astro.url)) +--- + + + Blog hello + + +

Blog hello

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/blog/oops.astro b/packages/astro/test/fixtures/reroute/src/pages/blog/oops.astro new file mode 100644 index 000000000000..df1f1f76a331 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/blog/oops.astro @@ -0,0 +1,11 @@ +--- +return Astro.rewrite("/404") +--- + + + Blog hello + + +

Blog hello

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/blog/salut/index.astro b/packages/astro/test/fixtures/reroute/src/pages/blog/salut/index.astro new file mode 100644 index 000000000000..89d35ce2564d --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/blog/salut/index.astro @@ -0,0 +1,11 @@ +--- +return Astro.rewrite(new Request(new URL("../../", Astro.url))) +--- + + + Blog hello + + +

Blog hello

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/dynamic/[id].astro b/packages/astro/test/fixtures/reroute/src/pages/dynamic/[id].astro new file mode 100644 index 000000000000..8d849de160bf --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/dynamic/[id].astro @@ -0,0 +1,21 @@ +--- + +export function getStaticPaths() { + return [ + { params: { id: 'hello' } }, + ]; +} + + +return Astro.rewrite("/") + +--- + + + + Dynamic [id].astro + + +

/dynamic/[id].astro

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/index.astro b/packages/astro/test/fixtures/reroute/src/pages/index.astro new file mode 100644 index 000000000000..91a6fd0fb0fc --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +const auth = Astro.locals.auth; +--- + + + Index + + +

Index

+ {auth ?

Called auth

: ""} + + diff --git a/packages/astro/test/fixtures/reroute/src/pages/reroute.astro b/packages/astro/test/fixtures/reroute/src/pages/reroute.astro new file mode 100644 index 000000000000..dbc7a6ae628a --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/reroute.astro @@ -0,0 +1,11 @@ +--- +return Astro.rewrite("/") +--- + + + Reroute + + +

Reroute

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/spread/[...id].astro b/packages/astro/test/fixtures/reroute/src/pages/spread/[...id].astro new file mode 100644 index 000000000000..0bab88d0f7b1 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/spread/[...id].astro @@ -0,0 +1,20 @@ +--- +export function getStaticPaths() { + return [ + { params: { id: 'hello' } }, + ]; +} + +return Astro.rewrite("/") + +--- + + + + + Spread [...id].astro + + +

/spread/[...id].astro

+ + diff --git a/packages/astro/test/i18n-routing-manual.test.js b/packages/astro/test/i18n-routing-manual.test.js index d664b3797889..1feaf963348c 100644 --- a/packages/astro/test/i18n-routing-manual.test.js +++ b/packages/astro/test/i18n-routing-manual.test.js @@ -58,8 +58,6 @@ describe('Dev server manual routing', () => { describe('SSG manual routing', () => { /** @type {import('./test-utils').Fixture} */ let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; before(async () => { fixture = await loadFixture({ diff --git a/packages/astro/test/rewrite.test.js b/packages/astro/test/rewrite.test.js new file mode 100644 index 000000000000..1c76ce10af74 --- /dev/null +++ b/packages/astro/test/rewrite.test.js @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import testAdapter from './test-adapter.js'; +import { loadFixture } from './test-utils.js'; + +describe('Dev reroute', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/reroute/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should render the index page when navigating /reroute ', async () => { + const html = await fixture.fetch('/reroute').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/hello ', async () => { + const html = await fixture.fetch('/blog/hello').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/salut ', async () => { + const html = await fixture.fetch('/blog/salut').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating dynamic route /dynamic/[id] ', async () => { + const html = await fixture.fetch('/dynamic/hello').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating spread route /spread/[...spread] ', async () => { + const html = await fixture.fetch('/spread/hello').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the 404 built-in page', async () => { + const html = await fixture.fetch('/blog/oops').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), '404: Not found'); + }); +}); + +describe('Build reroute', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/reroute/', + }); + await fixture.build(); + }); + + it('should render the index page when navigating /reroute ', async () => { + const html = await fixture.readFile('/reroute/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/hello ', async () => { + const html = await fixture.readFile('/blog/hello/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/salut ', async () => { + const html = await fixture.readFile('/blog/salut/index.html'); + + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating dynamic route /dynamic/[id] ', async () => { + const html = await fixture.readFile('/dynamic/hello/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating spread route /spread/[...spread] ', async () => { + const html = await fixture.readFile('/spread/hello/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the 404 built-in page', async () => { + try { + const html = await fixture.readFile('/spread/oops/index.html'); + assert.fail('Not found'); + } catch { + assert.ok; + } + }); +}); + +describe('SSR reroute', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let app; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/reroute/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should render the index page when navigating /reroute ', async () => { + const request = new Request('http://example.com/reroute'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/hello ', async () => { + const request = new Request('http://example.com/blog/hello'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/salut ', async () => { + const request = new Request('http://example.com/blog/salut'); + const response = await app.render(request); + const html = await response.text(); + + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating dynamic route /dynamic/[id] ', async () => { + const request = new Request('http://example.com/dynamic/hello'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating spread route /spread/[...spread] ', async () => { + const request = new Request('http://example.com/spread/hello'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the 404 built-in page', async () => { + const request = new Request('http://example.com/blog/oops'); + const response = await app.render(request); + const html = await response.text(); + assert.equal(html, 'Not found'); + }); +}); + +describe('Middleware', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/reroute/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should render a locals populated in the third middleware function, because we use next("/")', async () => { + const html = await fixture.fetch('/auth/base').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + assert.equal($('p').text(), 'Called auth'); + }); + + it('should NOT render locals populated in the third middleware function, because we use ctx.reroute("/")', async () => { + const html = await fixture.fetch('/auth/dashboard').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + assert.equal($('p').text(), ''); + }); +}); diff --git a/packages/astro/test/units/dev/collections-renderentry.test.js b/packages/astro/test/units/dev/collections-renderentry.test.js index 4c3849577629..3fa872289404 100644 --- a/packages/astro/test/units/dev/collections-renderentry.test.js +++ b/packages/astro/test/units/dev/collections-renderentry.test.js @@ -84,18 +84,6 @@ _describe('Content Collections - render()', () => { it('can be used in a layout component', async () => { const fs = createFsWithFallback( { - // Loading the content config with `astro:content` oddly - // causes this test to fail. Spoof a different src/content entry - // to ensure `existsSync` checks pass. - // TODO: revisit after addressing this issue - // https://github.com/withastro/astro/issues/6121 - '/src/content/blog/promo/launch-week.mdx': `--- -title: Launch Week -description: Astro is launching this week! ---- -# Launch Week -- [x] Launch Astro -- [ ] Celebrate`, '/src/components/Layout.astro': ` --- import { getCollection } from 'astro:content'; diff --git a/packages/astro/test/units/routing/route-matching.test.js b/packages/astro/test/units/routing/route-matching.test.js index b2f27d8c9f80..5eafa6c80aea 100644 --- a/packages/astro/test/units/routing/route-matching.test.js +++ b/packages/astro/test/units/routing/route-matching.test.js @@ -146,7 +146,7 @@ describe('Route matching', () => { const loader = createViteLoader(container.viteServer); const manifest = createDevelopmentManifest(container.settings); - pipeline = DevPipeline.create({ loader, logger: defaultLogger, manifest, settings }); + pipeline = DevPipeline.create(undefined, { loader, logger: defaultLogger, manifest, settings }); manifestData = createRouteManifest( { cwd: fileURLToPath(root), diff --git a/packages/astro/test/units/vite-plugin-astro-server/request.test.js b/packages/astro/test/units/vite-plugin-astro-server/request.test.js index 7ea587f97e2f..f976a9d30b50 100644 --- a/packages/astro/test/units/vite-plugin-astro-server/request.test.js +++ b/packages/astro/test/units/vite-plugin-astro-server/request.test.js @@ -22,7 +22,7 @@ async function createDevPipeline(overrides = {}) { const loader = overrides.loader ?? createLoader(); const manifest = createDevelopmentManifest(settings); - return DevPipeline.create({ loader, logger: defaultLogger, manifest, settings }); + return DevPipeline.create(undefined, { loader, logger: defaultLogger, manifest, settings }); } describe('vite-plugin-astro-server', () => { diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 8eb0bce21da1..5ea24d1609a7 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -50,10 +50,11 @@ "vfile": "^6.0.1" }, "peerDependencies": { - "astro": "^4.0.0" + "astro": "^4.8.0" }, "devDependencies": { "@types/estree": "^1.0.5", + "@types/hast": "^3.0.3", "@types/mdast": "^4.0.3", "@types/yargs-parser": "^21.0.3", "astro": "workspace:*", @@ -61,6 +62,7 @@ "cheerio": "1.0.0-rc.12", "linkedom": "^0.16.11", "mdast-util-mdx": "^3.0.0", + "mdast-util-mdx-jsx": "^3.1.2", "mdast-util-to-string": "^4.0.0", "reading-time": "^1.5.0", "rehype-mathjax": "^6.0.0", diff --git a/packages/integrations/mdx/src/README.md b/packages/integrations/mdx/src/README.md index bbbc6075c8af..3fc991b77c91 100644 --- a/packages/integrations/mdx/src/README.md +++ b/packages/integrations/mdx/src/README.md @@ -30,12 +30,7 @@ After: ```jsx function _createMdxContent() { - return ( - <> -

My MDX Content

-

-    
-  );
+  return ;
 }
 ```
 
@@ -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? 
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`?
@@ -71,8 +71,25 @@ Flow: - Q: Why before step 2's `node` enter handling?
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?
+ 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 `` node, and replace the group children with the new `` 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 fc1d92da48ca..3aaed8787585 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 = {}): 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 = {}): 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 = {}): AstroI const defaultMdxOptions = { extendMarkdownConfig: true, recmaPlugins: [], -}; + optimize: false, +} satisfies Partial; 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 99d0c70b2756..3978e5325435 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 000000000000..6c797fda235f --- /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 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; + + /* + * 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(); + + 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 + 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 573af317e99c..ebedb753e1cf 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(options?.customComponentNames); + const ignoreElementNames = new Set(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(); + const allPossibleElements = new Set(); // The current collapsible element stack while traversing the tree const elementStack: Node[] = []; + // Metadata used by `findElementGroups` later + const elementMetadatas = new WeakMap(); + + /** + * 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. `something` + 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 `` 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 `` 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, + elementMetadatas: WeakMap, + 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 `something` 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) { + 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 46d04d443341..000000000000 --- 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(); - - 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 {node.alt} - 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 - Object.entries(node.data.hProperties as Record).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 c60504be6c9c..7661c0ecf874 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 `` 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 6f2ec2cc487a..1b966ecd2a30 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 | 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 { + 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', + }, + }; +} diff --git a/packages/integrations/mdx/test/css-head-mdx.test.js b/packages/integrations/mdx/test/css-head-mdx.test.js index 5caab3d059b8..083348015c54 100644 --- a/packages/integrations/mdx/test/css-head-mdx.test.js +++ b/packages/integrations/mdx/test/css-head-mdx.test.js @@ -50,7 +50,7 @@ describe('Head injection w/ MDX', () => { assert.equal(links.length, 1); const scripts = document.querySelectorAll('head script[type=module]'); - assert.equal(scripts.length, 2); + assert.equal(scripts.length, 1); }); it('Using component using slots.render() API', async () => { diff --git a/packages/integrations/mdx/test/fixtures/mdx-optimize/astro.config.mjs b/packages/integrations/mdx/test/fixtures/mdx-optimize/astro.config.mjs index b92b48617c28..204549479f5d 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-optimize/astro.config.mjs +++ b/packages/integrations/mdx/test/fixtures/mdx-optimize/astro.config.mjs @@ -3,7 +3,7 @@ import mdx from '@astrojs/mdx'; export default { integrations: [mdx({ optimize: { - customComponentNames: ['strong'] + ignoreElementNames: ['strong'] } })] } diff --git a/packages/integrations/mdx/test/mdx-plugins.test.js b/packages/integrations/mdx/test/mdx-plugins.test.js index 6b15884fb712..6bc8e096c268 100644 --- a/packages/integrations/mdx/test/mdx-plugins.test.js +++ b/packages/integrations/mdx/test/mdx-plugins.test.js @@ -64,6 +64,30 @@ describe('MDX plugins', () => { assert.notEqual(selectRehypeExample(document), null); }); + it('supports custom rehype plugins from integrations', async () => { + const fixture = await buildFixture({ + integrations: [ + mdx(), + { + name: 'test', + hooks: { + 'astro:config:setup': ({ updateConfig }) => { + updateConfig({ + markdown: { + rehypePlugins: [rehypeExamplePlugin], + }, + }); + }, + }, + }, + ], + }); + const html = await fixture.readFile(FILE); + const { document } = parseHTML(html); + + assert.notEqual(selectRehypeExample(document), null); + }); + it('supports custom rehype plugins with namespaced attributes', async () => { const fixture = await buildFixture({ integrations: [ diff --git a/packages/integrations/mdx/test/units/rehype-optimize-static.test.js b/packages/integrations/mdx/test/units/rehype-optimize-static.test.js new file mode 100644 index 000000000000..132f3849f5bf --- /dev/null +++ b/packages/integrations/mdx/test/units/rehype-optimize-static.test.js @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { compile as _compile } from '@mdx-js/mdx'; +import { rehypeOptimizeStatic } from '../../dist/rehype-optimize-static.js'; + +/** + * @param {string} mdxCode + * @param {Readonly} options + */ +async function compile(mdxCode, options) { + const result = await _compile(mdxCode, { + jsx: true, + rehypePlugins: [rehypeOptimizeStatic], + ...options, + }); + const code = result.toString(); + // Capture the returned JSX code for testing + const jsx = code.match(/return (.+);\n\}\nexport default function MDXContent/s)?.[1]; + if (jsx == null) throw new Error('Could not find JSX code in compiled MDX'); + return dedent(jsx); +} + +function dedent(str) { + const lines = str.split('\n'); + if (lines.length <= 1) return str; + // Get last line indent, and dedent this amount for the other lines + const lastLineIndent = lines[lines.length - 1].match(/^\s*/)[0].length; + return lines.map((line, i) => (i === 0 ? line : line.slice(lastLineIndent))).join('\n'); +} + +describe('rehype-optimize-static', () => { + it('works', async () => { + const jsx = await compile(`# hello`); + assert.equal( + jsx, + `\ +<_components.h1 {...{ + "set:html": "hello" +}} />` + ); + }); + + it('groups sibling nodes as a single Fragment', async () => { + const jsx = await compile(`\ +# hello + +foo bar +`); + assert.equal( + jsx, + `\ +` + ); + }); + + it('skips optimization of components', async () => { + const jsx = await compile(`\ +import Comp from './Comp.jsx'; + +# hello + +This is a +`); + assert.equal( + jsx, + `\ +<><_components.p>{"This is a "}` + ); + }); + + it('optimizes explicit html elements', async () => { + const jsx = await compile(`\ +# hello + +foo bar baz + +qux +`); + assert.equal( + jsx, + `\ +` + ); + }); +}); diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json index 90eaba4c3f3d..e088be21d8cc 100644 --- a/packages/integrations/react/package.json +++ b/packages/integrations/react/package.json @@ -60,10 +60,10 @@ "vite": "^5.2.10" }, "peerDependencies": { - "@types/react": "^17.0.50 || ^18.0.21", - "@types/react-dom": "^17.0.17 || ^18.0.6", - "react": "^17.0.2 || ^18.0.0", - "react-dom": "^17.0.2 || ^18.0.0" + "@types/react": "^17.0.50 || ^18.0.21 || npm:types-react@beta", + "@types/react-dom": "^17.0.17 || ^18.0.6 || npm:types-react-dom@beta", + "react": "^17.0.2 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0-beta" }, "engines": { "node": "^18.17.1 || ^20.3.0 || >=21.0.0" diff --git a/packages/integrations/react/src/index.ts b/packages/integrations/react/src/index.ts index e0149e8e76c1..838640239ee3 100644 --- a/packages/integrations/react/src/index.ts +++ b/packages/integrations/react/src/index.ts @@ -12,15 +12,44 @@ export type ReactIntegrationOptions = Pick< const FAST_REFRESH_PREAMBLE = react.preambleCode; -function getRenderer() { +const versionsConfig = { + 17: { + server: '@astrojs/react/server-v17.js', + client: '@astrojs/react/client-v17.js', + externals: ['react-dom/server.js', 'react-dom/client.js'], + }, + 18: { + server: '@astrojs/react/server.js', + client: '@astrojs/react/client.js', + externals: ['react-dom/server', 'react-dom/client'], + }, + 19: { + server: '@astrojs/react/server.js', + client: '@astrojs/react/client.js', + externals: ['react-dom/server', 'react-dom/client'], + }, +}; + +type SupportedReactVersion = keyof typeof versionsConfig; +type ReactVersionConfig = (typeof versionsConfig)[SupportedReactVersion]; + +function getReactMajorVersion(): number { + const matches = /\d+\./.exec(ReactVersion); + if (!matches) { + return NaN; + } + return Number(matches[0]); +} + +function isUnsupportedVersion(majorVersion: number) { + return majorVersion < 17 || majorVersion > 19 || Number.isNaN(majorVersion); +} + +function getRenderer(reactConfig: ReactVersionConfig) { return { name: '@astrojs/react', - clientEntrypoint: ReactVersion.startsWith('18.') - ? '@astrojs/react/client.js' - : '@astrojs/react/client-v17.js', - serverEntrypoint: ReactVersion.startsWith('18.') - ? '@astrojs/react/server.js' - : '@astrojs/react/server-v17.js', + clientEntrypoint: reactConfig.client, + serverEntrypoint: reactConfig.server, }; } @@ -46,37 +75,27 @@ function optionsPlugin(experimentalReactChildren: boolean): vite.Plugin { }; } -function getViteConfiguration({ - include, - exclude, - babel, - experimentalReactChildren, -}: ReactIntegrationOptions = {}) { +function getViteConfiguration( + { include, exclude, babel, experimentalReactChildren }: ReactIntegrationOptions = {}, + reactConfig: ReactVersionConfig +) { return { optimizeDeps: { include: [ - ReactVersion.startsWith('18.') - ? '@astrojs/react/client.js' - : '@astrojs/react/client-v17.js', + reactConfig.client, 'react', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom', ], - exclude: [ - ReactVersion.startsWith('18.') - ? '@astrojs/react/server.js' - : '@astrojs/react/server-v17.js', - ], + exclude: [reactConfig.server], }, plugins: [react({ include, exclude, babel }), optionsPlugin(!!experimentalReactChildren)], resolve: { dedupe: ['react', 'react-dom', 'react-dom/server'], }, ssr: { - external: ReactVersion.startsWith('18.') - ? ['react-dom/server', 'react-dom/client'] - : ['react-dom/server.js', 'react-dom/client.js'], + external: reactConfig.externals, noExternal: [ // These are all needed to get mui to work. '@mui/material', @@ -95,13 +114,22 @@ export default function ({ babel, experimentalReactChildren, }: ReactIntegrationOptions = {}): AstroIntegration { + const majorVersion = getReactMajorVersion(); + if (isUnsupportedVersion(majorVersion)) { + throw new Error(`Unsupported React version: ${majorVersion}.`); + } + const versionConfig = versionsConfig[majorVersion as SupportedReactVersion]; + return { name: '@astrojs/react', hooks: { 'astro:config:setup': ({ command, addRenderer, updateConfig, injectScript }) => { - addRenderer(getRenderer()); + addRenderer(getRenderer(versionConfig)); updateConfig({ - vite: getViteConfiguration({ include, exclude, babel, experimentalReactChildren }), + vite: getViteConfiguration( + { include, exclude, babel, experimentalReactChildren }, + versionConfig + ), }); if (command === 'dev') { const preamble = FAST_REFRESH_PREAMBLE.replace(`__BASE__`, '/'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be362ddc2641..98b0868de4b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -775,6 +775,12 @@ importers: eol: specifier: ^0.9.1 version: 0.9.1 + mdast-util-mdx: + specifier: ^3.0.0 + version: 3.0.0 + mdast-util-mdx-jsx: + specifier: ^3.1.2 + version: 3.1.2 memfs: specifier: ^4.9.1 version: 4.9.1 @@ -2612,6 +2618,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/css-inline-stylesheets-3: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/css-no-code-split: dependencies: astro: @@ -3114,6 +3126,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/middleware-virtual: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/minification-html: dependencies: astro: @@ -3312,6 +3330,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/reroute: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/root-srcdir-css: dependencies: astro: @@ -4420,6 +4444,9 @@ importers: '@types/estree': specifier: ^1.0.5 version: 1.0.5 + '@types/hast': + specifier: ^3.0.3 + version: 3.0.4 '@types/mdast': specifier: ^4.0.3 version: 4.0.3 @@ -4441,6 +4468,9 @@ importers: mdast-util-mdx: specifier: ^3.0.0 version: 3.0.0 + mdast-util-mdx-jsx: + specifier: ^3.1.2 + version: 3.1.2 mdast-util-to-string: specifier: ^4.0.0 version: 4.0.0