Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/internal-helpers-mdx-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/internal-helpers': minor
---

Adds an `@astrojs/internal-helpers/mdx` entrypoint with shared helpers used by the Markdown processor packages to render `.mdx` files.
8 changes: 8 additions & 0 deletions .changeset/markdown-processors-own-mdx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@astrojs/markdown-remark': minor
'@astrojs/markdown-satteri': minor
---

The `unified()` and `satteri()` Markdown processors now render `.mdx` files themselves, through a new optional `createMdxRenderer` hook on the processor. `.mdx` files follow whichever processor you configure, and third-party processors can add their own MDX support by implementing the hook.

`unified()` also gains a `recmaPlugins` option for adding recma (estree/JSX) plugins to the MDX compiler.
9 changes: 9 additions & 0 deletions .changeset/mdx-extend-markdown-config-processor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@astrojs/mdx': patch
---

Fixes `.mdx` files ignoring `extendMarkdownConfig: false` for the Markdown processor ([#17030](https://github.com/withastro/astro/issues/17030)).

Previously, `.mdx` files always inherited your site's configured `markdown.processor` (and its plugins), even with `extendMarkdownConfig: false`. Now `extendMarkdownConfig: false` renders `.mdx` with a clean default Sätteri processor, matching the documented behaviour.

The deprecated `remarkPlugins`/`rehypePlugins`/`remarkRehype`/`recmaPlugins` options on `mdx({...})` continue to work: they are wired into the `unified` processor from `@astrojs/markdown-remark`, mirroring how `markdown.remarkPlugins` behaves in core. `recmaPlugins` on `mdx({...})` is now deprecated too — pass it to `unified({ recmaPlugins })` instead.
50 changes: 4 additions & 46 deletions packages/astro/src/core/viteUtils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { prependForwardSlash, slash } from '../core/path.js';
import type { ModuleLoader } from './module-loader/index.js';
import { resolveJsToTs, unwrapId, VALID_ID_PREFIX, viteID } from './util.js';
import { unwrapId, VALID_ID_PREFIX, viteID } from './util.js';

export { resolvePath } from '@astrojs/internal-helpers/mdx';

const isWindows = typeof process !== 'undefined' && process.platform === 'win32';

Expand All @@ -14,49 +15,6 @@ export function normalizePath(id: string) {
return path.posix.normalize(isWindows ? slash(id) : id);
}

/**
* Resolve island component specifiers to stable paths for hydration metadata.
*
* Examples:
* - `./components/Button.jsx` from `/app/src/pages/index.astro`
* -> `/app/src/pages/components/Button.tsx` (when `.tsx` exists)
* - `#components/react/Counter.tsx`
* -> `/app/src/components/react/Counter.tsx` via package `imports`
*/
export function resolvePath(specifier: string, importer: string) {
if (specifier.startsWith('.')) {
const absoluteSpecifier = path.resolve(path.dirname(importer), specifier);
return resolveJsToTs(normalizePath(absoluteSpecifier));
} else if (specifier.startsWith('#')) {
// Support Node subpath imports (package.json#imports), so this resolves
// before we hand off to non-runnable dev pipelines.
//
// Without this, unresolved values like `/@id/#components/...` can leak
// into client hydration URLs.
try {
// Primary path: CJS-style resolver rooted at the importer.
const resolved = createRequire(pathToFileURL(importer)).resolve(specifier);
return resolveJsToTs(normalizePath(resolved));
} catch {
try {
// Fallback: ESM resolver in case environments differ.
const importerURL = pathToFileURL(importer).toString();
const resolved = import.meta.resolve(specifier, importerURL);
const resolvedUrl = new URL(resolved);
if (resolvedUrl.protocol === 'file:') {
return resolveJsToTs(normalizePath(fileURLToPath(resolvedUrl)));
}
} catch {
// fall through
}
}
// Keep original behavior for unresolved specifiers (e.g. package ids).
return specifier;
} else {
return specifier;
}
}

export function rootRelativePath(
root: URL,
idOrUrl: URL | string,
Expand Down
11 changes: 2 additions & 9 deletions packages/astro/src/vite-plugin-astro/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createDefaultAstroMetadata as createSharedAstroMetadata } from '@astrojs/internal-helpers/mdx';
import type { ModuleInfo } from '../core/module-loader/index.js';
import type { PluginMetadata } from './types.js';

Expand All @@ -9,13 +10,5 @@ export function getAstroMetadata(modInfo: ModuleInfo): PluginMetadata['astro'] |
}

export function createDefaultAstroMetadata(): PluginMetadata['astro'] {
return {
hydratedComponents: [],
clientOnlyComponents: [],
serverComponents: [],
scripts: [],
propagation: 'none',
containsHead: false,
pageOptions: {},
};
return createSharedAstroMetadata() as PluginMetadata['astro'];
}
25 changes: 5 additions & 20 deletions packages/integrations/mdx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,31 +35,15 @@
},
"dependencies": {
"@astrojs/internal-helpers": "workspace:*",
"@astrojs/markdown-remark": "workspace:*",
"@mdx-js/mdx": "^3.1.1",
"acorn": "^8.16.0",
"es-module-lexer": "^2.0.0",
"estree-util-visit": "^2.0.0",
"hast-util-to-html": "^9.0.5",
"piccolore": "^0.1.3",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-smartypants": "^3.0.2",
"source-map": "^0.7.6",
"unist-util-visit": "^5.1.0",
"vfile": "^6.0.3"
"@astrojs/markdown-satteri": "workspace:*",
"es-module-lexer": "^2.0.0"
},
"peerDependencies": {
"@astrojs/markdown-satteri": "^0.3.1",
"astro": "^7.0.0"
},
"peerDependenciesMeta": {
"@astrojs/markdown-satteri": {
"optional": true
}
},
"devDependencies": {
"@astrojs/markdown-satteri": "workspace:*",
"@astrojs/markdown-remark": "workspace:*",
"@mdx-js/mdx": "^3.1.1",
"@shikijs/rehype": "^4.0.2",
"@shikijs/twoslash": "^4.0.2",
"@types/estree": "^1.0.8",
Expand All @@ -79,6 +63,7 @@
"satteri": "^0.9.1",
"shiki": "^4.0.2",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"vite": "^8.0.13"
},
"engines": {
Expand Down
10 changes: 0 additions & 10 deletions packages/integrations/mdx/src/image-constants.ts

This file was deleted.

129 changes: 62 additions & 67 deletions packages/integrations/mdx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { fileURLToPath } from 'node:url';
import {
type AstroMarkdownOptions,
markdownConfigDefaults,
type PluggableList,
type RehypePlugins,
type RemarkPlugins,
type RemarkRehype as RemarkRehypeOptions,
} from '@astrojs/internal-helpers/markdown';
import { satteri } from '@astrojs/markdown-satteri';
import type {
AstroIntegration,
AstroIntegrationLogger,
Expand All @@ -12,15 +17,17 @@ import type {
HookParameters,
} from 'astro';
import type { MarkdownProcessor } from 'astro/markdown';
import type { Options as RemarkRehypeOptions } from 'remark-rehype';
import type { PluggableList } from 'unified';
import { getContainerRenderer as getContainerRendererImpl } from './container-renderer.js';
import { isSatteriProcessor, isUnifiedProcessor } from './processor-guards.js';
import type { OptimizeOptions } from './rehype-optimize-static.js';
import { ignoreStringPlugins, safeParseFrontmatter } from './utils.js';
import { isUnifiedProcessor } from './processor-guards.js';
import { safeParseFrontmatter } from './utils.js';
import { type VitePluginMdxOptions, vitePluginMdx } from './vite-plugin-mdx.js';
import { vitePluginMdxPostprocess } from './vite-plugin-mdx-postprocess.js';

/** MDX static-optimization options. Mirror of the pipeline's `OptimizeOptions`. */
export interface OptimizeOptions {
ignoreElementNames?: string[];
}

// `gfm`/`smartypants` are deprecated and stay unset unless the user opts in; the
// MDX pipelines treat an absent value as the default (on), like the `.md` processors.
type SharedMarkdownOptions = Required<
Expand All @@ -30,6 +37,9 @@ type SharedMarkdownOptions = Required<

export type MdxOptions = SharedMarkdownOptions & {
extendMarkdownConfig: boolean;
/**
* @deprecated Pass `recmaPlugins` to `unified({ recmaPlugins })` from `@astrojs/markdown-remark` and set it as `markdown.processor` instead. Will be removed in a future major.
*/
recmaPlugins: PluggableList;
optimize: boolean | OptimizeOptions;
/**
Expand Down Expand Up @@ -59,11 +69,7 @@ export type MdxOptions = SharedMarkdownOptions & {
* @internal
*/
export type ResolvedMdxOptions = SharedMarkdownOptions & {
recmaPlugins: PluggableList;
optimize: boolean | OptimizeOptions;
remarkPlugins: PluggableList;
rehypePlugins: PluggableList;
remarkRehype: RemarkRehypeOptions;
};

type SetupHookParams = HookParameters<'astro:config:setup'> & {
Expand Down Expand Up @@ -126,7 +132,7 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
},
});
},
'astro:config:done': ({ config, logger }) => {
'astro:config:done': async ({ config, logger }) => {
warnDeprecatedMdxPluginOptions(partialMdxOptions, logger);

// We resolve the final MDX options here so that other integrations have a chance to modify
Expand All @@ -138,32 +144,52 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI

const resolvedMdxOptions = applyDefaultOptions({
options: partialMdxOptions,
defaults: markdownConfigToMdxOptions(markdownConfig, logger),
defaults: markdownConfigToMdxOptions(markdownConfig),
});

const processor = partialMdxOptions.processor ?? config.markdown.processor;
// `extendMarkdownConfig: false` renders `.mdx` with a clean default processor
// (Sätteri) instead of inheriting the site's `markdown.processor`. An explicit
// `mdx({ processor })` always wins.
let processor =
partialMdxOptions.processor ??
(extendMarkdownConfig ? config.markdown.processor : satteri());

if (extendMarkdownConfig && isUnifiedProcessor(processor)) {
// MDX inherits from the processor only when the user did NOT pass that option
// to `mdx({...})`. Following the historical contract: MDX's value REPLACES the
// markdown processor's value (no per-key merge).
if (partialMdxOptions.remarkPlugins === undefined) {
resolvedMdxOptions.remarkPlugins = ignoreStringPlugins(
processor.options.remarkPlugins,
logger,
);
}
if (partialMdxOptions.rehypePlugins === undefined) {
resolvedMdxOptions.rehypePlugins = ignoreStringPlugins(
processor.options.rehypePlugins,
logger,
// Deprecated `mdx({ remark/rehypePlugins, remarkRehype })` run on the `unified`
// processor. Wire them in by swapping to a `unified()` that carries them (per-key
// replacing, inheriting the rest when the active processor is already `unified`),
// mirroring how core folds `markdown.{remark,rehype}Plugins` into `unified()`.
const hasLegacyMdxPlugins =
(partialMdxOptions.remarkPlugins?.length ?? 0) > 0 ||
(partialMdxOptions.rehypePlugins?.length ?? 0) > 0 ||
(partialMdxOptions.recmaPlugins?.length ?? 0) > 0 ||
Object.keys(partialMdxOptions.remarkRehype ?? {}).length > 0;
if (hasLegacyMdxPlugins) {
let unified: typeof import('@astrojs/markdown-remark').unified;
try {
({ unified } = await import('@astrojs/markdown-remark'));
} catch {
throw new Error(
'`remarkPlugins`, `rehypePlugins`, `remarkRehype`, and `recmaPlugins` on `mdx({...})` run on the `unified` processor from `@astrojs/markdown-remark`, which is not installed. Install it with:\n npm install @astrojs/markdown-remark',
);
}
if (partialMdxOptions.remarkRehype === undefined) {
resolvedMdxOptions.remarkRehype = { ...processor.options.remarkRehype };
}
const base = isUnifiedProcessor(processor) ? processor.options : undefined;
processor = unified({
// MDX plugin lists are function-only; widen to the processor's plugin type.
remarkPlugins:
(partialMdxOptions.remarkPlugins as RemarkPlugins | undefined) ?? base?.remarkPlugins,
rehypePlugins:
(partialMdxOptions.rehypePlugins as RehypePlugins | undefined) ?? base?.rehypePlugins,
remarkRehype: partialMdxOptions.remarkRehype ?? base?.remarkRehype,
recmaPlugins: partialMdxOptions.recmaPlugins ?? base?.recmaPlugins,
gfm: base?.gfm,
smartypants: base?.smartypants,
});
}

if (extendMarkdownConfig && isUnifiedProcessor(processor)) {
// `gfm`/`smartypants` from `unified({...})` apply to `.mdx` too, unless
// `mdx({...})` set its own.
// `mdx({...})` set its own. The processor's remark/rehype plugins are read
// directly by its own `createMdxRenderer`, so no inheritance is needed here.
if (partialMdxOptions.gfm === undefined && processor.options.gfm !== undefined) {
resolvedMdxOptions.gfm = processor.options.gfm;
}
Expand All @@ -174,27 +200,8 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
resolvedMdxOptions.smartypants = processor.options.smartypants;
}
}
if (extendMarkdownConfig && isSatteriProcessor(processor)) {
// `gfm`/`smartPunctuation` from `satteri({ features: {...} })` apply to `.mdx`
// too, unless `mdx({...})` set its own. Mirrors the unified branch above.
const features = processor.options.features;
// `gfm` can be `boolean | GfmOptions`; only the boolean form is shape-compatible
// with `mdxOptions.gfm`. Object configs stay on the processor and are applied at
// the satteri/mdx boundary, like `smartPunctuation` below.
if (partialMdxOptions.gfm === undefined && typeof features.gfm === 'boolean') {
resolvedMdxOptions.gfm = features.gfm;
}
// `smartPunctuation` can be `boolean | SmartPunctuationOptions`; only the boolean
// form is shape-compatible with `mdxOptions.smartypants`. Object configs stay on
// the processor and are applied at the satteri/mdx boundary.
if (
partialMdxOptions.smartypants === undefined &&
typeof features.smartPunctuation === 'boolean'
) {
resolvedMdxOptions.smartypants = features.smartPunctuation;
}
}
// Other third-party processors handle their own pipeline via `createMdxRenderer`.
// Sätteri and other processors read their own options inside `createMdxRenderer`;
// only `unified` needs its `gfm`/`smartypants` lifted into the shared options above.

// Mutate `mdxOptions` so that `vitePluginMdx` can reference the actual options
Object.assign(vitePluginMdxOptions, {
Expand All @@ -221,9 +228,9 @@ function warnDeprecatedMdxPluginOptions(
logger: AstroIntegrationLogger,
): void {
if (didWarnAboutDeprecatedMdxPluginOptions) return;
const deprecated = (['remarkPlugins', 'rehypePlugins', 'remarkRehype'] as const).filter(
(key) => options[key] !== undefined,
);
const deprecated = (
['remarkPlugins', 'rehypePlugins', 'remarkRehype', 'recmaPlugins'] as const
).filter((key) => options[key] !== undefined);
if (deprecated.length === 0) return;
didWarnAboutDeprecatedMdxPluginOptions = true;

Expand All @@ -237,22 +244,14 @@ function warnDeprecatedMdxPluginOptions(
);
}

function markdownConfigToMdxOptions(
markdownConfig: SharedMarkdownOptions,
_logger: AstroIntegrationLogger,
): ResolvedMdxOptions {
function markdownConfigToMdxOptions(markdownConfig: SharedMarkdownOptions): ResolvedMdxOptions {
return {
...markdownConfig,
// Deprecated `markdown.{gfm,smartypants}` may be unset (optional in the schema);
// fall back to the processor defaults so the MDX pipeline still enables them by default.
gfm: markdownConfig.gfm ?? markdownConfigDefaults.gfm,
smartypants: markdownConfig.smartypants ?? markdownConfigDefaults.smartypants,
recmaPlugins: [],
optimize: false,
// Plugins come from the processor — merged in astro:config:done.
remarkPlugins: [],
rehypePlugins: [],
remarkRehype: {},
};
}

Expand All @@ -268,10 +267,6 @@ function applyDefaultOptions({
shikiConfig: options.shikiConfig ?? defaults.shikiConfig,
gfm: options.gfm ?? defaults.gfm,
smartypants: options.smartypants ?? defaults.smartypants,
recmaPlugins: options.recmaPlugins ?? defaults.recmaPlugins,
optimize: options.optimize ?? defaults.optimize,
remarkPlugins: options.remarkPlugins ?? defaults.remarkPlugins,
rehypePlugins: options.rehypePlugins ?? defaults.rehypePlugins,
remarkRehype: options.remarkRehype ?? defaults.remarkRehype,
};
}
10 changes: 3 additions & 7 deletions packages/integrations/mdx/src/processor-guards.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import type { UnifiedResolvedOptions } from '@astrojs/markdown-remark';
import type { SatteriResolvedOptions } from '@astrojs/markdown-satteri';
import type { MarkdownProcessor } from 'astro/markdown';

// Name-checks for the built-in processors. Type-only imports keep
// `@astrojs/markdown-satteri` (an optional peer) out of MDX's runtime graph.
// Name-check for the built-in `unified` processor. The type-only import keeps
// `@astrojs/markdown-remark` (a dev-only dependency now that its pipeline is invoked via the
// processor) out of MDX's runtime graph.

export const isUnifiedProcessor = (p: {
name: string;
}): p is MarkdownProcessor<UnifiedResolvedOptions> => p.name === 'unified';

export const isSatteriProcessor = (p: {
name: string;
}): p is MarkdownProcessor<SatteriResolvedOptions> => p.name === 'satteri';
Loading
Loading