From 50768373053dcd641abc9f47a61d92d12f904842 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 4 Jun 2026 02:45:59 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20make=20S=C3=A4tteri=20the=20default?= =?UTF-8?q?=20Markdown=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../satteri-default-markdown-processor.md | 23 ++ packages/astro/package.json | 12 +- .../astro/src/core/config/schemas/base.ts | 6 +- packages/astro/src/core/config/validate.ts | 12 +- packages/astro/src/jsx/rehype.ts | 377 ------------------ packages/astro/src/types/public/config.ts | 20 +- .../src/vite-plugin-html/transform/slots.ts | 5 +- packages/astro/test/astro-markdown.test.ts | 4 + packages/astro/test/markdown.test.ts | 2 +- .../image-remark-imgattr/astro.config.mjs | 5 +- .../image-remark-imgattr/package.json | 1 + .../astro.config.mjs | 14 +- .../mdx-frontmatter-injection/package.json | 1 + .../mdx-vite-env-vars/astro.config.mjs | 3 + .../fixtures/mdx-vite-env-vars/package.json | 1 + .../mdx/test/mdx-get-headings.test.ts | 10 +- .../integrations/mdx/test/mdx-plugins.test.ts | 7 +- .../mdx/test/mdx-syntax-highlighting.test.ts | 9 + pnpm-lock.yaml | 19 +- 19 files changed, 130 insertions(+), 401 deletions(-) create mode 100644 .changeset/satteri-default-markdown-processor.md delete mode 100644 packages/astro/src/jsx/rehype.ts diff --git a/.changeset/satteri-default-markdown-processor.md b/.changeset/satteri-default-markdown-processor.md new file mode 100644 index 000000000000..899f030c14ac --- /dev/null +++ b/.changeset/satteri-default-markdown-processor.md @@ -0,0 +1,23 @@ +--- +'astro': major +--- + +Makes Sätteri the default Markdown processor + +Astro now renders `.md` files with `satteri()` from `@astrojs/markdown-satteri`, its native Markdown pipeline, instead of the remark/rehype pipeline. `@astrojs/markdown-remark` is no longer installed by default. + +To keep using the remark/rehype pipeline, install `@astrojs/markdown-remark` and set it as your processor: + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import { unified } from '@astrojs/markdown-remark'; + +export default defineConfig({ + markdown: { + processor: unified(), + }, +}); +``` + +The deprecated `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` options still work, but now require `@astrojs/markdown-remark` to be installed and switch the processor to `unified()` for you. `syntaxHighlight: 'prism'` is only available on the `unified()` processor; Sätteri highlights code with Shiki. diff --git a/packages/astro/package.json b/packages/astro/package.json index 231fcaa822a0..a19ac7f4de56 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -136,7 +136,7 @@ "dependencies": { "@astrojs/compiler-rs": "^0.1.10", "@astrojs/internal-helpers": "workspace:*", - "@astrojs/markdown-remark": "workspace:*", + "@astrojs/markdown-satteri": "workspace:*", "@astrojs/telemetry": "workspace:*", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", @@ -193,10 +193,20 @@ "optionalDependencies": { "sharp": "^0.34.0" }, + "peerDependencies": { + "@astrojs/markdown-remark": "workspace:*" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + }, "devDependencies": { "@astrojs/check": "workspace:*", + "@astrojs/markdown-remark": "workspace:*", "@playwright/test": "1.58.2", "@types/aria-query": "^5.0.4", + "@types/estree": "^1.0.8", "@types/hast": "^3.0.4", "@types/html-escaper": "3.0.4", "@types/http-cache-semantics": "^4.2.0", diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index b762c3825bdc..686747d3a364 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -9,7 +9,7 @@ import { markdownConfigDefaults, syntaxHighlightDefaults, } from '@astrojs/internal-helpers/markdown'; -import { unified } from '@astrojs/markdown-remark'; +import { satteri } from '@astrojs/markdown-satteri'; import type { MarkdownProcessor } from '../../../markdown/index.js'; import type { OutgoingHttpHeaders } from 'node:http'; import { type BuiltinTheme, bundledThemes } from 'shiki'; @@ -424,7 +424,7 @@ export const AstroConfigSchema = z.object({ .default(ASTRO_CONFIG_DEFAULTS.markdown.remarkRehype), // Deprecated: left undefined unless the user explicitly sets them, so the // deprecation warning only fires when actually used. The active processor - // (`unified()`) supplies the real default (`gfm`/smart punctuation on) when + // (`satteri()`) supplies the real default (`gfm`/smart punctuation on) when // these are absent. gfm: z.boolean().optional(), smartypants: z @@ -454,7 +454,7 @@ export const AstroConfigSchema = z.object({ // A factory (not a shared value) so every config gets its own processor — // integrations extend the pipeline by mutating `processor.options`, which // would otherwise leak across configs built in the same process. - .default(() => unified()), + .default(() => satteri()), }) .prefault({}), vite: z diff --git a/packages/astro/src/core/config/validate.ts b/packages/astro/src/core/config/validate.ts index 5c6af337d5e4..063857a6007a 100644 --- a/packages/astro/src/core/config/validate.ts +++ b/packages/astro/src/core/config/validate.ts @@ -44,7 +44,7 @@ function warnDeprecatedMarkdownOptions( const names = deprecated.map((key) => `\`markdown.${key}\``).join(' and '); const isPlural = deprecated.length > 1; console.warn( - `[astro] ${names} ${isPlural ? 'are' : 'is'} deprecated. Move ${isPlural ? 'them' : 'it'} onto your processor instead (e.g. \`unified({ gfm: false, smartypants: false })\`). Will be removed in a future major.`, + `[astro] ${names} ${isPlural ? 'are' : 'is'} deprecated. Move ${isPlural ? 'them' : 'it'} onto your processor instead (e.g. \`satteri({ features: { gfm: false, smartPunctuation: false } })\`, or \`unified({ gfm: false, smartypants: false })\` from \`@astrojs/markdown-remark\`). Will be removed in a future major.`, ); } @@ -74,7 +74,15 @@ async function coerceLegacyMarkdownPlugins( return; } - const { unified, isUnifiedProcessor } = await import('@astrojs/markdown-remark'); + let unified: typeof import('@astrojs/markdown-remark').unified; + let isUnifiedProcessor: typeof import('@astrojs/markdown-remark').isUnifiedProcessor; + try { + ({ unified, isUnifiedProcessor } = await import('@astrojs/markdown-remark')); + } catch { + throw new Error( + '`markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` run on the `unified` processor from `@astrojs/markdown-remark`, which is no longer installed by default now that Sätteri is the default Markdown processor. Install it with:\n npm install @astrojs/markdown-remark', + ); + } const current = md.processor; if (!current || isUnifiedProcessor(current)) { diff --git a/packages/astro/src/jsx/rehype.ts b/packages/astro/src/jsx/rehype.ts deleted file mode 100644 index fe6b378aaff0..000000000000 --- a/packages/astro/src/jsx/rehype.ts +++ /dev/null @@ -1,377 +0,0 @@ -// TODO: Remove this file in Astro 7.0. It now lives directly in the MDX integration at packages/integrations/mdx/src/rehype-analyze-astro-metadata.ts - -import type { RehypePlugin } from '@astrojs/internal-helpers/markdown'; -import type { RootContent } from 'hast'; -import type {} from 'mdast-util-mdx'; -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/viteUtils.js'; -import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js'; -import type { PluginMetadata } from '../vite-plugin-astro/types.js'; - -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 = createDefaultAstroMetadata(); - - // 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) || hasServerDeferDirective(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) { - 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, - localName: '', - specifier: tagName, - resolvedPath, - }); - // Mutate node with additional island attributes - addClientOnlyMetadata(node, matchedImport, resolvedPath); - } else if (hasClientDirective(node)) { - // Add this component to the metadata - metadata.hydratedComponents.push({ - exportName: '*', - localName: '', - specifier: tagName, - resolvedPath, - }); - // Mutate node with additional island attributes - addClientMetadata(node, matchedImport, resolvedPath); - } else if (hasServerDeferDirective(node)) { - metadata.serverComponents.push({ - exportName: matchedImport.name, - localName: tagName, - specifier: matchedImport.path, - resolvedPath, - }); - // Mutate node with additional island attributes - addServerDeferMetadata(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.type === 'Identifier' - ? spec.imported.name - : String(spec.imported.value), - }; - } - 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 hasServerDeferDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) { - return node.attributes.some( - (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'server:defer', - ); -} - -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' - *