From 9b81609d40d291aabd9819414a8053f2de873a20 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:50 +0200 Subject: [PATCH 01/26] feat(mdx): Rust MDX via satteri --- .changeset/seven-emus-knock.md | 7 + knip.js | 6 + packages/astro/package.json | 1 + .../astro/src/core/config/schemas/base.ts | 18 + packages/astro/src/jsx/rehype.ts | 34 +- packages/astro/src/types/public/config.ts | 50 ++ .../content-entry-type.ts | 23 +- .../astro/src/vite-plugin-markdown/index.ts | 21 +- packages/integrations/mdx/package.json | 9 +- packages/integrations/mdx/src/index.ts | 34 +- .../mdx/src/rehype-optimize-static.ts | 4 +- .../integrations/mdx/src/satteri-plugins.ts | 689 ++++++++++++++++++ .../integrations/mdx/src/vite-plugin-mdx.ts | 110 +-- packages/markdown/remark/package.json | 10 + packages/markdown/remark/src/index.ts | 10 + .../remark/src/rehype-collect-headings.ts | 8 +- packages/markdown/remark/src/satteri.ts | 265 +++++++ packages/markdown/remark/test/browser.test.ts | 3 + pnpm-workspace.yaml | 3 + 19 files changed, 1226 insertions(+), 79 deletions(-) create mode 100644 .changeset/seven-emus-knock.md create mode 100644 packages/integrations/mdx/src/satteri-plugins.ts create mode 100644 packages/markdown/remark/src/satteri.ts diff --git a/.changeset/seven-emus-knock.md b/.changeset/seven-emus-knock.md new file mode 100644 index 000000000000..04df3d1395a7 --- /dev/null +++ b/.changeset/seven-emus-knock.md @@ -0,0 +1,7 @@ +--- +'astro': patch +'@astrojs/markdown-remark': patch +'@astrojs/mdx': patch +--- + +temp changeset for preview release diff --git a/knip.js b/knip.js index 09c35626ae9b..26369d44f32d 100644 --- a/knip.js +++ b/knip.js @@ -83,6 +83,12 @@ export default { entry: [srcEntry, dtsEntry, testEntry], // package.json#imports are not resolved at the moment ignore: ['src/import-plugin-browser.ts', 'src/shiki-engine-workerd.ts'], + ignoreDependencies: [ + // Optional peer dep loaded dynamically by createSatteriMarkdownProcessor + 'satteri', + // Pulled in indirectly through satteri's browser WASM shim during bundler tests + '@napi-rs/wasm-runtime', + ], }, 'packages/upgrade': { entry: ['src/index.ts', testEntry], diff --git a/packages/astro/package.json b/packages/astro/package.json index a88ef66dd70e..43923024943f 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -196,6 +196,7 @@ }, "devDependencies": { "@astrojs/check": "workspace:*", + "satteri": "^0.2.7", "@playwright/test": "1.58.2", "@types/aria-query": "^5.0.4", "@types/hast": "^3.0.4", diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index f20502c5b29e..a2cb04f38d13 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -111,6 +111,7 @@ export const ASTRO_CONFIG_DEFAULTS = { clientPrerender: false, contentIntellisense: false, chromeDevtoolsWorkspace: false, + nativeMarkdown: false, queuedRendering: { enabled: false, }, @@ -550,6 +551,23 @@ export const AstroConfigSchema = z.object({ svgOptimizer: SvgOptimizerSchema.optional(), cache: CacheSchema.optional(), routeRules: RouteRulesSchema.optional(), + nativeMarkdown: z + .union([ + z.boolean(), + z.object({ + mdastPlugins: z + .array(z.custom()) + .optional() + .default([]), + hastPlugins: z + .array(z.custom()) + .optional() + .default([]), + features: z.custom().optional(), + }), + ]) + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.nativeMarkdown), queuedRendering: z .object({ enabled: z.boolean().optional().prefault(false), diff --git a/packages/astro/src/jsx/rehype.ts b/packages/astro/src/jsx/rehype.ts index 23ead4eb0149..540a3412caf9 100644 --- a/packages/astro/src/jsx/rehype.ts +++ b/packages/astro/src/jsx/rehype.ts @@ -24,8 +24,13 @@ export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => { // 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; + visit(tree, (visitedNode) => { + if (visitedNode.type !== 'mdxJsxFlowElement' && visitedNode.type !== 'mdxJsxTextElement') + return; + + // `visit` returns nodes typed against widened hast augmentations (e.g. from satteri). + // Narrow back to the mdast-util-mdx-jsx types our helpers expect. + const node = visitedNode as unknown as MdxJsxFlowElementHast | MdxJsxTextElementHast; const tagName = node.name; if ( @@ -106,6 +111,27 @@ export function getAstroMetadata(file: VFile) { type ImportSpecifier = { local: string; imported: string }; +// Minimal ESTree shapes we need — avoids a direct dependency on @types/estree. +// `@types/estree` is present transitively but not in our direct deps, and satteri's +// hast augmentations widen `mdxjsEsm.data.estree` to `{}`, erasing its Program typing. +type EstreeId = { type: 'Identifier'; name: string }; +type EstreeLiteral = { type: 'Literal'; value: unknown }; +type EstreeImportSpecifier = + | { type: 'ImportDefaultSpecifier'; local: EstreeId } + | { type: 'ImportNamespaceSpecifier'; local: EstreeId } + | { + type: 'ImportSpecifier'; + local: EstreeId; + imported: EstreeId | EstreeLiteral; + }; +type EstreeImportDeclaration = { + type: 'ImportDeclaration'; + source: { value: string }; + specifiers: EstreeImportSpecifier[]; +}; +type EstreeStatement = EstreeImportDeclaration | { type: 'OtherStatement' }; +type EstreeProgram = { body: EstreeStatement[] }; + /** * ``` * import Foo from './Foo.jsx' @@ -130,7 +156,9 @@ function parseImports(children: RootContent[]) { for (const child of children) { if (child.type !== 'mdxjsEsm') continue; - const body = child.data?.estree?.body; + // `child.data?.estree` is widened by the hast augmentations; narrow it back to the shape + // we care about (an ESTree Program with ImportDeclaration bodies). + const body = (child.data?.estree as EstreeProgram | undefined)?.body; if (!body) continue; for (const ast of body) { diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 91974e1f6d9c..95fef1a87b1d 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -2997,6 +2997,56 @@ export interface AstroUserConfig< */ routeRules?: RouteRules; + /** + * @name experimental.nativeMarkdown + * @type {boolean | { mdastPlugins?: import('satteri').MdastPluginDefinition[]; hastPlugins?: import('satteri').HastPluginDefinition[]; features?: import('satteri').Features }} + * @default `false` + * @version 6.0.0 + * @description + * + * Enables the experimental native Markdown compiler (satteri) as a replacement for the default remark/rehype pipeline. + * When enabled, both Markdown and MDX files are processed using satteri, a Rust-based compiler that offers improved performance. + * + * This option requires installing the `satteri` package manually in your project. + * When enabled, `remarkPlugins` and `rehypePlugins` from the markdown config are ignored. + * Use `mdastPlugins` and `hastPlugins` (satteri plugin types) instead. + * + * ```js + * // astro.config.mjs + * import { defineConfig } from 'astro/config'; + * + * export default defineConfig({ + * experimental: { + * nativeMarkdown: true, + * }, + * }); + * ``` + * + * You can also pass satteri plugins and enable additional parser features: + * + * ```js + * // astro.config.mjs + * import { defineConfig } from 'astro/config'; + * + * export default defineConfig({ + * experimental: { + * nativeMarkdown: { + * hastPlugins: [myPlugin], + * features: { + * directive: true, + * definitionList: true, + * }, + * }, + * }, + * }); + * ``` + */ + nativeMarkdown?: boolean | { + mdastPlugins?: import('satteri').MdastPluginDefinition[]; + hastPlugins?: import('satteri').HastPluginDefinition[]; + features?: import('satteri').Features; + }; + /** * @name experimental.queuedRendering * @type {boolean | { poolSize?: number; cache?: boolean }} diff --git a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts index 15c7e53e13b7..d3b90b09f7f1 100644 --- a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts +++ b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts @@ -1,5 +1,5 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; -import { createMarkdownProcessor } from '@astrojs/markdown-remark'; +import { createMarkdownProcessor, createSatteriMarkdownProcessor } from '@astrojs/markdown-remark'; import { safeParseFrontmatter } from '../content/utils.js'; import type { ContentEntryType } from '../types/public/content.js'; @@ -18,10 +18,23 @@ export const markdownContentEntryType: ContentEntryType = { handlePropagation: true, async getRenderFunction(config) { - const processor = await createMarkdownProcessor({ - image: config.image, - ...config.markdown, - }); + const nativeMd = config.experimental.nativeMarkdown; + let processor; + if (nativeMd) { + const nativeOpts = typeof nativeMd === 'object' ? nativeMd : undefined; + processor = await createSatteriMarkdownProcessor({ + image: config.image, + ...config.markdown, + mdastPlugins: nativeOpts?.mdastPlugins, + hastPlugins: nativeOpts?.hastPlugins, + features: nativeOpts?.features, + }); + } else { + processor = await createMarkdownProcessor({ + image: config.image, + ...config.markdown, + }); + } return async function renderToString(entry) { // Process markdown even if it's empty as remark/rehype plugins may add content or frontmatter dynamically const result = await processor.render(entry.body ?? '', { diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 373e6e43fe81..e23c0531505a 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { createMarkdownProcessor, + createSatteriMarkdownProcessor, isFrontmatterValid, type MarkdownProcessor, } from '@astrojs/markdown-remark'; @@ -81,10 +82,22 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug // Lazily initialize the Markdown processor if (!processor) { - processor = createMarkdownProcessor({ - image: settings.config.image, - ...settings.config.markdown, - }); + const nativeMd = settings.config.experimental.nativeMarkdown; + if (nativeMd) { + const nativeOpts = typeof nativeMd === 'object' ? nativeMd : undefined; + processor = createSatteriMarkdownProcessor({ + image: settings.config.image, + ...settings.config.markdown, + mdastPlugins: nativeOpts?.mdastPlugins, + hastPlugins: nativeOpts?.hastPlugins, + features: nativeOpts?.features, + }); + } else { + processor = createMarkdownProcessor({ + image: settings.config.image, + ...settings.config.markdown, + }); + } } const renderResult = await (await processor).render(raw.content, { diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 1e3ed2d4b205..41ecc8ec47c6 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -39,11 +39,13 @@ "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", "estree-util-visit": "^2.0.0", + "github-slugger": "^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", + "satteri": "^0.2.7", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" @@ -52,8 +54,8 @@ "astro": "^7.0.0-alpha.0" }, "devDependencies": { - "@shikijs/rehype": "^4.0.2", - "@shikijs/twoslash": "^4.0.2", + "@shikijs/rehype": "^4.0.0", + "@shikijs/twoslash": "^4.0.0", "@types/estree": "^1.0.8", "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", @@ -74,8 +76,5 @@ }, "engines": { "node": ">=22.12.0" - }, - "publishConfig": { - "provenance": true } } diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index 10a43bc2cfe3..4f025f51809c 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -10,7 +10,7 @@ import type { } from 'astro'; import type { Options as RemarkRehypeOptions } from 'remark-rehype'; import type { PluggableList } from 'unified'; -import type { OptimizeOptions } from './rehype-optimize-static.js'; +import type { MdastPluginDefinition, HastPluginDefinition } from './satteri-plugins.js'; import { ignoreStringPlugins, safeParseFrontmatter } from './utils.js'; import { type VitePluginMdxOptions, vitePluginMdx } from './vite-plugin-mdx.js'; import { vitePluginMdxPostprocess } from './vite-plugin-mdx-postprocess.js'; @@ -23,7 +23,10 @@ export type MdxOptions = Omit & { @@ -97,10 +100,32 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI ), }); + const nativeMd = config.experimental.nativeMarkdown; + + // When nativeMarkdown is enabled, merge mdastPlugins/hastPlugins/features from the experimental config + if (nativeMd && extendMarkdownConfig) { + const nativeOpts = typeof nativeMd === 'object' ? nativeMd : undefined; + resolvedMdxOptions.mdastPlugins = [ + ...(nativeOpts?.mdastPlugins ?? []), + ...resolvedMdxOptions.mdastPlugins, + ]; + resolvedMdxOptions.hastPlugins = [ + ...(nativeOpts?.hastPlugins ?? []), + ...resolvedMdxOptions.hastPlugins, + ]; + if (nativeOpts?.features || resolvedMdxOptions.features) { + resolvedMdxOptions.features = { + ...nativeOpts?.features, + ...resolvedMdxOptions.features, + }; + } + } + // Mutate `mdxOptions` so that `vitePluginMdx` can reference the actual options Object.assign(vitePluginMdxOptions, { mdxOptions: resolvedMdxOptions, srcDir: config.srcDir, + nativeMarkdown: nativeMd, }); // @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. @@ -114,6 +139,8 @@ const defaultMdxOptions = { extendMarkdownConfig: true, recmaPlugins: [], optimize: false, + mdastPlugins: [], + hastPlugins: [], } satisfies Partial; function markdownConfigToMdxOptions( @@ -147,5 +174,8 @@ function applyDefaultOptions({ rehypePlugins: options.rehypePlugins ?? defaults.rehypePlugins, shikiConfig: options.shikiConfig ?? defaults.shikiConfig, optimize: options.optimize ?? defaults.optimize, + mdastPlugins: options.mdastPlugins ?? defaults.mdastPlugins, + hastPlugins: options.hastPlugins ?? defaults.hastPlugins, + features: options.features ?? defaults.features, }; } diff --git a/packages/integrations/mdx/src/rehype-optimize-static.ts b/packages/integrations/mdx/src/rehype-optimize-static.ts index b6f772925cb0..b4466896e111 100644 --- a/packages/integrations/mdx/src/rehype-optimize-static.ts +++ b/packages/integrations/mdx/src/rehype-optimize-static.ts @@ -12,7 +12,7 @@ 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 { +interface OptimizeOptions { ignoreElementNames?: string[]; } @@ -255,7 +255,7 @@ function getExportConstComponentObjectKeys(node: RootContentMap['mdxjsEsm']) { let variableInit: Expression | undefined | null; // Find the initial value of `components` in the AST. - for (const part of node.data?.estree?.body || []) { + for (const part of (node.data?.estree as any)?.body || []) { if ( part.type !== 'ExportNamedDeclaration' || part.declaration?.type !== 'VariableDeclaration' diff --git a/packages/integrations/mdx/src/satteri-plugins.ts b/packages/integrations/mdx/src/satteri-plugins.ts new file mode 100644 index 000000000000..86b4affa451d --- /dev/null +++ b/packages/integrations/mdx/src/satteri-plugins.ts @@ -0,0 +1,689 @@ +import * as path from 'node:path'; +import { createShikiHighlighter } from '@astrojs/markdown-remark'; +import { + defineMdastPlugin, + defineHastPlugin, + mdxToJs, + type HastNode, + type HastVisitorContext, + type MdastPluginDefinition, + type HastPluginDefinition, + type MdxJsxAttributeNode, +} from 'satteri'; +import Slugger from 'github-slugger'; +import type { MdxOptions } from './index.js'; +import { ASTRO_IMAGE_IMPORT, USES_ASTRO_IMAGE_FLAG } from './rehype-images-to-component.js'; + +export type { MdastPluginDefinition, HastPluginDefinition }; + +declare module 'hast' { + interface ElementData { + lang?: string | null; + } +} + +export interface MarkdownHeading { + depth: number; + slug: string; + text: string; +} + +type HighlightFn = (code: string, lang: string, meta?: string) => Promise; + +function resolveFrontmatterExpression( + expr: string, + frontmatter: Record, +): string | undefined { + if (!expr.startsWith('frontmatter.') && !expr.startsWith('frontmatter[')) return undefined; + + const pathStr = expr.slice('frontmatter'.length); + const parts: string[] = []; + const pathRegex = /\.(\w+)|\[(\d+)\]|\["([^"]+)"\]|\['([^']+)'\]/g; + let match; + while ((match = pathRegex.exec(pathStr)) !== null) { + parts.push(match[1] ?? match[2] ?? match[3] ?? match[4]); + } + + if (parts.length === 0) return undefined; + + let value: any = frontmatter; + for (const key of parts) { + if (value == null || typeof value !== 'object') return undefined; + value = value[key]; + } + + return typeof value === 'string' ? value : undefined; +} + +function collectHastText(node: HastNode, frontmatter: Record | undefined): string { + let text = ''; + if (node.type === 'mdxFlowExpression' || node.type === 'mdxTextExpression') { + if (node.value != null && frontmatter) { + const resolved = resolveFrontmatterExpression(node.value.trim(), frontmatter); + text += resolved ?? node.value; + } + } else if ('value' in node && node.value != null) { + text += node.value; + } + if ('children' in node && node.children) { + for (const child of node.children) { + text += collectHastText(child as HastNode, frontmatter); + } + } + return text; +} + +const defaultExcludeLanguages = ['math']; + +function makeFragmentNode(html: string): HastNode { + return { + type: 'mdxJsxFlowElement', + name: 'Fragment', + attributes: [{ type: 'mdxJsxAttribute', name: 'set:html', value: html }], + children: [], + }; +} + +interface AstroComponentMetadata { + exportName: string; + localName: string; + specifier: string; + resolvedPath: string; +} + +export interface AstroMetadata { + hydratedComponents: AstroComponentMetadata[]; + clientOnlyComponents: AstroComponentMetadata[]; + serverComponents: AstroComponentMetadata[]; + scripts: never[]; + propagation: 'none'; + containsHead: false; + pageOptions: Record; +} + +type ImportSpecifier = { local: string; imported: string }; +type MatchedImport = { name: string; path: string }; + +const nonAlphaRe = /[^a-zA-Z]/; + +function isComponent(tagName: string): boolean { + return ( + (tagName[0] && tagName[0].toLowerCase() !== tagName[0]) || + tagName.includes('.') || + nonAlphaRe.test(tagName[0]) + ); +} + +// ESTree shapes we care about from the parseExpression() output on mdxjsEsm nodes. +interface EstreeIdentifier { + type: 'Identifier'; + name: string; +} + +interface EstreeStringLiteral { + type: 'Literal'; + value: string; +} + +type EstreeModuleExportName = EstreeIdentifier | EstreeStringLiteral; + +interface EstreeImportDeclaration { + type: 'ImportDeclaration'; + source: EstreeStringLiteral; + specifiers: Array< + EstreeImportSpecifier | EstreeImportDefaultSpecifier | EstreeImportNamespaceSpecifier + >; +} + +interface EstreeImportSpecifier { + type: 'ImportSpecifier'; + local: EstreeIdentifier; + imported: EstreeModuleExportName; +} + +interface EstreeImportDefaultSpecifier { + type: 'ImportDefaultSpecifier'; + local: EstreeIdentifier; +} + +interface EstreeImportNamespaceSpecifier { + type: 'ImportNamespaceSpecifier'; + local: EstreeIdentifier; +} + +interface EstreeProgram { + body: Array<{ type: string } & Record>; +} + +function exportNameToString(name: EstreeModuleExportName): string { + return name.type === 'Identifier' ? name.name : name.value; +} + +function collectImportsFromEsm( + program: EstreeProgram, + imports: Map>, +): void { + for (const stmt of program.body) { + if (stmt.type !== 'ImportDeclaration') continue; + const decl = stmt as EstreeImportDeclaration; + const source = decl.source.value; + + let specSet = imports.get(source); + if (!specSet) { + specSet = new Set(); + imports.set(source, specSet); + } + + for (const spec of decl.specifiers) { + switch (spec.type) { + case 'ImportDefaultSpecifier': + specSet.add({ local: spec.local.name, imported: 'default' }); + break; + case 'ImportNamespaceSpecifier': + specSet.add({ local: spec.local.name, imported: '*' }); + break; + case 'ImportSpecifier': + specSet.add({ + local: spec.local.name, + imported: exportNameToString(spec.imported), + }); + break; + } + } + } +} + +function findMatchingImport( + tagName: string, + imports: Map>, +): MatchedImport | undefined { + const tagSpecifier = tagName.split('.')[0]; + for (const [source, specs] of imports) { + for (const { imported, local } of specs) { + if (local === tagSpecifier) { + if (tagSpecifier !== tagName) { + switch (imported) { + case '*': { + const accessPath = tagName.slice(tagSpecifier.length + 1); + return { name: accessPath, path: source }; + } + case 'default': { + const accessPath = tagName.slice(tagSpecifier.length + 1); + return { name: `default.${accessPath}`, path: source }; + } + default: { + return { name: tagName, path: source }; + } + } + } + return { name: imported, path: source }; + } + } + } +} + +function resolveImportPath(specifier: string, importer: string): string { + if (specifier.startsWith('.')) { + const absoluteSpecifier = path.resolve(path.dirname(importer), specifier); + return absoluteSpecifier.split(path.sep).join('/'); + } + return specifier; +} + +type MdxJsxFlowElement = Extract; +type MdxJsxTextElement = Extract; +type MdxJsxHastNode = MdxJsxFlowElement | MdxJsxTextElement; + +function hasDirective(node: MdxJsxHastNode, prefix: string): boolean { + for (const a of node.attributes) { + if (a.type === 'mdxJsxAttribute' && a.name.startsWith(prefix)) return true; + } + return false; +} + +function findAttrValue(node: MdxJsxHastNode, name: string): string | null { + for (const a of node.attributes) { + if (a.type === 'mdxJsxAttribute' && a.name === name) { + return typeof a.value === 'string' ? a.value : null; + } + } + return null; +} + +function processJsxNode( + node: MdxJsxHastNode, + ctx: HastVisitorContext, + metadata: AstroMetadata, + imports: Map>, + filePath: string, +) { + const tagName = node.name; + if (!tagName || !isComponent(tagName)) return; + + const hasClient = hasDirective(node, 'client:'); + const hasServerDefer = !hasClient && hasDirective(node, 'server:defer'); + if (!hasClient && !hasServerDefer) return; + + const matchedImport = findMatchingImport(tagName, imports); + if (!matchedImport) { + throw new Error( + `Expected a matching import for component \`${tagName}\`. Did you forget to import it?`, + ); + } + + if (matchedImport.path.endsWith('.astro') && hasClient) { + let clientAttr = 'client:*'; + for (const a of node.attributes) { + if (a.type === 'mdxJsxAttribute' && a.name.startsWith('client:')) { + clientAttr = a.name; + break; + } + } + console.warn( + `You are attempting to render <${tagName} ${clientAttr} />, but ${tagName} 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 = resolveImportPath(matchedImport.path, filePath); + const exportName = + matchedImport.name === '*' ? tagName.split('.').slice(1).join('.') : matchedImport.name; + + if (hasClient && findAttrValue(node, 'client:only') !== null) { + metadata.clientOnlyComponents.push({ + exportName: matchedImport.name, + localName: '', + specifier: tagName, + resolvedPath, + }); + ctx.setProperty(node, 'client:display-name', tagName); + ctx.setProperty(node, 'client:component-path', resolvedPath); + ctx.setProperty(node, 'client:component-export', exportName); + ctx.setProperty(node, 'client:component-hydration', ''); + } else if (hasClient) { + metadata.hydratedComponents.push({ + exportName: '*', + localName: '', + specifier: tagName, + resolvedPath, + }); + ctx.setProperty(node, 'client:component-path', resolvedPath); + ctx.setProperty(node, 'client:component-export', exportName); + ctx.setProperty(node, 'client:component-hydration', ''); + } else if (hasServerDefer) { + metadata.serverComponents.push({ + exportName: matchedImport.name, + localName: tagName, + specifier: matchedImport.path, + resolvedPath, + }); + ctx.setProperty(node, 'server:component-directive', 'server:defer'); + ctx.setProperty(node, 'server:component-path', resolvedPath); + ctx.setProperty(node, 'server:component-export', exportName); + } +} + + +function createCollectImagesPlugin( + localImagePaths: Set, + remoteImagePaths: Set, +): MdastPluginDefinition { + return defineMdastPlugin({ + name: 'collect-images', + image(node) { + const url = node.url ? decodeURI(node.url) : undefined; + if (!url) return; + + if (URL.canParse(url)) { + remoteImagePaths.add(url); + } else if (!url.startsWith('/')) { + localImagePaths.add(url); + } + }, + }); +} + +interface ImageImportInfo { + importedImages: Map; + hasImages: boolean; +} + +function makeJsxAttr(name: string, value: string): MdxJsxAttributeNode { + return { type: 'mdxJsxAttribute', name, value }; +} + +function makeJsxExprAttr(name: string, expression: string): MdxJsxAttributeNode { + return { + type: 'mdxJsxAttribute', + name, + value: { type: 'mdxJsxAttributeValueExpression', value: expression }, + }; +} + +function createImageToComponentPlugin( + localImagePaths: Set, + remoteImagePaths: Set, + imageImportInfo: ImageImportInfo, +): HastPluginDefinition { + return defineHastPlugin({ + name: 'image-to-component', + element: { + filter: ['img'], + visit(node) { + const src = node.properties?.src; + if (typeof src !== 'string') return; + + const decodedSrc = decodeURI(src); + const isLocal = localImagePaths.has(decodedSrc); + const isRemote = remoteImagePaths.has(decodedSrc); + if (!isLocal && !isRemote) return; + + const attrs: MdxJsxAttributeNode[] = []; + + if (node.properties) { + for (const [key, value] of Object.entries(node.properties)) { + if (key === 'src') continue; + if (value == null || value === false) continue; + + if (key === 'widths' || key === 'densities') { + attrs.push( + makeJsxExprAttr(key, JSON.stringify(String(value).split(' '))), + ); + } else { + const attrName = + key === 'className' ? 'class' : key === 'htmlFor' ? 'for' : key; + attrs.push(makeJsxAttr(attrName, String(value))); + } + } + } + + if (isLocal) { + let importName = imageImportInfo.importedImages.get(decodedSrc); + if (!importName) { + importName = `__${imageImportInfo.importedImages.size}_${decodedSrc.replace(/\W/g, '_')}__`; + imageImportInfo.importedImages.set(decodedSrc, importName); + } + attrs.push(makeJsxExprAttr('src', importName)); + } else { + const hasWidth = node.properties && 'width' in node.properties; + const hasHeight = node.properties && 'height' in node.properties; + if (!hasWidth || !hasHeight) { + attrs.push(makeJsxExprAttr('inferSize', 'true')); + } + attrs.push(makeJsxAttr('src', decodedSrc)); + } + + imageImportInfo.hasImages = true; + + // Use __AstroImage__ as the component name. Satteri will compile this to + // _jsx(__AstroImage__, ...) which resolves to the module-scope import we + // inject after compilation. We clean up the _components destructuring and + // _missingMdxReference check in post-processing. + return { + type: 'mdxJsxTextElement', + name: ASTRO_IMAGE_IMPORT, + attributes: attrs, + children: [], + } as unknown as HastNode; + }, + }, + }); +} + +function createHeadingIdsPlugin( + headings: MarkdownHeading[], + frontmatter: Record | undefined, +): HastPluginDefinition { + const slugger = new Slugger(); + return defineHastPlugin({ + name: 'heading-ids', + element: { + filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], + visit(node, ctx) { + const rawText = ctx.textContent(node); + const text = + frontmatter && rawText.includes('frontmatter') + ? collectHastText(node, frontmatter) + : rawText; + const slug = slugger.slug(text); + const depth = Number.parseInt(node.tagName[1], 10); + headings.push({ depth, slug, text }); + ctx.setProperty(node, 'id', slug); + }, + }, + }); +} + +function createShikiPlugin( + highlight: HighlightFn, + excludeLangs: string[] | undefined, +): HastPluginDefinition { + return defineHastPlugin({ + name: 'shiki-highlight', + element: { + filter: ['pre'], + async visit(node, ctx) { + const codeChild = node.children?.find( + (c: HastNode) => c.type === 'element' && c.tagName === 'code', + ) as HastNode | undefined; + if (!codeChild || codeChild.type !== 'element') return; + + const lang = codeChild.data?.lang ?? 'plaintext'; + const meta = codeChild.data?.meta ?? undefined; + + if ( + (excludeLangs && excludeLangs.includes(lang)) || + defaultExcludeLanguages.includes(lang) + ) { + return; + } + + const code = ctx.textContent(codeChild).replace(/\n$/, ''); + const html = await highlight(code, lang, meta); + return makeFragmentNode(html); + }, + }, + }); +} + +function createAstroMetadataPlugin( + metadata: AstroMetadata, + filePath: string, +): HastPluginDefinition { + const imports = new Map>(); + return defineHastPlugin({ + name: 'astro-metadata', + mdxjsEsm(node) { + const program = node.parseExpression() as EstreeProgram | null; + if (program) { + collectImportsFromEsm(program, imports); + } + }, + + mdxJsxFlowElement: { + filter: [], + visit(node, ctx) { + processJsxNode(node, ctx, metadata, imports, filePath); + }, + }, + + mdxJsxTextElement: { + filter: [], + visit(node, ctx) { + processJsxNode(node, ctx, metadata, imports, filePath); + }, + }, + }); +} + +export interface CompileMdxResult { + code: string; + astroMetadata: AstroMetadata; +} + +export function createMdxProcessor(mdxOptions: MdxOptions) { + let highlightFn: HighlightFn | undefined; + let initPromise: Promise | undefined; + + function initShiki() { + const syntaxHighlight = mdxOptions.syntaxHighlight; + const syntaxHighlightType = + typeof syntaxHighlight === 'string' + ? syntaxHighlight + : syntaxHighlight + ? syntaxHighlight.type + : undefined; + + if (syntaxHighlightType === 'shiki') { + const shikiConfig = mdxOptions.shikiConfig ?? {}; + initPromise = createShikiHighlighter({ + langs: shikiConfig.langs, + theme: shikiConfig.theme, + themes: shikiConfig.themes, + langAlias: shikiConfig.langAlias, + }).then((hl) => { + highlightFn = (code, lang, meta) => + hl.codeToHtml(code, lang, { + meta, + wrap: shikiConfig.wrap, + defaultColor: shikiConfig.defaultColor, + transformers: shikiConfig.transformers, + }); + }); + } + } + + return { + async process( + content: string, + filePath: string, + frontmatter: Record, + ): Promise { + if (!highlightFn && !initPromise) { + initShiki(); + } + if (initPromise) await initPromise; + + const headings: MarkdownHeading[] = []; + const localImagePaths = new Set(); + const remoteImagePaths = new Set(); + + const astroMetadata: AstroMetadata = { + hydratedComponents: [], + clientOnlyComponents: [], + serverComponents: [], + scripts: [], + propagation: 'none', + containsHead: false, + pageOptions: {}, + }; + + const collectImages = createCollectImagesPlugin(localImagePaths, remoteImagePaths); + const headingIds = createHeadingIdsPlugin(headings, frontmatter); + const astroMeta = createAstroMetadataPlugin(astroMetadata, filePath); + const imageImportInfo: ImageImportInfo = { + importedImages: new Map(), + hasImages: false, + }; + const imageToComponent = createImageToComponentPlugin( + localImagePaths, + remoteImagePaths, + imageImportInfo, + ); + + const syntaxHighlight = mdxOptions.syntaxHighlight; + const excludeLangs = + typeof syntaxHighlight === 'object' ? syntaxHighlight.excludeLangs : undefined; + + const allMdastPlugins: MdastPluginDefinition[] = mdxOptions.mdastPlugins?.length + ? [collectImages, ...mdxOptions.mdastPlugins] + : [collectImages]; + + const hastPlugins: HastPluginDefinition[] = []; + if (highlightFn) { + hastPlugins.push(createShikiPlugin(highlightFn, excludeLangs)); + } + hastPlugins.push(headingIds, imageToComponent, astroMeta); + if (mdxOptions.hastPlugins?.length) { + hastPlugins.push(...mdxOptions.hastPlugins); + } + + let optimizeStatic: import('satteri').MdxCompileOptions['optimizeStatic']; + if (mdxOptions.optimize) { + const ignoreElements = + typeof mdxOptions.optimize === 'object' + ? mdxOptions.optimize.ignoreElementNames + : undefined; + + optimizeStatic = { + component: 'Fragment', + prop: 'set:html', + ...(ignoreElements && { ignoreElements }), + }; + } + + let compiled = await mdxToJs(content, { + mdastPlugins: allMdastPlugins, + hastPlugins, + optimizeStatic, + features: mdxOptions.features, + filename: filePath, + jsxImportSource: 'astro', + }); + + compiled = compiled.replace(/^export default MDXContent;\s*$/m, ''); + + if (imageImportInfo.hasImages) { + // Remove satteri's _components destructuring and missing reference check + // for __AstroImage__ — we provide it via a module-level import instead. + compiled = compiled.replace( + new RegExp( + `\\s*const\\s*\\{\\s*${ASTRO_IMAGE_IMPORT}\\s*\\}\\s*=\\s*_components;`, + ), + '', + ); + compiled = compiled.replace( + new RegExp( + `\\s*if\\s*\\(!${ASTRO_IMAGE_IMPORT}\\)\\s*_missingMdxReference\\([^)]*\\);`, + ), + '', + ); + + compiled += `\nimport { Image as ${ASTRO_IMAGE_IMPORT} } from "astro:assets";`; + for (const [src, importName] of imageImportInfo.importedImages) { + compiled += `\nimport ${importName} from ${JSON.stringify(src)};`; + } + compiled += `\nexport const ${USES_ASTRO_IMAGE_FLAG} = true;`; + } + + compiled += `\nexport const frontmatter = ${JSON.stringify(frontmatter)};`; + compiled += `\nexport function getHeadings() { return ${JSON.stringify(headings)}; }`; + + if (frontmatter.layout) { + compiled = compiled.replace(/^function MDXContent\(/m, 'function __OriginalMDXContent__('); + compiled += ` +import { jsx as __astro_layout_jsx__ } from 'astro/jsx-runtime'; +import __astro_layout_component__ from ${JSON.stringify(frontmatter.layout)}; +export default function MDXContent(props) { + const content = __OriginalMDXContent__(props); + const { layout, ...frontmatterContent } = frontmatter; + frontmatterContent.file = file; + frontmatterContent.url = url; + return __astro_layout_jsx__(__astro_layout_component__, { + file, + url, + content: frontmatterContent, + frontmatter: frontmatterContent, + headings: getHeadings(), + 'server:root': true, + children: content, + }); +}`; + } + + return { + code: compiled, + astroMetadata, + }; + }, + }; +} diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts index 5c79a6bf669f..ac3e2edcf15d 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts @@ -3,24 +3,32 @@ 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 { createMdxProcessor as createRemarkMdxProcessor } from './plugins.js'; +import { createMdxProcessor as createSatteriMdxProcessor } from './satteri-plugins.js'; import { safeParseFrontmatter } from './utils.js'; export interface VitePluginMdxOptions { mdxOptions: MdxOptions; srcDir: URL; + nativeMarkdown?: boolean | { + mdastPlugins?: import('satteri').MdastPluginDefinition[]; + hastPlugins?: import('satteri').HastPluginDefinition[]; + features?: import('satteri').Features; + }; } // NOTE: Do not destructure `opts` as we're assigning a reference that will be mutated later export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin { - let processor: ReturnType | undefined; + let remarkProcessor: ReturnType | undefined; + let satteriProcessor: ReturnType | undefined; let sourcemapEnabled: boolean; return { name: '@mdx-js/rolldown', enforce: 'pre', buildEnd() { - processor = undefined; + remarkProcessor = undefined; + satteriProcessor = undefined; }, configResolved(resolved) { sourcemapEnabled = !!resolved.build.sourcemap; @@ -45,8 +53,6 @@ export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin { } }, }, - // Override transform to alter code before MDX compilation - // ex. inject layouts transform: { filter: { id: /\.mdx$/, @@ -54,64 +60,60 @@ export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin { async handler(code, id) { const { frontmatter, content } = safeParseFrontmatter(code, id); - const vfile = new VFile({ - value: content, - path: id, - data: { - astro: { - frontmatter, - }, - applyFrontmatterExport: { - srcDir: opts.srcDir, - }, - }, - }); - - // Lazily initialize the MDX processor - if (!processor) { - processor = createMdxProcessor(opts.mdxOptions, { - sourcemap: sourcemapEnabled, - }); - } - try { - const compiled = await processor.process(vfile); - - return { - code: String(compiled.value), - map: compiled.map, - meta: getMdxMeta(vfile), - }; + if (opts.nativeMarkdown) { + // Satteri (native) path + if (!satteriProcessor) { + satteriProcessor = createSatteriMdxProcessor(opts.mdxOptions); + } + const result = await satteriProcessor.process(content, id, frontmatter); + return { + code: result.code, + map: null, + meta: { + astro: result.astroMetadata, + vite: { lang: 'ts' }, + }, + }; + } else { + // Remark/rehype (default) path + if (!remarkProcessor) { + remarkProcessor = createRemarkMdxProcessor(opts.mdxOptions, { + sourcemap: sourcemapEnabled, + }); + } + const vfile = new VFile({ + value: content, + path: id, + data: { + astro: { frontmatter }, + applyFrontmatterExport: { srcDir: opts.srcDir }, + }, + }); + const compiled = await remarkProcessor.process(vfile); + const astroMetadata = getAstroMetadata(vfile); + if (!astroMetadata) { + throw new Error( + 'Internal MDX error: Astro metadata is not set by rehype-analyze-astro-metadata', + ); + } + return { + code: String(compiled.value), + map: compiled.map, + meta: { + astro: astroMetadata, + vite: { lang: 'ts' }, + }, + }; + } } 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: id, line: e.line, column: e.column }; - - // For another some reason, MDX doesn't include a stack trace. Weird Error.captureStackTrace(err); - throw err; } }, }, }; } - -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/markdown/remark/package.json b/packages/markdown/remark/package.json index 60042d9f8854..37720be67e85 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -59,13 +59,23 @@ "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" }, + "peerDependencies": { + "satteri": ">=0.2.7" + }, + "peerDependenciesMeta": { + "satteri": { + "optional": true + } + }, "devDependencies": { + "@napi-rs/wasm-runtime": "^1.1.2", "@types/estree": "^1.0.8", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "@types/unist": "^3.0.3", "astro-scripts": "workspace:*", + "satteri": "^0.2.7", "esbuild": "^0.27.3", "mdast-util-mdx-expression": "^2.0.1" }, diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index 4fcd8d304b72..aaa64cbdd090 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -38,6 +38,16 @@ export { type ShikiHighlighterHighlightOptions, } from './shiki.js'; export * from './types.js'; +export { + createSatteriMarkdownProcessor, + type SatteriMarkdownProcessorOptions, + // Shared satteri plugin factories for use by @astrojs/mdx + createCollectImagesPlugin as satteriCollectImagesPlugin, + createHeadingIdsPlugin as satteriHeadingIdsPlugin, + createShikiPlugin as satteriShikiPlugin, + collectHastText as satteriCollectHastText, + makeFragmentNode as satteriMakeFragmentNode, +} from './satteri.js'; export const syntaxHighlightDefaults: Required = { type: 'shiki', diff --git a/packages/markdown/remark/src/rehype-collect-headings.ts b/packages/markdown/remark/src/rehype-collect-headings.ts index 8f4aa55d369e..db0da42f0f00 100644 --- a/packages/markdown/remark/src/rehype-collect-headings.ts +++ b/packages/markdown/remark/src/rehype-collect-headings.ts @@ -40,9 +40,9 @@ export function rehypeHeadingIds(): ReturnType { return; } } - if (rawNodeTypes.has(child.type)) { - if (isMDX || codeTagNames.has(parent.tagName)) { - let value = child.value; + if (rawNodeTypes.has(child.type) && 'value' in child) { + if (isMDX || ('tagName' in parent && codeTagNames.has(parent.tagName))) { + let value = child.value as string; if (isMdxTextExpression(child) && frontmatter) { const frontmatterPath = getMdxFrontmatterVariablePath(child); if (Array.isArray(frontmatterPath) && frontmatterPath.length > 0) { @@ -57,7 +57,7 @@ export function rehypeHeadingIds(): ReturnType { } text += value; } else { - text += child.value.replace(/\{/g, '${'); + text += (child.value as string).replace(/\{/g, '${'); } } }); diff --git a/packages/markdown/remark/src/satteri.ts b/packages/markdown/remark/src/satteri.ts new file mode 100644 index 000000000000..ae5111d76774 --- /dev/null +++ b/packages/markdown/remark/src/satteri.ts @@ -0,0 +1,265 @@ +import Slugger from 'github-slugger'; +import { createShikiHighlighter } from './shiki.js'; +import { defaultExcludeLanguages } from './highlight.js'; +import type { + AstroMarkdownProcessorOptions, + MarkdownHeading, + MarkdownProcessor, +} from './types.js'; +import { markdownConfigDefaults, syntaxHighlightDefaults } from './index.js'; + +type HighlightFn = (code: string, lang: string, meta?: string) => Promise; + +// Lazily loaded satteri module +let satteri: typeof import('satteri') | undefined; + +async function loadSatteri(): Promise { + if (satteri) return satteri; + try { + satteri = await import('satteri'); + return satteri; + } catch { + throw new Error( + '`experimental.nativeMarkdown` requires the `satteri` package. Install it with:\n npm install satteri', + ); + } +} + +// ── Shared satteri plugin factories ── +// Used by both the markdown processor here and the MDX satteri processor +// in @astrojs/mdx. All require loadSatteri() to have been called first. + +export function createCollectImagesPlugin( + localImagePaths: Set, + remoteImagePaths: Set, +): import('satteri').MdastPluginDefinition { + return satteri!.defineMdastPlugin({ + name: 'collect-images', + image(node) { + const url = node.url ? decodeURI(node.url) : undefined; + if (!url) return; + + if (URL.canParse(url)) { + remoteImagePaths.add(url); + } else if (!url.startsWith('/')) { + localImagePaths.add(url); + } + }, + }); +} + +function resolveFrontmatterExpression( + expr: string, + frontmatter: Record, +): string | undefined { + if (!expr.startsWith('frontmatter.') && !expr.startsWith('frontmatter[')) return undefined; + + const pathStr = expr.slice('frontmatter'.length); + const parts: string[] = []; + const pathRegex = /\.(\w+)|\[(\d+)\]|\["([^"]+)"\]|\['([^']+)'\]/g; + let match; + while ((match = pathRegex.exec(pathStr)) !== null) { + parts.push(match[1] ?? match[2] ?? match[3] ?? match[4]); + } + + if (parts.length === 0) return undefined; + + let value: any = frontmatter; + for (const key of parts) { + if (value == null || typeof value !== 'object') return undefined; + value = value[key]; + } + + return typeof value === 'string' ? value : undefined; +} + +export function collectHastText( + node: import('satteri').HastNode, + frontmatter: Record | undefined, +): string { + let text = ''; + if (node.type === 'mdxFlowExpression' || node.type === 'mdxTextExpression') { + if (node.value != null && frontmatter) { + const resolved = resolveFrontmatterExpression(node.value.trim(), frontmatter); + text += resolved ?? node.value; + } + } else if ('value' in node && node.value != null) { + text += node.value; + } + if ('children' in node && node.children) { + for (const child of node.children) { + text += collectHastText(child as import('satteri').HastNode, frontmatter); + } + } + return text; +} + +export function makeFragmentNode(html: string): import('satteri').HastNode { + return { + type: 'mdxJsxFlowElement', + name: 'Fragment', + attributes: [{ type: 'mdxJsxAttribute', name: 'set:html', value: html }], + children: [], + } as unknown as import('satteri').HastNode; +} + +export function createHeadingIdsPlugin( + headings: MarkdownHeading[], + frontmatter: Record | undefined, +): import('satteri').HastPluginDefinition { + const slugger = new Slugger(); + return satteri!.defineHastPlugin({ + name: 'heading-ids', + element: { + filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], + visit(node, ctx) { + const rawText = ctx.textContent(node); + const text = + frontmatter && rawText.includes('frontmatter') + ? collectHastText(node, frontmatter) + : rawText; + const slug = slugger.slug(text); + const depth = Number.parseInt(node.tagName[1], 10); + headings.push({ depth, slug, text }); + ctx.setProperty(node, 'id', slug); + }, + }, + }); +} + +function makeRawNode(html: string): import('satteri').HastNode { + return { type: 'raw', value: html } as unknown as import('satteri').HastNode; +} + +export function createShikiPlugin( + highlight: HighlightFn, + excludeLangs: string[] | undefined, + options?: { mdx?: boolean }, +): import('satteri').HastPluginDefinition { + const wrapResult = options?.mdx ? makeFragmentNode : makeRawNode; + return satteri!.defineHastPlugin({ + name: 'shiki-highlight', + element: { + filter: ['pre'], + async visit(node, ctx) { + const codeChild = node.children?.find( + (c: import('satteri').HastNode) => c.type === 'element' && c.tagName === 'code', + ) as import('satteri').HastNode | undefined; + if (!codeChild || codeChild.type !== 'element') return; + + const lang = (codeChild.data as any)?.lang ?? 'plaintext'; + const meta = (codeChild.data as any)?.meta ?? undefined; + + if ( + (excludeLangs && excludeLangs.includes(lang)) || + defaultExcludeLanguages.includes(lang) + ) { + return; + } + + const code = ctx.textContent(codeChild).replace(/\n$/, ''); + const html = await highlight(code, lang, meta); + return wrapResult(html); + }, + }, + }); +} + +// ── Satteri Markdown Processor ��─ + +export interface SatteriMarkdownProcessorOptions extends AstroMarkdownProcessorOptions { + mdastPlugins?: import('satteri').MdastPluginDefinition[]; + hastPlugins?: import('satteri').HastPluginDefinition[]; + features?: import('satteri').Features; +} + +export async function createSatteriMarkdownProcessor( + opts?: SatteriMarkdownProcessorOptions, +): Promise { + const s = await loadSatteri(); + + const { + syntaxHighlight = syntaxHighlightDefaults, + shikiConfig = markdownConfigDefaults.shikiConfig, + gfm = markdownConfigDefaults.gfm, + smartypants = markdownConfigDefaults.smartypants, + mdastPlugins: userMdastPlugins = [], + hastPlugins: userHastPlugins = [], + features: userFeatures, + } = opts ?? {}; + + const syntaxHighlightType = + typeof syntaxHighlight === 'string' + ? syntaxHighlight + : syntaxHighlight + ? syntaxHighlight.type + : undefined; + + if (syntaxHighlightType === 'prism') { + throw new Error( + 'Prism syntax highlighting is not supported with `experimental.nativeMarkdown`. Use shiki instead.', + ); + } + + let highlightFn: HighlightFn | undefined; + if (syntaxHighlightType === 'shiki') { + const hl = await createShikiHighlighter({ + langs: shikiConfig.langs, + theme: shikiConfig.theme, + themes: shikiConfig.themes, + langAlias: shikiConfig.langAlias, + }); + highlightFn = (code, lang, meta) => + hl.codeToHtml(code, lang, { + meta, + wrap: shikiConfig.wrap, + defaultColor: shikiConfig.defaultColor, + transformers: shikiConfig.transformers, + }); + } + + const syntaxHighlightExcludeLangs = + typeof syntaxHighlight === 'object' ? syntaxHighlight.excludeLangs : undefined; + + return { + async render(content, renderOpts) { + const headings: MarkdownHeading[] = []; + const localImagePaths = new Set(); + const remoteImagePaths = new Set(); + const frontmatter = renderOpts?.frontmatter ?? {}; + + const allMdastPlugins: import('satteri').MdastPluginDefinition[] = [ + createCollectImagesPlugin(localImagePaths, remoteImagePaths), + ...userMdastPlugins, + ]; + + const hastPlugins: import('satteri').HastPluginDefinition[] = []; + if (highlightFn) { + hastPlugins.push(createShikiPlugin(highlightFn, syntaxHighlightExcludeLangs)); + } + hastPlugins.push(createHeadingIdsPlugin(headings, frontmatter)); + hastPlugins.push(...userHastPlugins); + + const html = await s.markdownToHtml(content, { + mdastPlugins: allMdastPlugins, + hastPlugins, + features: { + gfm: gfm !== false, + smartPunctuation: smartypants !== false, + ...userFeatures, + }, + filename: renderOpts?.fileURL?.pathname, + }); + + return { + code: html, + metadata: { + headings, + localImagePaths: Array.from(localImagePaths), + remoteImagePaths: Array.from(remoteImagePaths), + frontmatter, + }, + }; + }, + }; +} diff --git a/packages/markdown/remark/test/browser.test.ts b/packages/markdown/remark/test/browser.test.ts index 9a7f5b0c3cd8..e94028168d3a 100644 --- a/packages/markdown/remark/test/browser.test.ts +++ b/packages/markdown/remark/test/browser.test.ts @@ -7,6 +7,9 @@ describe('Bundle for browsers', async () => { try { const result = await esbuild.build({ platform: 'browser', + // ESM output so top-level await (used by satteri's WASM loader) is supported. + // The default IIFE format would reject the TLA in satteri's browser shim. + format: 'esm', entryPoints: ['@astrojs/markdown-remark'], bundle: true, write: false, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b5a46e5bdcfa..93489322680e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,6 +37,9 @@ minimumReleaseAgeExclude: - vite - rolldown - '@rolldown/*' + - satteri + - '@bruits/satteri-*' + - '@napi-rs/wasm-runtime' # Renovate security update: fast-xml-parser@5.3.8 || 5.7.0 - fast-xml-parser@5.3.8 || 5.7.0 # Renovate security update: svelte@5.53.5 From bcce353e0b26b1acfe2a8a4d9ad99d4d063b36bf Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 12 May 2026 02:34:53 +0200 Subject: [PATCH 02/26] feat: make Markdown pluggable --- .changeset/seven-emus-knock.md | 75 +++++- packages/astro/package.json | 5 +- packages/astro/src/config/entrypoint.ts | 1 + packages/astro/src/content/content-layer.ts | 18 +- packages/astro/src/content/data-store.ts | 2 +- packages/astro/src/content/runtime.ts | 2 +- packages/astro/src/content/utils.ts | 2 +- .../astro/src/core/config/schemas/base.ts | 48 ++-- packages/astro/src/core/config/validate.ts | 42 +++ packages/astro/src/jsx/rehype.ts | 6 +- packages/astro/src/markdown/frontmatter.ts | 86 ++++++ packages/astro/src/markdown/index.ts | 76 ++++++ packages/astro/src/types/public/config.ts | 117 ++++---- packages/astro/src/types/public/content.ts | 2 +- packages/astro/src/types/public/index.ts | 8 +- .../src/vite-plugin-html/transform/slots.ts | 4 +- .../content-entry-type.ts | 26 +- .../astro/src/vite-plugin-markdown/index.ts | 32 +-- packages/astro/tsconfig.build.json | 2 +- packages/integrations/mdx/package.json | 1 + packages/integrations/mdx/src/index.ts | 76 ++++-- .../integrations/mdx/src/satteri-plugins.ts | 2 +- .../integrations/mdx/src/vite-plugin-mdx.ts | 132 ++++++---- .../mdx/test/mdx-get-headings.test.ts | 2 +- packages/markdown/remark/src/index.ts | 14 +- packages/markdown/remark/src/processor.ts | 55 ++++ packages/markdown/remark/src/types.ts | 3 + packages/markdown/satteri/package.json | 58 ++++ packages/markdown/satteri/src/defaults.ts | 23 ++ packages/markdown/satteri/src/highlight.ts | 2 + packages/markdown/satteri/src/index.ts | 36 +++ packages/markdown/satteri/src/processor.ts | 48 ++++ .../src/satteri-processor.ts} | 21 +- .../satteri/src/shiki-engine-default.ts | 6 + .../satteri/src/shiki-engine-workerd.ts | 7 + packages/markdown/satteri/src/shiki.ts | 249 ++++++++++++++++++ packages/markdown/satteri/src/types.ts | 55 ++++ packages/markdown/satteri/tsconfig.build.json | 4 + packages/markdown/satteri/tsconfig.json | 5 + packages/markdown/satteri/tsconfig.test.json | 4 + pnpm-lock.yaml | 185 +++++++++++-- 41 files changed, 1272 insertions(+), 270 deletions(-) create mode 100644 packages/astro/src/markdown/frontmatter.ts create mode 100644 packages/astro/src/markdown/index.ts create mode 100644 packages/markdown/remark/src/processor.ts create mode 100644 packages/markdown/satteri/package.json create mode 100644 packages/markdown/satteri/src/defaults.ts create mode 100644 packages/markdown/satteri/src/highlight.ts create mode 100644 packages/markdown/satteri/src/index.ts create mode 100644 packages/markdown/satteri/src/processor.ts rename packages/markdown/{remark/src/satteri.ts => satteri/src/satteri-processor.ts} (90%) create mode 100644 packages/markdown/satteri/src/shiki-engine-default.ts create mode 100644 packages/markdown/satteri/src/shiki-engine-workerd.ts create mode 100644 packages/markdown/satteri/src/shiki.ts create mode 100644 packages/markdown/satteri/src/types.ts create mode 100644 packages/markdown/satteri/tsconfig.build.json create mode 100644 packages/markdown/satteri/tsconfig.json create mode 100644 packages/markdown/satteri/tsconfig.test.json diff --git a/.changeset/seven-emus-knock.md b/.changeset/seven-emus-knock.md index 04df3d1395a7..fb08d4c337dd 100644 --- a/.changeset/seven-emus-knock.md +++ b/.changeset/seven-emus-knock.md @@ -1,7 +1,74 @@ --- -'astro': patch -'@astrojs/markdown-remark': patch -'@astrojs/mdx': patch +'astro': major +'@astrojs/markdown-remark': major +'@astrojs/markdown-satteri': major +'@astrojs/mdx': major --- -temp changeset for preview release +Adds the new `markdown.processor` config option, which uses [satteri](https://github.com/bruits/satteri) (a Rust-based Markdown/MDX compiler) by default. Both `.md` and `.mdx` files are now rendered through satteri unless you opt back into the legacy remark/rehype pipeline with the new `unified()` factory. + +The previous `experimental.nativeMarkdown` flag has been removed; the same functionality is now available without an experimental opt-in: + +```js +// astro.config.mjs — default, no changes needed +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); +``` + +To pass satteri plugins or enable additional parser features, use the new `satteri()` factory: + +```js +// astro.config.mjs +import { defineConfig, satteri } from 'astro/config'; + +export default defineConfig({ + markdown: { + processor: satteri({ + hastPlugins: [myPlugin], + features: { directive: true, definitionList: true }, + }), + }, +}); +``` + +To keep using your existing remark/rehype plugins, opt into the `unified()` processor from `@astrojs/markdown-remark`: + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import { unified } from '@astrojs/markdown-remark'; +import remarkToc from 'remark-toc'; + +export default defineConfig({ + markdown: { + processor: unified({ remarkPlugins: [remarkToc] }), + }, +}); +``` + +The top-level `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` options are deprecated. They continue to work when `@astrojs/markdown-remark` is installed — Astro automatically wraps them in `unified({...})` and prints a deprecation warning — but you should migrate to the new factory form. + +The satteri processor (and its shared plugin factories) have been extracted from `@astrojs/markdown-remark` into a new dedicated `@astrojs/markdown-satteri` package, which Astro now ships with by default — no extra install needed. + +`@astrojs/markdown-remark` is no longer a transitive dependency of `astro`. If you want the legacy `unified()` pipeline (either directly via `markdown.processor: unified({...})` or indirectly via the deprecated `markdown.remarkPlugins` / `rehypePlugins` / `remarkRehype` fields), install it explicitly: + +```sh +pnpm add @astrojs/markdown-remark +``` + +`@astrojs/mdx` now picks up the processor from `markdown.processor` automatically, so a single satteri or unified configuration drives both `.md` and `.mdx` files. To run `.mdx` files through a different processor (or the same processor with different options) than your `.md` files, pass the `processor` option to the integration: + +```js +// astro.config.mjs +import { defineConfig, satteri } from 'astro/config'; +import mdx from '@astrojs/mdx'; +import { unified } from '@astrojs/markdown-remark'; + +export default defineConfig({ + markdown: { processor: satteri() }, + integrations: [mdx({ processor: unified({ remarkPlugins: [/* ... */] }) })], +}); +``` + +Third-party processors can plug into the same pipeline by exporting a factory whose return value implements the `MarkdownProcessorEntry` interface (exported from `astro/markdown`). Implement `createRenderer` for `.md` support, and optionally `createMdxRenderer` to enable `.mdx` rendering. diff --git a/packages/astro/package.json b/packages/astro/package.json index 43923024943f..426041f76f3d 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -31,6 +31,7 @@ }, "exports": { ".": "./dist/index.js", + "./markdown": "./dist/markdown/index.js", "./env": "./env.d.ts", "./env/runtime": "./dist/env/runtime.js", "./env/setup": "./dist/env/setup.js", @@ -137,7 +138,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", @@ -196,6 +197,7 @@ }, "devDependencies": { "@astrojs/check": "workspace:*", + "@astrojs/markdown-remark": "workspace:*", "satteri": "^0.2.7", "@playwright/test": "1.58.2", "@types/aria-query": "^5.0.4", @@ -235,6 +237,7 @@ "publishConfig": { "exports": { ".": "./dist/index.js", + "./markdown": "./dist/markdown/index.js", "./env": "./env.d.ts", "./env/runtime": "./dist/env/runtime.js", "./env/setup": "./dist/env/setup.js", diff --git a/packages/astro/src/config/entrypoint.ts b/packages/astro/src/config/entrypoint.ts index cd252d2b6fa2..c2c9d9eda0f8 100644 --- a/packages/astro/src/config/entrypoint.ts +++ b/packages/astro/src/config/entrypoint.ts @@ -14,6 +14,7 @@ export { defineConfig, getViteConfig } from './index.js'; export { sessionDrivers } from '../core/session/drivers.js'; export { svgoOptimizer } from '../assets/svg/svgo.js'; export { logHandlers } from '../core/logger/handlers.js'; +export { satteri } from '@astrojs/markdown-satteri'; /** * Return the configuration needed to use the Sharp-based image service diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts index d1eda4d9838f..94ce9d8614a9 100644 --- a/packages/astro/src/content/content-layer.ts +++ b/packages/astro/src/content/content-layer.ts @@ -1,9 +1,6 @@ import { existsSync, promises as fs } from 'node:fs'; -import { - createMarkdownProcessor, - parseFrontmatter, - type MarkdownProcessor, -} from '@astrojs/markdown-remark'; +import type { MarkdownProcessor } from '@astrojs/markdown-satteri'; +import { parseFrontmatter } from '../markdown/frontmatter.js'; import PQueue from 'p-queue'; import type { FSWatcher } from 'vite'; import xxhash from 'xxhash-wasm'; @@ -154,7 +151,16 @@ export class ContentLayer { content: string, options?: RenderMarkdownOptions, ): Promise { - this.#markdownProcessor ??= await createMarkdownProcessor(this.#settings.config.markdown); + if (!this.#markdownProcessor) { + const { markdown, image } = this.#settings.config; + this.#markdownProcessor = await markdown.processor.createRenderer({ + image, + syntaxHighlight: markdown.syntaxHighlight, + shikiConfig: markdown.shikiConfig, + gfm: markdown.gfm, + smartypants: markdown.smartypants, + }); + } const { frontmatter, content: body } = parseFrontmatter(content); const { code, metadata } = await this.#markdownProcessor.render(body, { frontmatter, diff --git a/packages/astro/src/content/data-store.ts b/packages/astro/src/content/data-store.ts index 1e89919feacb..9d7ff4d16c77 100644 --- a/packages/astro/src/content/data-store.ts +++ b/packages/astro/src/content/data-store.ts @@ -1,4 +1,4 @@ -import type { MarkdownHeading } from '@astrojs/markdown-remark'; +import type { MarkdownHeading } from '@astrojs/markdown-satteri'; import * as devalue from 'devalue'; export interface RenderedContent { diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index c24c84541e98..b9d9c61d045b 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -1,4 +1,4 @@ -import type { MarkdownHeading } from '@astrojs/markdown-remark'; +import type { MarkdownHeading } from '@astrojs/markdown-satteri'; import { escape } from 'html-escaper'; import { Traverse } from 'neotraverse/modern'; import * as z from 'zod/v4'; diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index 9139789070ab..5b5d6046f661 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -1,7 +1,7 @@ import fsMod from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { parseFrontmatter } from '@astrojs/markdown-remark'; +import { parseFrontmatter } from '../markdown/frontmatter.js'; import { slug as githubSlug } from 'github-slugger'; import colors from 'piccolore'; import type { RunnableDevEnvironment, Rolldown } from 'vite'; diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index a2cb04f38d13..c32d2dda091e 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -1,11 +1,11 @@ +import type { ShikiConfig, Smartypants as _Smartypants } from '@astrojs/markdown-satteri'; +import { satteri, satteriMarkdownDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-satteri'; +import type { MarkdownProcessorEntry } from '../../../markdown/index.js'; import type { RehypePlugin as _RehypePlugin, RemarkPlugin as _RemarkPlugin, RemarkRehype as _RemarkRehype, - Smartypants as _Smartypants, - ShikiConfig, -} from '@astrojs/markdown-remark'; -import { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-remark'; +} from '../../../types/public/config.js'; import type { OutgoingHttpHeaders } from 'node:http'; import { type BuiltinTheme, bundledThemes } from 'shiki'; import * as z from 'zod/v4'; @@ -88,7 +88,12 @@ export const ASTRO_CONFIG_DEFAULTS = { allowedHosts: [], }, integrations: [], - markdown: markdownConfigDefaults, + markdown: { + ...satteriMarkdownDefaults, + remarkPlugins: [] as RemarkPlugin[], + rehypePlugins: [] as RehypePlugin[], + remarkRehype: {} as RemarkRehype, + }, vite: {}, legacy: { collectionsBackwardsCompat: false, @@ -111,7 +116,6 @@ export const ASTRO_CONFIG_DEFAULTS = { clientPrerender: false, contentIntellisense: false, chromeDevtoolsWorkspace: false, - nativeMarkdown: false, queuedRendering: { enabled: false, }, @@ -426,6 +430,21 @@ export const AstroConfigSchema = z.object({ return val; }) .prefault(ASTRO_CONFIG_DEFAULTS.markdown.smartypants), + processor: z + .custom( + (value): value is MarkdownProcessorEntry => + typeof value === 'object' && + value !== null && + 'name' in value && + typeof value.name === 'string' && + 'createRenderer' in value && + typeof value.createRenderer === 'function', + { + error: + 'markdown.processor must be the return value of a markdown processor factory (e.g. satteri() or unified())', + }, + ) + .default(() => satteri()), }) .prefault({}), vite: z @@ -551,23 +570,6 @@ export const AstroConfigSchema = z.object({ svgOptimizer: SvgOptimizerSchema.optional(), cache: CacheSchema.optional(), routeRules: RouteRulesSchema.optional(), - nativeMarkdown: z - .union([ - z.boolean(), - z.object({ - mdastPlugins: z - .array(z.custom()) - .optional() - .default([]), - hastPlugins: z - .array(z.custom()) - .optional() - .default([]), - features: z.custom().optional(), - }), - ]) - .optional() - .default(ASTRO_CONFIG_DEFAULTS.experimental.nativeMarkdown), queuedRendering: z .object({ enabled: z.boolean().optional().prefault(false), diff --git a/packages/astro/src/core/config/validate.ts b/packages/astro/src/core/config/validate.ts index 50bd9bf522d8..29fcf89fef3b 100644 --- a/packages/astro/src/core/config/validate.ts +++ b/packages/astro/src/core/config/validate.ts @@ -10,6 +10,8 @@ export async function validateConfig( ): Promise { const AstroConfigRelativeSchema = createRelativeSchema(cmd, root); + await coerceLegacyMarkdownPlugins(userConfig); + // First-Pass Validation return await validateConfigRefined( await AstroConfigRelativeSchema.parseAsync(userConfig, { @@ -26,6 +28,46 @@ export async function validateConfig( ); } +/** + * Wraps legacy `markdown.{remark,rehype}Plugins` / `remarkRehype` into a + * `unified({...})` processor (with a warning). Mutates `userConfig` in place. + */ +async function coerceLegacyMarkdownPlugins(userConfig: any): Promise { + const md = userConfig?.markdown; + if (!md || typeof md !== 'object' || Array.isArray(md)) return; + if (md.processor) return; + + const hasRemarkPlugins = Array.isArray(md.remarkPlugins) && md.remarkPlugins.length > 0; + const hasRehypePlugins = Array.isArray(md.rehypePlugins) && md.rehypePlugins.length > 0; + const hasRemarkRehype = + md.remarkRehype && + typeof md.remarkRehype === 'object' && + !Array.isArray(md.remarkRehype) && + Object.keys(md.remarkRehype).length > 0; + if (!hasRemarkPlugins && !hasRehypePlugins && !hasRemarkRehype) return; + + let unified: typeof import('@astrojs/markdown-remark').unified; + try { + ({ unified } = await import('@astrojs/markdown-remark')); + } catch { + throw new Error( + '`markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` require `@astrojs/markdown-remark`. Install it with:\n pnpm add @astrojs/markdown-remark\n\nThen migrate to the new processor API:\n import { unified } from \'@astrojs/markdown-remark\';\n markdown: { processor: unified({ remarkPlugins: [...] }) }', + ); + } + + console.warn( + '[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` are deprecated. Use `markdown.processor: unified({...})` from `@astrojs/markdown-remark` instead.', + ); + md.processor = unified({ + remarkPlugins: md.remarkPlugins ?? [], + rehypePlugins: md.rehypePlugins ?? [], + remarkRehype: md.remarkRehype ?? {}, + }); + delete md.remarkPlugins; + delete md.rehypePlugins; + delete md.remarkRehype; +} + /** * Used twice: * - To validate the user config diff --git a/packages/astro/src/jsx/rehype.ts b/packages/astro/src/jsx/rehype.ts index 540a3412caf9..267ed1f54fba 100644 --- a/packages/astro/src/jsx/rehype.ts +++ b/packages/astro/src/jsx/rehype.ts @@ -1,5 +1,5 @@ -import type { RehypePlugin } from '@astrojs/markdown-remark'; -import type { RootContent } from 'hast'; +import type { RootContent, Root as HastRoot } from 'hast'; +import type { Plugin } from 'unified'; import type {} from 'mdast-util-mdx'; import type { MdxJsxAttribute, @@ -16,7 +16,7 @@ import type { PluginMetadata } from '../vite-plugin-astro/types.js'; const ClientOnlyPlaceholder = 'astro-client-only'; -export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => { +export const rehypeAnalyzeAstroMetadata: Plugin<[], HastRoot> = () => { return (tree, file) => { // Initial metadata for this MDX file, it will be mutated as we traverse the tree const metadata = createDefaultAstroMetadata(); diff --git a/packages/astro/src/markdown/frontmatter.ts b/packages/astro/src/markdown/frontmatter.ts new file mode 100644 index 000000000000..fad0c4e03434 --- /dev/null +++ b/packages/astro/src/markdown/frontmatter.ts @@ -0,0 +1,86 @@ +import yaml from 'js-yaml'; +import * as toml from 'smol-toml'; + +export function isFrontmatterValid(frontmatter: Record) { + try { + JSON.stringify(frontmatter); + } catch { + return false; + } + return typeof frontmatter === 'object' && frontmatter !== null; +} + +// Capture frontmatter wrapped with `---` or `+++` near the top of the file — +// either at the very start (allowing BOM) or after leading whitespace, with the +// fence still starting on a new line. +const frontmatterRE = /(?:^?|^\s*\n)(?:---|\+\+\+)([\s\S]*?\n)(?:---|\+\+\+)/; +const frontmatterTypeRE = /(?:^?|^\s*\n)(---|\+\+\+)/; + +export function extractFrontmatter(code: string): string | undefined { + return frontmatterRE.exec(code)?.[1]; +} + +function getFrontmatterParser(code: string): [string, (str: string) => unknown] { + return frontmatterTypeRE.exec(code)?.[1] === '+++' ? ['+++', toml.parse] : ['---', yaml.load]; +} + +export interface ParseFrontmatterOptions { + /** + * How the frontmatter should be handled in the returned `content` string. + * - `preserve`: Keep the frontmatter. + * - `remove`: Remove the frontmatter. + * - `empty-with-spaces`: Replace the frontmatter with empty spaces. (preserves sourcemap line/col/offset) + * - `empty-with-lines`: Replace the frontmatter with empty line breaks. (preserves sourcemap line/col) + * + * @default 'remove' + */ + frontmatter: 'preserve' | 'remove' | 'empty-with-spaces' | 'empty-with-lines'; +} + +export interface ParseFrontmatterResult { + frontmatter: Record; + rawFrontmatter: string; + content: string; +} + +export function parseFrontmatter( + code: string, + options?: ParseFrontmatterOptions, +): ParseFrontmatterResult { + const rawFrontmatter = extractFrontmatter(code); + + if (rawFrontmatter == null) { + return { frontmatter: {}, rawFrontmatter: '', content: code }; + } + const [delims, parser] = getFrontmatterParser(code); + const parsed = parser(rawFrontmatter); + const frontmatter = (parsed && typeof parsed === 'object' ? parsed : {}) as Record; + + let content: string; + switch (options?.frontmatter ?? 'remove') { + case 'preserve': + content = code; + break; + case 'remove': + content = code.replace(`${delims}${rawFrontmatter}${delims}`, ''); + break; + case 'empty-with-spaces': + content = code.replace( + `${delims}${rawFrontmatter}${delims}`, + ` ${rawFrontmatter.replace(/[^\r\n]/g, ' ')} `, + ); + break; + case 'empty-with-lines': + content = code.replace( + `${delims}${rawFrontmatter}${delims}`, + rawFrontmatter.replace(/[^\r\n]/g, ''), + ); + break; + } + + return { + frontmatter, + rawFrontmatter, + content, + }; +} diff --git a/packages/astro/src/markdown/index.ts b/packages/astro/src/markdown/index.ts new file mode 100644 index 000000000000..051f1b182113 --- /dev/null +++ b/packages/astro/src/markdown/index.ts @@ -0,0 +1,76 @@ +import type { RemotePattern } from '@astrojs/internal-helpers/remote'; +import type { + MarkdownProcessor, + ShikiConfig, + Smartypants, + SyntaxHighlightConfig, + SyntaxHighlightConfigType, +} from '@astrojs/markdown-satteri'; +import type { PluggableList } from 'unified'; +import type { PluginMetadata } from '../vite-plugin-astro/types.js'; + +export { + extractFrontmatter, + isFrontmatterValid, + parseFrontmatter, + type ParseFrontmatterOptions, + type ParseFrontmatterResult, +} from './frontmatter.js'; + +/** + * Configuration that ends up on `markdown.processor`. Returned by factory functions like + * `satteri()` or `unified()`. Third-party processors implement this interface to plug in + * their own markdown rendering pipeline. + */ +export interface MarkdownProcessorEntry { + /** Identifier for this processor. Used by integrations to look up built-in MDX support. */ + readonly name: string; + /** Create the runtime renderer for `.md` files. */ + createRenderer(shared: SharedMarkdownConfig): Promise; + /** + * Create the runtime renderer for `.mdx` files. Optional — when absent, `@astrojs/mdx` + * falls back to its built-in handling for the known `satteri` and `unified` processor + * names. Third-party processors should provide this to enable MDX support. + */ + createMdxRenderer?( + shared: SharedMarkdownConfig, + mdx: MdxRendererOptions, + ): Promise; +} + +/** Markdown config fields that apply to every processor regardless of flavor. */ +export interface SharedMarkdownConfig { + syntaxHighlight?: SyntaxHighlightConfig | SyntaxHighlightConfigType | false; + shikiConfig?: ShikiConfig; + gfm?: boolean; + smartypants?: Smartypants | boolean; + image?: { + domains?: string[]; + remotePatterns?: RemotePattern[]; + }; +} + +/** Cross-cutting MDX options passed to `createMdxRenderer` regardless of processor. */ +export interface MdxRendererOptions { + optimize: boolean | { ignoreElementNames?: string[] }; + recmaPlugins: PluggableList; +} + +/** Runtime renderer for `.mdx` files returned by `createMdxRenderer`. */ +export interface MdxRenderer { + process( + content: string, + filePath: string, + frontmatter: Record, + ): Promise; +} + +export interface MdxRenderResult { + code: string; + /** Source map. Stringified to satisfy Vite's `SourceMapInput`. */ + map?: string | null; + astroMetadata: MdxAstroMetadata; +} + +/** Shape mirrored from `PluginMetadata['astro']` so MDX results plug into Vite's `meta.astro`. */ +export type MdxAstroMetadata = PluginMetadata['astro']; diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 95fef1a87b1d..a29ebe979fd4 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -1,14 +1,32 @@ import type { OutgoingHttpHeaders } from 'node:http'; import type { RemotePattern } from '@astrojs/internal-helpers/remote'; import type { - RehypePlugins, - RemarkPlugins, - RemarkRehype, ShikiConfig, Smartypants, SyntaxHighlightConfigType, -} from '@astrojs/markdown-remark'; +} from '@astrojs/markdown-satteri'; +import type { MarkdownProcessorEntry } from '../../markdown/index.js'; import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite'; + +// Loose plugin/option shapes for the deprecated `markdown.remarkPlugins` / +// `rehypePlugins` / `remarkRehype` fields. Strict typing lives in +// `@astrojs/markdown-remark`; install it to get sharper inference when +// migrating to `markdown.processor: unified({...})`. + +/** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ remarkPlugins })`. */ +export type RemarkPlugin = (...args: any[]) => any; + +/** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ remarkPlugins })`. */ +export type RemarkPlugins = (string | [string, any] | RemarkPlugin | [RemarkPlugin, any])[]; + +/** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ rehypePlugins })`. */ +export type RehypePlugin = (...args: any[]) => any; + +/** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ rehypePlugins })`. */ +export type RehypePlugins = (string | [string, any] | RehypePlugin | [RehypePlugin, any])[]; + +/** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ remarkRehype })`. */ +export type RemarkRehype = Record; import type { FontFamily, FontProvider } from '../../assets/fonts/types.js'; import type { ImageFit, ImageLayout } from '../../assets/types.js'; import type { AssetsPrefix } from '../../core/app/types.js'; @@ -2165,6 +2183,47 @@ export interface AstroUserConfig< * ``` */ remarkRehype?: RemarkRehype; + + /** + * @docs + * @name markdown.processor + * @type {MarkdownProcessorEntry} + * @default `satteri()` + * @version 7.0.0 + * @description + * + * The markdown processor used to render `.md` and `.mdx` files. By default Astro uses [satteri](https://github.com/bruits/satteri), a Rust-based Markdown / MDX compiler. + * + * To opt into the legacy remark/rehype pipeline, use the `unified()` factory from `@astrojs/markdown-remark`: + * + * ```js + * import { defineConfig } from 'astro/config'; + * import { unified } from '@astrojs/markdown-remark'; + * import remarkToc from 'remark-toc'; + * + * export default defineConfig({ + * markdown: { + * processor: unified({ remarkPlugins: [remarkToc] }), + * }, + * }); + * ``` + * + * To pass satteri plugins or enable additional features, use the `satteri()` factory: + * + * ```js + * import { defineConfig, satteri } from 'astro/config'; + * + * export default defineConfig({ + * markdown: { + * processor: satteri({ + * hastPlugins: [myPlugin], + * features: { directive: true, definitionList: true }, + * }), + * }, + * }); + * ``` + */ + processor?: MarkdownProcessorEntry; }; /** @@ -2997,56 +3056,6 @@ export interface AstroUserConfig< */ routeRules?: RouteRules; - /** - * @name experimental.nativeMarkdown - * @type {boolean | { mdastPlugins?: import('satteri').MdastPluginDefinition[]; hastPlugins?: import('satteri').HastPluginDefinition[]; features?: import('satteri').Features }} - * @default `false` - * @version 6.0.0 - * @description - * - * Enables the experimental native Markdown compiler (satteri) as a replacement for the default remark/rehype pipeline. - * When enabled, both Markdown and MDX files are processed using satteri, a Rust-based compiler that offers improved performance. - * - * This option requires installing the `satteri` package manually in your project. - * When enabled, `remarkPlugins` and `rehypePlugins` from the markdown config are ignored. - * Use `mdastPlugins` and `hastPlugins` (satteri plugin types) instead. - * - * ```js - * // astro.config.mjs - * import { defineConfig } from 'astro/config'; - * - * export default defineConfig({ - * experimental: { - * nativeMarkdown: true, - * }, - * }); - * ``` - * - * You can also pass satteri plugins and enable additional parser features: - * - * ```js - * // astro.config.mjs - * import { defineConfig } from 'astro/config'; - * - * export default defineConfig({ - * experimental: { - * nativeMarkdown: { - * hastPlugins: [myPlugin], - * features: { - * directive: true, - * definitionList: true, - * }, - * }, - * }, - * }); - * ``` - */ - nativeMarkdown?: boolean | { - mdastPlugins?: import('satteri').MdastPluginDefinition[]; - hastPlugins?: import('satteri').HastPluginDefinition[]; - features?: import('satteri').Features; - }; - /** * @name experimental.queuedRendering * @type {boolean | { poolSize?: number; cache?: boolean }} diff --git a/packages/astro/src/types/public/content.ts b/packages/astro/src/types/public/content.ts index 6f2a06bdf551..35100f8a65bb 100644 --- a/packages/astro/src/types/public/content.ts +++ b/packages/astro/src/types/public/content.ts @@ -1,4 +1,4 @@ -import type { MarkdownHeading } from '@astrojs/markdown-remark'; +import type { MarkdownHeading } from '@astrojs/markdown-satteri'; import type { DataEntry, RenderedContent } from '../../content/data-store.js'; import type { LiveCollectionError } from '../../content/loaders/errors.js'; import type { AstroComponentFactory } from '../../runtime/server/index.js'; diff --git a/packages/astro/src/types/public/index.ts b/packages/astro/src/types/public/index.ts index 654ac53ef797..4ca7911073ea 100644 --- a/packages/astro/src/types/public/index.ts +++ b/packages/astro/src/types/public/index.ts @@ -1,10 +1,6 @@ export type { RemotePattern } from '@astrojs/internal-helpers/remote'; -export type { - MarkdownHeading, - RehypePlugins, - RemarkPlugins, - ShikiConfig, -} from '@astrojs/markdown-remark'; +export type { MarkdownHeading, ShikiConfig } from '@astrojs/markdown-satteri'; +export type { RehypePlugins, RemarkPlugins } from './config.js'; export type { ExternalImageService, ImageService, diff --git a/packages/astro/src/vite-plugin-html/transform/slots.ts b/packages/astro/src/vite-plugin-html/transform/slots.ts index a7dc54a4041e..39aded0ea21f 100644 --- a/packages/astro/src/vite-plugin-html/transform/slots.ts +++ b/packages/astro/src/vite-plugin-html/transform/slots.ts @@ -14,8 +14,10 @@ const rehypeSlots: Plugin<[{ s: MagicString }], Root> = ({ s }) => { const end = node.position?.end.offset ?? 0; const first = node.children.at(0) ?? node; const last = node.children.at(-1) ?? node; + const firstPos = 'position' in first ? first.position : undefined; + const lastPos = 'position' in last ? last.position : undefined; const text = file.value - .slice(first.position?.start.offset ?? 0, last.position?.end.offset ?? 0) + .slice(firstPos?.start.offset ?? 0, lastPos?.end.offset ?? 0) .toString(); s.overwrite( start, diff --git a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts index d3b90b09f7f1..40b9caf147c1 100644 --- a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts +++ b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts @@ -1,5 +1,4 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; -import { createMarkdownProcessor, createSatteriMarkdownProcessor } from '@astrojs/markdown-remark'; import { safeParseFrontmatter } from '../content/utils.js'; import type { ContentEntryType } from '../types/public/content.js'; @@ -18,23 +17,14 @@ export const markdownContentEntryType: ContentEntryType = { handlePropagation: true, async getRenderFunction(config) { - const nativeMd = config.experimental.nativeMarkdown; - let processor; - if (nativeMd) { - const nativeOpts = typeof nativeMd === 'object' ? nativeMd : undefined; - processor = await createSatteriMarkdownProcessor({ - image: config.image, - ...config.markdown, - mdastPlugins: nativeOpts?.mdastPlugins, - hastPlugins: nativeOpts?.hastPlugins, - features: nativeOpts?.features, - }); - } else { - processor = await createMarkdownProcessor({ - image: config.image, - ...config.markdown, - }); - } + const { markdown, image } = config; + const processor = await markdown.processor.createRenderer({ + image, + syntaxHighlight: markdown.syntaxHighlight, + shikiConfig: markdown.shikiConfig, + gfm: markdown.gfm, + smartypants: markdown.smartypants, + }); return async function renderToString(entry) { // Process markdown even if it's empty as remark/rehype plugins may add content or frontmatter dynamically const result = await processor.render(entry.body ?? '', { diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index e23c0531505a..be07aa288a83 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -1,11 +1,7 @@ import fs from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { - createMarkdownProcessor, - createSatteriMarkdownProcessor, - isFrontmatterValid, - type MarkdownProcessor, -} from '@astrojs/markdown-remark'; +import type { MarkdownProcessor } from '@astrojs/markdown-satteri'; +import { isFrontmatterValid } from '../markdown/frontmatter.js'; import type { Plugin } from 'vite'; import { safeParseFrontmatter } from '../content/utils.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; @@ -82,22 +78,14 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug // Lazily initialize the Markdown processor if (!processor) { - const nativeMd = settings.config.experimental.nativeMarkdown; - if (nativeMd) { - const nativeOpts = typeof nativeMd === 'object' ? nativeMd : undefined; - processor = createSatteriMarkdownProcessor({ - image: settings.config.image, - ...settings.config.markdown, - mdastPlugins: nativeOpts?.mdastPlugins, - hastPlugins: nativeOpts?.hastPlugins, - features: nativeOpts?.features, - }); - } else { - processor = createMarkdownProcessor({ - image: settings.config.image, - ...settings.config.markdown, - }); - } + const { markdown, image } = settings.config; + processor = markdown.processor.createRenderer({ + image, + syntaxHighlight: markdown.syntaxHighlight, + shikiConfig: markdown.shikiConfig, + gfm: markdown.gfm, + smartypants: markdown.smartypants, + }); } const renderResult = await (await processor).render(raw.content, { diff --git a/packages/astro/tsconfig.build.json b/packages/astro/tsconfig.build.json index aef5245acc21..c1739174e508 100644 --- a/packages/astro/tsconfig.build.json +++ b/packages/astro/tsconfig.build.json @@ -7,6 +7,6 @@ "references": [ { "path": "../internal-helpers/tsconfig.json" }, { "path": "../telemetry/tsconfig.json" }, - { "path": "../markdown/remark/tsconfig.json" } + { "path": "../markdown/satteri/tsconfig.json" } ] } diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 41ecc8ec47c6..ec546cd6d937 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@astrojs/markdown-remark": "workspace:*", + "@astrojs/markdown-satteri": "workspace:*", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index 4f025f51809c..7f599d7d5a1b 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; -import { markdownConfigDefaults } from '@astrojs/markdown-remark'; +import { isUnifiedProcessor, markdownConfigDefaults } from '@astrojs/markdown-remark'; +import { isSatteriProcessor } from '@astrojs/markdown-satteri'; import type { AstroIntegration, AstroIntegrationLogger, @@ -8,6 +9,7 @@ import type { ContentEntryType, HookParameters, } from 'astro'; +import type { MarkdownProcessorEntry } from 'astro/markdown'; import type { Options as RemarkRehypeOptions } from 'remark-rehype'; import type { PluggableList } from 'unified'; import type { MdastPluginDefinition, HastPluginDefinition } from './satteri-plugins.js'; @@ -15,7 +17,10 @@ import { ignoreStringPlugins, safeParseFrontmatter } from './utils.js'; import { type VitePluginMdxOptions, vitePluginMdx } from './vite-plugin-mdx.js'; import { vitePluginMdxPostprocess } from './vite-plugin-mdx-postprocess.js'; -export type MdxOptions = Omit & { +export type MdxOptions = Omit< + typeof markdownConfigDefaults, + 'remarkPlugins' | 'rehypePlugins' +> & { extendMarkdownConfig: boolean; recmaPlugins: PluggableList; // Markdown allows strings as remark and rehype plugins. @@ -27,6 +32,12 @@ export type MdxOptions = Omit & { @@ -100,32 +111,47 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI ), }); - const nativeMd = config.experimental.nativeMarkdown; + const descriptor = partialMdxOptions.processor ?? config.markdown.processor; - // When nativeMarkdown is enabled, merge mdastPlugins/hastPlugins/features from the experimental config - if (nativeMd && extendMarkdownConfig) { - const nativeOpts = typeof nativeMd === 'object' ? nativeMd : undefined; - resolvedMdxOptions.mdastPlugins = [ - ...(nativeOpts?.mdastPlugins ?? []), - ...resolvedMdxOptions.mdastPlugins, - ]; - resolvedMdxOptions.hastPlugins = [ - ...(nativeOpts?.hastPlugins ?? []), - ...resolvedMdxOptions.hastPlugins, - ]; - if (nativeOpts?.features || resolvedMdxOptions.features) { - resolvedMdxOptions.features = { - ...nativeOpts?.features, - ...resolvedMdxOptions.features, + if (extendMarkdownConfig) { + if (isSatteriProcessor(descriptor)) { + resolvedMdxOptions.mdastPlugins = [ + ...descriptor.mdastPlugins, + ...resolvedMdxOptions.mdastPlugins, + ]; + resolvedMdxOptions.hastPlugins = [ + ...descriptor.hastPlugins, + ...resolvedMdxOptions.hastPlugins, + ]; + if (descriptor.features || resolvedMdxOptions.features) { + resolvedMdxOptions.features = { + ...descriptor.features, + ...resolvedMdxOptions.features, + }; + } + } else if (isUnifiedProcessor(descriptor)) { + resolvedMdxOptions.remarkPlugins = [ + ...ignoreStringPlugins(descriptor.remarkPlugins, logger), + ...resolvedMdxOptions.remarkPlugins, + ]; + resolvedMdxOptions.rehypePlugins = [ + ...ignoreStringPlugins(descriptor.rehypePlugins, logger), + ...resolvedMdxOptions.rehypePlugins, + ]; + resolvedMdxOptions.remarkRehype = { + ...descriptor.remarkRehype, + ...resolvedMdxOptions.remarkRehype, }; } + // Third-party processors don't expose their plugins to MDX's built-in option + // merging; they handle their own pipeline via `createMdxRenderer`. } // Mutate `mdxOptions` so that `vitePluginMdx` can reference the actual options Object.assign(vitePluginMdxOptions, { mdxOptions: resolvedMdxOptions, srcDir: config.srcDir, - nativeMarkdown: nativeMd, + processor: descriptor, }); // @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. @@ -145,14 +171,16 @@ const defaultMdxOptions = { function markdownConfigToMdxOptions( markdownConfig: typeof markdownConfigDefaults, - logger: AstroIntegrationLogger, + _logger: AstroIntegrationLogger, ): MdxOptions { + const { remarkPlugins: _r, rehypePlugins: _rh, ...shared } = markdownConfig; return { ...defaultMdxOptions, - ...markdownConfig, - remarkPlugins: ignoreStringPlugins(markdownConfig.remarkPlugins, logger), - rehypePlugins: ignoreStringPlugins(markdownConfig.rehypePlugins, logger), - remarkRehype: (markdownConfig.remarkRehype as any) ?? {}, + ...shared, + // Plugins come from the processor descriptor — merged in astro:config:done. + remarkPlugins: [], + rehypePlugins: [], + remarkRehype: {}, }; } diff --git a/packages/integrations/mdx/src/satteri-plugins.ts b/packages/integrations/mdx/src/satteri-plugins.ts index 86b4affa451d..021dddb587a3 100644 --- a/packages/integrations/mdx/src/satteri-plugins.ts +++ b/packages/integrations/mdx/src/satteri-plugins.ts @@ -1,5 +1,5 @@ import * as path from 'node:path'; -import { createShikiHighlighter } from '@astrojs/markdown-remark'; +import { createShikiHighlighter } from '@astrojs/markdown-satteri'; import { defineMdastPlugin, defineHastPlugin, diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts index ac3e2edcf15d..60c6d0fa9af0 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts @@ -1,5 +1,8 @@ +import { isUnifiedProcessor } from '@astrojs/markdown-remark'; +import { isSatteriProcessor } from '@astrojs/markdown-satteri'; import type { SSRError } from 'astro'; import { getAstroMetadata } from 'astro/jsx/rehype.js'; +import type { MarkdownProcessorEntry, MdxRenderer } from 'astro/markdown'; import { VFile } from 'vfile'; import type { Plugin } from 'vite'; import type { MdxOptions } from './index.js'; @@ -10,25 +13,19 @@ import { safeParseFrontmatter } from './utils.js'; export interface VitePluginMdxOptions { mdxOptions: MdxOptions; srcDir: URL; - nativeMarkdown?: boolean | { - mdastPlugins?: import('satteri').MdastPluginDefinition[]; - hastPlugins?: import('satteri').HastPluginDefinition[]; - features?: import('satteri').Features; - }; + processor: MarkdownProcessorEntry; } // NOTE: Do not destructure `opts` as we're assigning a reference that will be mutated later export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin { - let remarkProcessor: ReturnType | undefined; - let satteriProcessor: ReturnType | undefined; + let mdxRenderer: MdxRenderer | undefined; let sourcemapEnabled: boolean; return { name: '@mdx-js/rolldown', enforce: 'pre', buildEnd() { - remarkProcessor = undefined; - satteriProcessor = undefined; + mdxRenderer = undefined; }, configResolved(resolved) { sourcemapEnabled = !!resolved.build.sourcemap; @@ -61,51 +58,18 @@ export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin { const { frontmatter, content } = safeParseFrontmatter(code, id); try { - if (opts.nativeMarkdown) { - // Satteri (native) path - if (!satteriProcessor) { - satteriProcessor = createSatteriMdxProcessor(opts.mdxOptions); - } - const result = await satteriProcessor.process(content, id, frontmatter); - return { - code: result.code, - map: null, - meta: { - astro: result.astroMetadata, - vite: { lang: 'ts' }, - }, - }; - } else { - // Remark/rehype (default) path - if (!remarkProcessor) { - remarkProcessor = createRemarkMdxProcessor(opts.mdxOptions, { - sourcemap: sourcemapEnabled, - }); - } - const vfile = new VFile({ - value: content, - path: id, - data: { - astro: { frontmatter }, - applyFrontmatterExport: { srcDir: opts.srcDir }, - }, - }); - const compiled = await remarkProcessor.process(vfile); - const astroMetadata = getAstroMetadata(vfile); - if (!astroMetadata) { - throw new Error( - 'Internal MDX error: Astro metadata is not set by rehype-analyze-astro-metadata', - ); - } - return { - code: String(compiled.value), - map: compiled.map, - meta: { - astro: astroMetadata, - vite: { lang: 'ts' }, - }, - }; + if (!mdxRenderer) { + mdxRenderer = await resolveMdxRenderer(opts, sourcemapEnabled); } + const result = await mdxRenderer.process(content, id, frontmatter); + return { + code: result.code, + map: result.map ?? null, + meta: { + astro: result.astroMetadata, + vite: { lang: 'ts' }, + }, + }; } catch (e: any) { const err: SSRError = e; err.name = 'MDXError'; @@ -117,3 +81,65 @@ export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin { }, }; } + +async function resolveMdxRenderer( + opts: VitePluginMdxOptions, + sourcemap: boolean, +): Promise { + const { processor } = opts; + + // Third-party processors opt into MDX support by implementing createMdxRenderer themselves. + if (processor.createMdxRenderer) { + return processor.createMdxRenderer( + { + syntaxHighlight: opts.mdxOptions.syntaxHighlight, + shikiConfig: opts.mdxOptions.shikiConfig, + gfm: opts.mdxOptions.gfm, + smartypants: opts.mdxOptions.smartypants, + }, + { optimize: opts.mdxOptions.optimize, recmaPlugins: opts.mdxOptions.recmaPlugins }, + ); + } + + if (isSatteriProcessor(processor)) { + const satteriProcessor = createSatteriMdxProcessor(opts.mdxOptions); + return { + async process(content, filePath, frontmatter) { + const result = await satteriProcessor.process(content, filePath, frontmatter); + return { code: result.code, map: null, astroMetadata: result.astroMetadata }; + }, + }; + } + if (isUnifiedProcessor(processor)) { + const remarkProcessor = createRemarkMdxProcessor(opts.mdxOptions, { sourcemap }); + return { + async process(content, filePath, frontmatter) { + const vfile = new VFile({ + value: content, + path: filePath, + data: { + astro: { frontmatter }, + applyFrontmatterExport: { srcDir: opts.srcDir }, + }, + }); + const compiled = await remarkProcessor.process(vfile); + const astroMetadata = getAstroMetadata(vfile); + if (!astroMetadata) { + throw new Error( + 'Internal MDX error: Astro metadata is not set by rehype-analyze-astro-metadata', + ); + } + return { + code: String(compiled.value), + map: compiled.map ? JSON.stringify(compiled.map) : null, + astroMetadata, + }; + }, + }; + } + + throw new Error( + `The markdown processor "${processor.name}" does not provide MDX support. ` + + `Implement \`createMdxRenderer\` on the processor descriptor to enable MDX rendering.`, + ); +} diff --git a/packages/integrations/mdx/test/mdx-get-headings.test.ts b/packages/integrations/mdx/test/mdx-get-headings.test.ts index dec741a661af..cc9162d99382 100644 --- a/packages/integrations/mdx/test/mdx-get-headings.test.ts +++ b/packages/integrations/mdx/test/mdx-get-headings.test.ts @@ -108,7 +108,7 @@ describe('MDX heading IDs can be customized by user plugins', () => { integrations: [mdx()], markdown: { rehypePlugins: [ - () => (tree) => { + () => (tree: any) => { let count = 0; visit(tree, 'element', (node) => { if (!/^h\d$/.test(node.tagName)) return; diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index aaa64cbdd090..2d790cb26f91 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -39,15 +39,11 @@ export { } from './shiki.js'; export * from './types.js'; export { - createSatteriMarkdownProcessor, - type SatteriMarkdownProcessorOptions, - // Shared satteri plugin factories for use by @astrojs/mdx - createCollectImagesPlugin as satteriCollectImagesPlugin, - createHeadingIdsPlugin as satteriHeadingIdsPlugin, - createShikiPlugin as satteriShikiPlugin, - collectHastText as satteriCollectHastText, - makeFragmentNode as satteriMakeFragmentNode, -} from './satteri.js'; + isUnifiedProcessor, + type UnifiedProcessorDescriptor, + type UnifiedProcessorOptions, + unified, +} from './processor.js'; export const syntaxHighlightDefaults: Required = { type: 'shiki', diff --git a/packages/markdown/remark/src/processor.ts b/packages/markdown/remark/src/processor.ts new file mode 100644 index 000000000000..4b4ebadf94ea --- /dev/null +++ b/packages/markdown/remark/src/processor.ts @@ -0,0 +1,55 @@ +import type { + AstroMarkdownProcessorOptions, + MarkdownProcessor, + RehypePlugins, + RemarkPlugins, + RemarkRehype, +} from './types.js'; + +export interface UnifiedProcessorOptions { + remarkPlugins?: RemarkPlugins; + rehypePlugins?: RehypePlugins; + remarkRehype?: RemarkRehype; +} + +/** + * The descriptor returned by `unified()`. Implements `MarkdownProcessorEntry` (astro core's + * pluggable processor interface) and additionally exposes the remark/rehype plugin lists + * so the MDX integration can merge them into MDX options. + */ +export interface UnifiedProcessorDescriptor { + readonly name: 'unified'; + remarkPlugins: RemarkPlugins; + rehypePlugins: RehypePlugins; + remarkRehype: RemarkRehype; + createRenderer(shared: AstroMarkdownProcessorOptions): Promise; +} + +export function unified(opts: UnifiedProcessorOptions = {}): UnifiedProcessorDescriptor { + const remarkPlugins = opts.remarkPlugins ?? []; + const rehypePlugins = opts.rehypePlugins ?? []; + const remarkRehype = opts.remarkRehype ?? {}; + + return { + name: 'unified', + remarkPlugins, + rehypePlugins, + remarkRehype, + async createRenderer(shared) { + // Lazy import to avoid a circular module load with `./index.js`. + const { createMarkdownProcessor } = await import('./index.js'); + return createMarkdownProcessor({ + ...shared, + remarkPlugins, + rehypePlugins, + remarkRehype, + }); + }, + }; +} + +export function isUnifiedProcessor(p: { + name: string; +}): p is UnifiedProcessorDescriptor { + return p.name === 'unified'; +} diff --git a/packages/markdown/remark/src/types.ts b/packages/markdown/remark/src/types.ts index 3fbba1ed9812..44aa004f49af 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/markdown/remark/src/types.ts @@ -57,8 +57,11 @@ export interface ShikiConfig export interface AstroMarkdownOptions { syntaxHighlight?: SyntaxHighlightConfig | SyntaxHighlightConfigType | false; shikiConfig?: ShikiConfig; + /** @deprecated Use `markdown.processor: unified({ remarkPlugins })` instead. */ remarkPlugins?: RemarkPlugins; + /** @deprecated Use `markdown.processor: unified({ rehypePlugins })` instead. */ rehypePlugins?: RehypePlugins; + /** @deprecated Use `markdown.processor: unified({ remarkRehype })` instead. */ remarkRehype?: RemarkRehype; gfm?: boolean; smartypants?: boolean | SmartypantsOptions; diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json new file mode 100644 index 000000000000..67f73e4509ef --- /dev/null +++ b/packages/markdown/satteri/package.json @@ -0,0 +1,58 @@ +{ + "name": "@astrojs/markdown-satteri", + "version": "0.1.0", + "type": "module", + "author": "withastro", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/withastro/astro.git", + "directory": "packages/markdown/satteri" + }, + "bugs": "https://github.com/withastro/astro/issues", + "homepage": "https://astro.build", + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js" + }, + "imports": { + "#shiki-engine": { + "workerd": "./dist/shiki-engine-workerd.js", + "default": "./dist/shiki-engine-default.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "prepublish": "pnpm build", + "build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json", + "build:ci": "astro-scripts build \"src/**/*.ts\"", + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" + }, + "dependencies": { + "@astrojs/internal-helpers": "workspace:*", + "github-slugger": "^2.0.0", + "shiki": "^4.0.0" + }, + "peerDependencies": { + "satteri": ">=0.2.7" + }, + "peerDependenciesMeta": { + "satteri": { + "optional": true + } + }, + "devDependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "astro-scripts": "workspace:*", + "retext-smartypants": "^6.2.0", + "satteri": "^0.2.7" + }, + "publishConfig": { + "provenance": true + } +} diff --git a/packages/markdown/satteri/src/defaults.ts b/packages/markdown/satteri/src/defaults.ts new file mode 100644 index 000000000000..7c4609947809 --- /dev/null +++ b/packages/markdown/satteri/src/defaults.ts @@ -0,0 +1,23 @@ +import { defaultExcludeLanguages } from './highlight.js'; +import type { ShikiConfig, SyntaxHighlightConfig } from './types.js'; + +export const syntaxHighlightDefaults: Required = { + type: 'shiki', + excludeLangs: defaultExcludeLanguages, +}; + +const shikiConfigDefaults: ShikiConfig = { + langs: [], + theme: 'github-dark', + themes: {}, + wrap: false, + transformers: [], + langAlias: {}, +}; + +export const satteriMarkdownDefaults = { + syntaxHighlight: syntaxHighlightDefaults, + shikiConfig: shikiConfigDefaults, + gfm: true, + smartypants: true, +}; diff --git a/packages/markdown/satteri/src/highlight.ts b/packages/markdown/satteri/src/highlight.ts new file mode 100644 index 000000000000..36636616985d --- /dev/null +++ b/packages/markdown/satteri/src/highlight.ts @@ -0,0 +1,2 @@ +// Don't highlight math code blocks by default. +export const defaultExcludeLanguages = ['math']; diff --git a/packages/markdown/satteri/src/index.ts b/packages/markdown/satteri/src/index.ts new file mode 100644 index 000000000000..a7f11c14aadc --- /dev/null +++ b/packages/markdown/satteri/src/index.ts @@ -0,0 +1,36 @@ +export { defaultExcludeLanguages } from './highlight.js'; +export { + createCollectImagesPlugin as satteriCollectImagesPlugin, + createHeadingIdsPlugin as satteriHeadingIdsPlugin, + createShikiPlugin as satteriShikiPlugin, + collectHastText as satteriCollectHastText, + makeFragmentNode as satteriMakeFragmentNode, + createSatteriMarkdownProcessor, + type SatteriMarkdownProcessorOptions, +} from './satteri-processor.js'; +export { + isSatteriProcessor, + satteri, + type SatteriProcessorDescriptor, + type SatteriProcessorOptions, +} from './processor.js'; +export { satteriMarkdownDefaults, syntaxHighlightDefaults } from './defaults.js'; +export { + clearShikiHighlighterCache, + createShikiHighlighter, + type CreateShikiHighlighterOptions, + type ShikiHighlighter, + type ShikiHighlighterHighlightOptions, + type ThemePresets, +} from './shiki.js'; +export type { + AstroMarkdownProcessorOptions, + MarkdownHeading, + MarkdownProcessor, + MarkdownProcessorRenderOptions, + MarkdownProcessorRenderResult, + ShikiConfig, + Smartypants, + SyntaxHighlightConfig, + SyntaxHighlightConfigType, +} from './types.js'; diff --git a/packages/markdown/satteri/src/processor.ts b/packages/markdown/satteri/src/processor.ts new file mode 100644 index 000000000000..fe60ac10a6cc --- /dev/null +++ b/packages/markdown/satteri/src/processor.ts @@ -0,0 +1,48 @@ +import { createSatteriMarkdownProcessor } from './satteri-processor.js'; +import type { AstroMarkdownProcessorOptions, MarkdownProcessor } from './types.js'; + +export interface SatteriProcessorOptions { + mdastPlugins?: import('satteri').MdastPluginDefinition[]; + hastPlugins?: import('satteri').HastPluginDefinition[]; + features?: import('satteri').Features; +} + +/** + * The descriptor returned by `satteri()`. Implements `MarkdownProcessorEntry` (astro core's + * pluggable processor interface) and additionally exposes the satteri-specific plugin lists + * so the MDX integration can merge them into MDX options. + */ +export interface SatteriProcessorDescriptor { + readonly name: 'satteri'; + mdastPlugins: import('satteri').MdastPluginDefinition[]; + hastPlugins: import('satteri').HastPluginDefinition[]; + features?: import('satteri').Features; + createRenderer(shared: AstroMarkdownProcessorOptions): Promise; +} + +export function satteri(opts: SatteriProcessorOptions = {}): SatteriProcessorDescriptor { + const mdastPlugins = opts.mdastPlugins ?? []; + const hastPlugins = opts.hastPlugins ?? []; + const features = opts.features; + + return { + name: 'satteri', + mdastPlugins, + hastPlugins, + features, + createRenderer(shared) { + return createSatteriMarkdownProcessor({ + ...shared, + mdastPlugins, + hastPlugins, + features, + }); + }, + }; +} + +export function isSatteriProcessor(p: { + name: string; +}): p is SatteriProcessorDescriptor { + return p.name === 'satteri'; +} diff --git a/packages/markdown/remark/src/satteri.ts b/packages/markdown/satteri/src/satteri-processor.ts similarity index 90% rename from packages/markdown/remark/src/satteri.ts rename to packages/markdown/satteri/src/satteri-processor.ts index ae5111d76774..a2863cec759f 100644 --- a/packages/markdown/remark/src/satteri.ts +++ b/packages/markdown/satteri/src/satteri-processor.ts @@ -1,16 +1,15 @@ import Slugger from 'github-slugger'; -import { createShikiHighlighter } from './shiki.js'; +import { satteriMarkdownDefaults, syntaxHighlightDefaults } from './defaults.js'; import { defaultExcludeLanguages } from './highlight.js'; +import { createShikiHighlighter } from './shiki.js'; import type { AstroMarkdownProcessorOptions, MarkdownHeading, MarkdownProcessor, } from './types.js'; -import { markdownConfigDefaults, syntaxHighlightDefaults } from './index.js'; type HighlightFn = (code: string, lang: string, meta?: string) => Promise; -// Lazily loaded satteri module let satteri: typeof import('satteri') | undefined; async function loadSatteri(): Promise { @@ -20,15 +19,11 @@ async function loadSatteri(): Promise { return satteri; } catch { throw new Error( - '`experimental.nativeMarkdown` requires the `satteri` package. Install it with:\n npm install satteri', + 'The default `satteri()` markdown processor requires the `satteri` package. Install it with:\n npm install satteri\n\nOr opt into the legacy unified pipeline with `markdown.processor: unified({...})` from `@astrojs/markdown-remark`.', ); } } -// ── Shared satteri plugin factories ── -// Used by both the markdown processor here and the MDX satteri processor -// in @astrojs/mdx. All require loadSatteri() to have been called first. - export function createCollectImagesPlugin( localImagePaths: Set, remoteImagePaths: Set, @@ -165,8 +160,6 @@ export function createShikiPlugin( }); } -// ── Satteri Markdown Processor ��─ - export interface SatteriMarkdownProcessorOptions extends AstroMarkdownProcessorOptions { mdastPlugins?: import('satteri').MdastPluginDefinition[]; hastPlugins?: import('satteri').HastPluginDefinition[]; @@ -180,9 +173,9 @@ export async function createSatteriMarkdownProcessor( const { syntaxHighlight = syntaxHighlightDefaults, - shikiConfig = markdownConfigDefaults.shikiConfig, - gfm = markdownConfigDefaults.gfm, - smartypants = markdownConfigDefaults.smartypants, + shikiConfig = satteriMarkdownDefaults.shikiConfig, + gfm = satteriMarkdownDefaults.gfm, + smartypants = satteriMarkdownDefaults.smartypants, mdastPlugins: userMdastPlugins = [], hastPlugins: userHastPlugins = [], features: userFeatures, @@ -197,7 +190,7 @@ export async function createSatteriMarkdownProcessor( if (syntaxHighlightType === 'prism') { throw new Error( - 'Prism syntax highlighting is not supported with `experimental.nativeMarkdown`. Use shiki instead.', + 'Prism syntax highlighting is not supported by the `satteri()` markdown processor. Use shiki instead, or switch to `markdown.processor: unified({...})`.', ); } diff --git a/packages/markdown/satteri/src/shiki-engine-default.ts b/packages/markdown/satteri/src/shiki-engine-default.ts new file mode 100644 index 000000000000..dc597633f6fa --- /dev/null +++ b/packages/markdown/satteri/src/shiki-engine-default.ts @@ -0,0 +1,6 @@ +import type { RegexEngine } from 'shiki'; +import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; + +export function loadShikiEngine(): Promise { + return createOnigurumaEngine(import('shiki/wasm')); +} diff --git a/packages/markdown/satteri/src/shiki-engine-workerd.ts b/packages/markdown/satteri/src/shiki-engine-workerd.ts new file mode 100644 index 000000000000..6d3b0dda61c1 --- /dev/null +++ b/packages/markdown/satteri/src/shiki-engine-workerd.ts @@ -0,0 +1,7 @@ +import type { RegexEngine } from 'shiki'; +import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; + +export function loadShikiEngine(): Promise { + // @ts-ignore wasm type + return createOnigurumaEngine(import('shiki/onig.wasm')); +} diff --git a/packages/markdown/satteri/src/shiki.ts b/packages/markdown/satteri/src/shiki.ts new file mode 100644 index 000000000000..7344f5d75ac1 --- /dev/null +++ b/packages/markdown/satteri/src/shiki.ts @@ -0,0 +1,249 @@ +import type { Properties, Root } from 'hast'; +import { + type BuiltinLanguage, + type BuiltinTheme, + type BundledLanguage, + createCssVariablesTheme, + createHighlighter, + type HighlighterCoreOptions, + isSpecialLang, + type LanguageInput, + type LanguageRegistration, + type RegexEngine, + type ShikiTransformer, + type SpecialLanguage, + type ThemeRegistration, + type ThemeRegistrationRaw, +} from 'shiki'; +import { loadShikiEngine } from '#shiki-engine'; + +export type ThemePresets = BuiltinTheme | 'css-variables'; + +export interface ShikiHighlighter { + codeToHast( + code: string, + lang?: string, + options?: ShikiHighlighterHighlightOptions, + ): Promise; + codeToHtml( + code: string, + lang?: string, + options?: ShikiHighlighterHighlightOptions, + ): Promise; +} + +type ShikiLanguage = LanguageInput | BuiltinLanguage | SpecialLanguage; + +interface ShikiHighlighterInternal extends ShikiHighlighter { + loadLanguage(...langs: ShikiLanguage[]): Promise; + getLoadedLanguages(): string[]; +} + +export interface CreateShikiHighlighterOptions { + langs?: LanguageRegistration[]; + theme?: ThemePresets | ThemeRegistration | ThemeRegistrationRaw; + themes?: Record; + langAlias?: HighlighterCoreOptions['langAlias']; +} + +export interface ShikiHighlighterHighlightOptions { + inline?: boolean; + wrap?: boolean | null; + defaultColor?: 'light' | 'dark' | string | false; + transformers?: ShikiTransformer[]; + attributes?: Record; + meta?: string; +} + +let _cssVariablesTheme: ReturnType; +const cssVariablesTheme = () => + _cssVariablesTheme ?? + (_cssVariablesTheme = createCssVariablesTheme({ + variablePrefix: '--astro-code-', + })); + +const cachedHighlighters = new Map>(); + +/** @internal */ +export function clearShikiHighlighterCache(): void { + cachedHighlighters.clear(); +} + +export function createShikiHighlighter( + options?: CreateShikiHighlighterOptions, +): Promise { + const key: string = getCacheKey(options); + let highlighterPromise = cachedHighlighters.get(key); + if (!highlighterPromise) { + highlighterPromise = createShikiHighlighterInternal(options); + cachedHighlighters.set(key, highlighterPromise); + } + return ensureLanguagesLoaded(highlighterPromise, options?.langs); +} + +function getCacheKey(options?: CreateShikiHighlighterOptions): string { + const keyCache: unknown[] = []; + const { theme, themes, langAlias } = options ?? {}; + if (theme) { + keyCache.push(theme); + } + if (themes) { + keyCache.push(Object.entries(themes).sort()); + } + if (langAlias) { + keyCache.push(Object.entries(langAlias).sort()); + } + return keyCache.length > 0 ? JSON.stringify(keyCache) : ''; +} + +async function ensureLanguagesLoaded( + promise: Promise, + langs?: ShikiLanguage[], +): Promise { + const highlighter = await promise; + if (!langs) { + return highlighter; + } + const loadedLanguages = highlighter.getLoadedLanguages(); + for (const lang of langs) { + if (typeof lang === 'string' && (isSpecialLang(lang) || loadedLanguages.includes(lang))) { + continue; + } + await highlighter.loadLanguage(lang); + } + return highlighter; +} + +let shikiEngine: RegexEngine | undefined = undefined; + +async function createShikiHighlighterInternal({ + langs = [], + theme = 'github-dark', + themes = {}, + langAlias = {}, +}: CreateShikiHighlighterOptions = {}): Promise { + theme = theme === 'css-variables' ? cssVariablesTheme() : theme; + + if (shikiEngine === undefined) { + shikiEngine = await loadShikiEngine(); + } + + const highlighter = await createHighlighter({ + langs: ['plaintext', ...langs], + langAlias, + themes: Object.values(themes).length ? Object.values(themes) : [theme], + engine: shikiEngine, + }); + + async function highlight( + code: string, + lang = 'plaintext', + options: ShikiHighlighterHighlightOptions, + to: 'hast' | 'html', + ) { + const resolvedLang = langAlias[lang] ?? lang; + const loadedLanguages = highlighter.getLoadedLanguages(); + + if (!isSpecialLang(lang) && !loadedLanguages.includes(resolvedLang)) { + try { + await highlighter.loadLanguage(resolvedLang as BundledLanguage); + } catch (_err) { + const langStr = + lang === resolvedLang ? `"${lang}"` : `"${lang}" (aliased to "${resolvedLang}")`; + console.warn(`[Shiki] The language ${langStr} doesn't exist, falling back to "plaintext".`); + lang = 'plaintext'; + } + } + + code = code.replace(/(?:\r\n|\r|\n)$/, ''); + + const themeOptions = Object.values(themes).length ? { themes } : { theme }; + const inline = options?.inline ?? false; + + return highlighter[to === 'html' ? 'codeToHtml' : 'codeToHast'](code, { + ...themeOptions, + defaultColor: options.defaultColor, + lang, + meta: options?.meta ? { __raw: options?.meta } : undefined, + transformers: [ + { + pre(node) { + if (inline) { + node.tagName = 'code'; + } + + const { + class: attributesClass, + style: attributesStyle, + ...rest + } = options?.attributes ?? {}; + Object.assign(node.properties, rest); + + const classValue = + (normalizePropAsString(node.properties.class) ?? '') + + (attributesClass ? ` ${attributesClass}` : ''); + const styleValue = + (normalizePropAsString(node.properties.style) ?? '') + + (attributesStyle ? `; ${attributesStyle}` : ''); + + node.properties.class = classValue.replace(/shiki/g, 'astro-code'); + + node.properties.dataLanguage = lang; + + if (options.wrap === false || options.wrap === undefined) { + node.properties.style = styleValue + '; overflow-x: auto;'; + } else if (options.wrap === true) { + node.properties.style = + styleValue + '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'; + } + }, + line(node) { + if (resolvedLang === 'diff') { + const innerSpanNode = node.children[0]; + const innerSpanTextNode = + innerSpanNode?.type === 'element' && innerSpanNode.children?.[0]; + + if (innerSpanTextNode && innerSpanTextNode.type === 'text') { + const start = innerSpanTextNode.value[0]; + if (start === '+' || start === '-') { + innerSpanTextNode.value = innerSpanTextNode.value.slice(1); + innerSpanNode.children.unshift({ + type: 'element', + tagName: 'span', + properties: { style: 'user-select: none;' }, + children: [{ type: 'text', value: start }], + }); + } + } + } + }, + code(node) { + if (inline) { + return node.children[0] as typeof node; + } + }, + }, + ...(options.transformers ?? []), + ], + }); + } + + return { + codeToHast(code, lang, options = {}) { + return highlight(code, lang, options, 'hast') as Promise; + }, + codeToHtml(code, lang, options = {}) { + return highlight(code, lang, options, 'html') as Promise; + }, + loadLanguage(...newLangs) { + return highlighter.loadLanguage(...newLangs); + }, + getLoadedLanguages() { + return highlighter.getLoadedLanguages(); + }, + }; +} + +function normalizePropAsString(value: Properties[string]): string | null { + return Array.isArray(value) ? value.join(' ') : (value as string | null); +} diff --git a/packages/markdown/satteri/src/types.ts b/packages/markdown/satteri/src/types.ts new file mode 100644 index 000000000000..f2bdada05294 --- /dev/null +++ b/packages/markdown/satteri/src/types.ts @@ -0,0 +1,55 @@ +import type { RemotePattern } from '@astrojs/internal-helpers/remote'; +import type { Options as SmartypantsOptions } from 'retext-smartypants'; +import type { CreateShikiHighlighterOptions, ShikiHighlighterHighlightOptions } from './shiki.js'; + +export type SyntaxHighlightConfigType = 'shiki' | 'prism'; + +export interface SyntaxHighlightConfig { + type: SyntaxHighlightConfigType; + excludeLangs?: string[]; +} + +export interface ShikiConfig + extends Pick, + Pick {} + +export type Smartypants = SmartypantsOptions; + +export interface MarkdownHeading { + depth: number; + slug: string; + text: string; +} + +export interface AstroMarkdownProcessorOptions { + syntaxHighlight?: SyntaxHighlightConfig | SyntaxHighlightConfigType | false; + shikiConfig?: ShikiConfig; + gfm?: boolean; + smartypants?: boolean | SmartypantsOptions; + image?: { + domains?: string[]; + remotePatterns?: RemotePattern[]; + }; +} + +export interface MarkdownProcessor { + render: ( + content: string, + opts?: MarkdownProcessorRenderOptions, + ) => Promise; +} + +export interface MarkdownProcessorRenderOptions { + fileURL?: URL; + frontmatter?: Record; +} + +export interface MarkdownProcessorRenderResult { + code: string; + metadata: { + headings: MarkdownHeading[]; + localImagePaths: string[]; + remoteImagePaths: string[]; + frontmatter: Record; + }; +} diff --git a/packages/markdown/satteri/tsconfig.build.json b/packages/markdown/satteri/tsconfig.build.json new file mode 100644 index 000000000000..2a10b0fd387a --- /dev/null +++ b/packages/markdown/satteri/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../configs/tsconfig.build.json", + "references": [{ "path": "../../internal-helpers/tsconfig.json" }] +} diff --git a/packages/markdown/satteri/tsconfig.json b/packages/markdown/satteri/tsconfig.json new file mode 100644 index 000000000000..c0e0de315104 --- /dev/null +++ b/packages/markdown/satteri/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../configs/tsconfig.base.json", + "files": [], + "references": [{ "path": "./tsconfig.build.json" }, { "path": "./tsconfig.test.json" }] +} diff --git a/packages/markdown/satteri/tsconfig.test.json b/packages/markdown/satteri/tsconfig.test.json new file mode 100644 index 000000000000..9af37150bd9d --- /dev/null +++ b/packages/markdown/satteri/tsconfig.test.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../configs/tsconfig.test.json", + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1632823e50ae..93f6abcc067c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,7 +56,7 @@ importers: version: 3.0.0(eslint@9.39.3(jiti@2.6.1)) knip: specifier: 5.82.1 - version: 5.82.1(@types/node@22.19.11)(typescript@5.9.3) + version: 5.82.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@22.19.11)(typescript@5.9.3) only-allow: specifier: ^1.2.2 version: 1.2.2 @@ -532,9 +532,9 @@ importers: '@astrojs/internal-helpers': specifier: workspace:* version: link:../internal-helpers - '@astrojs/markdown-remark': + '@astrojs/markdown-satteri': specifier: workspace:* - version: link:../markdown/remark + version: link:../markdown/satteri '@astrojs/telemetry': specifier: workspace:* version: link:../telemetry @@ -695,6 +695,9 @@ importers: '@astrojs/check': specifier: workspace:* version: link:../language-tools/astro-check + '@astrojs/markdown-remark': + specifier: workspace:* + version: link:../markdown/remark '@playwright/test': specifier: 1.58.2 version: 1.58.2 @@ -770,6 +773,9 @@ importers: sass: specifier: ^1.98.0 version: 1.98.0 + satteri: + specifier: ^0.2.7 + version: 0.2.8 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -5318,6 +5324,9 @@ importers: '@astrojs/markdown-remark': specifier: workspace:* version: link:../../markdown/remark + '@astrojs/markdown-satteri': + specifier: workspace:* + version: link:../../markdown/satteri '@mdx-js/mdx': specifier: ^3.1.1 version: 3.1.1 @@ -5330,6 +5339,9 @@ importers: estree-util-visit: specifier: ^2.0.0 version: 2.0.0 + github-slugger: + specifier: ^2.0.0 + version: 2.0.0 hast-util-to-html: specifier: ^9.0.5 version: 9.0.5 @@ -5345,6 +5357,9 @@ importers: remark-smartypants: specifier: ^3.0.2 version: 3.0.2 + satteri: + specifier: ^0.2.7 + version: 0.2.8 source-map: specifier: ^0.7.6 version: 0.7.6 @@ -5356,10 +5371,10 @@ importers: version: 6.0.3 devDependencies: '@shikijs/rehype': - specifier: ^4.0.2 + specifier: ^4.0.0 version: 4.0.2 '@shikijs/twoslash': - specifier: ^4.0.2 + specifier: ^4.0.0 version: 4.0.2(typescript@5.9.3) '@types/estree': specifier: ^1.0.8 @@ -6955,6 +6970,9 @@ importers: specifier: ^6.0.3 version: 6.0.3 devDependencies: + '@napi-rs/wasm-runtime': + specifier: ^1.1.2 + version: 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@types/estree': specifier: ^1.0.8 version: 1.0.8 @@ -6979,6 +6997,37 @@ importers: mdast-util-mdx-expression: specifier: ^2.0.1 version: 2.0.1 + satteri: + specifier: ^0.2.7 + version: 0.2.8 + + packages/markdown/satteri: + dependencies: + '@astrojs/internal-helpers': + specifier: workspace:* + version: link:../../internal-helpers + github-slugger: + specifier: ^2.0.0 + version: 2.0.0 + shiki: + specifier: ^4.0.0 + version: 4.0.2 + devDependencies: + '@types/hast': + specifier: ^3.0.4 + version: 3.0.4 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 + astro-scripts: + specifier: workspace:* + version: link:../../../scripts + retext-smartypants: + specifier: ^6.2.0 + version: 6.2.0 + satteri: + specifier: ^0.2.7 + version: 0.2.8 packages/telemetry: dependencies: @@ -7631,6 +7680,32 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@bruits/satteri-darwin-arm64@0.2.8': + resolution: {integrity: sha512-sslTdEUNKvE6+wLQOrzsIZt7QqSdYLCLVLvKWWXXru9wM6g8nLxCmKkjHhWRdFjto+pDRNEG/nP8pyYtn5hL8g==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.2.8': + resolution: {integrity: sha512-B7LOdXobwE7PKGNMsMbnk1Ihfdo5Hamw31XWdg8janBwhh+8sleWusmFXa6gT5PUKlrsRDf6c+kssD720DmCfw==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-x64-gnu@0.2.8': + resolution: {integrity: sha512-q4J+5R66AP+Vftr0RuPMpyuaKXaRKpdgV+d+QauTkDEuaRAX2VgApg+jSXqIIAtJle5+JjpYR9Z/tfZKlqvgNg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-wasm32-wasi@0.2.8': + resolution: {integrity: sha512-qtZ4Jb/4K20c+dAYwWbwYQyxSCIKokJ/OTppoDDTBj5e50Ge8bkQRrS8d45IY0Ct0UBqIpRoD8i6WQI3Y6Heog==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-x64-msvc@0.2.8': + resolution: {integrity: sha512-BFQ0tIuqjM2mn1AGCdjTEqtR/d87+9q0aD6/LunpW6QsIwxYIDizOpAE9dHGpZXqn+4Q4uFS1bnqLu6y/dMaXQ==} + cpu: [x64] + os: [win32] + '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} @@ -8149,15 +8224,24 @@ packages: '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -9239,9 +9323,6 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@napi-rs/wasm-runtime@1.1.1': - resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} - '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -15356,6 +15437,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + satteri@0.2.8: + resolution: {integrity: sha512-hLSMgno8jTm1iyijPJQE+N7HS+keaMCHHOcTgwHE0ue1i9cbdyb3Z/9cq0DF8JH0ib5P6DZXEQGIkLhL8WdSbA==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -17838,6 +17922,25 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@bruits/satteri-darwin-arm64@0.2.8': + optional: true + + '@bruits/satteri-darwin-x64@0.2.8': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.2.8': + optional: true + + '@bruits/satteri-wasm32-wasi@0.2.8': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + optional: true + + '@bruits/satteri-win32-x64-msvc@0.2.8': + optional: true + '@capsizecss/unpack@4.0.0': dependencies: fontkitten: 1.0.2 @@ -18463,7 +18566,6 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 - optional: true '@emnapi/core@1.8.1': dependencies: @@ -18471,26 +18573,40 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.10.0': + '@emnapi/core@1.9.1': dependencies: + '@emnapi/wasi-threads': 1.2.0 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.1': + '@emnapi/wasi-threads@1.2.0': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + '@envelop/instrumentation@1.0.0': dependencies: '@whatwg-node/promise-helpers': 1.3.2 @@ -19363,18 +19479,17 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.1.1': - dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 - optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.1 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 optional: true '@neon-rs/load@0.0.4': {} @@ -19896,9 +20011,12 @@ snapshots: '@oxc-resolver/binding-openharmony-arm64@11.17.1': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.17.1': + '@oxc-resolver/binding-wasm32-wasi@11.17.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.17.1': @@ -20807,7 +20925,6 @@ snapshots: '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 - optional: true '@types/alpinejs@3.13.11': {} @@ -24218,7 +24335,7 @@ snapshots: kleur@4.1.5: {} - knip@5.82.1(@types/node@22.19.11)(typescript@5.9.3): + knip@5.82.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@22.19.11)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 '@types/node': 22.19.11 @@ -24227,13 +24344,16 @@ snapshots: jiti: 2.6.1 js-yaml: 4.1.1 minimist: 1.2.8 - oxc-resolver: 11.17.1 + oxc-resolver: 11.17.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) picocolors: 1.1.1 picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 typescript: 5.9.3 zod: 4.3.6 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' kolorist@1.8.0: {} @@ -25418,7 +25538,7 @@ snapshots: - debug - supports-color - oxc-resolver@11.17.1: + oxc-resolver@11.17.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.17.1 '@oxc-resolver/binding-android-arm64': 11.17.1 @@ -25436,10 +25556,13 @@ snapshots: '@oxc-resolver/binding-linux-x64-gnu': 11.17.1 '@oxc-resolver/binding-linux-x64-musl': 11.17.1 '@oxc-resolver/binding-openharmony-arm64': 11.17.1 - '@oxc-resolver/binding-wasm32-wasi': 11.17.1 + '@oxc-resolver/binding-wasm32-wasi': 11.17.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@oxc-resolver/binding-win32-arm64-msvc': 11.17.1 '@oxc-resolver/binding-win32-ia32-msvc': 11.17.1 '@oxc-resolver/binding-win32-x64-msvc': 11.17.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' p-event@6.0.1: dependencies: @@ -26593,6 +26716,18 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 + satteri@0.2.8: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.2.8 + '@bruits/satteri-darwin-x64': 0.2.8 + '@bruits/satteri-linux-x64-gnu': 0.2.8 + '@bruits/satteri-wasm32-wasi': 0.2.8 + '@bruits/satteri-win32-x64-msvc': 0.2.8 + sax@1.6.0: {} scheduler@0.23.2: From f6b78722dfcf7720654f100326621dcc3777c0ad Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 12 May 2026 02:55:54 +0200 Subject: [PATCH 03/26] fix: clean deps --- packages/markdown/remark/package.json | 9 --------- pnpm-lock.yaml | 3 --- 2 files changed, 12 deletions(-) diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 37720be67e85..e5af16ff1112 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -59,14 +59,6 @@ "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" }, - "peerDependencies": { - "satteri": ">=0.2.7" - }, - "peerDependenciesMeta": { - "satteri": { - "optional": true - } - }, "devDependencies": { "@napi-rs/wasm-runtime": "^1.1.2", "@types/estree": "^1.0.8", @@ -75,7 +67,6 @@ "@types/mdast": "^4.0.4", "@types/unist": "^3.0.3", "astro-scripts": "workspace:*", - "satteri": "^0.2.7", "esbuild": "^0.27.3", "mdast-util-mdx-expression": "^2.0.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93f6abcc067c..20898e4112f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6997,9 +6997,6 @@ importers: mdast-util-mdx-expression: specifier: ^2.0.1 version: 2.0.1 - satteri: - specifier: ^0.2.7 - version: 0.2.8 packages/markdown/satteri: dependencies: From 4d0a4c4d7e40856ccf2ac4277a6abecbaf5e507a Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 12 May 2026 03:18:14 +0200 Subject: [PATCH 04/26] fix: tests --- knip.js | 11 +++++----- packages/astro/package.json | 1 - .../test/units/content-layer/test-helpers.ts | 7 +++++-- packages/markdown/remark/package.json | 1 - packages/markdown/satteri/package.json | 13 ++---------- .../markdown/satteri/src/satteri-processor.ts | 10 ++++++++-- pnpm-lock.yaml | 20 ++++++++----------- 7 files changed, 28 insertions(+), 35 deletions(-) diff --git a/knip.js b/knip.js index 26369d44f32d..758f73be343c 100644 --- a/knip.js +++ b/knip.js @@ -83,12 +83,11 @@ export default { entry: [srcEntry, dtsEntry, testEntry], // package.json#imports are not resolved at the moment ignore: ['src/import-plugin-browser.ts', 'src/shiki-engine-workerd.ts'], - ignoreDependencies: [ - // Optional peer dep loaded dynamically by createSatteriMarkdownProcessor - 'satteri', - // Pulled in indirectly through satteri's browser WASM shim during bundler tests - '@napi-rs/wasm-runtime', - ], + }, + 'packages/markdown/satteri': { + entry: [srcEntry, dtsEntry, testEntry], + // package.json#imports are not resolved at the moment + ignore: ['src/shiki-engine-default.ts', 'src/shiki-engine-workerd.ts'], }, 'packages/upgrade': { entry: ['src/index.ts', testEntry], diff --git a/packages/astro/package.json b/packages/astro/package.json index 426041f76f3d..8fd8f8aa4b53 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -198,7 +198,6 @@ "devDependencies": { "@astrojs/check": "workspace:*", "@astrojs/markdown-remark": "workspace:*", - "satteri": "^0.2.7", "@playwright/test": "1.58.2", "@types/aria-query": "^5.0.4", "@types/hast": "^3.0.4", diff --git a/packages/astro/test/units/content-layer/test-helpers.ts b/packages/astro/test/units/content-layer/test-helpers.ts index df3beb649756..245f8912baf8 100644 --- a/packages/astro/test/units/content-layer/test-helpers.ts +++ b/packages/astro/test/units/content-layer/test-helpers.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { tmpdir } from 'node:os'; import { mkdtempSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; +import { satteri } from '@astrojs/markdown-satteri'; /** * Creates a temporary directory for tests @@ -41,14 +42,16 @@ export function createMinimalSettings(root: URL, overrides: Record root, srcDir: new URL('./src/', root), cacheDir: new URL('./.cache/', root), - markdown: {}, + markdown: { processor: satteri() }, experimental: {}, }; + const overrideConfig = overrides.config || {}; const settings: Record = { config: { ...defaultConfig, - ...(overrides.config || {}), + ...overrideConfig, + markdown: { ...defaultConfig.markdown, ...(overrideConfig.markdown ?? {}) }, }, dotAstroDir: new URL('./.astro/', root), contentEntryTypes: [], diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index e5af16ff1112..60042d9f8854 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -60,7 +60,6 @@ "vfile": "^6.0.3" }, "devDependencies": { - "@napi-rs/wasm-runtime": "^1.1.2", "@types/estree": "^1.0.8", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index 67f73e4509ef..288b1cd1da15 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -35,22 +35,13 @@ "dependencies": { "@astrojs/internal-helpers": "workspace:*", "github-slugger": "^2.0.0", + "satteri": "^0.2.7", "shiki": "^4.0.0" }, - "peerDependencies": { - "satteri": ">=0.2.7" - }, - "peerDependenciesMeta": { - "satteri": { - "optional": true - } - }, "devDependencies": { "@types/hast": "^3.0.4", - "@types/mdast": "^4.0.4", "astro-scripts": "workspace:*", - "retext-smartypants": "^6.2.0", - "satteri": "^0.2.7" + "retext-smartypants": "^6.2.0" }, "publishConfig": { "provenance": true diff --git a/packages/markdown/satteri/src/satteri-processor.ts b/packages/markdown/satteri/src/satteri-processor.ts index a2863cec759f..964e07fa95ca 100644 --- a/packages/markdown/satteri/src/satteri-processor.ts +++ b/packages/markdown/satteri/src/satteri-processor.ts @@ -1,3 +1,4 @@ +import { isRemoteAllowed } from '@astrojs/internal-helpers/remote'; import Slugger from 'github-slugger'; import { satteriMarkdownDefaults, syntaxHighlightDefaults } from './defaults.js'; import { defaultExcludeLanguages } from './highlight.js'; @@ -27,7 +28,10 @@ async function loadSatteri(): Promise { export function createCollectImagesPlugin( localImagePaths: Set, remoteImagePaths: Set, + image: AstroMarkdownProcessorOptions['image'] = {}, ): import('satteri').MdastPluginDefinition { + const domains = image?.domains ?? []; + const remotePatterns = image?.remotePatterns ?? []; return satteri!.defineMdastPlugin({ name: 'collect-images', image(node) { @@ -35,7 +39,9 @@ export function createCollectImagesPlugin( if (!url) return; if (URL.canParse(url)) { - remoteImagePaths.add(url); + if (isRemoteAllowed(url, { domains, remotePatterns })) { + remoteImagePaths.add(url); + } } else if (!url.startsWith('/')) { localImagePaths.add(url); } @@ -222,7 +228,7 @@ export async function createSatteriMarkdownProcessor( const frontmatter = renderOpts?.frontmatter ?? {}; const allMdastPlugins: import('satteri').MdastPluginDefinition[] = [ - createCollectImagesPlugin(localImagePaths, remoteImagePaths), + createCollectImagesPlugin(localImagePaths, remoteImagePaths, opts?.image), ...userMdastPlugins, ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20898e4112f3..b151e3ffde6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -773,9 +773,6 @@ importers: sass: specifier: ^1.98.0 version: 1.98.0 - satteri: - specifier: ^0.2.7 - version: 0.2.8 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -6970,9 +6967,6 @@ importers: specifier: ^6.0.3 version: 6.0.3 devDependencies: - '@napi-rs/wasm-runtime': - specifier: ^1.1.2 - version: 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@types/estree': specifier: ^1.0.8 version: 1.0.8 @@ -7006,6 +7000,9 @@ importers: github-slugger: specifier: ^2.0.0 version: 2.0.0 + satteri: + specifier: ^0.2.7 + version: 0.2.8 shiki: specifier: ^4.0.0 version: 4.0.2 @@ -7013,18 +7010,12 @@ importers: '@types/hast': specifier: ^3.0.4 version: 3.0.4 - '@types/mdast': - specifier: ^4.0.4 - version: 4.0.4 astro-scripts: specifier: workspace:* version: link:../../../scripts retext-smartypants: specifier: ^6.2.0 version: 6.2.0 - satteri: - specifier: ^0.2.7 - version: 0.2.8 packages/telemetry: dependencies: @@ -18563,6 +18554,7 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 + optional: true '@emnapi/core@1.8.1': dependencies: @@ -18579,6 +18571,7 @@ snapshots: '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 + optional: true '@emnapi/runtime@1.8.1': dependencies: @@ -18603,6 +18596,7 @@ snapshots: '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 + optional: true '@envelop/instrumentation@1.0.0': dependencies: @@ -19481,6 +19475,7 @@ snapshots: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.1 + optional: true '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: @@ -20922,6 +20917,7 @@ snapshots: '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 + optional: true '@types/alpinejs@3.13.11': {} From 5e54e368dfb72de1e19c6f49e373730207cd04f5 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 12 May 2026 03:36:36 +0200 Subject: [PATCH 05/26] fix: lint --- packages/astro/src/markdown/frontmatter.ts | 4 ++-- packages/astro/src/vite-plugin-markdown/index.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/astro/src/markdown/frontmatter.ts b/packages/astro/src/markdown/frontmatter.ts index fad0c4e03434..a71f8483a5db 100644 --- a/packages/astro/src/markdown/frontmatter.ts +++ b/packages/astro/src/markdown/frontmatter.ts @@ -13,8 +13,8 @@ export function isFrontmatterValid(frontmatter: Record) { // Capture frontmatter wrapped with `---` or `+++` near the top of the file — // either at the very start (allowing BOM) or after leading whitespace, with the // fence still starting on a new line. -const frontmatterRE = /(?:^?|^\s*\n)(?:---|\+\+\+)([\s\S]*?\n)(?:---|\+\+\+)/; -const frontmatterTypeRE = /(?:^?|^\s*\n)(---|\+\+\+)/; +const frontmatterRE = /(?:^\uFEFF?|^\s*\n)(?:---|\+\+\+)([\s\S]*?\n)(?:---|\+\+\+)/; +const frontmatterTypeRE = /(?:^\uFEFF?|^\s*\n)(---|\+\+\+)/; export function extractFrontmatter(code: string): string | undefined { return frontmatterRE.exec(code)?.[1]; diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index be07aa288a83..5bfe2b322d93 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -78,13 +78,13 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug // Lazily initialize the Markdown processor if (!processor) { - const { markdown, image } = settings.config; - processor = markdown.processor.createRenderer({ + const { markdown: md, image } = settings.config; + processor = md.processor.createRenderer({ image, - syntaxHighlight: markdown.syntaxHighlight, - shikiConfig: markdown.shikiConfig, - gfm: markdown.gfm, - smartypants: markdown.smartypants, + syntaxHighlight: md.syntaxHighlight, + shikiConfig: md.shikiConfig, + gfm: md.gfm, + smartypants: md.smartypants, }); } From d9a0f666707d2233e253fe9c2b999351598f5cd6 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 12 May 2026 04:16:45 +0200 Subject: [PATCH 06/26] fix: tests --- packages/astro/src/content/runtime.ts | 4 +- .../astro/src/vite-plugin-markdown/images.ts | 6 +- packages/astro/test/astro-markdown.test.ts | 2 + packages/integrations/mdx/package.json | 2 +- packages/markdown/satteri/SATTERI_NOTES.md | 26 +++++++ packages/markdown/satteri/package.json | 2 +- packages/markdown/satteri/src/index.ts | 1 + .../markdown/satteri/src/satteri-processor.ts | 51 ++++++++++++- pnpm-lock.yaml | 75 +++++++------------ 9 files changed, 115 insertions(+), 54 deletions(-) create mode 100644 packages/markdown/satteri/SATTERI_NOTES.md diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index b9d9c61d045b..4bc4d554cadd 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -460,7 +460,9 @@ async function updateImageReferencesInBody(html: string, fileName: string) { // function because getImage is async. for (const [_full, imagePath] of html.matchAll(CONTENT_LAYER_IMAGE_REGEX)) { try { - const decodedImagePath = JSON.parse(imagePath.replaceAll('"', '"')); + const decodedImagePath = JSON.parse( + imagePath.replace(/&(?:#x22|quot);/g, '"').replace(/&(?:#x27|apos);/g, "'"), + ); let image: GetImageResult; if (URL.canParse(decodedImagePath.src)) { diff --git a/packages/astro/src/vite-plugin-markdown/images.ts b/packages/astro/src/vite-plugin-markdown/images.ts index c98d3c2ca04f..e494a0606ac0 100644 --- a/packages/astro/src/vite-plugin-markdown/images.ts +++ b/packages/astro/src/vite-plugin-markdown/images.ts @@ -25,7 +25,7 @@ export function getMarkdownCodeForImages( let occurrenceCounter = 0; while ((match = regex.exec(html)) !== null) { const matchKey = ${rawUrl} + '_' + occurrenceCounter; - const imageProps = JSON.parse(match[1].replace(/"/g, '"').replace(/'/g, "'")); + const imageProps = JSON.parse(match[1].replace(/&(?:#x22|quot);/g, '"').replace(/&(?:#x27|apos);/g, "'")); const { src, ...props } = imageProps; imageSources[matchKey] = await getImage({src: Astro__${entry.safeName}, ...props}); occurrenceCounter++; @@ -45,7 +45,7 @@ export function getMarkdownCodeForImages( let occurrenceCounter = 0; while ((match = regex.exec(html)) !== null) { const matchKey = ${rawUrl} + '_' + occurrenceCounter; - const props = JSON.parse(match[1].replace(/"/g, '"').replace(/'/g, "'")); + const props = JSON.parse(match[1].replace(/&(?:#x22|quot);/g, '"').replace(/&(?:#x27|apos);/g, "'")); imageSources[matchKey] = await getImage(props); occurrenceCounter++; } @@ -59,7 +59,7 @@ export function getMarkdownCodeForImages( const imageSources = await images(html); return html.replaceAll(/__ASTRO_IMAGE_="([^"]+)"/gm, (full, imagePath) => { - const decodedImagePath = JSON.parse(imagePath.replace(/"/g, '"')); + const decodedImagePath = JSON.parse(imagePath.replace(/&(?:#x22|quot);/g, '"')); // Use the 'index' property for each image occurrence const srcKey = decodedImagePath.src + '_' + decodedImagePath.index; diff --git a/packages/astro/test/astro-markdown.test.ts b/packages/astro/test/astro-markdown.test.ts index d86659511f4b..01d2043e2cd6 100644 --- a/packages/astro/test/astro-markdown.test.ts +++ b/packages/astro/test/astro-markdown.test.ts @@ -61,9 +61,11 @@ describe('Astro Markdown', () => { }); it('handles Prism', async () => { + const { unified } = await import('@astrojs/markdown-remark'); const prismFixture = await loadFixture({ root: FIXTURE_ROOT, markdown: { + processor: unified(), syntaxHighlight: 'prism', }, }); diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index ec546cd6d937..1d63d03c1e03 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -46,7 +46,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", - "satteri": "^0.2.7", + "satteri": "^0.4.0", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" diff --git a/packages/markdown/satteri/SATTERI_NOTES.md b/packages/markdown/satteri/SATTERI_NOTES.md new file mode 100644 index 000000000000..c6f78b25ce0e --- /dev/null +++ b/packages/markdown/satteri/SATTERI_NOTES.md @@ -0,0 +1,26 @@ +# Satteri notes + +Observations about `satteri@0.4.0` (the upstream Rust crate / npm package). + +## Bugs / surprises + +### HTML serializer encodes `"` in attribute values as `"` instead of `"` + +When a hast plugin sets an attribute value containing a `"`, the serializer escapes it as the named entity `"`. The rest of the unified-stack (rehype-stringify, etc.) emits the hex entity `"`. Downstream tooling that does `attr.replace(/"/g, '"')` before parsing won't round-trip. + +Repro: + +```js +import * as s from 'satteri'; +const html = await s.markdownToHtml('test', { + hastPlugins: [s.defineHastPlugin({ + name: 'mark', + element: { filter: ['p'], visit(node, ctx) { + ctx.setProperty(node, 'data-test', '{"a":"b"}'); + } }, + })], +}); +// →

test

+``` + +Ideally the serializer would emit `"` (matches convention) or expose an option. diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index 288b1cd1da15..4347cce80e49 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -35,7 +35,7 @@ "dependencies": { "@astrojs/internal-helpers": "workspace:*", "github-slugger": "^2.0.0", - "satteri": "^0.2.7", + "satteri": "^0.4.0", "shiki": "^4.0.0" }, "devDependencies": { diff --git a/packages/markdown/satteri/src/index.ts b/packages/markdown/satteri/src/index.ts index a7f11c14aadc..ffd5752c2044 100644 --- a/packages/markdown/satteri/src/index.ts +++ b/packages/markdown/satteri/src/index.ts @@ -2,6 +2,7 @@ export { defaultExcludeLanguages } from './highlight.js'; export { createCollectImagesPlugin as satteriCollectImagesPlugin, createHeadingIdsPlugin as satteriHeadingIdsPlugin, + createImageMarkerPlugin as satteriImageMarkerPlugin, createShikiPlugin as satteriShikiPlugin, collectHastText as satteriCollectHastText, makeFragmentNode as satteriMakeFragmentNode, diff --git a/packages/markdown/satteri/src/satteri-processor.ts b/packages/markdown/satteri/src/satteri-processor.ts index 964e07fa95ca..d9ca77f70d52 100644 --- a/packages/markdown/satteri/src/satteri-processor.ts +++ b/packages/markdown/satteri/src/satteri-processor.ts @@ -119,10 +119,13 @@ export function createHeadingIdsPlugin( frontmatter && rawText.includes('frontmatter') ? collectHastText(node, frontmatter) : rawText; - const slug = slugger.slug(text); + const existingId = node.properties?.id; + const slug = typeof existingId === 'string' ? existingId : slugger.slug(text); const depth = Number.parseInt(node.tagName[1], 10); headings.push({ depth, slug, text }); - ctx.setProperty(node, 'id', slug); + if (typeof existingId !== 'string') { + ctx.setProperty(node, 'id', slug); + } }, }, }); @@ -132,6 +135,49 @@ function makeRawNode(html: string): import('satteri').HastNode { return { type: 'raw', value: html } as unknown as import('satteri').HastNode; } +const HAST_PRESERVED_PROPERTIES = new Set(['className', 'htmlFor']); + +/** Tags with `__ASTRO_IMAGE_` for astro's vite-plugin-markdown to replace with the processed image's attributes. */ +export function createImageMarkerPlugin( + localImagePaths: Set, + remoteImagePaths: Set, +): import('satteri').HastPluginDefinition { + const indexBySrc = new Map(); + return satteri!.defineHastPlugin({ + name: 'image-marker', + element: { + filter: ['img'], + visit(node, ctx) { + const props = (node.properties ?? {}) as Record; + const rawSrc = typeof props.src === 'string' ? props.src : undefined; + if (!rawSrc) return; + const src = decodeURI(rawSrc); + + const isLocal = localImagePaths.has(src); + const isRemote = !isLocal && remoteImagePaths.has(src); + if (!isLocal && !isRemote) return; + + const { src: _src, ...rest } = props; + const index = indexBySrc.get(rawSrc) ?? 0; + indexBySrc.set(rawSrc, index + 1); + + const imageProperties: Record = { ...rest, src, index }; + if (isRemote && !('width' in props) && !('height' in props)) { + imageProperties.inferSize = true; + } + + ctx.setProperty(node, '__ASTRO_IMAGE_', JSON.stringify(imageProperties)); + for (const key of Object.keys(rest)) { + if (!HAST_PRESERVED_PROPERTIES.has(key)) { + ctx.setProperty(node, key, null); + } + } + ctx.setProperty(node, 'src', null); + }, + }, + }); +} + export function createShikiPlugin( highlight: HighlightFn, excludeLangs: string[] | undefined, @@ -238,6 +284,7 @@ export async function createSatteriMarkdownProcessor( } hastPlugins.push(createHeadingIdsPlugin(headings, frontmatter)); hastPlugins.push(...userHastPlugins); + hastPlugins.push(createImageMarkerPlugin(localImagePaths, remoteImagePaths)); const html = await s.markdownToHtml(content, { mdastPlugins: allMdastPlugins, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b151e3ffde6c..38a2cf3c76bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5355,8 +5355,8 @@ importers: specifier: ^3.0.2 version: 3.0.2 satteri: - specifier: ^0.2.7 - version: 0.2.8 + specifier: ^0.4.0 + version: 0.4.0 source-map: specifier: ^0.7.6 version: 0.7.6 @@ -7001,8 +7001,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 satteri: - specifier: ^0.2.7 - version: 0.2.8 + specifier: ^0.4.0 + version: 0.4.0 shiki: specifier: ^4.0.0 version: 4.0.2 @@ -7668,29 +7668,29 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - '@bruits/satteri-darwin-arm64@0.2.8': - resolution: {integrity: sha512-sslTdEUNKvE6+wLQOrzsIZt7QqSdYLCLVLvKWWXXru9wM6g8nLxCmKkjHhWRdFjto+pDRNEG/nP8pyYtn5hL8g==} + '@bruits/satteri-darwin-arm64@0.4.0': + resolution: {integrity: sha512-gPFVvfSuHPzDspAr3EYK6IIcz1YPNXj4chZKqY6AgWi0Wlhlei+z60WKBsESfRE1KHZN+FINJgVJ0nZZWB4O7w==} cpu: [arm64] os: [darwin] - '@bruits/satteri-darwin-x64@0.2.8': - resolution: {integrity: sha512-B7LOdXobwE7PKGNMsMbnk1Ihfdo5Hamw31XWdg8janBwhh+8sleWusmFXa6gT5PUKlrsRDf6c+kssD720DmCfw==} + '@bruits/satteri-darwin-x64@0.4.0': + resolution: {integrity: sha512-HQZmo5cy5MAgvamsykrnEAn02jKdT8vGWL7xUJdt2CeHW1oElLRTGd6JRHlPEwZaXM2LnHuMJfaEd1MUjopcJA==} cpu: [x64] os: [darwin] - '@bruits/satteri-linux-x64-gnu@0.2.8': - resolution: {integrity: sha512-q4J+5R66AP+Vftr0RuPMpyuaKXaRKpdgV+d+QauTkDEuaRAX2VgApg+jSXqIIAtJle5+JjpYR9Z/tfZKlqvgNg==} + '@bruits/satteri-linux-x64-gnu@0.4.0': + resolution: {integrity: sha512-KsE5v5wNTq0BFWTD6mrf5hTW9x8GjeohaA7I+I4ifVGeUruJECDaZn9BJwkZzrDtEzawL7NT1HV+k01Lu/wzaA==} cpu: [x64] os: [linux] libc: [glibc] - '@bruits/satteri-wasm32-wasi@0.2.8': - resolution: {integrity: sha512-qtZ4Jb/4K20c+dAYwWbwYQyxSCIKokJ/OTppoDDTBj5e50Ge8bkQRrS8d45IY0Ct0UBqIpRoD8i6WQI3Y6Heog==} + '@bruits/satteri-wasm32-wasi@0.4.0': + resolution: {integrity: sha512-i62LosNUPYcfZ8+RnZzquwO5yf+/KbSKR5SnitINxPWEh+dxtr1jHPnSgoAQo+dWNroK7+IvwZeM3UoK1vV/pA==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@bruits/satteri-win32-x64-msvc@0.2.8': - resolution: {integrity: sha512-BFQ0tIuqjM2mn1AGCdjTEqtR/d87+9q0aD6/LunpW6QsIwxYIDizOpAE9dHGpZXqn+4Q4uFS1bnqLu6y/dMaXQ==} + '@bruits/satteri-win32-x64-msvc@0.4.0': + resolution: {integrity: sha512-VjDTS/SRNKpD5DFMhwNUHl5aXU1HtTNLCitz6PXtEAo0dhVrReafj8CGvCEGY2kuj5gCqjmkEOpkNyRlDalnRQ==} cpu: [x64] os: [win32] @@ -8209,9 +8209,6 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/core@1.8.1': - resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -8224,9 +8221,6 @@ packages: '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@emnapi/wasi-threads@1.2.0': resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} @@ -15425,8 +15419,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - satteri@0.2.8: - resolution: {integrity: sha512-hLSMgno8jTm1iyijPJQE+N7HS+keaMCHHOcTgwHE0ue1i9cbdyb3Z/9cq0DF8JH0ib5P6DZXEQGIkLhL8WdSbA==} + satteri@0.4.0: + resolution: {integrity: sha512-9PG+ej0PdEUqhWsREdWCn/oGtZT0J13G53nmiCUuxPmBKaH2nFnwf15UM4mTs1KJClGsxKqCD2rMEg0/hWu6qg==} sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} @@ -17910,23 +17904,23 @@ snapshots: '@borewit/text-codec@0.2.2': {} - '@bruits/satteri-darwin-arm64@0.2.8': + '@bruits/satteri-darwin-arm64@0.4.0': optional: true - '@bruits/satteri-darwin-x64@0.2.8': + '@bruits/satteri-darwin-x64@0.4.0': optional: true - '@bruits/satteri-linux-x64-gnu@0.2.8': + '@bruits/satteri-linux-x64-gnu@0.4.0': optional: true - '@bruits/satteri-wasm32-wasi@0.2.8': + '@bruits/satteri-wasm32-wasi@0.4.0': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optional: true - '@bruits/satteri-win32-x64-msvc@0.2.8': + '@bruits/satteri-win32-x64-msvc@0.4.0': optional: true '@capsizecss/unpack@4.0.0': @@ -18556,12 +18550,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.8.1': - dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 - optional: true - '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -18583,11 +18571,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.0': dependencies: tslib: 2.8.1 @@ -19465,8 +19448,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.1 optional: true @@ -26709,17 +26692,17 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 - satteri@0.2.8: + satteri@0.4.0: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 optionalDependencies: - '@bruits/satteri-darwin-arm64': 0.2.8 - '@bruits/satteri-darwin-x64': 0.2.8 - '@bruits/satteri-linux-x64-gnu': 0.2.8 - '@bruits/satteri-wasm32-wasi': 0.2.8 - '@bruits/satteri-win32-x64-msvc': 0.2.8 + '@bruits/satteri-darwin-arm64': 0.4.0 + '@bruits/satteri-darwin-x64': 0.4.0 + '@bruits/satteri-linux-x64-gnu': 0.4.0 + '@bruits/satteri-wasm32-wasi': 0.4.0 + '@bruits/satteri-win32-x64-msvc': 0.4.0 sax@1.6.0: {} From 8a4a7ef2fa9157ad7ab77c1ed3262bf303040d15 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 12 May 2026 04:17:02 +0200 Subject: [PATCH 07/26] fix: don't commit notes --- packages/markdown/satteri/SATTERI_NOTES.md | 26 ---------------------- 1 file changed, 26 deletions(-) delete mode 100644 packages/markdown/satteri/SATTERI_NOTES.md diff --git a/packages/markdown/satteri/SATTERI_NOTES.md b/packages/markdown/satteri/SATTERI_NOTES.md deleted file mode 100644 index c6f78b25ce0e..000000000000 --- a/packages/markdown/satteri/SATTERI_NOTES.md +++ /dev/null @@ -1,26 +0,0 @@ -# Satteri notes - -Observations about `satteri@0.4.0` (the upstream Rust crate / npm package). - -## Bugs / surprises - -### HTML serializer encodes `"` in attribute values as `"` instead of `"` - -When a hast plugin sets an attribute value containing a `"`, the serializer escapes it as the named entity `"`. The rest of the unified-stack (rehype-stringify, etc.) emits the hex entity `"`. Downstream tooling that does `attr.replace(/"/g, '"')` before parsing won't round-trip. - -Repro: - -```js -import * as s from 'satteri'; -const html = await s.markdownToHtml('test', { - hastPlugins: [s.defineHastPlugin({ - name: 'mark', - element: { filter: ['p'], visit(node, ctx) { - ctx.setProperty(node, 'data-test', '{"a":"b"}'); - } }, - })], -}); -// →

test

-``` - -Ideally the serializer would emit `"` (matches convention) or expose an option. From 838a475be6e7b2ec7e8b7aff8c0e35a0f41cc533 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 12 May 2026 04:52:52 +0200 Subject: [PATCH 08/26] fix: test assert --- packages/astro/test/markdown.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/astro/test/markdown.test.ts b/packages/astro/test/markdown.test.ts index bceb54a0d72c..7a00df398e40 100644 --- a/packages/astro/test/markdown.test.ts +++ b/packages/astro/test/markdown.test.ts @@ -69,7 +69,10 @@ describe('Markdown tests', () => { it('Does not unescape entities', async () => { const html = await fixture.readFile('/entities/index.html'); - assert.match(html, /<i>This should NOT be italic<\/i>/); + assert.match( + html, + /(?:<|<)i(?:>|>)This should NOT be italic(?:<|<)\/i(?:>|>)/, + ); }); it('Resolves the image paths correctly', async () => { From ac179d197815fbbc53f3a06e0177680ca2012766 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Wed, 13 May 2026 03:14:13 +0200 Subject: [PATCH 09/26] refactor: move things where they make sense --- packages/integrations/mdx/package.json | 10 +- .../integrations/mdx/src/image-constants.ts | 10 + packages/integrations/mdx/src/index.ts | 27 +- .../integrations/mdx/src/satteri-plugins.ts | 689 ------------------ .../integrations/mdx/src/satteri/charset.ts | 47 ++ .../mdx/src/satteri/hast-astro-metadata.ts | 245 +++++++ .../src/satteri/hast-images-to-component.ts | 81 ++ .../integrations/mdx/src/satteri/index.ts | 215 ++++++ .../integrations/mdx/src/satteri/jsx-utils.ts | 47 ++ .../mdx/src/{plugins.ts => unified/index.ts} | 2 +- .../rehype-apply-frontmatter-export.ts | 2 +- .../{ => unified}/rehype-collect-headings.ts | 2 +- .../rehype-images-to-component.ts | 26 +- .../src/{ => unified}/rehype-meta-string.ts | 0 .../{ => unified}/rehype-optimize-static.ts | 0 packages/integrations/mdx/src/utils.ts | 2 +- .../mdx/src/vite-plugin-mdx-postprocess.ts | 2 +- .../integrations/mdx/src/vite-plugin-mdx.ts | 14 +- .../astro.config.mjs | 12 +- .../mdx-frontmatter-injection/package.json | 1 + .../mdx-astro-markdown-remarkRehype.test.ts | 13 +- .../mdx/test/mdx-get-headings.test.ts | 9 +- .../integrations/mdx/test/mdx-plugins.test.ts | 5 +- .../mdx/test/mdx-syntax-highlighting.test.ts | 5 + .../test/units/rehype-optimize-static.test.ts | 2 +- .../mdx/test/units/rehype-plugins.test.ts | 4 +- packages/markdown/satteri/package.json | 2 +- .../markdown/satteri/src/satteri-processor.ts | 18 +- pnpm-lock.yaml | 63 +- 29 files changed, 771 insertions(+), 784 deletions(-) create mode 100644 packages/integrations/mdx/src/image-constants.ts delete mode 100644 packages/integrations/mdx/src/satteri-plugins.ts create mode 100644 packages/integrations/mdx/src/satteri/charset.ts create mode 100644 packages/integrations/mdx/src/satteri/hast-astro-metadata.ts create mode 100644 packages/integrations/mdx/src/satteri/hast-images-to-component.ts create mode 100644 packages/integrations/mdx/src/satteri/index.ts create mode 100644 packages/integrations/mdx/src/satteri/jsx-utils.ts rename packages/integrations/mdx/src/{plugins.ts => unified/index.ts} (98%) rename packages/integrations/mdx/src/{ => unified}/rehype-apply-frontmatter-export.ts (98%) rename packages/integrations/mdx/src/{ => unified}/rehype-collect-headings.ts (87%) rename packages/integrations/mdx/src/{ => unified}/rehype-images-to-component.ts (86%) rename packages/integrations/mdx/src/{ => unified}/rehype-meta-string.ts (100%) rename packages/integrations/mdx/src/{ => unified}/rehype-optimize-static.ts (100%) diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 1d63d03c1e03..5a83f22c921d 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -34,7 +34,6 @@ "test": "astro-scripts test --timeout 70000 \"test/**/*.test.ts\"" }, "dependencies": { - "@astrojs/markdown-remark": "workspace:*", "@astrojs/markdown-satteri": "workspace:*", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", @@ -46,15 +45,22 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", - "satteri": "^0.4.0", + "satteri": "^0.5.1", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { + "@astrojs/markdown-remark": "workspace:*", "astro": "^7.0.0-alpha.0" }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + }, "devDependencies": { + "@astrojs/markdown-remark": "workspace:*", "@shikijs/rehype": "^4.0.0", "@shikijs/twoslash": "^4.0.0", "@types/estree": "^1.0.8", diff --git a/packages/integrations/mdx/src/image-constants.ts b/packages/integrations/mdx/src/image-constants.ts new file mode 100644 index 000000000000..643015ac683d --- /dev/null +++ b/packages/integrations/mdx/src/image-constants.ts @@ -0,0 +1,10 @@ +// Tag name we rewrite markdown-derived `` elements to. Lowercase + hyphenated +// so MDX routes the tag through the `_components` map. +export const ASTRO_IMAGE_ELEMENT = 'astro-image'; +// Module-level identifier bound to Astro's `Image` component (from `astro:assets`). +// Imported by every compiled MDX file that contains a rewritten image; used as the +// fallback when no `components.img` is provided. +export const ASTRO_IMAGE_IMPORT = '__AstroImage__'; +// Boolean export set on MDX modules that contain rewritten images. Read by +// `vite-plugin-mdx-postprocess` to decide whether to wire up the image component. +export const USES_ASTRO_IMAGE_FLAG = '__usesAstroImage'; diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index 7f599d7d5a1b..5f4ed08ecf8f 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; -import { isUnifiedProcessor, markdownConfigDefaults } from '@astrojs/markdown-remark'; -import { isSatteriProcessor } from '@astrojs/markdown-satteri'; +import type { markdownConfigDefaults, UnifiedProcessorDescriptor } from '@astrojs/markdown-remark'; +import type { SatteriProcessorDescriptor } from '@astrojs/markdown-satteri'; import type { AstroIntegration, AstroIntegrationLogger, @@ -12,11 +12,18 @@ import type { import type { MarkdownProcessorEntry } from 'astro/markdown'; import type { Options as RemarkRehypeOptions } from 'remark-rehype'; import type { PluggableList } from 'unified'; -import type { MdastPluginDefinition, HastPluginDefinition } from './satteri-plugins.js'; +import type { MdastPluginDefinition, HastPluginDefinition } from './satteri/index.js'; import { ignoreStringPlugins, safeParseFrontmatter } from './utils.js'; import { type VitePluginMdxOptions, vitePluginMdx } from './vite-plugin-mdx.js'; import { vitePluginMdxPostprocess } from './vite-plugin-mdx-postprocess.js'; +// Inlined name-checks to avoid eagerly importing the unified/satteri runtime +// modules (and their shiki + WASM bindings) when they're not the active processor. +const isSatteriProcessor = (p: { name: string }): p is SatteriProcessorDescriptor => + p.name === 'satteri'; +const isUnifiedProcessor = (p: { name: string }): p is UnifiedProcessorDescriptor => + p.name === 'unified'; + export type MdxOptions = Omit< typeof markdownConfigDefaults, 'remarkPlugins' | 'rehypePlugins' @@ -97,18 +104,22 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI }, }); }, - 'astro:config:done': ({ config, logger }) => { + 'astro:config:done': async ({ config, logger }) => { // 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; + // `markdownConfigDefaults` is only needed when the user opts out of extending + // the active markdown config. Lazy-load to avoid eagerly pulling in the unified + // pipeline (and shiki + remark/rehype trees) at integration init time. + const markdownConfig = extendMarkdownConfig + ? config.markdown + : (await import('@astrojs/markdown-remark')).markdownConfigDefaults; + const resolvedMdxOptions = applyDefaultOptions({ options: partialMdxOptions, - defaults: markdownConfigToMdxOptions( - extendMarkdownConfig ? config.markdown : markdownConfigDefaults, - logger, - ), + defaults: markdownConfigToMdxOptions(markdownConfig, logger), }); const descriptor = partialMdxOptions.processor ?? config.markdown.processor; diff --git a/packages/integrations/mdx/src/satteri-plugins.ts b/packages/integrations/mdx/src/satteri-plugins.ts deleted file mode 100644 index 021dddb587a3..000000000000 --- a/packages/integrations/mdx/src/satteri-plugins.ts +++ /dev/null @@ -1,689 +0,0 @@ -import * as path from 'node:path'; -import { createShikiHighlighter } from '@astrojs/markdown-satteri'; -import { - defineMdastPlugin, - defineHastPlugin, - mdxToJs, - type HastNode, - type HastVisitorContext, - type MdastPluginDefinition, - type HastPluginDefinition, - type MdxJsxAttributeNode, -} from 'satteri'; -import Slugger from 'github-slugger'; -import type { MdxOptions } from './index.js'; -import { ASTRO_IMAGE_IMPORT, USES_ASTRO_IMAGE_FLAG } from './rehype-images-to-component.js'; - -export type { MdastPluginDefinition, HastPluginDefinition }; - -declare module 'hast' { - interface ElementData { - lang?: string | null; - } -} - -export interface MarkdownHeading { - depth: number; - slug: string; - text: string; -} - -type HighlightFn = (code: string, lang: string, meta?: string) => Promise; - -function resolveFrontmatterExpression( - expr: string, - frontmatter: Record, -): string | undefined { - if (!expr.startsWith('frontmatter.') && !expr.startsWith('frontmatter[')) return undefined; - - const pathStr = expr.slice('frontmatter'.length); - const parts: string[] = []; - const pathRegex = /\.(\w+)|\[(\d+)\]|\["([^"]+)"\]|\['([^']+)'\]/g; - let match; - while ((match = pathRegex.exec(pathStr)) !== null) { - parts.push(match[1] ?? match[2] ?? match[3] ?? match[4]); - } - - if (parts.length === 0) return undefined; - - let value: any = frontmatter; - for (const key of parts) { - if (value == null || typeof value !== 'object') return undefined; - value = value[key]; - } - - return typeof value === 'string' ? value : undefined; -} - -function collectHastText(node: HastNode, frontmatter: Record | undefined): string { - let text = ''; - if (node.type === 'mdxFlowExpression' || node.type === 'mdxTextExpression') { - if (node.value != null && frontmatter) { - const resolved = resolveFrontmatterExpression(node.value.trim(), frontmatter); - text += resolved ?? node.value; - } - } else if ('value' in node && node.value != null) { - text += node.value; - } - if ('children' in node && node.children) { - for (const child of node.children) { - text += collectHastText(child as HastNode, frontmatter); - } - } - return text; -} - -const defaultExcludeLanguages = ['math']; - -function makeFragmentNode(html: string): HastNode { - return { - type: 'mdxJsxFlowElement', - name: 'Fragment', - attributes: [{ type: 'mdxJsxAttribute', name: 'set:html', value: html }], - children: [], - }; -} - -interface AstroComponentMetadata { - exportName: string; - localName: string; - specifier: string; - resolvedPath: string; -} - -export interface AstroMetadata { - hydratedComponents: AstroComponentMetadata[]; - clientOnlyComponents: AstroComponentMetadata[]; - serverComponents: AstroComponentMetadata[]; - scripts: never[]; - propagation: 'none'; - containsHead: false; - pageOptions: Record; -} - -type ImportSpecifier = { local: string; imported: string }; -type MatchedImport = { name: string; path: string }; - -const nonAlphaRe = /[^a-zA-Z]/; - -function isComponent(tagName: string): boolean { - return ( - (tagName[0] && tagName[0].toLowerCase() !== tagName[0]) || - tagName.includes('.') || - nonAlphaRe.test(tagName[0]) - ); -} - -// ESTree shapes we care about from the parseExpression() output on mdxjsEsm nodes. -interface EstreeIdentifier { - type: 'Identifier'; - name: string; -} - -interface EstreeStringLiteral { - type: 'Literal'; - value: string; -} - -type EstreeModuleExportName = EstreeIdentifier | EstreeStringLiteral; - -interface EstreeImportDeclaration { - type: 'ImportDeclaration'; - source: EstreeStringLiteral; - specifiers: Array< - EstreeImportSpecifier | EstreeImportDefaultSpecifier | EstreeImportNamespaceSpecifier - >; -} - -interface EstreeImportSpecifier { - type: 'ImportSpecifier'; - local: EstreeIdentifier; - imported: EstreeModuleExportName; -} - -interface EstreeImportDefaultSpecifier { - type: 'ImportDefaultSpecifier'; - local: EstreeIdentifier; -} - -interface EstreeImportNamespaceSpecifier { - type: 'ImportNamespaceSpecifier'; - local: EstreeIdentifier; -} - -interface EstreeProgram { - body: Array<{ type: string } & Record>; -} - -function exportNameToString(name: EstreeModuleExportName): string { - return name.type === 'Identifier' ? name.name : name.value; -} - -function collectImportsFromEsm( - program: EstreeProgram, - imports: Map>, -): void { - for (const stmt of program.body) { - if (stmt.type !== 'ImportDeclaration') continue; - const decl = stmt as EstreeImportDeclaration; - const source = decl.source.value; - - let specSet = imports.get(source); - if (!specSet) { - specSet = new Set(); - imports.set(source, specSet); - } - - for (const spec of decl.specifiers) { - switch (spec.type) { - case 'ImportDefaultSpecifier': - specSet.add({ local: spec.local.name, imported: 'default' }); - break; - case 'ImportNamespaceSpecifier': - specSet.add({ local: spec.local.name, imported: '*' }); - break; - case 'ImportSpecifier': - specSet.add({ - local: spec.local.name, - imported: exportNameToString(spec.imported), - }); - break; - } - } - } -} - -function findMatchingImport( - tagName: string, - imports: Map>, -): MatchedImport | undefined { - const tagSpecifier = tagName.split('.')[0]; - for (const [source, specs] of imports) { - for (const { imported, local } of specs) { - if (local === tagSpecifier) { - if (tagSpecifier !== tagName) { - switch (imported) { - case '*': { - const accessPath = tagName.slice(tagSpecifier.length + 1); - return { name: accessPath, path: source }; - } - case 'default': { - const accessPath = tagName.slice(tagSpecifier.length + 1); - return { name: `default.${accessPath}`, path: source }; - } - default: { - return { name: tagName, path: source }; - } - } - } - return { name: imported, path: source }; - } - } - } -} - -function resolveImportPath(specifier: string, importer: string): string { - if (specifier.startsWith('.')) { - const absoluteSpecifier = path.resolve(path.dirname(importer), specifier); - return absoluteSpecifier.split(path.sep).join('/'); - } - return specifier; -} - -type MdxJsxFlowElement = Extract; -type MdxJsxTextElement = Extract; -type MdxJsxHastNode = MdxJsxFlowElement | MdxJsxTextElement; - -function hasDirective(node: MdxJsxHastNode, prefix: string): boolean { - for (const a of node.attributes) { - if (a.type === 'mdxJsxAttribute' && a.name.startsWith(prefix)) return true; - } - return false; -} - -function findAttrValue(node: MdxJsxHastNode, name: string): string | null { - for (const a of node.attributes) { - if (a.type === 'mdxJsxAttribute' && a.name === name) { - return typeof a.value === 'string' ? a.value : null; - } - } - return null; -} - -function processJsxNode( - node: MdxJsxHastNode, - ctx: HastVisitorContext, - metadata: AstroMetadata, - imports: Map>, - filePath: string, -) { - const tagName = node.name; - if (!tagName || !isComponent(tagName)) return; - - const hasClient = hasDirective(node, 'client:'); - const hasServerDefer = !hasClient && hasDirective(node, 'server:defer'); - if (!hasClient && !hasServerDefer) return; - - const matchedImport = findMatchingImport(tagName, imports); - if (!matchedImport) { - throw new Error( - `Expected a matching import for component \`${tagName}\`. Did you forget to import it?`, - ); - } - - if (matchedImport.path.endsWith('.astro') && hasClient) { - let clientAttr = 'client:*'; - for (const a of node.attributes) { - if (a.type === 'mdxJsxAttribute' && a.name.startsWith('client:')) { - clientAttr = a.name; - break; - } - } - console.warn( - `You are attempting to render <${tagName} ${clientAttr} />, but ${tagName} 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 = resolveImportPath(matchedImport.path, filePath); - const exportName = - matchedImport.name === '*' ? tagName.split('.').slice(1).join('.') : matchedImport.name; - - if (hasClient && findAttrValue(node, 'client:only') !== null) { - metadata.clientOnlyComponents.push({ - exportName: matchedImport.name, - localName: '', - specifier: tagName, - resolvedPath, - }); - ctx.setProperty(node, 'client:display-name', tagName); - ctx.setProperty(node, 'client:component-path', resolvedPath); - ctx.setProperty(node, 'client:component-export', exportName); - ctx.setProperty(node, 'client:component-hydration', ''); - } else if (hasClient) { - metadata.hydratedComponents.push({ - exportName: '*', - localName: '', - specifier: tagName, - resolvedPath, - }); - ctx.setProperty(node, 'client:component-path', resolvedPath); - ctx.setProperty(node, 'client:component-export', exportName); - ctx.setProperty(node, 'client:component-hydration', ''); - } else if (hasServerDefer) { - metadata.serverComponents.push({ - exportName: matchedImport.name, - localName: tagName, - specifier: matchedImport.path, - resolvedPath, - }); - ctx.setProperty(node, 'server:component-directive', 'server:defer'); - ctx.setProperty(node, 'server:component-path', resolvedPath); - ctx.setProperty(node, 'server:component-export', exportName); - } -} - - -function createCollectImagesPlugin( - localImagePaths: Set, - remoteImagePaths: Set, -): MdastPluginDefinition { - return defineMdastPlugin({ - name: 'collect-images', - image(node) { - const url = node.url ? decodeURI(node.url) : undefined; - if (!url) return; - - if (URL.canParse(url)) { - remoteImagePaths.add(url); - } else if (!url.startsWith('/')) { - localImagePaths.add(url); - } - }, - }); -} - -interface ImageImportInfo { - importedImages: Map; - hasImages: boolean; -} - -function makeJsxAttr(name: string, value: string): MdxJsxAttributeNode { - return { type: 'mdxJsxAttribute', name, value }; -} - -function makeJsxExprAttr(name: string, expression: string): MdxJsxAttributeNode { - return { - type: 'mdxJsxAttribute', - name, - value: { type: 'mdxJsxAttributeValueExpression', value: expression }, - }; -} - -function createImageToComponentPlugin( - localImagePaths: Set, - remoteImagePaths: Set, - imageImportInfo: ImageImportInfo, -): HastPluginDefinition { - return defineHastPlugin({ - name: 'image-to-component', - element: { - filter: ['img'], - visit(node) { - const src = node.properties?.src; - if (typeof src !== 'string') return; - - const decodedSrc = decodeURI(src); - const isLocal = localImagePaths.has(decodedSrc); - const isRemote = remoteImagePaths.has(decodedSrc); - if (!isLocal && !isRemote) return; - - const attrs: MdxJsxAttributeNode[] = []; - - if (node.properties) { - for (const [key, value] of Object.entries(node.properties)) { - if (key === 'src') continue; - if (value == null || value === false) continue; - - if (key === 'widths' || key === 'densities') { - attrs.push( - makeJsxExprAttr(key, JSON.stringify(String(value).split(' '))), - ); - } else { - const attrName = - key === 'className' ? 'class' : key === 'htmlFor' ? 'for' : key; - attrs.push(makeJsxAttr(attrName, String(value))); - } - } - } - - if (isLocal) { - let importName = imageImportInfo.importedImages.get(decodedSrc); - if (!importName) { - importName = `__${imageImportInfo.importedImages.size}_${decodedSrc.replace(/\W/g, '_')}__`; - imageImportInfo.importedImages.set(decodedSrc, importName); - } - attrs.push(makeJsxExprAttr('src', importName)); - } else { - const hasWidth = node.properties && 'width' in node.properties; - const hasHeight = node.properties && 'height' in node.properties; - if (!hasWidth || !hasHeight) { - attrs.push(makeJsxExprAttr('inferSize', 'true')); - } - attrs.push(makeJsxAttr('src', decodedSrc)); - } - - imageImportInfo.hasImages = true; - - // Use __AstroImage__ as the component name. Satteri will compile this to - // _jsx(__AstroImage__, ...) which resolves to the module-scope import we - // inject after compilation. We clean up the _components destructuring and - // _missingMdxReference check in post-processing. - return { - type: 'mdxJsxTextElement', - name: ASTRO_IMAGE_IMPORT, - attributes: attrs, - children: [], - } as unknown as HastNode; - }, - }, - }); -} - -function createHeadingIdsPlugin( - headings: MarkdownHeading[], - frontmatter: Record | undefined, -): HastPluginDefinition { - const slugger = new Slugger(); - return defineHastPlugin({ - name: 'heading-ids', - element: { - filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], - visit(node, ctx) { - const rawText = ctx.textContent(node); - const text = - frontmatter && rawText.includes('frontmatter') - ? collectHastText(node, frontmatter) - : rawText; - const slug = slugger.slug(text); - const depth = Number.parseInt(node.tagName[1], 10); - headings.push({ depth, slug, text }); - ctx.setProperty(node, 'id', slug); - }, - }, - }); -} - -function createShikiPlugin( - highlight: HighlightFn, - excludeLangs: string[] | undefined, -): HastPluginDefinition { - return defineHastPlugin({ - name: 'shiki-highlight', - element: { - filter: ['pre'], - async visit(node, ctx) { - const codeChild = node.children?.find( - (c: HastNode) => c.type === 'element' && c.tagName === 'code', - ) as HastNode | undefined; - if (!codeChild || codeChild.type !== 'element') return; - - const lang = codeChild.data?.lang ?? 'plaintext'; - const meta = codeChild.data?.meta ?? undefined; - - if ( - (excludeLangs && excludeLangs.includes(lang)) || - defaultExcludeLanguages.includes(lang) - ) { - return; - } - - const code = ctx.textContent(codeChild).replace(/\n$/, ''); - const html = await highlight(code, lang, meta); - return makeFragmentNode(html); - }, - }, - }); -} - -function createAstroMetadataPlugin( - metadata: AstroMetadata, - filePath: string, -): HastPluginDefinition { - const imports = new Map>(); - return defineHastPlugin({ - name: 'astro-metadata', - mdxjsEsm(node) { - const program = node.parseExpression() as EstreeProgram | null; - if (program) { - collectImportsFromEsm(program, imports); - } - }, - - mdxJsxFlowElement: { - filter: [], - visit(node, ctx) { - processJsxNode(node, ctx, metadata, imports, filePath); - }, - }, - - mdxJsxTextElement: { - filter: [], - visit(node, ctx) { - processJsxNode(node, ctx, metadata, imports, filePath); - }, - }, - }); -} - -export interface CompileMdxResult { - code: string; - astroMetadata: AstroMetadata; -} - -export function createMdxProcessor(mdxOptions: MdxOptions) { - let highlightFn: HighlightFn | undefined; - let initPromise: Promise | undefined; - - function initShiki() { - const syntaxHighlight = mdxOptions.syntaxHighlight; - const syntaxHighlightType = - typeof syntaxHighlight === 'string' - ? syntaxHighlight - : syntaxHighlight - ? syntaxHighlight.type - : undefined; - - if (syntaxHighlightType === 'shiki') { - const shikiConfig = mdxOptions.shikiConfig ?? {}; - initPromise = createShikiHighlighter({ - langs: shikiConfig.langs, - theme: shikiConfig.theme, - themes: shikiConfig.themes, - langAlias: shikiConfig.langAlias, - }).then((hl) => { - highlightFn = (code, lang, meta) => - hl.codeToHtml(code, lang, { - meta, - wrap: shikiConfig.wrap, - defaultColor: shikiConfig.defaultColor, - transformers: shikiConfig.transformers, - }); - }); - } - } - - return { - async process( - content: string, - filePath: string, - frontmatter: Record, - ): Promise { - if (!highlightFn && !initPromise) { - initShiki(); - } - if (initPromise) await initPromise; - - const headings: MarkdownHeading[] = []; - const localImagePaths = new Set(); - const remoteImagePaths = new Set(); - - const astroMetadata: AstroMetadata = { - hydratedComponents: [], - clientOnlyComponents: [], - serverComponents: [], - scripts: [], - propagation: 'none', - containsHead: false, - pageOptions: {}, - }; - - const collectImages = createCollectImagesPlugin(localImagePaths, remoteImagePaths); - const headingIds = createHeadingIdsPlugin(headings, frontmatter); - const astroMeta = createAstroMetadataPlugin(astroMetadata, filePath); - const imageImportInfo: ImageImportInfo = { - importedImages: new Map(), - hasImages: false, - }; - const imageToComponent = createImageToComponentPlugin( - localImagePaths, - remoteImagePaths, - imageImportInfo, - ); - - const syntaxHighlight = mdxOptions.syntaxHighlight; - const excludeLangs = - typeof syntaxHighlight === 'object' ? syntaxHighlight.excludeLangs : undefined; - - const allMdastPlugins: MdastPluginDefinition[] = mdxOptions.mdastPlugins?.length - ? [collectImages, ...mdxOptions.mdastPlugins] - : [collectImages]; - - const hastPlugins: HastPluginDefinition[] = []; - if (highlightFn) { - hastPlugins.push(createShikiPlugin(highlightFn, excludeLangs)); - } - hastPlugins.push(headingIds, imageToComponent, astroMeta); - if (mdxOptions.hastPlugins?.length) { - hastPlugins.push(...mdxOptions.hastPlugins); - } - - let optimizeStatic: import('satteri').MdxCompileOptions['optimizeStatic']; - if (mdxOptions.optimize) { - const ignoreElements = - typeof mdxOptions.optimize === 'object' - ? mdxOptions.optimize.ignoreElementNames - : undefined; - - optimizeStatic = { - component: 'Fragment', - prop: 'set:html', - ...(ignoreElements && { ignoreElements }), - }; - } - - let compiled = await mdxToJs(content, { - mdastPlugins: allMdastPlugins, - hastPlugins, - optimizeStatic, - features: mdxOptions.features, - filename: filePath, - jsxImportSource: 'astro', - }); - - compiled = compiled.replace(/^export default MDXContent;\s*$/m, ''); - - if (imageImportInfo.hasImages) { - // Remove satteri's _components destructuring and missing reference check - // for __AstroImage__ — we provide it via a module-level import instead. - compiled = compiled.replace( - new RegExp( - `\\s*const\\s*\\{\\s*${ASTRO_IMAGE_IMPORT}\\s*\\}\\s*=\\s*_components;`, - ), - '', - ); - compiled = compiled.replace( - new RegExp( - `\\s*if\\s*\\(!${ASTRO_IMAGE_IMPORT}\\)\\s*_missingMdxReference\\([^)]*\\);`, - ), - '', - ); - - compiled += `\nimport { Image as ${ASTRO_IMAGE_IMPORT} } from "astro:assets";`; - for (const [src, importName] of imageImportInfo.importedImages) { - compiled += `\nimport ${importName} from ${JSON.stringify(src)};`; - } - compiled += `\nexport const ${USES_ASTRO_IMAGE_FLAG} = true;`; - } - - compiled += `\nexport const frontmatter = ${JSON.stringify(frontmatter)};`; - compiled += `\nexport function getHeadings() { return ${JSON.stringify(headings)}; }`; - - if (frontmatter.layout) { - compiled = compiled.replace(/^function MDXContent\(/m, 'function __OriginalMDXContent__('); - compiled += ` -import { jsx as __astro_layout_jsx__ } from 'astro/jsx-runtime'; -import __astro_layout_component__ from ${JSON.stringify(frontmatter.layout)}; -export default function MDXContent(props) { - const content = __OriginalMDXContent__(props); - const { layout, ...frontmatterContent } = frontmatter; - frontmatterContent.file = file; - frontmatterContent.url = url; - return __astro_layout_jsx__(__astro_layout_component__, { - file, - url, - content: frontmatterContent, - frontmatter: frontmatterContent, - headings: getHeadings(), - 'server:root': true, - children: content, - }); -}`; - } - - return { - code: compiled, - astroMetadata, - }; - }, - }; -} diff --git a/packages/integrations/mdx/src/satteri/charset.ts b/packages/integrations/mdx/src/satteri/charset.ts new file mode 100644 index 000000000000..ce3f36589bb6 --- /dev/null +++ b/packages/integrations/mdx/src/satteri/charset.ts @@ -0,0 +1,47 @@ +// TODO: This is a workaround around a missing API in Sätteri. The visitor architecture naturally does not provide +// a way to "visit" the root and prepend nodes (it'd kill performance), as such this file currently works around that +// in a clumsy way. Once Sätteri ship an actual API to prepend / append, this file could get removed. + +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const exportConstPartialTrueRe = /export\s+const\s+partial\s*=\s*true/; +const leadingComponentRe = /^\s*<\s*([A-Za-z][A-Za-z0-9]*)\b/; + +// Scans MDX source directly because satteri exposes no root-level visitor. +// Skips imports/exports/blank lines and bails when the first content line is a +// capitalized JSX element (treated as a wrapping layout). +export function shouldAddCharset(content: string, filePath: string, srcDir: URL): boolean { + const srcDirPath = fileURLToPath(srcDir).replace(/\\/g, '/'); + const pagesDir = path.posix.join(srcDirPath, 'pages'); + const normalizedFilePath = filePath.replace(/\\/g, '/'); + if (!normalizedFilePath.startsWith(pagesDir)) return false; + + const segments = normalizedFilePath.slice(pagesDir.length).split('/'); + if (segments.some((part) => part.startsWith('_'))) return false; + + if (exportConstPartialTrueRe.test(content)) return false; + + // Strip MDX JSX expression comments `{/* ... */}` so they don't trip the + // "first content" scan below — they're not the wrapping layout. + const stripped = content.replace(/\{\/\*[\s\S]*?\*\/\}/g, ''); + + for (const rawLine of stripped.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + if (line.startsWith('import ') || line.startsWith('export ')) continue; + if (line.startsWith('//') || line.startsWith('/*')) continue; + // Other JSX expressions (e.g. `{someVar}` at top level) aren't a wrapping layout. + if (line.startsWith('{')) continue; + + const match = leadingComponentRe.exec(line); + if (match) { + const tag = match[1]; + // Capitalized tag suggests a user-provided wrapping layout component + if (tag[0] >= 'A' && tag[0] <= 'Z') return false; + } + break; + } + + return true; +} diff --git a/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts b/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts new file mode 100644 index 000000000000..cd7aad442552 --- /dev/null +++ b/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts @@ -0,0 +1,245 @@ +import * as path from 'node:path'; +import { + defineHastPlugin, + type HastPluginDefinition, + type HastVisitorContext, +} from 'satteri'; +import { findAttrValue, hasDirective, isComponent, type MdxJsxHastNode } from './jsx-utils.js'; + +interface AstroComponentMetadata { + exportName: string; + localName: string; + specifier: string; + resolvedPath: string; +} + +export interface AstroMetadata { + hydratedComponents: AstroComponentMetadata[]; + clientOnlyComponents: AstroComponentMetadata[]; + serverComponents: AstroComponentMetadata[]; + scripts: never[]; + propagation: 'none'; + containsHead: false; + pageOptions: Record; +} + +type ImportSpecifier = { local: string; imported: string }; +type MatchedImport = { name: string; path: string }; + +// ESTree shapes we care about from the parseExpression() output on mdxjsEsm nodes. +interface EstreeIdentifier { + type: 'Identifier'; + name: string; +} + +interface EstreeStringLiteral { + type: 'Literal'; + value: string; +} + +type EstreeModuleExportName = EstreeIdentifier | EstreeStringLiteral; + +interface EstreeImportDeclaration { + type: 'ImportDeclaration'; + source: EstreeStringLiteral; + specifiers: Array< + EstreeImportSpecifier | EstreeImportDefaultSpecifier | EstreeImportNamespaceSpecifier + >; +} + +interface EstreeImportSpecifier { + type: 'ImportSpecifier'; + local: EstreeIdentifier; + imported: EstreeModuleExportName; +} + +interface EstreeImportDefaultSpecifier { + type: 'ImportDefaultSpecifier'; + local: EstreeIdentifier; +} + +interface EstreeImportNamespaceSpecifier { + type: 'ImportNamespaceSpecifier'; + local: EstreeIdentifier; +} + +interface EstreeProgram { + body: Array<{ type: string } & Record>; +} + +function exportNameToString(name: EstreeModuleExportName): string { + return name.type === 'Identifier' ? name.name : name.value; +} + +function collectImportsFromEsm( + program: EstreeProgram, + imports: Map>, +): void { + for (const stmt of program.body) { + if (stmt.type !== 'ImportDeclaration') continue; + const decl = stmt as EstreeImportDeclaration; + const source = decl.source.value; + + let specSet = imports.get(source); + if (!specSet) { + specSet = new Set(); + imports.set(source, specSet); + } + + for (const spec of decl.specifiers) { + switch (spec.type) { + case 'ImportDefaultSpecifier': + specSet.add({ local: spec.local.name, imported: 'default' }); + break; + case 'ImportNamespaceSpecifier': + specSet.add({ local: spec.local.name, imported: '*' }); + break; + case 'ImportSpecifier': + specSet.add({ + local: spec.local.name, + imported: exportNameToString(spec.imported), + }); + break; + } + } + } +} + +function findMatchingImport( + tagName: string, + imports: Map>, +): MatchedImport | undefined { + const tagSpecifier = tagName.split('.')[0]; + for (const [source, specs] of imports) { + for (const { imported, local } of specs) { + if (local === tagSpecifier) { + if (tagSpecifier !== tagName) { + switch (imported) { + case '*': { + const accessPath = tagName.slice(tagSpecifier.length + 1); + return { name: accessPath, path: source }; + } + case 'default': { + const accessPath = tagName.slice(tagSpecifier.length + 1); + return { name: `default.${accessPath}`, path: source }; + } + default: { + return { name: tagName, path: source }; + } + } + } + return { name: imported, path: source }; + } + } + } +} + +function resolveImportPath(specifier: string, importer: string): string { + if (specifier.startsWith('.')) { + const absoluteSpecifier = path.resolve(path.dirname(importer), specifier); + return absoluteSpecifier.split(path.sep).join('/'); + } + return specifier; +} + +function processJsxNode( + node: MdxJsxHastNode, + ctx: HastVisitorContext, + metadata: AstroMetadata, + imports: Map>, + filePath: string, +) { + const tagName = node.name; + if (!tagName || !isComponent(tagName)) return; + + const hasClient = hasDirective(node, 'client:'); + const hasServerDefer = !hasClient && hasDirective(node, 'server:defer'); + if (!hasClient && !hasServerDefer) return; + + const matchedImport = findMatchingImport(tagName, imports); + if (!matchedImport) { + throw new Error( + `Expected a matching import for component \`${tagName}\`. Did you forget to import it?`, + ); + } + + if (matchedImport.path.endsWith('.astro') && hasClient) { + let clientAttr = 'client:*'; + for (const a of node.attributes) { + if (a.type === 'mdxJsxAttribute' && a.name.startsWith('client:')) { + clientAttr = a.name; + break; + } + } + console.warn( + `You are attempting to render <${tagName} ${clientAttr} />, but ${tagName} 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 = resolveImportPath(matchedImport.path, filePath); + const exportName = + matchedImport.name === '*' ? tagName.split('.').slice(1).join('.') : matchedImport.name; + + if (hasClient && findAttrValue(node, 'client:only') !== null) { + metadata.clientOnlyComponents.push({ + exportName: matchedImport.name, + localName: '', + specifier: tagName, + resolvedPath, + }); + ctx.setProperty(node, 'client:display-name', tagName); + ctx.setProperty(node, 'client:component-path', resolvedPath); + ctx.setProperty(node, 'client:component-export', exportName); + ctx.setProperty(node, 'client:component-hydration', ''); + } else if (hasClient) { + metadata.hydratedComponents.push({ + exportName: '*', + localName: '', + specifier: tagName, + resolvedPath, + }); + ctx.setProperty(node, 'client:component-path', resolvedPath); + ctx.setProperty(node, 'client:component-export', exportName); + ctx.setProperty(node, 'client:component-hydration', ''); + } else if (hasServerDefer) { + metadata.serverComponents.push({ + exportName: matchedImport.name, + localName: tagName, + specifier: matchedImport.path, + resolvedPath, + }); + ctx.setProperty(node, 'server:component-directive', 'server:defer'); + ctx.setProperty(node, 'server:component-path', resolvedPath); + ctx.setProperty(node, 'server:component-export', exportName); + } +} + +export function createAstroMetadataPlugin( + metadata: AstroMetadata, + filePath: string, +): HastPluginDefinition { + const imports = new Map>(); + return defineHastPlugin({ + name: 'astro-metadata', + mdxjsEsm(node) { + const program = node.parseExpression() as EstreeProgram | null; + if (program) { + collectImportsFromEsm(program, imports); + } + }, + + mdxJsxFlowElement: { + filter: [], + visit(node, ctx) { + processJsxNode(node, ctx, metadata, imports, filePath); + }, + }, + + mdxJsxTextElement: { + filter: [], + visit(node, ctx) { + processJsxNode(node, ctx, metadata, imports, filePath); + }, + }, + }); +} diff --git a/packages/integrations/mdx/src/satteri/hast-images-to-component.ts b/packages/integrations/mdx/src/satteri/hast-images-to-component.ts new file mode 100644 index 000000000000..fbd50bd9934c --- /dev/null +++ b/packages/integrations/mdx/src/satteri/hast-images-to-component.ts @@ -0,0 +1,81 @@ +import { + defineHastPlugin, + type HastNode, + type HastPluginDefinition, + type MdxJsxAttributeNode, +} from 'satteri'; +import { ASTRO_IMAGE_ELEMENT } from '../image-constants.js'; +import { makeJsxAttr, makeJsxExprAttr } from './jsx-utils.js'; + +export interface ImageImportInfo { + importedImages: Map; + hasImages: boolean; +} + +export function createImageToComponentPlugin( + localImagePaths: Set, + remoteImagePaths: Set, + imageImportInfo: ImageImportInfo, +): HastPluginDefinition { + return defineHastPlugin({ + name: 'image-to-component', + element: { + filter: ['img'], + visit(node) { + const src = node.properties?.src; + if (typeof src !== 'string') return; + + const decodedSrc = decodeURI(src); + const isLocal = localImagePaths.has(decodedSrc); + const isRemote = remoteImagePaths.has(decodedSrc); + if (!isLocal && !isRemote) return; + + const attrs: MdxJsxAttributeNode[] = []; + + if (node.properties) { + for (const [key, value] of Object.entries(node.properties)) { + if (key === 'src') continue; + if (value == null || value === false) continue; + + if (key === 'widths' || key === 'densities') { + attrs.push(makeJsxExprAttr(key, JSON.stringify(String(value).split(' ')))); + } else { + const attrName = + key === 'className' ? 'class' : key === 'htmlFor' ? 'for' : key; + attrs.push(makeJsxAttr(attrName, String(value))); + } + } + } + + if (isLocal) { + let importName = imageImportInfo.importedImages.get(decodedSrc); + if (!importName) { + importName = `__${imageImportInfo.importedImages.size}_${decodedSrc.replace(/\W/g, '_')}__`; + imageImportInfo.importedImages.set(decodedSrc, importName); + } + attrs.push(makeJsxExprAttr('src', importName)); + } else { + const hasWidth = node.properties && 'width' in node.properties; + const hasHeight = node.properties && 'height' in node.properties; + if (!hasWidth || !hasHeight) { + attrs.push(makeJsxExprAttr('inferSize', 'true')); + } + attrs.push(makeJsxAttr('src', decodedSrc)); + } + + imageImportInfo.hasImages = true; + + // Emit `` (lowercase hyphenated) so MDX routes the tag through + // `_components['astro-image']`. The vite-plugin-mdx-postprocess wraps the + // final component to map `astro-image` → `components.img ?? __AstroImage__`, + // which lets a user's `export const components = { img: MyImage }` win. + return { + type: 'mdxJsxTextElement', + name: ASTRO_IMAGE_ELEMENT, + attributes: attrs, + children: [], + } as unknown as HastNode; + }, + }, + }); +} diff --git a/packages/integrations/mdx/src/satteri/index.ts b/packages/integrations/mdx/src/satteri/index.ts new file mode 100644 index 000000000000..f24666924335 --- /dev/null +++ b/packages/integrations/mdx/src/satteri/index.ts @@ -0,0 +1,215 @@ +import { + createShikiHighlighter, + type MarkdownHeading, + satteriCollectImagesPlugin, + satteriHeadingIdsPlugin, + satteriShikiPlugin, +} from '@astrojs/markdown-satteri'; +import { + mdxToJs, + type HastPluginDefinition, + type MdastPluginDefinition, +} from 'satteri'; +import { + ASTRO_IMAGE_IMPORT, + USES_ASTRO_IMAGE_FLAG, +} from '../image-constants.js'; +import type { MdxOptions } from '../index.js'; +import { shouldAddCharset } from './charset.js'; +import { type AstroMetadata, createAstroMetadataPlugin } from './hast-astro-metadata.js'; +import { + createImageToComponentPlugin, + type ImageImportInfo, +} from './hast-images-to-component.js'; + +type HighlightFn = (code: string, lang: string, meta?: string) => Promise; + +export type { MdastPluginDefinition, HastPluginDefinition, MarkdownHeading }; +export type { AstroMetadata } from './hast-astro-metadata.js'; + +declare module 'hast' { + interface ElementData { + lang?: string | null; + } +} + +export interface CompileMdxResult { + code: string; + astroMetadata: AstroMetadata; +} + +interface CreateMdxProcessorContext { + srcDir: URL; +} + +export function createMdxProcessor(mdxOptions: MdxOptions, ctx: CreateMdxProcessorContext) { + let highlightFn: HighlightFn | undefined; + let initPromise: Promise | undefined; + + function initShiki() { + const syntaxHighlight = mdxOptions.syntaxHighlight; + const syntaxHighlightType = + typeof syntaxHighlight === 'string' + ? syntaxHighlight + : syntaxHighlight + ? syntaxHighlight.type + : undefined; + + if (syntaxHighlightType === 'shiki') { + const shikiConfig = mdxOptions.shikiConfig ?? {}; + initPromise = createShikiHighlighter({ + langs: shikiConfig.langs, + theme: shikiConfig.theme, + themes: shikiConfig.themes, + langAlias: shikiConfig.langAlias, + }).then((hl) => { + highlightFn = (code, lang, meta) => + hl.codeToHtml(code, lang, { + meta, + wrap: shikiConfig.wrap, + defaultColor: shikiConfig.defaultColor, + transformers: shikiConfig.transformers, + }); + }); + } + } + + return { + async process( + content: string, + filePath: string, + frontmatter: Record, + ): Promise { + if (!highlightFn && !initPromise) { + initShiki(); + } + if (initPromise) await initPromise; + + const headings: MarkdownHeading[] = []; + const localImagePaths = new Set(); + const remoteImagePaths = new Set(); + + const astroMetadata: AstroMetadata = { + hydratedComponents: [], + clientOnlyComponents: [], + serverComponents: [], + scripts: [], + propagation: 'none', + containsHead: false, + pageOptions: {}, + }; + + const collectImages = satteriCollectImagesPlugin(localImagePaths, remoteImagePaths); + const headingIds = satteriHeadingIdsPlugin(headings, frontmatter); + const astroMeta = createAstroMetadataPlugin(astroMetadata, filePath); + const imageImportInfo: ImageImportInfo = { + importedImages: new Map(), + hasImages: false, + }; + const imageToComponent = createImageToComponentPlugin( + localImagePaths, + remoteImagePaths, + imageImportInfo, + ); + + const syntaxHighlight = mdxOptions.syntaxHighlight; + const excludeLangs = + typeof syntaxHighlight === 'object' ? syntaxHighlight.excludeLangs : undefined; + + const allMdastPlugins: MdastPluginDefinition[] = mdxOptions.mdastPlugins?.length + ? [collectImages, ...mdxOptions.mdastPlugins] + : [collectImages]; + + const hastPlugins: HastPluginDefinition[] = []; + if (highlightFn) { + // `mdx: true` wraps the highlighted HTML in a JSX `` node + // rather than a raw HTML node, since the satteri pipeline is compiling to JSX. + hastPlugins.push(satteriShikiPlugin(highlightFn, excludeLangs, { mdx: true })); + } + hastPlugins.push(headingIds, imageToComponent, astroMeta); + if (mdxOptions.hastPlugins?.length) { + hastPlugins.push(...mdxOptions.hastPlugins); + } + + let optimizeStatic: import('satteri').MdxCompileOptions['optimizeStatic']; + if (mdxOptions.optimize) { + const ignoreElements = + typeof mdxOptions.optimize === 'object' + ? mdxOptions.optimize.ignoreElementNames + : undefined; + + optimizeStatic = { + component: 'Fragment', + prop: 'set:html', + ...(ignoreElements && { ignoreElements }), + }; + } + + const mdxResult = await mdxToJs(content, { + mdastPlugins: allMdastPlugins, + hastPlugins, + optimizeStatic, + features: mdxOptions.features, + filename: filePath, + jsxImportSource: 'astro', + }); + let compiled = mdxResult.code; + + compiled = compiled.replace(/^export default MDXContent;\s*$/m, ''); + + if (imageImportInfo.hasImages) { + // `vite-plugin-mdx-postprocess` wraps Content to map + // `astro-image` → `components.img ?? __AstroImage__`, so a user's + // `export const components = { img: ... }` override is honored. + compiled += `\nimport { Image as ${ASTRO_IMAGE_IMPORT} } from "astro:assets";`; + for (const [src, importName] of imageImportInfo.importedImages) { + compiled += `\nimport ${importName} from ${JSON.stringify(src)};`; + } + compiled += `\nexport const ${USES_ASTRO_IMAGE_FLAG} = true;`; + } + + compiled += `\nexport const frontmatter = ${JSON.stringify(frontmatter)};`; + compiled += `\nexport function getHeadings() { return ${JSON.stringify(headings)}; }`; + + if (frontmatter.layout) { + compiled = compiled.replace(/^function MDXContent\(/m, 'function __OriginalMDXContent__('); + compiled += ` +import { jsx as __astro_layout_jsx__ } from 'astro/jsx-runtime'; +import __astro_layout_component__ from ${JSON.stringify(frontmatter.layout)}; +export default function MDXContent(props) { + const content = __OriginalMDXContent__(props); + const { layout, ...frontmatterContent } = frontmatter; + frontmatterContent.file = file; + frontmatterContent.url = url; + return __astro_layout_jsx__(__astro_layout_component__, { + file, + url, + content: frontmatterContent, + frontmatter: frontmatterContent, + headings: getHeadings(), + 'server:root': true, + children: content, + }); +}`; + } else if (shouldAddCharset(content, filePath, ctx.srcDir)) { + // Default MDX pages without a layout to UTF-8 so users don't have to think about it. + compiled = compiled.replace(/^function MDXContent\(/m, 'function __OriginalMDXContent__('); + compiled += ` +import { jsx as __astro_charset_jsx__, jsxs as __astro_charset_jsxs__, Fragment as __astro_charset_Fragment__ } from 'astro/jsx-runtime'; +export default function MDXContent(props) { + return __astro_charset_jsxs__(__astro_charset_Fragment__, { + children: [ + __astro_charset_jsx__('meta', { charset: 'utf-8' }), + __OriginalMDXContent__(props), + ], + }); +}`; + } + + return { + code: compiled, + astroMetadata, + }; + }, + }; +} diff --git a/packages/integrations/mdx/src/satteri/jsx-utils.ts b/packages/integrations/mdx/src/satteri/jsx-utils.ts new file mode 100644 index 000000000000..a29ce58756d9 --- /dev/null +++ b/packages/integrations/mdx/src/satteri/jsx-utils.ts @@ -0,0 +1,47 @@ +import type { HastNode, MdxJsxAttributeNode } from 'satteri'; + +// JSX-specific helpers used by the MDX pipeline. Non-MDX equivalents (e.g. +// `makeFragmentNode`, `collectHastText`) live in `@astrojs/markdown-satteri` +// because they're shared with the markdown render pipeline. + +type MdxJsxFlowElement = Extract; +type MdxJsxTextElement = Extract; +export type MdxJsxHastNode = MdxJsxFlowElement | MdxJsxTextElement; + +const nonAlphaRe = /[^a-zA-Z]/; + +export function isComponent(tagName: string): boolean { + return ( + (tagName[0] && tagName[0].toLowerCase() !== tagName[0]) || + tagName.includes('.') || + nonAlphaRe.test(tagName[0]) + ); +} + +export function hasDirective(node: MdxJsxHastNode, prefix: string): boolean { + for (const a of node.attributes) { + if (a.type === 'mdxJsxAttribute' && a.name.startsWith(prefix)) return true; + } + return false; +} + +export function findAttrValue(node: MdxJsxHastNode, name: string): string | null { + for (const a of node.attributes) { + if (a.type === 'mdxJsxAttribute' && a.name === name) { + return typeof a.value === 'string' ? a.value : null; + } + } + return null; +} + +export function makeJsxAttr(name: string, value: string): MdxJsxAttributeNode { + return { type: 'mdxJsxAttribute', name, value }; +} + +export function makeJsxExprAttr(name: string, expression: string): MdxJsxAttributeNode { + return { + type: 'mdxJsxAttribute', + name, + value: { type: 'mdxJsxAttributeValueExpression', value: expression }, + }; +} diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/unified/index.ts similarity index 98% rename from packages/integrations/mdx/src/plugins.ts rename to packages/integrations/mdx/src/unified/index.ts index 1907ec4cf65e..0df6de153e9e 100644 --- a/packages/integrations/mdx/src/plugins.ts +++ b/packages/integrations/mdx/src/unified/index.ts @@ -11,7 +11,7 @@ import remarkGfm from 'remark-gfm'; import remarkSmartypants from 'remark-smartypants'; import { SourceMapGenerator } from 'source-map'; import type { PluggableList } from 'unified'; -import type { MdxOptions } from './index.js'; +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'; diff --git a/packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts b/packages/integrations/mdx/src/unified/rehype-apply-frontmatter-export.ts similarity index 98% rename from packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts rename to packages/integrations/mdx/src/unified/rehype-apply-frontmatter-export.ts index 5880c30b318b..168afc689cc5 100644 --- a/packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts +++ b/packages/integrations/mdx/src/unified/rehype-apply-frontmatter-export.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { isFrontmatterValid } from '@astrojs/markdown-remark'; import type { Root, RootContent } from 'hast'; import type { VFile } from 'vfile'; -import { jsToTreeNode } from './utils.js'; +import { jsToTreeNode } from '../utils.js'; // Passed metadata to help determine adding charset utf8 by default declare module 'vfile' { diff --git a/packages/integrations/mdx/src/rehype-collect-headings.ts b/packages/integrations/mdx/src/unified/rehype-collect-headings.ts similarity index 87% rename from packages/integrations/mdx/src/rehype-collect-headings.ts rename to packages/integrations/mdx/src/unified/rehype-collect-headings.ts index a51e8e9f02d8..b75df4876b1a 100644 --- a/packages/integrations/mdx/src/rehype-collect-headings.ts +++ b/packages/integrations/mdx/src/unified/rehype-collect-headings.ts @@ -1,5 +1,5 @@ import type { VFile } from 'vfile'; -import { jsToTreeNode } from './utils.js'; +import { jsToTreeNode } from '../utils.js'; export function rehypeInjectHeadingsExport() { return function (tree: any, file: VFile) { diff --git a/packages/integrations/mdx/src/rehype-images-to-component.ts b/packages/integrations/mdx/src/unified/rehype-images-to-component.ts similarity index 86% rename from packages/integrations/mdx/src/rehype-images-to-component.ts rename to packages/integrations/mdx/src/unified/rehype-images-to-component.ts index 6676ee3230a6..6ae50a29a248 100644 --- a/packages/integrations/mdx/src/rehype-images-to-component.ts +++ b/packages/integrations/mdx/src/unified/rehype-images-to-component.ts @@ -3,11 +3,12 @@ import type { MdxJsxAttribute, MdxjsEsm } from 'mdast-util-mdx'; import type { MdxJsxFlowElementHast } from 'mdast-util-mdx-jsx'; import { visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; -import { jsToTreeNode } from './utils.js'; - -export const ASTRO_IMAGE_ELEMENT = 'astro-image'; -export const ASTRO_IMAGE_IMPORT = '__AstroImage__'; -export const USES_ASTRO_IMAGE_FLAG = '__usesAstroImage'; +import { + ASTRO_IMAGE_ELEMENT, + ASTRO_IMAGE_IMPORT, + USES_ASTRO_IMAGE_FLAG, +} from '../image-constants.js'; +import { jsToTreeNode } from '../utils.js'; function createArrayAttribute(name: string, values: (string | number)[]): MdxJsxAttribute { return { @@ -40,23 +41,13 @@ function createArrayAttribute(name: string, values: (string | number)[]): MdxJsx }; } -/** - * 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. - */ + // expects widths/densities as arrays; hast properties arrive as strings. if (prop === 'widths' || prop === 'densities') { attrs.push(createArrayAttribute(prop, String(value).split(' '))); } else { @@ -175,14 +166,13 @@ export function rehypeImageToComponent() { parent!.children.splice(index!, 1, element); }); - // 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 + // @see `vite-plugin-mdx-postprocess`. tree.children.push(jsToTreeNode(`export const ${USES_ASTRO_IMAGE_FLAG} = true`)); }; } diff --git a/packages/integrations/mdx/src/rehype-meta-string.ts b/packages/integrations/mdx/src/unified/rehype-meta-string.ts similarity index 100% rename from packages/integrations/mdx/src/rehype-meta-string.ts rename to packages/integrations/mdx/src/unified/rehype-meta-string.ts diff --git a/packages/integrations/mdx/src/rehype-optimize-static.ts b/packages/integrations/mdx/src/unified/rehype-optimize-static.ts similarity index 100% rename from packages/integrations/mdx/src/rehype-optimize-static.ts rename to packages/integrations/mdx/src/unified/rehype-optimize-static.ts diff --git a/packages/integrations/mdx/src/utils.ts b/packages/integrations/mdx/src/utils.ts index b2e798212d34..3a23fb8e8403 100644 --- a/packages/integrations/mdx/src/utils.ts +++ b/packages/integrations/mdx/src/utils.ts @@ -1,7 +1,7 @@ -import { parseFrontmatter } from '@astrojs/markdown-remark'; import type { Options as AcornOpts } from 'acorn'; import { parse } from 'acorn'; import type { AstroConfig, AstroIntegrationLogger, SSRError } from 'astro'; +import { parseFrontmatter } from 'astro/markdown'; import type { MdxjsEsm } from 'mdast-util-mdx'; import colors from 'piccolore'; import type { PluggableList } from 'unified'; diff --git a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts index c401f25e5abf..6a5115796e67 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts @@ -5,7 +5,7 @@ import { ASTRO_IMAGE_ELEMENT, ASTRO_IMAGE_IMPORT, USES_ASTRO_IMAGE_FLAG, -} from './rehype-images-to-component.js'; +} from './image-constants.js'; import { type FileInfo, getFileInfo } from './utils.js'; const underscoreFragmentImportRegex = /[\s,{]_Fragment[\s,}]/; diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts index 60c6d0fa9af0..f46e3e029ea5 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts @@ -1,15 +1,17 @@ -import { isUnifiedProcessor } from '@astrojs/markdown-remark'; -import { isSatteriProcessor } from '@astrojs/markdown-satteri'; import type { SSRError } from 'astro'; import { getAstroMetadata } from 'astro/jsx/rehype.js'; import type { MarkdownProcessorEntry, MdxRenderer } from 'astro/markdown'; import { VFile } from 'vfile'; import type { Plugin } from 'vite'; import type { MdxOptions } from './index.js'; -import { createMdxProcessor as createRemarkMdxProcessor } from './plugins.js'; -import { createMdxProcessor as createSatteriMdxProcessor } from './satteri-plugins.js'; import { safeParseFrontmatter } from './utils.js'; +// Inlined name-checks to avoid eagerly importing the unified/satteri runtime +// modules (each pulls in shiki + WASM bindings). The pipeline-specific factory +// is loaded lazily inside `resolveMdxRenderer`. +const isSatteriProcessor = (p: { name: string }) => p.name === 'satteri'; +const isUnifiedProcessor = (p: { name: string }) => p.name === 'unified'; + export interface VitePluginMdxOptions { mdxOptions: MdxOptions; srcDir: URL; @@ -102,7 +104,8 @@ async function resolveMdxRenderer( } if (isSatteriProcessor(processor)) { - const satteriProcessor = createSatteriMdxProcessor(opts.mdxOptions); + const { createMdxProcessor: createSatteriMdxProcessor } = await import('./satteri/index.js'); + const satteriProcessor = createSatteriMdxProcessor(opts.mdxOptions, { srcDir: opts.srcDir }); return { async process(content, filePath, frontmatter) { const result = await satteriProcessor.process(content, filePath, frontmatter); @@ -111,6 +114,7 @@ async function resolveMdxRenderer( }; } if (isUnifiedProcessor(processor)) { + const { createMdxProcessor: createRemarkMdxProcessor } = await import('./unified/index.js'); const remarkProcessor = createRemarkMdxProcessor(opts.mdxOptions, { sourcemap }); return { async process(content, filePath, frontmatter) { diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs index 00d8e38f5397..232cea4b2e8f 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs @@ -1,12 +1,16 @@ import mdx from '@astrojs/mdx'; +import { unified } from '@astrojs/markdown-remark'; import { defineConfig } from 'astro/config'; import { rehypeReadingTime, remarkDescription, remarkTitle } from './src/markdown-plugins.mjs'; // https://astro.build/config export default defineConfig({ site: 'https://astro.build/', - integrations: [mdx({ - remarkPlugins: [remarkTitle, remarkDescription], - rehypePlugins: [rehypeReadingTime], - })], + integrations: [mdx()], + markdown: { + processor: unified({ + remarkPlugins: [remarkTitle, remarkDescription], + rehypePlugins: [rehypeReadingTime], + }), + }, }); diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/package.json b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/package.json index 0a193d6e2f65..0e8fc96f6527 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/package.json +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { + "@astrojs/markdown-remark": "workspace:*", "@astrojs/mdx": "workspace:*", "astro": "workspace:*", "mdast-util-to-string": "^4.0.0", diff --git a/packages/integrations/mdx/test/mdx-astro-markdown-remarkRehype.test.ts b/packages/integrations/mdx/test/mdx-astro-markdown-remarkRehype.test.ts index 6e82a89f938a..56a7b8f66ede 100644 --- a/packages/integrations/mdx/test/mdx-astro-markdown-remarkRehype.test.ts +++ b/packages/integrations/mdx/test/mdx-astro-markdown-remarkRehype.test.ts @@ -1,5 +1,6 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { unified } from '@astrojs/markdown-remark'; import mdx from '@astrojs/mdx'; import { parseHTML } from 'linkedom'; import { loadFixture } from './test-utils.ts'; @@ -57,7 +58,7 @@ describe('MDX with Astro Markdown remark-rehype config', () => { ); }); - it('Renders footnotes with values from custom configuration without extending the default', async () => { + it('Renders footnotes by merging mdx remarkRehype on top of the markdown processor remarkRehype', async () => { const fixture = await loadFixture({ root: new URL('./fixtures/mdx-astro-markdown-remarkRehype/', import.meta.url), integrations: [ @@ -68,9 +69,11 @@ describe('MDX with Astro Markdown remark-rehype config', () => { }), ], markdown: { - remarkRehype: { - footnoteBackLabel: 'Kembali ke konten', - }, + processor: unified({ + remarkRehype: { + footnoteBackLabel: 'Kembali ke konten', + }, + }), }, }); @@ -81,7 +84,7 @@ describe('MDX with Astro Markdown remark-rehype config', () => { assert.equal(document.querySelector('#footnote-label')!.textContent, 'Catatan kaki'); assert.equal( document.querySelector('.data-footnote-backref')!.getAttribute('aria-label'), - 'Back to reference 1', + 'Kembali ke konten', ); }); }); diff --git a/packages/integrations/mdx/test/mdx-get-headings.test.ts b/packages/integrations/mdx/test/mdx-get-headings.test.ts index cc9162d99382..bc2974a52d03 100644 --- a/packages/integrations/mdx/test/mdx-get-headings.test.ts +++ b/packages/integrations/mdx/test/mdx-get-headings.test.ts @@ -1,6 +1,6 @@ import * as assert from 'node:assert/strict'; import { before, describe, it } from 'node:test'; -import { rehypeHeadingIds } from '@astrojs/markdown-remark'; +import { rehypeHeadingIds, unified } from '@astrojs/markdown-remark'; import mdx from '@astrojs/mdx'; import { parseHTML } from 'linkedom'; import { visit } from 'unist-util-visit'; @@ -162,8 +162,9 @@ describe('MDX heading IDs can be injected before user plugins', () => { before(async () => { fixture = await loadFixture({ root: new URL('./fixtures/mdx-get-headings/', import.meta.url), - integrations: [ - mdx({ + integrations: [mdx()], + markdown: { + processor: unified({ rehypePlugins: [ rehypeHeadingIds, () => (tree) => { @@ -176,7 +177,7 @@ describe('MDX heading IDs can be injected before user plugins', () => { }, ], }), - ], + }, }); await fixture.build(); diff --git a/packages/integrations/mdx/test/mdx-plugins.test.ts b/packages/integrations/mdx/test/mdx-plugins.test.ts index 6eb7b1b8f2d1..f6f88bafabcd 100644 --- a/packages/integrations/mdx/test/mdx-plugins.test.ts +++ b/packages/integrations/mdx/test/mdx-plugins.test.ts @@ -1,5 +1,6 @@ import * as assert from 'node:assert/strict'; import { before, describe, it } from 'node:test'; +import { unified } from '@astrojs/markdown-remark'; import mdx from '@astrojs/mdx'; import { parseHTML } from 'linkedom'; import remarkToc from 'remark-toc'; @@ -25,7 +26,9 @@ describe('MDX plugins - Astro config integration', () => { 'astro:config:setup': ({ updateConfig }) => { updateConfig({ markdown: { - rehypePlugins: [rehypeExamplePlugin], + processor: unified({ + rehypePlugins: [rehypeExamplePlugin], + }), }, }); }, diff --git a/packages/integrations/mdx/test/mdx-syntax-highlighting.test.ts b/packages/integrations/mdx/test/mdx-syntax-highlighting.test.ts index 7058f8e45826..c199f588d03c 100644 --- a/packages/integrations/mdx/test/mdx-syntax-highlighting.test.ts +++ b/packages/integrations/mdx/test/mdx-syntax-highlighting.test.ts @@ -1,5 +1,6 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { unified } from '@astrojs/markdown-remark'; import mdx from '@astrojs/mdx'; import rehypeShiki from '@shikijs/rehype'; import { transformerTwoslash } from '@shikijs/twoslash'; @@ -63,6 +64,7 @@ describe('MDX syntax highlighting', () => { root: FIXTURE_ROOT, markdown: { syntaxHighlight: 'prism', + processor: unified(), }, integrations: [mdx()], }); @@ -81,6 +83,7 @@ describe('MDX syntax highlighting', () => { root: FIXTURE_ROOT, markdown: { syntaxHighlight: 'shiki', + processor: unified(), }, integrations: [ mdx({ @@ -107,6 +110,7 @@ describe('MDX syntax highlighting', () => { root: FIXTURE_ROOT, markdown: { syntaxHighlight: false, + processor: unified(), }, integrations: [ mdx({ @@ -139,6 +143,7 @@ describe('MDX syntax highlighting', () => { root: FIXTURE_ROOT, markdown: { syntaxHighlight: false, + processor: unified(), }, integrations: [ mdx({ diff --git a/packages/integrations/mdx/test/units/rehype-optimize-static.test.ts b/packages/integrations/mdx/test/units/rehype-optimize-static.test.ts index f93b8d3f3338..9063256e38b4 100644 --- a/packages/integrations/mdx/test/units/rehype-optimize-static.test.ts +++ b/packages/integrations/mdx/test/units/rehype-optimize-static.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { compile as _compile, type CompileOptions } from '@mdx-js/mdx'; -import { rehypeOptimizeStatic } from '../../dist/rehype-optimize-static.js'; +import { rehypeOptimizeStatic } from '../../dist/unified/rehype-optimize-static.js'; async function compile(mdxCode: string, options?: Readonly) { const result = await _compile(mdxCode, { diff --git a/packages/integrations/mdx/test/units/rehype-plugins.test.ts b/packages/integrations/mdx/test/units/rehype-plugins.test.ts index 89e65acaa53b..e3b80f1a9982 100644 --- a/packages/integrations/mdx/test/units/rehype-plugins.test.ts +++ b/packages/integrations/mdx/test/units/rehype-plugins.test.ts @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type * as hast from 'hast'; import { VFile } from 'vfile'; -import { rehypeInjectHeadingsExport } from '../../dist/rehype-collect-headings.js'; -import rehypeMetaString from '../../dist/rehype-meta-string.js'; +import { rehypeInjectHeadingsExport } from '../../dist/unified/rehype-collect-headings.js'; +import rehypeMetaString from '../../dist/unified/rehype-meta-string.js'; describe('rehypeMetaString', () => { function createCodeNode(meta: string | undefined): hast.Element { diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index 4347cce80e49..6e7f02074ed4 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -35,7 +35,7 @@ "dependencies": { "@astrojs/internal-helpers": "workspace:*", "github-slugger": "^2.0.0", - "satteri": "^0.4.0", + "satteri": "^0.5.1", "shiki": "^4.0.0" }, "devDependencies": { diff --git a/packages/markdown/satteri/src/satteri-processor.ts b/packages/markdown/satteri/src/satteri-processor.ts index d9ca77f70d52..2caf3d82df06 100644 --- a/packages/markdown/satteri/src/satteri-processor.ts +++ b/packages/markdown/satteri/src/satteri-processor.ts @@ -32,7 +32,7 @@ export function createCollectImagesPlugin( ): import('satteri').MdastPluginDefinition { const domains = image?.domains ?? []; const remotePatterns = image?.remotePatterns ?? []; - return satteri!.defineMdastPlugin({ + return { name: 'collect-images', image(node) { const url = node.url ? decodeURI(node.url) : undefined; @@ -46,7 +46,7 @@ export function createCollectImagesPlugin( localImagePaths.add(url); } }, - }); + }; } function resolveFrontmatterExpression( @@ -109,7 +109,7 @@ export function createHeadingIdsPlugin( frontmatter: Record | undefined, ): import('satteri').HastPluginDefinition { const slugger = new Slugger(); - return satteri!.defineHastPlugin({ + return { name: 'heading-ids', element: { filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], @@ -128,7 +128,7 @@ export function createHeadingIdsPlugin( } }, }, - }); + }; } function makeRawNode(html: string): import('satteri').HastNode { @@ -143,7 +143,7 @@ export function createImageMarkerPlugin( remoteImagePaths: Set, ): import('satteri').HastPluginDefinition { const indexBySrc = new Map(); - return satteri!.defineHastPlugin({ + return { name: 'image-marker', element: { filter: ['img'], @@ -175,7 +175,7 @@ export function createImageMarkerPlugin( ctx.setProperty(node, 'src', null); }, }, - }); + }; } export function createShikiPlugin( @@ -184,7 +184,7 @@ export function createShikiPlugin( options?: { mdx?: boolean }, ): import('satteri').HastPluginDefinition { const wrapResult = options?.mdx ? makeFragmentNode : makeRawNode; - return satteri!.defineHastPlugin({ + return { name: 'shiki-highlight', element: { filter: ['pre'], @@ -209,7 +209,7 @@ export function createShikiPlugin( return wrapResult(html); }, }, - }); + }; } export interface SatteriMarkdownProcessorOptions extends AstroMarkdownProcessorOptions { @@ -286,7 +286,7 @@ export async function createSatteriMarkdownProcessor( hastPlugins.push(...userHastPlugins); hastPlugins.push(createImageMarkerPlugin(localImagePaths, remoteImagePaths)); - const html = await s.markdownToHtml(content, { + const { html } = await s.markdownToHtml(content, { mdastPlugins: allMdastPlugins, hastPlugins, features: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d276078a1dc..08c55706ee3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5317,9 +5317,6 @@ importers: packages/integrations/mdx: dependencies: - '@astrojs/markdown-remark': - specifier: workspace:* - version: link:../../markdown/remark '@astrojs/markdown-satteri': specifier: workspace:* version: link:../../markdown/satteri @@ -5354,8 +5351,8 @@ importers: specifier: ^3.0.2 version: 3.0.2 satteri: - specifier: ^0.4.0 - version: 0.4.0 + specifier: ^0.5.1 + version: 0.5.1 source-map: specifier: ^0.7.6 version: 0.7.6 @@ -5366,6 +5363,9 @@ importers: specifier: ^6.0.3 version: 6.0.3 devDependencies: + '@astrojs/markdown-remark': + specifier: workspace:* + version: link:../../markdown/remark '@shikijs/rehype': specifier: ^4.0.0 version: 4.0.2 @@ -5465,6 +5465,9 @@ importers: packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection: dependencies: + '@astrojs/markdown-remark': + specifier: workspace:* + version: link:../../../../../markdown/remark '@astrojs/mdx': specifier: workspace:* version: link:../../.. @@ -7000,8 +7003,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 satteri: - specifier: ^0.4.0 - version: 0.4.0 + specifier: ^0.5.1 + version: 0.5.1 shiki: specifier: ^4.0.0 version: 4.0.2 @@ -7667,29 +7670,29 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - '@bruits/satteri-darwin-arm64@0.4.0': - resolution: {integrity: sha512-gPFVvfSuHPzDspAr3EYK6IIcz1YPNXj4chZKqY6AgWi0Wlhlei+z60WKBsESfRE1KHZN+FINJgVJ0nZZWB4O7w==} + '@bruits/satteri-darwin-arm64@0.5.1': + resolution: {integrity: sha512-sOsfu2KKNqELLkSpiGCBqwXOgMyF2zYENWEtvynt57C9x/fxgfCexuQ6cs6EorqLXVEfSaZ0JHWEXErkyOfjkA==} cpu: [arm64] os: [darwin] - '@bruits/satteri-darwin-x64@0.4.0': - resolution: {integrity: sha512-HQZmo5cy5MAgvamsykrnEAn02jKdT8vGWL7xUJdt2CeHW1oElLRTGd6JRHlPEwZaXM2LnHuMJfaEd1MUjopcJA==} + '@bruits/satteri-darwin-x64@0.5.1': + resolution: {integrity: sha512-xq1+KL0pKGQ+ldJyqEA53O4fwViAeUWqjEGUt+ZkqywX2Rx89hnT1OaIutbvP9dWXhfRSekPGxQhZI9A2lgRhQ==} cpu: [x64] os: [darwin] - '@bruits/satteri-linux-x64-gnu@0.4.0': - resolution: {integrity: sha512-KsE5v5wNTq0BFWTD6mrf5hTW9x8GjeohaA7I+I4ifVGeUruJECDaZn9BJwkZzrDtEzawL7NT1HV+k01Lu/wzaA==} + '@bruits/satteri-linux-x64-gnu@0.5.1': + resolution: {integrity: sha512-uzxR6uRdnjz+u7zsXWxBzEJpEfT1hlTvPzP19RKTPxeNPoS+hRNGTka41k/Vyl6/+a3Ry0pHA5dlREtQotydBQ==} cpu: [x64] os: [linux] libc: [glibc] - '@bruits/satteri-wasm32-wasi@0.4.0': - resolution: {integrity: sha512-i62LosNUPYcfZ8+RnZzquwO5yf+/KbSKR5SnitINxPWEh+dxtr1jHPnSgoAQo+dWNroK7+IvwZeM3UoK1vV/pA==} + '@bruits/satteri-wasm32-wasi@0.5.1': + resolution: {integrity: sha512-VMthsKH6/NZ6UAW7HpPmcEfteSDtitBlR9zpPz2NBnUpqDdCQTuytxqYvRrn8YetClQms48tjK+AQU1ds6Mrfg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@bruits/satteri-win32-x64-msvc@0.4.0': - resolution: {integrity: sha512-VjDTS/SRNKpD5DFMhwNUHl5aXU1HtTNLCitz6PXtEAo0dhVrReafj8CGvCEGY2kuj5gCqjmkEOpkNyRlDalnRQ==} + '@bruits/satteri-win32-x64-msvc@0.5.1': + resolution: {integrity: sha512-PEuchy+REgFcsDI1JnRLMmTuZAZ1LFGgvTZodU5G3JDUqJT9deJgF4iPH2s5cKPgOfPi9OgQbi+ok6kQalDEoQ==} cpu: [x64] os: [win32] @@ -15418,8 +15421,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - satteri@0.4.0: - resolution: {integrity: sha512-9PG+ej0PdEUqhWsREdWCn/oGtZT0J13G53nmiCUuxPmBKaH2nFnwf15UM4mTs1KJClGsxKqCD2rMEg0/hWu6qg==} + satteri@0.5.1: + resolution: {integrity: sha512-myWnXRT70IY5i7pinRErW/g7twCs8t6RwAd6Rj0oiak4WEx4XM3txX+thc1fxWko2oXkvTsF7lxS5wEST4+EcQ==} sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} @@ -17863,23 +17866,23 @@ snapshots: '@borewit/text-codec@0.2.2': {} - '@bruits/satteri-darwin-arm64@0.4.0': + '@bruits/satteri-darwin-arm64@0.5.1': optional: true - '@bruits/satteri-darwin-x64@0.4.0': + '@bruits/satteri-darwin-x64@0.5.1': optional: true - '@bruits/satteri-linux-x64-gnu@0.4.0': + '@bruits/satteri-linux-x64-gnu@0.5.1': optional: true - '@bruits/satteri-wasm32-wasi@0.4.0': + '@bruits/satteri-wasm32-wasi@0.5.1': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optional: true - '@bruits/satteri-win32-x64-msvc@0.4.0': + '@bruits/satteri-win32-x64-msvc@0.5.1': optional: true '@capsizecss/unpack@4.0.0': @@ -26651,17 +26654,17 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 - satteri@0.4.0: + satteri@0.5.1: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 optionalDependencies: - '@bruits/satteri-darwin-arm64': 0.4.0 - '@bruits/satteri-darwin-x64': 0.4.0 - '@bruits/satteri-linux-x64-gnu': 0.4.0 - '@bruits/satteri-wasm32-wasi': 0.4.0 - '@bruits/satteri-win32-x64-msvc': 0.4.0 + '@bruits/satteri-darwin-arm64': 0.5.1 + '@bruits/satteri-darwin-x64': 0.5.1 + '@bruits/satteri-linux-x64-gnu': 0.5.1 + '@bruits/satteri-wasm32-wasi': 0.5.1 + '@bruits/satteri-win32-x64-msvc': 0.5.1 sax@1.6.0: {} From d74c82e8c549eacb8466dd8c0a93670e15870c2a Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Wed, 13 May 2026 03:23:30 +0200 Subject: [PATCH 10/26] chore: remove unused deps --- packages/integrations/mdx/package.json | 1 - pnpm-lock.yaml | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 5a83f22c921d..b86d63b49d1a 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -39,7 +39,6 @@ "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", "estree-util-visit": "^2.0.0", - "github-slugger": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08c55706ee3e..628cc82161f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5332,9 +5332,6 @@ importers: estree-util-visit: specifier: ^2.0.0 version: 2.0.0 - github-slugger: - specifier: ^2.0.0 - version: 2.0.0 hast-util-to-html: specifier: ^9.0.5 version: 9.0.5 From a03e004745e3f7e41413dd2f3edec5d1ccead81a Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Mon, 18 May 2026 22:57:45 +0200 Subject: [PATCH 11/26] =?UTF-8?q?feat:=20update=20S=C3=A4tteri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/integrations/mdx/package.json | 2 +- pnpm-lock.yaml | 64 +++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index b86d63b49d1a..87f8706e9050 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -44,7 +44,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", - "satteri": "^0.5.1", + "satteri": "^0.6.0", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 628cc82161f5..aef9d64d372c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5348,8 +5348,8 @@ importers: specifier: ^3.0.2 version: 3.0.2 satteri: - specifier: ^0.5.1 - version: 0.5.1 + specifier: ^0.6.0 + version: 0.6.0 source-map: specifier: ^0.7.6 version: 0.7.6 @@ -7672,27 +7672,53 @@ packages: cpu: [arm64] os: [darwin] + '@bruits/satteri-darwin-arm64@0.6.0': + resolution: {integrity: sha512-J+3cxmiCDtVCo/rwr0f7I+MgR2YUhjc45NbG/Rt4yR/nv/fqeA3FmdLSZMjx9OfF+oAZMR4b9WD93e5nN2J+xA==} + cpu: [arm64] + os: [darwin] + '@bruits/satteri-darwin-x64@0.5.1': resolution: {integrity: sha512-xq1+KL0pKGQ+ldJyqEA53O4fwViAeUWqjEGUt+ZkqywX2Rx89hnT1OaIutbvP9dWXhfRSekPGxQhZI9A2lgRhQ==} cpu: [x64] os: [darwin] + '@bruits/satteri-darwin-x64@0.6.0': + resolution: {integrity: sha512-hybKk5Yu1FQk+CwCJn3JUdkMoEuMyhWlDL2eaclVsbskn9D/miAYgf9MQp3DZzifGXg5rflxOp6CvPa8uNuaPA==} + cpu: [x64] + os: [darwin] + '@bruits/satteri-linux-x64-gnu@0.5.1': resolution: {integrity: sha512-uzxR6uRdnjz+u7zsXWxBzEJpEfT1hlTvPzP19RKTPxeNPoS+hRNGTka41k/Vyl6/+a3Ry0pHA5dlREtQotydBQ==} cpu: [x64] os: [linux] libc: [glibc] + '@bruits/satteri-linux-x64-gnu@0.6.0': + resolution: {integrity: sha512-47DzI6OG1YrHbx/Bnsf7YbpTzkk/Nxh87IF6yUAZLQ+oCmeiLB5ofAHe+s49IQpF6h08TcdMmOqzns5hbARPpg==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@bruits/satteri-wasm32-wasi@0.5.1': resolution: {integrity: sha512-VMthsKH6/NZ6UAW7HpPmcEfteSDtitBlR9zpPz2NBnUpqDdCQTuytxqYvRrn8YetClQms48tjK+AQU1ds6Mrfg==} engines: {node: '>=14.0.0'} cpu: [wasm32] + '@bruits/satteri-wasm32-wasi@0.6.0': + resolution: {integrity: sha512-aYmeGE5dXIm6KXVvRy3ojsm5Wt/GaaEYbJiB+U8xy27mB7dkzWOeD1uuZ4ACmIP70a90q8VOfddnzQceRXksIg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + '@bruits/satteri-win32-x64-msvc@0.5.1': resolution: {integrity: sha512-PEuchy+REgFcsDI1JnRLMmTuZAZ1LFGgvTZodU5G3JDUqJT9deJgF4iPH2s5cKPgOfPi9OgQbi+ok6kQalDEoQ==} cpu: [x64] os: [win32] + '@bruits/satteri-win32-x64-msvc@0.6.0': + resolution: {integrity: sha512-o2/bz5SZ/H1NnKglBv4JV1mNW6ORb7MLsElIDsR+iEYAfRH32S1cKPiG0NqZ2aHZQhnILIqWRBywJqadNA8kZg==} + cpu: [x64] + os: [win32] + '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} @@ -15421,6 +15447,9 @@ packages: satteri@0.5.1: resolution: {integrity: sha512-myWnXRT70IY5i7pinRErW/g7twCs8t6RwAd6Rj0oiak4WEx4XM3txX+thc1fxWko2oXkvTsF7lxS5wEST4+EcQ==} + satteri@0.6.0: + resolution: {integrity: sha512-86mMXZrvuP9mnoFF9y8PjbSmiWFYJy3w4Rjj+etRHXA2e5Vu+M+DNjuhYJyrcFOXjCLw8Nfs0gSNyimAaPbhUw==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -17866,12 +17895,21 @@ snapshots: '@bruits/satteri-darwin-arm64@0.5.1': optional: true + '@bruits/satteri-darwin-arm64@0.6.0': + optional: true + '@bruits/satteri-darwin-x64@0.5.1': optional: true + '@bruits/satteri-darwin-x64@0.6.0': + optional: true + '@bruits/satteri-linux-x64-gnu@0.5.1': optional: true + '@bruits/satteri-linux-x64-gnu@0.6.0': + optional: true + '@bruits/satteri-wasm32-wasi@0.5.1': dependencies: '@emnapi/core': 1.9.1 @@ -17879,9 +17917,19 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optional: true + '@bruits/satteri-wasm32-wasi@0.6.0': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + optional: true + '@bruits/satteri-win32-x64-msvc@0.5.1': optional: true + '@bruits/satteri-win32-x64-msvc@0.6.0': + optional: true + '@capsizecss/unpack@4.0.0': dependencies: fontkitten: 1.0.2 @@ -26663,6 +26711,18 @@ snapshots: '@bruits/satteri-wasm32-wasi': 0.5.1 '@bruits/satteri-win32-x64-msvc': 0.5.1 + satteri@0.6.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.6.0 + '@bruits/satteri-darwin-x64': 0.6.0 + '@bruits/satteri-linux-x64-gnu': 0.6.0 + '@bruits/satteri-wasm32-wasi': 0.6.0 + '@bruits/satteri-win32-x64-msvc': 0.6.0 + sax@1.6.0: {} scheduler@0.23.2: From 857f3225f63971bd424fb2dd866d295c2240e9db Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Mon, 18 May 2026 23:01:04 +0200 Subject: [PATCH 12/26] fix: update deps --- packages/markdown/satteri/package.json | 2 +- pnpm-lock.yaml | 106 +++++-------------------- 2 files changed, 23 insertions(+), 85 deletions(-) diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index 6e7f02074ed4..24a8933e860e 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -35,7 +35,7 @@ "dependencies": { "@astrojs/internal-helpers": "workspace:*", "github-slugger": "^2.0.0", - "satteri": "^0.5.1", + "satteri": "^0.6.0", "shiki": "^4.0.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aef9d64d372c..21336c265811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,10 +125,10 @@ importers: devDependencies: '@codspeed/vitest-plugin': specifier: 5.2.0 - version: 5.2.0(tinybench@2.9.0)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 5.2.0(tinybench@2.9.0)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))) vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) benchmark/packages/adapter: dependencies: @@ -247,7 +247,7 @@ importers: version: 18.3.1(react@18.3.1) vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) devDependencies: '@types/react': specifier: ^18.3.28 @@ -521,7 +521,7 @@ importers: version: link:../../packages/astro vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) packages/astro: dependencies: @@ -783,7 +783,7 @@ importers: version: 11.0.5 vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) optionalDependencies: sharp: specifier: ^0.34.0 @@ -4323,7 +4323,7 @@ importers: version: link:../../.. vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) packages/astro/test/fixtures/vue-component: dependencies: @@ -7000,8 +7000,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 satteri: - specifier: ^0.5.1 - version: 0.5.1 + specifier: ^0.6.0 + version: 0.6.0 shiki: specifier: ^4.0.0 version: 4.0.2 @@ -7667,53 +7667,27 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - '@bruits/satteri-darwin-arm64@0.5.1': - resolution: {integrity: sha512-sOsfu2KKNqELLkSpiGCBqwXOgMyF2zYENWEtvynt57C9x/fxgfCexuQ6cs6EorqLXVEfSaZ0JHWEXErkyOfjkA==} - cpu: [arm64] - os: [darwin] - '@bruits/satteri-darwin-arm64@0.6.0': resolution: {integrity: sha512-J+3cxmiCDtVCo/rwr0f7I+MgR2YUhjc45NbG/Rt4yR/nv/fqeA3FmdLSZMjx9OfF+oAZMR4b9WD93e5nN2J+xA==} cpu: [arm64] os: [darwin] - '@bruits/satteri-darwin-x64@0.5.1': - resolution: {integrity: sha512-xq1+KL0pKGQ+ldJyqEA53O4fwViAeUWqjEGUt+ZkqywX2Rx89hnT1OaIutbvP9dWXhfRSekPGxQhZI9A2lgRhQ==} - cpu: [x64] - os: [darwin] - '@bruits/satteri-darwin-x64@0.6.0': resolution: {integrity: sha512-hybKk5Yu1FQk+CwCJn3JUdkMoEuMyhWlDL2eaclVsbskn9D/miAYgf9MQp3DZzifGXg5rflxOp6CvPa8uNuaPA==} cpu: [x64] os: [darwin] - '@bruits/satteri-linux-x64-gnu@0.5.1': - resolution: {integrity: sha512-uzxR6uRdnjz+u7zsXWxBzEJpEfT1hlTvPzP19RKTPxeNPoS+hRNGTka41k/Vyl6/+a3Ry0pHA5dlREtQotydBQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@bruits/satteri-linux-x64-gnu@0.6.0': resolution: {integrity: sha512-47DzI6OG1YrHbx/Bnsf7YbpTzkk/Nxh87IF6yUAZLQ+oCmeiLB5ofAHe+s49IQpF6h08TcdMmOqzns5hbARPpg==} cpu: [x64] os: [linux] libc: [glibc] - '@bruits/satteri-wasm32-wasi@0.5.1': - resolution: {integrity: sha512-VMthsKH6/NZ6UAW7HpPmcEfteSDtitBlR9zpPz2NBnUpqDdCQTuytxqYvRrn8YetClQms48tjK+AQU1ds6Mrfg==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - '@bruits/satteri-wasm32-wasi@0.6.0': resolution: {integrity: sha512-aYmeGE5dXIm6KXVvRy3ojsm5Wt/GaaEYbJiB+U8xy27mB7dkzWOeD1uuZ4ACmIP70a90q8VOfddnzQceRXksIg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@bruits/satteri-win32-x64-msvc@0.5.1': - resolution: {integrity: sha512-PEuchy+REgFcsDI1JnRLMmTuZAZ1LFGgvTZodU5G3JDUqJT9deJgF4iPH2s5cKPgOfPi9OgQbi+ok6kQalDEoQ==} - cpu: [x64] - os: [win32] - '@bruits/satteri-win32-x64-msvc@0.6.0': resolution: {integrity: sha512-o2/bz5SZ/H1NnKglBv4JV1mNW6ORb7MLsElIDsR+iEYAfRH32S1cKPiG0NqZ2aHZQhnILIqWRBywJqadNA8kZg==} cpu: [x64] @@ -10633,6 +10607,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/alpinejs@3.13.11': resolution: {integrity: sha512-3KhGkDixCPiLdL3Z/ok1GxHwLxEWqQOKJccgaQL01wc0EVM2tCTaqlC3NIedmxAXkVzt/V6VTM8qPgnOHKJ1MA==} @@ -15444,9 +15421,6 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - satteri@0.5.1: - resolution: {integrity: sha512-myWnXRT70IY5i7pinRErW/g7twCs8t6RwAd6Rj0oiak4WEx4XM3txX+thc1fxWko2oXkvTsF7lxS5wEST4+EcQ==} - satteri@0.6.0: resolution: {integrity: sha512-86mMXZrvuP9mnoFF9y8PjbSmiWFYJy3w4Rjj+etRHXA2e5Vu+M+DNjuhYJyrcFOXjCLw8Nfs0gSNyimAaPbhUw==} @@ -16576,6 +16550,7 @@ packages: '@vitest/ui': 4.1.0 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -17892,31 +17867,15 @@ snapshots: '@borewit/text-codec@0.2.2': {} - '@bruits/satteri-darwin-arm64@0.5.1': - optional: true - '@bruits/satteri-darwin-arm64@0.6.0': optional: true - '@bruits/satteri-darwin-x64@0.5.1': - optional: true - '@bruits/satteri-darwin-x64@0.6.0': optional: true - '@bruits/satteri-linux-x64-gnu@0.5.1': - optional: true - '@bruits/satteri-linux-x64-gnu@0.6.0': optional: true - '@bruits/satteri-wasm32-wasi@0.5.1': - dependencies: - '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - optional: true - '@bruits/satteri-wasm32-wasi@0.6.0': dependencies: '@emnapi/core': 1.9.1 @@ -17924,9 +17883,6 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optional: true - '@bruits/satteri-win32-x64-msvc@0.5.1': - optional: true - '@bruits/satteri-win32-x64-msvc@0.6.0': optional: true @@ -18179,12 +18135,12 @@ snapshots: transitivePeerDependencies: - debug - '@codspeed/vitest-plugin@5.2.0(tinybench@2.9.0)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))': + '@codspeed/vitest-plugin@5.2.0(tinybench@2.9.0)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: '@codspeed/core': 5.2.0 tinybench: 2.9.0 vite: 8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) - vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) + vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - debug @@ -19464,14 +19420,14 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.2 optional: true '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.2 optional: true '@neon-rs/load@0.0.4': {} @@ -20909,6 +20865,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + '@types/alpinejs@3.13.11': {} '@types/aria-query@5.0.4': {} @@ -26699,18 +26660,6 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 - satteri@0.5.1: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - optionalDependencies: - '@bruits/satteri-darwin-arm64': 0.5.1 - '@bruits/satteri-darwin-x64': 0.5.1 - '@bruits/satteri-linux-x64-gnu': 0.5.1 - '@bruits/satteri-wasm32-wasi': 0.5.1 - '@bruits/satteri-win32-x64-msvc': 0.5.1 - satteri@0.6.0: dependencies: '@types/hast': 3.0.4 @@ -27913,7 +27862,7 @@ snapshots: optionalDependencies: vite: 8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) - vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3): + vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.0 '@vitest/mocker': 4.1.0(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -27939,18 +27888,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@types/node': 25.2.3 transitivePeerDependencies: - - '@vitejs/devtools' - - esbuild - - jiti - - less - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml volar-service-css@0.0.70(@volar/language-service@2.4.28): dependencies: From e9e0a6e78bd3efd5a165a10c2bdd1e35054e4b8a Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 19 May 2026 03:32:07 +0200 Subject: [PATCH 13/26] fix: don't blow up memory all over --- packages/astro/src/core/config/merge.ts | 13 +++++++ packages/integrations/mdx/src/index.ts | 37 +++++++++++-------- .../integrations/mdx/test/mdx-plugins.test.ts | 26 ++++++++++--- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/packages/astro/src/core/config/merge.ts b/packages/astro/src/core/config/merge.ts index 5f6487147998..b51e540d8e45 100644 --- a/packages/astro/src/core/config/merge.ts +++ b/packages/astro/src/core/config/merge.ts @@ -58,6 +58,19 @@ function mergeConfigRecursively( merged[key] = value; continue; } + // Markdown processor descriptors (satteri(), unified(), third-party) are opaque + // objects with a `createRenderer` function — treat them as atomic so swapping + // processors via updateConfig replaces cleanly instead of merging fields from + // the previous descriptor into the new one. + if ( + key === 'processor' && + rootPath === 'markdown' && + isObject(value) && + typeof value.createRenderer === 'function' + ) { + merged[key] = value; + continue; + } if (isObject(existing) && isObject(value)) { merged[key] = mergeConfigRecursively(existing, value, rootPath ? `${rootPath}.${key}` : key); continue; diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index 5f4ed08ecf8f..da77ed448003 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -125,15 +125,16 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI const descriptor = partialMdxOptions.processor ?? config.markdown.processor; if (extendMarkdownConfig) { + // Per docs: when MDX provides its own plugin list, it REPLACES the + // markdown processor's plugins; when MDX omits it, MDX inherits. + // (Object-shaped options like `remarkRehype`/`features` still merge.) if (isSatteriProcessor(descriptor)) { - resolvedMdxOptions.mdastPlugins = [ - ...descriptor.mdastPlugins, - ...resolvedMdxOptions.mdastPlugins, - ]; - resolvedMdxOptions.hastPlugins = [ - ...descriptor.hastPlugins, - ...resolvedMdxOptions.hastPlugins, - ]; + if (partialMdxOptions.mdastPlugins === undefined) { + resolvedMdxOptions.mdastPlugins = [...descriptor.mdastPlugins]; + } + if (partialMdxOptions.hastPlugins === undefined) { + resolvedMdxOptions.hastPlugins = [...descriptor.hastPlugins]; + } if (descriptor.features || resolvedMdxOptions.features) { resolvedMdxOptions.features = { ...descriptor.features, @@ -141,14 +142,18 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI }; } } else if (isUnifiedProcessor(descriptor)) { - resolvedMdxOptions.remarkPlugins = [ - ...ignoreStringPlugins(descriptor.remarkPlugins, logger), - ...resolvedMdxOptions.remarkPlugins, - ]; - resolvedMdxOptions.rehypePlugins = [ - ...ignoreStringPlugins(descriptor.rehypePlugins, logger), - ...resolvedMdxOptions.rehypePlugins, - ]; + if (partialMdxOptions.remarkPlugins === undefined) { + resolvedMdxOptions.remarkPlugins = ignoreStringPlugins( + descriptor.remarkPlugins, + logger, + ); + } + if (partialMdxOptions.rehypePlugins === undefined) { + resolvedMdxOptions.rehypePlugins = ignoreStringPlugins( + descriptor.rehypePlugins, + logger, + ); + } resolvedMdxOptions.remarkRehype = { ...descriptor.remarkRehype, ...resolvedMdxOptions.remarkRehype, diff --git a/packages/integrations/mdx/test/mdx-plugins.test.ts b/packages/integrations/mdx/test/mdx-plugins.test.ts index f6f88bafabcd..ac4166a7dd53 100644 --- a/packages/integrations/mdx/test/mdx-plugins.test.ts +++ b/packages/integrations/mdx/test/mdx-plugins.test.ts @@ -84,17 +84,30 @@ describe('MDX plugins - Astro config integration', () => { const html = await fixture.readFile(FILE); const { document } = parseHTML(html); - assert.notEqual(selectRemarkExample(document), null, 'MDX remark plugins not applied.'); - assert.notEqual(selectRehypeExample(document), null, 'MDX rehype plugins not applied.'); + // Coerce element/null to boolean before asserting — assert.equal on a raw + // linkedom Element vs null pulls util.inspect through the DOM's circular + // refs and can take down the test process on failure. + assert.equal( + selectRemarkExample(document) !== null, + true, + 'MDX remark plugins not applied.', + ); + assert.equal( + selectRehypeExample(document) !== null, + true, + 'MDX rehype plugins not applied.', + ); }); it('Handles Markdown plugins', async () => { const html = await fixture.readFile(FILE); const { document } = parseHTML(html); + // MDX provides its own `remarkPlugins`, so per docs it replaces + // markdown's `remarkToc` regardless of `extendMarkdownConfig`. assert.equal( - selectTocLink(document), - null, + selectTocLink(document) !== null, + false, '`remarkToc` plugin applied unexpectedly. Should override Markdown config.', ); }); @@ -103,10 +116,11 @@ describe('MDX plugins - Astro config integration', () => { const html = await fixture.readFile(FILE); const { document } = parseHTML(html); + const gfmLinkPresent = selectGfmLink(document) !== null; if (extendMarkdownConfig === true) { - assert.equal(selectGfmLink(document), null, 'Does not respect `markdown.gfm` option.'); + assert.equal(gfmLinkPresent, false, 'Does not respect `markdown.gfm` option.'); } else { - assert.notEqual(selectGfmLink(document), null, 'Respects `markdown.gfm` unexpectedly.'); + assert.equal(gfmLinkPresent, true, 'Respects `markdown.gfm` unexpectedly.'); } }); From a1e593a592fd1286ac50c302c3b249b5016165d0 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 19 May 2026 03:44:09 +0200 Subject: [PATCH 14/26] chore: changeset --- .changeset/mdx-processor-option.md | 17 +++++++++++ .changeset/seven-emus-knock.md | 46 ++++++------------------------ 2 files changed, 25 insertions(+), 38 deletions(-) create mode 100644 .changeset/mdx-processor-option.md diff --git a/.changeset/mdx-processor-option.md b/.changeset/mdx-processor-option.md new file mode 100644 index 000000000000..76985800467b --- /dev/null +++ b/.changeset/mdx-processor-option.md @@ -0,0 +1,17 @@ +--- +'@astrojs/mdx': major +--- + +`@astrojs/mdx` now picks up the processor from `markdown.processor` automatically, so a single Sätteri or unified configuration drives both `.md` and `.mdx` files. To run `.mdx` files through a different processor (or the same processor with different options) than your `.md` files, pass the `processor` option to the integration: + +```js +// astro.config.mjs +import { defineConfig, satteri } from 'astro/config'; +import mdx from '@astrojs/mdx'; +import { unified } from '@astrojs/markdown-remark'; + +export default defineConfig({ + markdown: { processor: satteri() }, + integrations: [mdx({ processor: unified({ remarkPlugins: [/* ... */] }) })], +}); +``` diff --git a/.changeset/seven-emus-knock.md b/.changeset/seven-emus-knock.md index fb08d4c337dd..e48dde6f4fdb 100644 --- a/.changeset/seven-emus-knock.md +++ b/.changeset/seven-emus-knock.md @@ -2,21 +2,11 @@ 'astro': major '@astrojs/markdown-remark': major '@astrojs/markdown-satteri': major -'@astrojs/mdx': major --- -Adds the new `markdown.processor` config option, which uses [satteri](https://github.com/bruits/satteri) (a Rust-based Markdown/MDX compiler) by default. Both `.md` and `.mdx` files are now rendered through satteri unless you opt back into the legacy remark/rehype pipeline with the new `unified()` factory. +Adds a `markdown.processor` config option to configure the Markdown processor to use. By default, [Sätteri](https://github.com/bruits/satteri) a fast Rust-based Markdown/MDX compiler is used. Both `.md` and `.mdx` files now render through Sätteri. -The previous `experimental.nativeMarkdown` flag has been removed; the same functionality is now available without an experimental opt-in: - -```js -// astro.config.mjs — default, no changes needed -import { defineConfig } from 'astro/config'; - -export default defineConfig({}); -``` - -To pass satteri plugins or enable additional parser features, use the new `satteri()` factory: +To pass Sätteri plugins or enable additional parser features, use the `satteri()` explicitely: ```js // astro.config.mjs @@ -32,7 +22,11 @@ export default defineConfig({ }); ``` -To keep using your existing remark/rehype plugins, opt into the `unified()` processor from `@astrojs/markdown-remark`: +To keep using your existing remark/rehype plugins, install `@astrojs/markdown-remark` into your project and use the `unified` export: + +```sh +pnpm add @astrojs/markdown-remark +``` ```js // astro.config.mjs @@ -47,28 +41,4 @@ export default defineConfig({ }); ``` -The top-level `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` options are deprecated. They continue to work when `@astrojs/markdown-remark` is installed — Astro automatically wraps them in `unified({...})` and prints a deprecation warning — but you should migrate to the new factory form. - -The satteri processor (and its shared plugin factories) have been extracted from `@astrojs/markdown-remark` into a new dedicated `@astrojs/markdown-satteri` package, which Astro now ships with by default — no extra install needed. - -`@astrojs/markdown-remark` is no longer a transitive dependency of `astro`. If you want the legacy `unified()` pipeline (either directly via `markdown.processor: unified({...})` or indirectly via the deprecated `markdown.remarkPlugins` / `rehypePlugins` / `remarkRehype` fields), install it explicitly: - -```sh -pnpm add @astrojs/markdown-remark -``` - -`@astrojs/mdx` now picks up the processor from `markdown.processor` automatically, so a single satteri or unified configuration drives both `.md` and `.mdx` files. To run `.mdx` files through a different processor (or the same processor with different options) than your `.md` files, pass the `processor` option to the integration: - -```js -// astro.config.mjs -import { defineConfig, satteri } from 'astro/config'; -import mdx from '@astrojs/mdx'; -import { unified } from '@astrojs/markdown-remark'; - -export default defineConfig({ - markdown: { processor: satteri() }, - integrations: [mdx({ processor: unified({ remarkPlugins: [/* ... */] }) })], -}); -``` - -Third-party processors can plug into the same pipeline by exporting a factory whose return value implements the `MarkdownProcessorEntry` interface (exported from `astro/markdown`). Implement `createRenderer` for `.md` support, and optionally `createMdxRenderer` to enable `.mdx` rendering. +The top-level `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` options are deprecated. They'll continue to work when `@astrojs/markdown-remark` is installed for now, but this will be removed in the next major. From a108c433281e6be0f7e320e1234075a01f20ee64 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 19 May 2026 04:09:19 +0200 Subject: [PATCH 15/26] fix: tests --- knip.js | 5 + .../image-remark-imgattr/astro.config.mjs | 3 +- satteri-inline-component-shadow.md | 116 ++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 satteri-inline-component-shadow.md diff --git a/knip.js b/knip.js index 758f73be343c..019a741fbd9b 100644 --- a/knip.js +++ b/knip.js @@ -70,6 +70,11 @@ export default { // False positive because of cloudflare:workers ignoreDependencies: ['cloudflare'], }, + 'packages/integrations/mdx': { + entry: [srcEntry, dtsEntry, testEntry], + // Optional peer dep, only loaded when the user picks the unified processor + ignoreDependencies: ['@astrojs/markdown-remark'], + }, 'packages/integrations/netlify': { entry: [srcEntry, dtsEntry, testEntry], ignore: ['test/hosted/**'], diff --git a/packages/integrations/mdx/test/fixtures/image-remark-imgattr/astro.config.mjs b/packages/integrations/mdx/test/fixtures/image-remark-imgattr/astro.config.mjs index 7cd97f8b14a6..681653342969 100644 --- a/packages/integrations/mdx/test/fixtures/image-remark-imgattr/astro.config.mjs +++ b/packages/integrations/mdx/test/fixtures/image-remark-imgattr/astro.config.mjs @@ -1,8 +1,9 @@ import mdx from '@astrojs/mdx'; +import { unified } from '@astrojs/markdown-remark'; import { defineConfig } from 'astro/config'; import plugin from "./remarkPlugin" // https://astro.build/config export default defineConfig({ - integrations: [mdx({remarkPlugins:[plugin]})], + integrations: [mdx({ processor: unified({ remarkPlugins: [plugin] }) })], }); diff --git a/satteri-inline-component-shadow.md b/satteri-inline-component-shadow.md new file mode 100644 index 000000000000..a39fe98d2f9a --- /dev/null +++ b/satteri-inline-component-shadow.md @@ -0,0 +1,116 @@ +# Sätteri MDX bug: local bindings shadowed by `_components` destructuring + +## Summary + +When an MDX file uses a JSX component whose name is already bound at module scope (either imported or exported locally), Sätteri's `mdxToJs` still emits a `const { Name } = _components` destructuring inside `_createMdxContent`. That destructuring shadows the module-level binding and, when `props.components.Name` is not supplied, hits `_missingMdxReference("Name", true)` and throws. + +Standard `@mdx-js/mdx` doesn't destructure names that already have a module-scope binding. The local binding wins. + +## Repro + +```js +import { mdxToJs } from 'satteri'; + +const { code } = await mdxToJs( + `export const Comp = () => Comp\n\n\n`, + { jsxImportSource: 'astro', filename: 'test.mdx' }, +); +console.log(code); +``` + +Sätteri output (the broken part): + +```js +export const Comp = () => _jsx("span", { children: "Comp" }); + +function _createMdxContent(props) { + const _components = Object.assign({ p: "p" }, props.components); + const { Comp } = _components; // shadows module-level Comp + if (!Comp) _missingMdxReference("Comp", true); // throws when prop not provided + return _jsx(_components.p, { children: _jsx(Comp, {}) }); +} +``` + +`@mdx-js/mdx` output for the same input: + +```js +const Comp = () => Comp; + +function _createMdxContent(props) { + const _components = { p: "p", ...props.components }; + return <_components.p>; +} +``` + +## Cases to cover + +The fix needs to skip the destructuring + missing-reference check for any uppercase JSX identifier that already has a module-scope binding. Sources of bindings: + +1. Top-level `export const X = …` / `export function X() {…}` / `export class X {}`. +2. `export { X }` re-exports. +3. Top-level `import X from …` (default). +4. Top-level `import { X } from …` (named) and `import { Y as X }`. +5. Top-level `import * as X from …`. +6. Top-level `const X = …` / `let X = …` / `function X(){}` / `class X {}` that aren't exported (rare in MDX but legal). + +Identifiers used in JSX that have *no* module-scope binding should still be destructured and checked — that path is correct. + +Mixed example (only `NotInScope` should be destructured): + +```mdx +import Other from './other.jsx' +export const Comp = () => Comp + + and and +``` + +Current Sätteri output (incorrect): + +```js +const { Comp, NotInScope } = _components; +if (!Comp) _missingMdxReference("Comp", true); +if (!NotInScope) _missingMdxReference("NotInScope", true); +``` + +Expected: + +```js +const { NotInScope } = _components; +if (!NotInScope) _missingMdxReference("NotInScope", true); +``` + +`Comp` resolves to the module-level `const Comp`, `Other` resolves to the import. + +## Fix plan + +In the MDX-to-JS lowering pass (the stage that synthesizes `_createMdxContent`): + +1. Build a set of module-scope binding names before walking JSX. Walk the program's top-level statements once and collect names from imports, exports, and top-level declarations. +2. When building the list of JSX identifiers to surface from `_components`, exclude any name present in that set. +3. When emitting the `if (!Name) _missingMdxReference("Name", true)` guards, exclude the same names. +4. If the resulting destructuring pattern is empty, omit the `const { } = _components;` statement entirely. + +The set should respect lexical scope: a JSX identifier inside a nested function that shadows a module-scope name with a local declaration shouldn't be excluded. In practice MDX content is at the top level of `_createMdxContent`, so module-scope is the only scope that matters here, but the implementation should still walk scopes correctly to avoid future regressions. + +## Tests to add in Sätteri + +Add cases to whatever harness covers `mdxToJs`: + +1. `export const Comp = …` then `` — destructuring should not include `Comp`; no missing-ref guard for `Comp`. +2. `import X from 'foo'` then `` — same. +3. `import { X } from 'foo'` then `` — same. +4. `import * as N from 'foo'` then `` — namespace member access uses `N` directly; `N` should not be destructured. +5. Mixed: locally-bound `` plus unbound `` — destructuring should only include `Other`, only `Other` gets a missing-ref guard. +6. `export { X } from './x.js'` — re-exports also bind `X` in scope; should be excluded. +7. Lowercase tags (`

`, `

`) — still get pulled from `_components` (unchanged). + +## Verification in Astro + +After Sätteri ships the fix: + +1. Bump `satteri` and `@astrojs/markdown-satteri` to the patched version. +2. `pnpm --filter @astrojs/mdx test`. The 5 cascaded failures in `test/mdx-plus-react.test.ts` should clear without changes to `inline-component.mdx` or the test file. The build error currently raised at `src/pages/inline-component.mdx` during the fixture's `before` hook is the only thing keeping them red. + +## Workaround (only if Sätteri can't ship soon) + +Post-process the compiled JS in `packages/integrations/mdx/src/satteri/index.ts` after `mdxToJs` returns: scan top-level exports/imports with `es-module-lexer`, then strip matching entries from `const { … } = _components;` and remove the corresponding `_missingMdxReference` lines. This is regex-fragile and shouldn't ship if the upstream fix is in reach. From 8857e172dd06d51ef8dcaa372552e7996a0e1298 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 19 May 2026 13:03:51 +0200 Subject: [PATCH 16/26] fix: update satteri --- packages/integrations/mdx/package.json | 2 +- packages/markdown/satteri/package.json | 2 +- pnpm-lock.yaml | 84 ++++++++++-------- satteri-inline-component-shadow.md | 116 ------------------------- 4 files changed, 49 insertions(+), 155 deletions(-) delete mode 100644 satteri-inline-component-shadow.md diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 87f8706e9050..ad3c7e9c71e6 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -44,7 +44,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", - "satteri": "^0.6.0", + "satteri": "^0.6.1", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index 24a8933e860e..8db164ea8817 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -35,7 +35,7 @@ "dependencies": { "@astrojs/internal-helpers": "workspace:*", "github-slugger": "^2.0.0", - "satteri": "^0.6.0", + "satteri": "^0.6.1", "shiki": "^4.0.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21336c265811..b116a5347b77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,10 +125,10 @@ importers: devDependencies: '@codspeed/vitest-plugin': specifier: 5.2.0 - version: 5.2.0(tinybench@2.9.0)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))) + version: 5.2.0(tinybench@2.9.0)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) benchmark/packages/adapter: dependencies: @@ -247,7 +247,7 @@ importers: version: 18.3.1(react@18.3.1) vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) devDependencies: '@types/react': specifier: ^18.3.28 @@ -521,7 +521,7 @@ importers: version: link:../../packages/astro vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) packages/astro: dependencies: @@ -783,7 +783,7 @@ importers: version: 11.0.5 vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) optionalDependencies: sharp: specifier: ^0.34.0 @@ -4323,7 +4323,7 @@ importers: version: link:../../.. vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) packages/astro/test/fixtures/vue-component: dependencies: @@ -5348,8 +5348,8 @@ importers: specifier: ^3.0.2 version: 3.0.2 satteri: - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.6.1 + version: 0.6.1 source-map: specifier: ^0.7.6 version: 0.7.6 @@ -7000,8 +7000,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 satteri: - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.6.1 + version: 0.6.1 shiki: specifier: ^4.0.0 version: 4.0.2 @@ -7667,29 +7667,29 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - '@bruits/satteri-darwin-arm64@0.6.0': - resolution: {integrity: sha512-J+3cxmiCDtVCo/rwr0f7I+MgR2YUhjc45NbG/Rt4yR/nv/fqeA3FmdLSZMjx9OfF+oAZMR4b9WD93e5nN2J+xA==} + '@bruits/satteri-darwin-arm64@0.6.1': + resolution: {integrity: sha512-NKLcnlY0AOmCTWUziyxRc0U2A/+q0399bdn/ljNWHhb0s8H9jKIgTNtqi1f/GcJUX3kmTPAprBCJm+tempB6Mg==} cpu: [arm64] os: [darwin] - '@bruits/satteri-darwin-x64@0.6.0': - resolution: {integrity: sha512-hybKk5Yu1FQk+CwCJn3JUdkMoEuMyhWlDL2eaclVsbskn9D/miAYgf9MQp3DZzifGXg5rflxOp6CvPa8uNuaPA==} + '@bruits/satteri-darwin-x64@0.6.1': + resolution: {integrity: sha512-b/cZLW/JkUreNHM7HebE7pStXU6MHHgdaYq8M4Kr+9n2BYtFky1AHSVlF5n4uUOcLZocP4g8On3vBcYwm/j8Mw==} cpu: [x64] os: [darwin] - '@bruits/satteri-linux-x64-gnu@0.6.0': - resolution: {integrity: sha512-47DzI6OG1YrHbx/Bnsf7YbpTzkk/Nxh87IF6yUAZLQ+oCmeiLB5ofAHe+s49IQpF6h08TcdMmOqzns5hbARPpg==} + '@bruits/satteri-linux-x64-gnu@0.6.1': + resolution: {integrity: sha512-70RLvQuy8wiujTd/1+jJv1Qghj6mDhUWYCsbZtuEvoZ06rlDboyyACjXb9KQluPUbTSVTPVMHvLTZ1W0wzcaZA==} cpu: [x64] os: [linux] libc: [glibc] - '@bruits/satteri-wasm32-wasi@0.6.0': - resolution: {integrity: sha512-aYmeGE5dXIm6KXVvRy3ojsm5Wt/GaaEYbJiB+U8xy27mB7dkzWOeD1uuZ4ACmIP70a90q8VOfddnzQceRXksIg==} + '@bruits/satteri-wasm32-wasi@0.6.1': + resolution: {integrity: sha512-NuzychCFthdqb+LJecvrn/qEtFVZWq7aNwddYDM3VsEJuFP2BWEvQrTN+jCrdGDg/H45nfuzGlP/rDO3i901iw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@bruits/satteri-win32-x64-msvc@0.6.0': - resolution: {integrity: sha512-o2/bz5SZ/H1NnKglBv4JV1mNW6ORb7MLsElIDsR+iEYAfRH32S1cKPiG0NqZ2aHZQhnILIqWRBywJqadNA8kZg==} + '@bruits/satteri-win32-x64-msvc@0.6.1': + resolution: {integrity: sha512-hakBJQvKVD+dT4iChz/bF3NI58AJNu5LQsQLhaMqTd5FBu87No0+LKRhyAtApzVGEPlR4TfABH753s06c0C38g==} cpu: [x64] os: [win32] @@ -15421,8 +15421,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - satteri@0.6.0: - resolution: {integrity: sha512-86mMXZrvuP9mnoFF9y8PjbSmiWFYJy3w4Rjj+etRHXA2e5Vu+M+DNjuhYJyrcFOXjCLw8Nfs0gSNyimAaPbhUw==} + satteri@0.6.1: + resolution: {integrity: sha512-+18droHlUtbxUC1uH51LBlwiaSe41cV4kqJwcGwbS7scy7i0l37y6d13QS0YALnpYzA3QxEfw/WYtlB090k0rg==} sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} @@ -16550,7 +16550,6 @@ packages: '@vitest/ui': 4.1.0 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -17867,23 +17866,23 @@ snapshots: '@borewit/text-codec@0.2.2': {} - '@bruits/satteri-darwin-arm64@0.6.0': + '@bruits/satteri-darwin-arm64@0.6.1': optional: true - '@bruits/satteri-darwin-x64@0.6.0': + '@bruits/satteri-darwin-x64@0.6.1': optional: true - '@bruits/satteri-linux-x64-gnu@0.6.0': + '@bruits/satteri-linux-x64-gnu@0.6.1': optional: true - '@bruits/satteri-wasm32-wasi@0.6.0': + '@bruits/satteri-wasm32-wasi@0.6.1': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optional: true - '@bruits/satteri-win32-x64-msvc@0.6.0': + '@bruits/satteri-win32-x64-msvc@0.6.1': optional: true '@capsizecss/unpack@4.0.0': @@ -18135,12 +18134,12 @@ snapshots: transitivePeerDependencies: - debug - '@codspeed/vitest-plugin@5.2.0(tinybench@2.9.0)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)))': + '@codspeed/vitest-plugin@5.2.0(tinybench@2.9.0)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@codspeed/core': 5.2.0 tinybench: 2.9.0 vite: 8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) - vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - debug @@ -26660,17 +26659,17 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 - satteri@0.6.0: + satteri@0.6.1: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 optionalDependencies: - '@bruits/satteri-darwin-arm64': 0.6.0 - '@bruits/satteri-darwin-x64': 0.6.0 - '@bruits/satteri-linux-x64-gnu': 0.6.0 - '@bruits/satteri-wasm32-wasi': 0.6.0 - '@bruits/satteri-win32-x64-msvc': 0.6.0 + '@bruits/satteri-darwin-arm64': 0.6.1 + '@bruits/satteri-darwin-x64': 0.6.1 + '@bruits/satteri-linux-x64-gnu': 0.6.1 + '@bruits/satteri-wasm32-wasi': 0.6.1 + '@bruits/satteri-win32-x64-msvc': 0.6.1 sax@1.6.0: {} @@ -27862,7 +27861,7 @@ snapshots: optionalDependencies: vite: 8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) - vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@vitest/expect': 4.1.0 '@vitest/mocker': 4.1.0(vite@8.0.10(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -27888,7 +27887,18 @@ snapshots: '@opentelemetry/api': 1.9.0 '@types/node': 25.2.3 transitivePeerDependencies: + - '@vitejs/devtools' + - esbuild + - jiti + - less - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml volar-service-css@0.0.70(@volar/language-service@2.4.28): dependencies: diff --git a/satteri-inline-component-shadow.md b/satteri-inline-component-shadow.md deleted file mode 100644 index a39fe98d2f9a..000000000000 --- a/satteri-inline-component-shadow.md +++ /dev/null @@ -1,116 +0,0 @@ -# Sätteri MDX bug: local bindings shadowed by `_components` destructuring - -## Summary - -When an MDX file uses a JSX component whose name is already bound at module scope (either imported or exported locally), Sätteri's `mdxToJs` still emits a `const { Name } = _components` destructuring inside `_createMdxContent`. That destructuring shadows the module-level binding and, when `props.components.Name` is not supplied, hits `_missingMdxReference("Name", true)` and throws. - -Standard `@mdx-js/mdx` doesn't destructure names that already have a module-scope binding. The local binding wins. - -## Repro - -```js -import { mdxToJs } from 'satteri'; - -const { code } = await mdxToJs( - `export const Comp = () => Comp\n\n\n`, - { jsxImportSource: 'astro', filename: 'test.mdx' }, -); -console.log(code); -``` - -Sätteri output (the broken part): - -```js -export const Comp = () => _jsx("span", { children: "Comp" }); - -function _createMdxContent(props) { - const _components = Object.assign({ p: "p" }, props.components); - const { Comp } = _components; // shadows module-level Comp - if (!Comp) _missingMdxReference("Comp", true); // throws when prop not provided - return _jsx(_components.p, { children: _jsx(Comp, {}) }); -} -``` - -`@mdx-js/mdx` output for the same input: - -```js -const Comp = () => Comp; - -function _createMdxContent(props) { - const _components = { p: "p", ...props.components }; - return <_components.p>; -} -``` - -## Cases to cover - -The fix needs to skip the destructuring + missing-reference check for any uppercase JSX identifier that already has a module-scope binding. Sources of bindings: - -1. Top-level `export const X = …` / `export function X() {…}` / `export class X {}`. -2. `export { X }` re-exports. -3. Top-level `import X from …` (default). -4. Top-level `import { X } from …` (named) and `import { Y as X }`. -5. Top-level `import * as X from …`. -6. Top-level `const X = …` / `let X = …` / `function X(){}` / `class X {}` that aren't exported (rare in MDX but legal). - -Identifiers used in JSX that have *no* module-scope binding should still be destructured and checked — that path is correct. - -Mixed example (only `NotInScope` should be destructured): - -```mdx -import Other from './other.jsx' -export const Comp = () => Comp - - and and -``` - -Current Sätteri output (incorrect): - -```js -const { Comp, NotInScope } = _components; -if (!Comp) _missingMdxReference("Comp", true); -if (!NotInScope) _missingMdxReference("NotInScope", true); -``` - -Expected: - -```js -const { NotInScope } = _components; -if (!NotInScope) _missingMdxReference("NotInScope", true); -``` - -`Comp` resolves to the module-level `const Comp`, `Other` resolves to the import. - -## Fix plan - -In the MDX-to-JS lowering pass (the stage that synthesizes `_createMdxContent`): - -1. Build a set of module-scope binding names before walking JSX. Walk the program's top-level statements once and collect names from imports, exports, and top-level declarations. -2. When building the list of JSX identifiers to surface from `_components`, exclude any name present in that set. -3. When emitting the `if (!Name) _missingMdxReference("Name", true)` guards, exclude the same names. -4. If the resulting destructuring pattern is empty, omit the `const { } = _components;` statement entirely. - -The set should respect lexical scope: a JSX identifier inside a nested function that shadows a module-scope name with a local declaration shouldn't be excluded. In practice MDX content is at the top level of `_createMdxContent`, so module-scope is the only scope that matters here, but the implementation should still walk scopes correctly to avoid future regressions. - -## Tests to add in Sätteri - -Add cases to whatever harness covers `mdxToJs`: - -1. `export const Comp = …` then `` — destructuring should not include `Comp`; no missing-ref guard for `Comp`. -2. `import X from 'foo'` then `` — same. -3. `import { X } from 'foo'` then `` — same. -4. `import * as N from 'foo'` then `` — namespace member access uses `N` directly; `N` should not be destructured. -5. Mixed: locally-bound `` plus unbound `` — destructuring should only include `Other`, only `Other` gets a missing-ref guard. -6. `export { X } from './x.js'` — re-exports also bind `X` in scope; should be excluded. -7. Lowercase tags (`

`, `

`) — still get pulled from `_components` (unchanged). - -## Verification in Astro - -After Sätteri ships the fix: - -1. Bump `satteri` and `@astrojs/markdown-satteri` to the patched version. -2. `pnpm --filter @astrojs/mdx test`. The 5 cascaded failures in `test/mdx-plus-react.test.ts` should clear without changes to `inline-component.mdx` or the test file. The build error currently raised at `src/pages/inline-component.mdx` during the fixture's `before` hook is the only thing keeping them red. - -## Workaround (only if Sätteri can't ship soon) - -Post-process the compiled JS in `packages/integrations/mdx/src/satteri/index.ts` after `mdxToJs` returns: scan top-level exports/imports with `es-module-lexer`, then strip matching entries from `const { … } = _components;` and remove the corresponding `_missingMdxReference` lines. This is regex-fragile and shouldn't ship if the upstream fix is in reach. From 1fbfd6e903d07cb605a6af2e3f1c1bf09e8d274f Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Wed, 20 May 2026 00:25:19 +0200 Subject: [PATCH 17/26] feat: deprecate other properties --- .../astro/src/core/config/schemas/base.ts | 40 +++++---- packages/astro/src/core/config/validate.ts | 85 +++++++++++++------ packages/astro/src/markdown/index.ts | 8 +- packages/astro/src/types/public/config.ts | 38 +++++---- packages/integrations/mdx/package.json | 3 + packages/integrations/mdx/src/index.ts | 51 ++++++----- .../integrations/mdx/src/satteri/index.ts | 6 +- packages/markdown/remark/src/processor.ts | 34 ++++---- packages/markdown/remark/test/browser.test.ts | 3 - packages/markdown/satteri/src/processor.ts | 39 +++++---- 10 files changed, 181 insertions(+), 126 deletions(-) diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index c32d2dda091e..e276004ddead 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -90,9 +90,10 @@ export const ASTRO_CONFIG_DEFAULTS = { integrations: [], markdown: { ...satteriMarkdownDefaults, - remarkPlugins: [] as RemarkPlugin[], - rehypePlugins: [] as RehypePlugin[], - remarkRehype: {} as RemarkRehype, + remarkPlugins: [], + rehypePlugins: [], + remarkRehype: {}, + processor: satteri(), }, vite: {}, legacy: { @@ -431,20 +432,25 @@ export const AstroConfigSchema = z.object({ }) .prefault(ASTRO_CONFIG_DEFAULTS.markdown.smartypants), processor: z - .custom( - (value): value is MarkdownProcessorEntry => - typeof value === 'object' && - value !== null && - 'name' in value && - typeof value.name === 'string' && - 'createRenderer' in value && - typeof value.createRenderer === 'function', - { - error: - 'markdown.processor must be the return value of a markdown processor factory (e.g. satteri() or unified())', - }, - ) - .default(() => satteri()), + .object({ + name: z.string(), + // `z.custom` preserves reference identity; `z.record` would clone, breaking + // the closure inside `createRenderer` that reads `descriptor.options.*`. + options: z + .custom>( + (v) => typeof v === 'object' && v !== null && !Array.isArray(v), + ) + .default(() => ({})), + createRenderer: z.custom( + (v) => typeof v === 'function', + ), + createMdxRenderer: z + .custom( + (v) => v === undefined || typeof v === 'function', + ) + .optional(), + }) + .default(ASTRO_CONFIG_DEFAULTS.markdown.processor), }) .prefault({}), vite: z diff --git a/packages/astro/src/core/config/validate.ts b/packages/astro/src/core/config/validate.ts index 29fcf89fef3b..818f3f2f6b5f 100644 --- a/packages/astro/src/core/config/validate.ts +++ b/packages/astro/src/core/config/validate.ts @@ -1,4 +1,4 @@ -import type { AstroConfig } from '../../types/public/config.js'; +import type { AstroConfig, AstroUserConfig } from '../../types/public/config.js'; import { errorMap } from '../errors/index.js'; import { AstroConfigRefinedSchema, createRelativeSchema } from './schemas/index.js'; @@ -11,6 +11,7 @@ export async function validateConfig( const AstroConfigRelativeSchema = createRelativeSchema(cmd, root); await coerceLegacyMarkdownPlugins(userConfig); + warnDeprecatedMarkdownOptions(userConfig); // First-Pass Validation return await validateConfigRefined( @@ -28,41 +29,73 @@ export async function validateConfig( ); } +let didWarnAboutDeprecatedMarkdownOptions = false; + +function warnDeprecatedMarkdownOptions( + config: Pick | undefined, +): void { + if (didWarnAboutDeprecatedMarkdownOptions) return; + const md = config?.markdown; + if (!md) return; + const deprecated = (['gfm', 'smartypants'] as const).filter((k) => md[k] !== undefined); + if (deprecated.length === 0) return; + didWarnAboutDeprecatedMarkdownOptions = true; + console.warn( + `[astro] \`markdown.${deprecated.join('\` and `markdown.')}\` are deprecated. Configure these via your processor instead (e.g. \`satteri({ features: { gfm: false } })\` or \`unified({ remarkPlugins: [...] })\`). They'll be removed in a future major.`, + ); +} + +let didWarnAboutLegacyMarkdownPlugins = false; + /** - * Wraps legacy `markdown.{remark,rehype}Plugins` / `remarkRehype` into a - * `unified({...})` processor (with a warning). Mutates `userConfig` in place. + * Folds legacy `markdown.{remark,rehype}Plugins` / `remarkRehype` into a unified + * processor descriptor and clears the legacy keys. Mutates `config` in place. + * Runs on every validate pass so integration-added legacy plugins are picked up. + * Without this they'd land in `config.markdown.*` and be silently dropped. */ -async function coerceLegacyMarkdownPlugins(userConfig: any): Promise { - const md = userConfig?.markdown; - if (!md || typeof md !== 'object' || Array.isArray(md)) return; - if (md.processor) return; +async function coerceLegacyMarkdownPlugins(config: Pick): Promise { + const md = config?.markdown; + if (!md) return; - const hasRemarkPlugins = Array.isArray(md.remarkPlugins) && md.remarkPlugins.length > 0; - const hasRehypePlugins = Array.isArray(md.rehypePlugins) && md.rehypePlugins.length > 0; - const hasRemarkRehype = - md.remarkRehype && - typeof md.remarkRehype === 'object' && - !Array.isArray(md.remarkRehype) && - Object.keys(md.remarkRehype).length > 0; - if (!hasRemarkPlugins && !hasRehypePlugins && !hasRemarkRehype) return; + const remarkPlugins = md.remarkPlugins ?? []; + const rehypePlugins = md.rehypePlugins ?? []; + const remarkRehype = md.remarkRehype ?? {}; + if ( + remarkPlugins.length === 0 && + rehypePlugins.length === 0 && + Object.keys(remarkRehype).length === 0 + ) { + return; + } let unified: typeof import('@astrojs/markdown-remark').unified; + let isUnifiedProcessor: typeof import('@astrojs/markdown-remark').isUnifiedProcessor; try { - ({ unified } = await import('@astrojs/markdown-remark')); + ({ unified, isUnifiedProcessor } = await import('@astrojs/markdown-remark')); } catch { throw new Error( - '`markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` require `@astrojs/markdown-remark`. Install it with:\n pnpm add @astrojs/markdown-remark\n\nThen migrate to the new processor API:\n import { unified } from \'@astrojs/markdown-remark\';\n markdown: { processor: unified({ remarkPlugins: [...] }) }', + '`markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` require `@astrojs/markdown-remark`. Install it with:\n npm install @astrojs/markdown-remark', ); } - console.warn( - '[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` are deprecated. Use `markdown.processor: unified({...})` from `@astrojs/markdown-remark` instead.', - ); - md.processor = unified({ - remarkPlugins: md.remarkPlugins ?? [], - rehypePlugins: md.rehypePlugins ?? [], - remarkRehype: md.remarkRehype ?? {}, - }); + if (!didWarnAboutLegacyMarkdownPlugins) { + didWarnAboutLegacyMarkdownPlugins = true; + console.warn( + '[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` are deprecated. Use `markdown.processor: unified({...})` from `@astrojs/markdown-remark` instead.', + ); + } + + const current = md.processor; + if (current && isUnifiedProcessor(current)) { + // `createRenderer` reads `.options.*` at render time, so in-place mutation propagates. + current.options.remarkPlugins.push(...remarkPlugins); + current.options.rehypePlugins.push(...rehypePlugins); + Object.assign(current.options.remarkRehype, remarkRehype); + } else { + // Swap to unified(); satteri and third-party processors can't run remark/rehype plugins. + md.processor = unified({ remarkPlugins, rehypePlugins, remarkRehype }); + } + delete md.remarkPlugins; delete md.rehypePlugins; delete md.remarkRehype; @@ -74,5 +107,7 @@ async function coerceLegacyMarkdownPlugins(userConfig: any): Promise { * - To validate the config after all integrations (that may have updated it) */ export async function validateConfigRefined(updatedConfig: AstroConfig): Promise { + await coerceLegacyMarkdownPlugins(updatedConfig); + warnDeprecatedMarkdownOptions(updatedConfig); return await AstroConfigRefinedSchema.parseAsync(updatedConfig, { error: errorMap }); } diff --git a/packages/astro/src/markdown/index.ts b/packages/astro/src/markdown/index.ts index 051f1b182113..1866b124c0f4 100644 --- a/packages/astro/src/markdown/index.ts +++ b/packages/astro/src/markdown/index.ts @@ -22,9 +22,15 @@ export { * `satteri()` or `unified()`. Third-party processors implement this interface to plug in * their own markdown rendering pipeline. */ -export interface MarkdownProcessorEntry { +export interface MarkdownProcessorEntry< + TOptions extends Record = Record, +> { /** Identifier for this processor. Used by integrations to look up built-in MDX support. */ readonly name: string; + /** + * Processor-specific options. + */ + options?: TOptions; /** Create the runtime renderer for `.md` files. */ createRenderer(shared: SharedMarkdownConfig): Promise; /** diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index a29ebe979fd4..2311cf1cc4c6 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -5,8 +5,25 @@ import type { Smartypants, SyntaxHighlightConfigType, } from '@astrojs/markdown-satteri'; -import type { MarkdownProcessorEntry } from '../../markdown/index.js'; import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite'; +import type { FontFamily, FontProvider } from '../../assets/fonts/types.js'; +import type { SvgOptimizer } from '../../assets/svg/types.js'; +import type { ImageFit, ImageLayout } from '../../assets/types.js'; +import type { AssetsPrefix } from '../../core/app/types.js'; +import type { CacheProviderConfig, RouteRules } from '../../core/cache/types.js'; +import type { AstroConfigType } from '../../core/config/schemas/index.js'; +import type { REDIRECT_STATUS_CODES } from '../../core/constants.js'; +import type { CspAlgorithm, CspDirective, CspHash } from '../../core/csp/config.js'; +import type { LoggerHandlerConfig } from '../../core/logger/config.js'; +import type { AstroLogger, AstroLoggerLevel } from '../../core/logger/core.js'; +import type { + SessionConfig, + SessionDriverConfig, + SessionDriverName, +} from '../../core/session/types.js'; +import type { EnvSchema } from '../../env/schema.js'; +import type { MarkdownProcessorEntry } from '../../markdown/index.js'; +import type { AstroIntegration } from './integrations.js'; // Loose plugin/option shapes for the deprecated `markdown.remarkPlugins` / // `rehypePlugins` / `remarkRehype` fields. Strict typing lives in @@ -27,23 +44,6 @@ export type RehypePlugins = (string | [string, any] | RehypePlugin | [RehypePlug /** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ remarkRehype })`. */ export type RemarkRehype = Record; -import type { FontFamily, FontProvider } from '../../assets/fonts/types.js'; -import type { ImageFit, ImageLayout } from '../../assets/types.js'; -import type { AssetsPrefix } from '../../core/app/types.js'; -import type { CacheProviderConfig, RouteRules } from '../../core/cache/types.js'; -import type { AstroConfigType } from '../../core/config/schemas/index.js'; -import type { REDIRECT_STATUS_CODES } from '../../core/constants.js'; -import type { CspAlgorithm, CspDirective, CspHash } from '../../core/csp/config.js'; -import type { AstroLogger, AstroLoggerLevel } from '../../core/logger/core.js'; -import type { - SessionConfig, - SessionDriverConfig, - SessionDriverName, -} from '../../core/session/types.js'; -import type { EnvSchema } from '../../env/schema.js'; -import type { AstroIntegration } from './integrations.js'; -import type { SvgOptimizer } from '../../assets/svg/types.js'; -import type { LoggerHandlerConfig } from '../../core/logger/config.js'; export type Locales = (string | { codes: [string, ...string[]]; path: string })[]; @@ -2138,6 +2138,7 @@ export interface AstroUserConfig< * @type {boolean} * @default `true` * @version 2.0.0 + * @deprecated Configure via the processor instead: `satteri({ features: { gfm: false } })` or `unified({ remarkPlugins: [] })` (omit `remarkGfm`). Will be removed in a future major. * @description * Astro uses [GitHub-flavored Markdown](https://github.com/remarkjs/remark-gfm) by default. To disable this, set the `gfm` flag to `false`: * @@ -2157,6 +2158,7 @@ export interface AstroUserConfig< * @type {boolean | Smartypants} * @default `true` * @version 2.0.0 + * @deprecated Configure via the processor instead: `satteri({ features: { smartPunctuation: false } })` or `unified({ remarkPlugins: [remarkSmartypants] })`. Will be removed in a future major. * @description * Whether to use the [SmartyPants formatter](https://daringfireball.net/projects/smartypants/) to transform straight quotes into smart quotes, dashes into en/em dashes, and triple dots into ellipses. * diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index ad3c7e9c71e6..66e634d3a5fe 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -82,5 +82,8 @@ }, "engines": { "node": ">=22.12.0" + }, + "publishConfig": { + "provenance": true } } diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index da77ed448003..5141e17ea129 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -1,7 +1,11 @@ import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; -import type { markdownConfigDefaults, UnifiedProcessorDescriptor } from '@astrojs/markdown-remark'; -import type { SatteriProcessorDescriptor } from '@astrojs/markdown-satteri'; +import type { UnifiedProcessorDescriptor } from '@astrojs/markdown-remark'; +import { + type AstroMarkdownProcessorOptions, + satteriMarkdownDefaults, + type SatteriProcessorDescriptor, +} from '@astrojs/markdown-satteri'; import type { AstroIntegration, AstroIntegrationLogger, @@ -18,16 +22,17 @@ import { type VitePluginMdxOptions, vitePluginMdx } from './vite-plugin-mdx.js'; import { vitePluginMdxPostprocess } from './vite-plugin-mdx-postprocess.js'; // Inlined name-checks to avoid eagerly importing the unified/satteri runtime -// modules (and their shiki + WASM bindings) when they're not the active processor. +// modules when they're not the active processor. const isSatteriProcessor = (p: { name: string }): p is SatteriProcessorDescriptor => p.name === 'satteri'; const isUnifiedProcessor = (p: { name: string }): p is UnifiedProcessorDescriptor => p.name === 'unified'; -export type MdxOptions = Omit< - typeof markdownConfigDefaults, - 'remarkPlugins' | 'rehypePlugins' -> & { +type SharedMarkdownOptions = Required< + Pick +>; + +export type MdxOptions = SharedMarkdownOptions & { extendMarkdownConfig: boolean; recmaPlugins: PluggableList; // Markdown allows strings as remark and rehype plugins. @@ -110,12 +115,7 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI const extendMarkdownConfig = partialMdxOptions.extendMarkdownConfig ?? defaultMdxOptions.extendMarkdownConfig; - // `markdownConfigDefaults` is only needed when the user opts out of extending - // the active markdown config. Lazy-load to avoid eagerly pulling in the unified - // pipeline (and shiki + remark/rehype trees) at integration init time. - const markdownConfig = extendMarkdownConfig - ? config.markdown - : (await import('@astrojs/markdown-remark')).markdownConfigDefaults; + const markdownConfig = extendMarkdownConfig ? config.markdown : satteriMarkdownDefaults; const resolvedMdxOptions = applyDefaultOptions({ options: partialMdxOptions, @@ -130,32 +130,30 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI // (Object-shaped options like `remarkRehype`/`features` still merge.) if (isSatteriProcessor(descriptor)) { if (partialMdxOptions.mdastPlugins === undefined) { - resolvedMdxOptions.mdastPlugins = [...descriptor.mdastPlugins]; + resolvedMdxOptions.mdastPlugins = [...descriptor.options.mdastPlugins]; } if (partialMdxOptions.hastPlugins === undefined) { - resolvedMdxOptions.hastPlugins = [...descriptor.hastPlugins]; - } - if (descriptor.features || resolvedMdxOptions.features) { - resolvedMdxOptions.features = { - ...descriptor.features, - ...resolvedMdxOptions.features, - }; + resolvedMdxOptions.hastPlugins = [...descriptor.options.hastPlugins]; } + resolvedMdxOptions.features = { + ...descriptor.options.features, + ...resolvedMdxOptions.features, + }; } else if (isUnifiedProcessor(descriptor)) { if (partialMdxOptions.remarkPlugins === undefined) { resolvedMdxOptions.remarkPlugins = ignoreStringPlugins( - descriptor.remarkPlugins, + descriptor.options.remarkPlugins, logger, ); } if (partialMdxOptions.rehypePlugins === undefined) { resolvedMdxOptions.rehypePlugins = ignoreStringPlugins( - descriptor.rehypePlugins, + descriptor.options.rehypePlugins, logger, ); } resolvedMdxOptions.remarkRehype = { - ...descriptor.remarkRehype, + ...descriptor.options.remarkRehype, ...resolvedMdxOptions.remarkRehype, }; } @@ -186,13 +184,12 @@ const defaultMdxOptions = { } satisfies Partial; function markdownConfigToMdxOptions( - markdownConfig: typeof markdownConfigDefaults, + markdownConfig: SharedMarkdownOptions, _logger: AstroIntegrationLogger, ): MdxOptions { - const { remarkPlugins: _r, rehypePlugins: _rh, ...shared } = markdownConfig; return { ...defaultMdxOptions, - ...shared, + ...markdownConfig, // Plugins come from the processor descriptor — merged in astro:config:done. remarkPlugins: [], rehypePlugins: [], diff --git a/packages/integrations/mdx/src/satteri/index.ts b/packages/integrations/mdx/src/satteri/index.ts index f24666924335..c02fa3fa3640 100644 --- a/packages/integrations/mdx/src/satteri/index.ts +++ b/packages/integrations/mdx/src/satteri/index.ts @@ -149,7 +149,11 @@ export function createMdxProcessor(mdxOptions: MdxOptions, ctx: CreateMdxProcess mdastPlugins: allMdastPlugins, hastPlugins, optimizeStatic, - features: mdxOptions.features, + features: { + gfm: mdxOptions.gfm !== false, + smartPunctuation: mdxOptions.smartypants !== false, + ...mdxOptions.features, + }, filename: filePath, jsxImportSource: 'astro', }); diff --git a/packages/markdown/remark/src/processor.ts b/packages/markdown/remark/src/processor.ts index 4b4ebadf94ea..2a260566959e 100644 --- a/packages/markdown/remark/src/processor.ts +++ b/packages/markdown/remark/src/processor.ts @@ -13,39 +13,39 @@ export interface UnifiedProcessorOptions { } /** - * The descriptor returned by `unified()`. Implements `MarkdownProcessorEntry` (astro core's - * pluggable processor interface) and additionally exposes the remark/rehype plugin lists - * so the MDX integration can merge them into MDX options. + * The descriptor returned by `unified()`. Integrations extend the pipeline by mutating + * `descriptor.options.remarkPlugins` / `rehypePlugins` / `remarkRehype` directly. */ export interface UnifiedProcessorDescriptor { readonly name: 'unified'; - remarkPlugins: RemarkPlugins; - rehypePlugins: RehypePlugins; - remarkRehype: RemarkRehype; + options: { + remarkPlugins: RemarkPlugins; + rehypePlugins: RehypePlugins; + remarkRehype: RemarkRehype; + }; createRenderer(shared: AstroMarkdownProcessorOptions): Promise; } export function unified(opts: UnifiedProcessorOptions = {}): UnifiedProcessorDescriptor { - const remarkPlugins = opts.remarkPlugins ?? []; - const rehypePlugins = opts.rehypePlugins ?? []; - const remarkRehype = opts.remarkRehype ?? {}; - - return { + const descriptor: UnifiedProcessorDescriptor = { name: 'unified', - remarkPlugins, - rehypePlugins, - remarkRehype, + options: { + remarkPlugins: [...(opts.remarkPlugins ?? [])], + rehypePlugins: [...(opts.rehypePlugins ?? [])], + remarkRehype: { ...(opts.remarkRehype) }, + }, async createRenderer(shared) { // Lazy import to avoid a circular module load with `./index.js`. const { createMarkdownProcessor } = await import('./index.js'); return createMarkdownProcessor({ ...shared, - remarkPlugins, - rehypePlugins, - remarkRehype, + remarkPlugins: descriptor.options.remarkPlugins, + rehypePlugins: descriptor.options.rehypePlugins, + remarkRehype: descriptor.options.remarkRehype, }); }, }; + return descriptor; } export function isUnifiedProcessor(p: { diff --git a/packages/markdown/remark/test/browser.test.ts b/packages/markdown/remark/test/browser.test.ts index e94028168d3a..9a7f5b0c3cd8 100644 --- a/packages/markdown/remark/test/browser.test.ts +++ b/packages/markdown/remark/test/browser.test.ts @@ -7,9 +7,6 @@ describe('Bundle for browsers', async () => { try { const result = await esbuild.build({ platform: 'browser', - // ESM output so top-level await (used by satteri's WASM loader) is supported. - // The default IIFE format would reject the TLA in satteri's browser shim. - format: 'esm', entryPoints: ['@astrojs/markdown-remark'], bundle: true, write: false, diff --git a/packages/markdown/satteri/src/processor.ts b/packages/markdown/satteri/src/processor.ts index fe60ac10a6cc..5330da116f51 100644 --- a/packages/markdown/satteri/src/processor.ts +++ b/packages/markdown/satteri/src/processor.ts @@ -1,5 +1,8 @@ import { createSatteriMarkdownProcessor } from './satteri-processor.js'; import type { AstroMarkdownProcessorOptions, MarkdownProcessor } from './types.js'; +import type { + MdastPluginDefinition, HastPluginDefinition, Features +} from 'satteri'; export interface SatteriProcessorOptions { mdastPlugins?: import('satteri').MdastPluginDefinition[]; @@ -8,37 +11,39 @@ export interface SatteriProcessorOptions { } /** - * The descriptor returned by `satteri()`. Implements `MarkdownProcessorEntry` (astro core's - * pluggable processor interface) and additionally exposes the satteri-specific plugin lists - * so the MDX integration can merge them into MDX options. + * The descriptor returned by `satteri()`. Integrations extend the pipeline by mutating + * `descriptor.options.mdastPlugins` / `hastPlugins` / `features` directly. */ export interface SatteriProcessorDescriptor { readonly name: 'satteri'; - mdastPlugins: import('satteri').MdastPluginDefinition[]; - hastPlugins: import('satteri').HastPluginDefinition[]; - features?: import('satteri').Features; + options: { + mdastPlugins: MdastPluginDefinition[]; + hastPlugins: HastPluginDefinition[]; + features: Features; + }; createRenderer(shared: AstroMarkdownProcessorOptions): Promise; } export function satteri(opts: SatteriProcessorOptions = {}): SatteriProcessorDescriptor { - const mdastPlugins = opts.mdastPlugins ?? []; - const hastPlugins = opts.hastPlugins ?? []; - const features = opts.features; - - return { + const descriptor: SatteriProcessorDescriptor = { name: 'satteri', - mdastPlugins, - hastPlugins, - features, + options: { + mdastPlugins: [...(opts.mdastPlugins ?? [])], + hastPlugins: [...(opts.hastPlugins ?? [])], + // Default to `{}` so integrations can write `options.features.gfm = false` + // without an `??=` check. + features: { ...(opts.features) }, + }, createRenderer(shared) { return createSatteriMarkdownProcessor({ ...shared, - mdastPlugins, - hastPlugins, - features, + mdastPlugins: descriptor.options.mdastPlugins, + hastPlugins: descriptor.options.hastPlugins, + features: descriptor.options.features, }); }, }; + return descriptor; } export function isSatteriProcessor(p: { From d26ddd43fd76ac29fcfa016170df5f5c930c7eb7 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Wed, 20 May 2026 13:24:55 +0200 Subject: [PATCH 18/26] fix: test --- .changeset/seven-emus-knock.md | 2 +- packages/integrations/mdx/test/mdx-vite-env-vars.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/seven-emus-knock.md b/.changeset/seven-emus-knock.md index e48dde6f4fdb..613982cb4b19 100644 --- a/.changeset/seven-emus-knock.md +++ b/.changeset/seven-emus-knock.md @@ -16,7 +16,7 @@ export default defineConfig({ markdown: { processor: satteri({ hastPlugins: [myPlugin], - features: { directive: true, definitionList: true }, + features: { directive: true, smartPunctuation: false }, }), }, }); diff --git a/packages/integrations/mdx/test/mdx-vite-env-vars.test.ts b/packages/integrations/mdx/test/mdx-vite-env-vars.test.ts index f2f105a8e3c3..576d1cb505e0 100644 --- a/packages/integrations/mdx/test/mdx-vite-env-vars.test.ts +++ b/packages/integrations/mdx/test/mdx-vite-env-vars.test.ts @@ -46,7 +46,7 @@ describe('MDX - Vite env vars', () => { assert.equal( document .querySelector('[data-env-variable-exports-unknown]') - ?.innerHTML.includes('exports: ""'), + ?.textContent?.includes('exports: ""'), true, ); }); From f8e5d1904dc0073976dc324f9d0c9bd28cc06f72 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Wed, 20 May 2026 14:26:20 +0200 Subject: [PATCH 19/26] fix: just disable smartpunctuation actually --- .../mdx/test/fixtures/mdx-vite-env-vars/astro.config.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/integrations/mdx/test/fixtures/mdx-vite-env-vars/astro.config.mjs b/packages/integrations/mdx/test/fixtures/mdx-vite-env-vars/astro.config.mjs index 717d379c01cd..863caf393602 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-vite-env-vars/astro.config.mjs +++ b/packages/integrations/mdx/test/fixtures/mdx-vite-env-vars/astro.config.mjs @@ -1,9 +1,12 @@ import mdx from '@astrojs/mdx'; +import { satteri } from 'astro/config'; export default { site: 'https://mdx-is-neat.com/', markdown: { syntaxHighlight: false, + // Keep straight quotes so assertions can match `import.meta.env` output literally + processor: satteri({ features: { smartPunctuation: false } }), }, integrations: [mdx()], vite: { From 8ab9eacb727b2d684c4af358c7057420d93193ab Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 21 May 2026 02:10:01 +0200 Subject: [PATCH 20/26] refactor: and tests --- packages/astro/package.json | 4 - packages/astro/src/content/content-layer.ts | 2 +- packages/astro/src/content/data-store.ts | 2 +- packages/astro/src/content/runtime.ts | 2 +- .../astro/src/core/config/schemas/base.ts | 2 +- packages/astro/src/markdown/index.ts | 26 ++--- packages/astro/src/types/public/config.ts | 37 +++---- packages/astro/src/types/public/content.ts | 2 +- packages/astro/src/types/public/index.ts | 2 +- .../astro/src/vite-plugin-markdown/index.ts | 2 +- packages/integrations/mdx/package.json | 3 +- packages/integrations/mdx/src/index.ts | 10 +- .../mdx/src/satteri/hast-astro-metadata.ts | 12 +-- .../integrations/mdx/src/satteri/index.ts | 5 +- .../integrations/mdx/src/satteri/jsx-utils.ts | 7 +- .../integrations/mdx/src/unified/index.ts | 4 +- .../unified/rehype-analyze-astro-metadata.ts} | 73 +++++++------ .../mdx/src/unified/rehype-optimize-static.ts | 6 +- .../integrations/mdx/src/vite-plugin-mdx.ts | 5 +- packages/internal-helpers/package.json | 13 ++- .../src/markdown.ts} | 102 ++++++++---------- packages/markdown/remark/src/index.ts | 38 ++++++- packages/markdown/remark/src/processor.ts | 2 +- .../remark/src/rehype-collect-headings.ts | 2 +- packages/markdown/remark/src/rehype-shiki.ts | 2 +- .../remark/src/remark-collect-images.ts | 2 +- packages/markdown/remark/src/shiki.ts | 7 +- packages/markdown/satteri/package.json | 5 +- packages/markdown/satteri/src/defaults.ts | 2 +- packages/markdown/satteri/src/index.ts | 11 -- packages/markdown/satteri/src/processor.ts | 11 +- .../markdown/satteri/src/satteri-processor.ts | 50 ++++----- packages/markdown/satteri/src/types.ts | 55 ---------- .../markdown/satteri/test/highlight.test.ts | 32 ++++++ .../markdown/satteri/test/markdown.test.ts | 51 +++++++++ pnpm-lock.yaml | 81 +++++++------- 36 files changed, 353 insertions(+), 319 deletions(-) rename packages/{astro/src/jsx/rehype.ts => integrations/mdx/src/unified/rehype-analyze-astro-metadata.ts} (86%) rename packages/{markdown/remark/src/types.ts => internal-helpers/src/markdown.ts} (58%) delete mode 100644 packages/markdown/satteri/src/types.ts create mode 100644 packages/markdown/satteri/test/highlight.test.ts create mode 100644 packages/markdown/satteri/test/markdown.test.ts diff --git a/packages/astro/package.json b/packages/astro/package.json index 2ad0216d35e7..5aedb8997c18 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -40,7 +40,6 @@ "./astro-jsx": "./astro-jsx.d.ts", "./tsconfigs/*.json": "./tsconfigs/*", "./tsconfigs/*": "./tsconfigs/*.json", - "./jsx/rehype.js": "./dist/jsx/rehype.js", "./jsx-runtime": { "types": "./jsx-runtime.d.ts", "default": "./dist/jsx-runtime/index.js" @@ -212,8 +211,6 @@ "expect-type": "^1.3.0", "fs-fixture": "^2.13.0", "hono": "^4.12.14", - "mdast-util-mdx": "^3.0.0", - "mdast-util-mdx-jsx": "^3.2.0", "node-mocks-http": "^1.17.2", "parse-srcset": "^1.0.2", "rehype-autolink-headings": "^7.1.0", @@ -243,7 +240,6 @@ "./astro-jsx": "./astro-jsx.d.ts", "./tsconfigs/*.json": "./tsconfigs/*", "./tsconfigs/*": "./tsconfigs/*.json", - "./jsx/rehype.js": "./dist/jsx/rehype.js", "./jsx-runtime": { "types": "./jsx-runtime.d.ts", "default": "./dist/jsx-runtime/index.js" diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts index 94ce9d8614a9..3cd5c9ac374e 100644 --- a/packages/astro/src/content/content-layer.ts +++ b/packages/astro/src/content/content-layer.ts @@ -1,5 +1,5 @@ import { existsSync, promises as fs } from 'node:fs'; -import type { MarkdownProcessor } from '@astrojs/markdown-satteri'; +import type { MarkdownProcessor } from '@astrojs/internal-helpers/markdown'; import { parseFrontmatter } from '../markdown/frontmatter.js'; import PQueue from 'p-queue'; import type { FSWatcher } from 'vite'; diff --git a/packages/astro/src/content/data-store.ts b/packages/astro/src/content/data-store.ts index 9d7ff4d16c77..35565df9a1a5 100644 --- a/packages/astro/src/content/data-store.ts +++ b/packages/astro/src/content/data-store.ts @@ -1,4 +1,4 @@ -import type { MarkdownHeading } from '@astrojs/markdown-satteri'; +import type { MarkdownHeading } from '@astrojs/internal-helpers/markdown'; import * as devalue from 'devalue'; export interface RenderedContent { diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index 9dd8c6499288..13beface7c30 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -1,4 +1,4 @@ -import type { MarkdownHeading } from '@astrojs/markdown-satteri'; +import type { MarkdownHeading } from '@astrojs/internal-helpers/markdown'; import { escape } from 'html-escaper'; import { Traverse } from 'neotraverse/modern'; import * as z from 'zod/v4'; diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index eab7786d4c42..deff78abe5ef 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -1,4 +1,4 @@ -import type { ShikiConfig, Smartypants as _Smartypants } from '@astrojs/markdown-satteri'; +import type { ShikiConfig, Smartypants as _Smartypants } from '@astrojs/internal-helpers/markdown'; import { satteri, satteriMarkdownDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-satteri'; import type { MarkdownProcessorEntry } from '../../../markdown/index.js'; import type { diff --git a/packages/astro/src/markdown/index.ts b/packages/astro/src/markdown/index.ts index 1866b124c0f4..f002b059f738 100644 --- a/packages/astro/src/markdown/index.ts +++ b/packages/astro/src/markdown/index.ts @@ -1,11 +1,7 @@ -import type { RemotePattern } from '@astrojs/internal-helpers/remote'; import type { + AstroMarkdownProcessorOptions, MarkdownProcessor, - ShikiConfig, - Smartypants, - SyntaxHighlightConfig, - SyntaxHighlightConfigType, -} from '@astrojs/markdown-satteri'; +} from '@astrojs/internal-helpers/markdown'; import type { PluggableList } from 'unified'; import type { PluginMetadata } from '../vite-plugin-astro/types.js'; @@ -16,6 +12,8 @@ export { type ParseFrontmatterOptions, type ParseFrontmatterResult, } from './frontmatter.js'; +export { resolvePath } from '../core/viteUtils.js'; +export type { AstroMarkdownProcessorOptions } from '@astrojs/internal-helpers/markdown'; /** * Configuration that ends up on `markdown.processor`. Returned by factory functions like @@ -32,30 +30,18 @@ export interface MarkdownProcessorEntry< */ options?: TOptions; /** Create the runtime renderer for `.md` files. */ - createRenderer(shared: SharedMarkdownConfig): Promise; + createRenderer(shared: AstroMarkdownProcessorOptions): Promise; /** * Create the runtime renderer for `.mdx` files. Optional — when absent, `@astrojs/mdx` * falls back to its built-in handling for the known `satteri` and `unified` processor * names. Third-party processors should provide this to enable MDX support. */ createMdxRenderer?( - shared: SharedMarkdownConfig, + shared: AstroMarkdownProcessorOptions, mdx: MdxRendererOptions, ): Promise; } -/** Markdown config fields that apply to every processor regardless of flavor. */ -export interface SharedMarkdownConfig { - syntaxHighlight?: SyntaxHighlightConfig | SyntaxHighlightConfigType | false; - shikiConfig?: ShikiConfig; - gfm?: boolean; - smartypants?: Smartypants | boolean; - image?: { - domains?: string[]; - remotePatterns?: RemotePattern[]; - }; -} - /** Cross-cutting MDX options passed to `createMdxRenderer` regardless of processor. */ export interface MdxRendererOptions { optimize: boolean | { ignoreElementNames?: string[] }; diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 595a75749c7d..4e4481ab0c05 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -1,10 +1,13 @@ import type { OutgoingHttpHeaders } from 'node:http'; import type { RemotePattern } from '@astrojs/internal-helpers/remote'; import type { + RehypePlugins, + RemarkPlugins, + RemarkRehype, ShikiConfig, Smartypants, SyntaxHighlightConfigType, -} from '@astrojs/markdown-satteri'; +} from '@astrojs/internal-helpers/markdown'; import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite'; import type { FontFamily, FontProvider } from '../../assets/fonts/types.js'; import type { SvgOptimizer } from '../../assets/svg/types.js'; @@ -25,25 +28,16 @@ import type { EnvSchema } from '../../env/schema.js'; import type { MarkdownProcessorEntry } from '../../markdown/index.js'; import type { AstroIntegration } from './integrations.js'; -// Loose plugin/option shapes for the deprecated `markdown.remarkPlugins` / -// `rehypePlugins` / `remarkRehype` fields. Strict typing lives in -// `@astrojs/markdown-remark`; install it to get sharper inference when -// migrating to `markdown.processor: unified({...})`. - -/** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ remarkPlugins })`. */ -export type RemarkPlugin = (...args: any[]) => any; - -/** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ remarkPlugins })`. */ -export type RemarkPlugins = (string | [string, any] | RemarkPlugin | [RemarkPlugin, any])[]; - -/** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ rehypePlugins })`. */ -export type RehypePlugin = (...args: any[]) => any; - -/** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ rehypePlugins })`. */ -export type RehypePlugins = (string | [string, any] | RehypePlugin | [RehypePlugin, any])[]; - -/** @deprecated Install `@astrojs/markdown-remark` and use `markdown.processor: unified({ remarkRehype })`. */ -export type RemarkRehype = Record; +// Plugin/option shapes for the deprecated `markdown.remarkPlugins` / +// `rehypePlugins` / `remarkRehype` fields. Defined in `@astrojs/internal-helpers/markdown` +// alongside the rest of the markdown config contract. +export type { + RehypePlugin, + RehypePlugins, + RemarkPlugin, + RemarkPlugins, + RemarkRehype, +} from '@astrojs/internal-helpers/markdown'; export type Locales = (string | { codes: [string, ...string[]]; path: string })[]; @@ -2103,6 +2097,7 @@ export interface AstroUserConfig< * @docs * @name markdown.remarkPlugins * @type {RemarkPlugins} + * @deprecated Use `markdown.processor: unified({ remarkPlugins })` from `@astrojs/markdown-remark` instead. Will be removed in a future major. * @description * Pass [remark plugins](https://github.com/remarkjs/remark) to customize how your Markdown is built. You can import and apply the plugin function (recommended), or pass the plugin name as a string. * @@ -2120,6 +2115,7 @@ export interface AstroUserConfig< * @docs * @name markdown.rehypePlugins * @type {RehypePlugins} + * @deprecated Use `markdown.processor: unified({ rehypePlugins })` from `@astrojs/markdown-remark` instead. Will be removed in a future major. * @description * Pass [rehype plugins](https://github.com/remarkjs/remark-rehype) to customize how your Markdown's output HTML is processed. You can import and apply the plugin function (recommended), or pass the plugin name as a string. * @@ -2173,6 +2169,7 @@ export interface AstroUserConfig< * @docs * @name markdown.remarkRehype * @type {RemarkRehype} + * @deprecated Use `markdown.processor: unified({ remarkRehype })` from `@astrojs/markdown-remark` instead. Will be removed in a future major. * @description * Pass options to [remark-rehype](https://github.com/remarkjs/remark-rehype#api). * diff --git a/packages/astro/src/types/public/content.ts b/packages/astro/src/types/public/content.ts index 35100f8a65bb..12a43279d7e5 100644 --- a/packages/astro/src/types/public/content.ts +++ b/packages/astro/src/types/public/content.ts @@ -1,4 +1,4 @@ -import type { MarkdownHeading } from '@astrojs/markdown-satteri'; +import type { MarkdownHeading } from '@astrojs/internal-helpers/markdown'; import type { DataEntry, RenderedContent } from '../../content/data-store.js'; import type { LiveCollectionError } from '../../content/loaders/errors.js'; import type { AstroComponentFactory } from '../../runtime/server/index.js'; diff --git a/packages/astro/src/types/public/index.ts b/packages/astro/src/types/public/index.ts index 864bbb72faa6..c119501aa9e5 100644 --- a/packages/astro/src/types/public/index.ts +++ b/packages/astro/src/types/public/index.ts @@ -1,5 +1,5 @@ export type { RemotePattern } from '@astrojs/internal-helpers/remote'; -export type { MarkdownHeading, ShikiConfig } from '@astrojs/markdown-satteri'; +export type { MarkdownHeading, ShikiConfig } from '@astrojs/internal-helpers/markdown'; export type { RehypePlugins, RemarkPlugins } from './config.js'; export type { ExternalImageService, diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 5bfe2b322d93..8313b9988ced 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import type { MarkdownProcessor } from '@astrojs/markdown-satteri'; +import type { MarkdownProcessor } from '@astrojs/internal-helpers/markdown'; import { isFrontmatterValid } from '../markdown/frontmatter.js'; import type { Plugin } from 'vite'; import { safeParseFrontmatter } from '../content/utils.js'; diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 5d950fbb3833..de2d38867439 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -34,6 +34,7 @@ "test": "astro-scripts test --timeout 70000 \"test/**/*.test.ts\"" }, "dependencies": { + "@astrojs/internal-helpers": "workspace:*", "@astrojs/markdown-satteri": "workspace:*", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", @@ -44,7 +45,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", - "satteri": "^0.6.1", + "satteri": "^0.6.2", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index 5141e17ea129..17e5447db483 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -1,11 +1,9 @@ import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import type { UnifiedProcessorDescriptor } from '@astrojs/markdown-remark'; -import { - type AstroMarkdownProcessorOptions, - satteriMarkdownDefaults, - type SatteriProcessorDescriptor, -} from '@astrojs/markdown-satteri'; +import type { AstroMarkdownProcessorOptions } from '@astrojs/internal-helpers/markdown'; +import { satteriMarkdownDefaults, type SatteriProcessorDescriptor } from '@astrojs/markdown-satteri'; +import type { Features } from 'satteri'; import type { AstroIntegration, AstroIntegrationLogger, @@ -43,7 +41,7 @@ export type MdxOptions = SharedMarkdownOptions & { optimize: boolean | { ignoreElementNames?: string[] }; mdastPlugins: MdastPluginDefinition[]; hastPlugins: HastPluginDefinition[]; - features?: import('satteri').Features; + features?: Features; /** * Override the markdown processor for `.mdx` files. Defaults to `config.markdown.processor`. * Use this to run `.mdx` files through a different processor (or the same processor with diff --git a/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts b/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts index cd7aad442552..88ddb95d424a 100644 --- a/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts +++ b/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts @@ -1,4 +1,4 @@ -import * as path from 'node:path'; +import { resolvePath } from 'astro/markdown'; import { defineHastPlugin, type HastPluginDefinition, @@ -134,14 +134,6 @@ function findMatchingImport( } } -function resolveImportPath(specifier: string, importer: string): string { - if (specifier.startsWith('.')) { - const absoluteSpecifier = path.resolve(path.dirname(importer), specifier); - return absoluteSpecifier.split(path.sep).join('/'); - } - return specifier; -} - function processJsxNode( node: MdxJsxHastNode, ctx: HastVisitorContext, @@ -176,7 +168,7 @@ function processJsxNode( ); } - const resolvedPath = resolveImportPath(matchedImport.path, filePath); + const resolvedPath = resolvePath(matchedImport.path, filePath); const exportName = matchedImport.name === '*' ? tagName.split('.').slice(1).join('.') : matchedImport.name; diff --git a/packages/integrations/mdx/src/satteri/index.ts b/packages/integrations/mdx/src/satteri/index.ts index c02fa3fa3640..a826ad6839d0 100644 --- a/packages/integrations/mdx/src/satteri/index.ts +++ b/packages/integrations/mdx/src/satteri/index.ts @@ -1,6 +1,6 @@ +import type { MarkdownHeading } from '@astrojs/internal-helpers/markdown'; import { createShikiHighlighter, - type MarkdownHeading, satteriCollectImagesPlugin, satteriHeadingIdsPlugin, satteriShikiPlugin, @@ -9,6 +9,7 @@ import { mdxToJs, type HastPluginDefinition, type MdastPluginDefinition, + type MdxCompileOptions, } from 'satteri'; import { ASTRO_IMAGE_IMPORT, @@ -131,7 +132,7 @@ export function createMdxProcessor(mdxOptions: MdxOptions, ctx: CreateMdxProcess hastPlugins.push(...mdxOptions.hastPlugins); } - let optimizeStatic: import('satteri').MdxCompileOptions['optimizeStatic']; + let optimizeStatic: MdxCompileOptions['optimizeStatic']; if (mdxOptions.optimize) { const ignoreElements = typeof mdxOptions.optimize === 'object' diff --git a/packages/integrations/mdx/src/satteri/jsx-utils.ts b/packages/integrations/mdx/src/satteri/jsx-utils.ts index a29ce58756d9..2d592d0cded9 100644 --- a/packages/integrations/mdx/src/satteri/jsx-utils.ts +++ b/packages/integrations/mdx/src/satteri/jsx-utils.ts @@ -1,12 +1,11 @@ -import type { HastNode, MdxJsxAttributeNode } from 'satteri'; +import type { MdxJsxAttributeNode, MdxJsxFlowElementHast, MdxJsxTextElementHast } from 'satteri'; // JSX-specific helpers used by the MDX pipeline. Non-MDX equivalents (e.g. // `makeFragmentNode`, `collectHastText`) live in `@astrojs/markdown-satteri` // because they're shared with the markdown render pipeline. -type MdxJsxFlowElement = Extract; -type MdxJsxTextElement = Extract; -export type MdxJsxHastNode = MdxJsxFlowElement | MdxJsxTextElement; +// satteri hands plugin visitors readonly nodes; these helpers only read them. +export type MdxJsxHastNode = Readonly; const nonAlphaRe = /[^a-zA-Z]/; diff --git a/packages/integrations/mdx/src/unified/index.ts b/packages/integrations/mdx/src/unified/index.ts index 2302c0078ffd..a7061fda18ef 100644 --- a/packages/integrations/mdx/src/unified/index.ts +++ b/packages/integrations/mdx/src/unified/index.ts @@ -5,19 +5,21 @@ 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'; import { SourceMapGenerator } from 'source-map'; import type { PluggableList } from 'unified'; import type { MdxOptions } from '../index.js'; +import { rehypeAnalyzeAstroMetadata } from './rehype-analyze-astro-metadata.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'; +export { getAstroMetadata } from './rehype-analyze-astro-metadata.js'; + // Skip nonessential plugins during performance benchmark runs const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK); diff --git a/packages/astro/src/jsx/rehype.ts b/packages/integrations/mdx/src/unified/rehype-analyze-astro-metadata.ts similarity index 86% rename from packages/astro/src/jsx/rehype.ts rename to packages/integrations/mdx/src/unified/rehype-analyze-astro-metadata.ts index 267ed1f54fba..5010a0a422de 100644 --- a/packages/astro/src/jsx/rehype.ts +++ b/packages/integrations/mdx/src/unified/rehype-analyze-astro-metadata.ts @@ -1,3 +1,5 @@ +import { resolvePath } from 'astro/markdown'; +import type { Program } from 'estree'; import type { RootContent, Root as HastRoot } from 'hast'; import type { Plugin } from 'unified'; import type {} from 'mdast-util-mdx'; @@ -8,14 +10,38 @@ import type { } 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'; +interface AstroComponentMetadata { + exportName: string; + localName: string; + specifier: string; + resolvedPath: string; +} + +export interface AstroMetadata { + hydratedComponents: AstroComponentMetadata[]; + clientOnlyComponents: AstroComponentMetadata[]; + serverComponents: AstroComponentMetadata[]; + scripts: never[]; + propagation: 'none'; + containsHead: false; + pageOptions: Record; +} + +function createDefaultAstroMetadata(): AstroMetadata { + return { + hydratedComponents: [], + clientOnlyComponents: [], + serverComponents: [], + scripts: [], + propagation: 'none', + containsHead: false, + pageOptions: {}, + }; +} + export const rehypeAnalyzeAstroMetadata: Plugin<[], HastRoot> = () => { return (tree, file) => { // Initial metadata for this MDX file, it will be mutated as we traverse the tree @@ -45,10 +71,9 @@ export const rehypeAnalyzeAstroMetadata: Plugin<[], HastRoot> = () => { // 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!), - }); + throw new Error( + `Expected a matching import for component \`${node.name}\`. Did you forget to import it?`, + ); } // If this is an Astro component, that means the `client:` directive is misused as it doesn't @@ -106,32 +131,11 @@ export const rehypeAnalyzeAstroMetadata: Plugin<[], HastRoot> = () => { }; export function getAstroMetadata(file: VFile) { - return file.data.__astroMetadata as PluginMetadata['astro'] | undefined; + return file.data.__astroMetadata as AstroMetadata | undefined; } type ImportSpecifier = { local: string; imported: string }; -// Minimal ESTree shapes we need — avoids a direct dependency on @types/estree. -// `@types/estree` is present transitively but not in our direct deps, and satteri's -// hast augmentations widen `mdxjsEsm.data.estree` to `{}`, erasing its Program typing. -type EstreeId = { type: 'Identifier'; name: string }; -type EstreeLiteral = { type: 'Literal'; value: unknown }; -type EstreeImportSpecifier = - | { type: 'ImportDefaultSpecifier'; local: EstreeId } - | { type: 'ImportNamespaceSpecifier'; local: EstreeId } - | { - type: 'ImportSpecifier'; - local: EstreeId; - imported: EstreeId | EstreeLiteral; - }; -type EstreeImportDeclaration = { - type: 'ImportDeclaration'; - source: { value: string }; - specifiers: EstreeImportSpecifier[]; -}; -type EstreeStatement = EstreeImportDeclaration | { type: 'OtherStatement' }; -type EstreeProgram = { body: EstreeStatement[] }; - /** * ``` * import Foo from './Foo.jsx' @@ -156,9 +160,10 @@ function parseImports(children: RootContent[]) { for (const child of children) { if (child.type !== 'mdxjsEsm') continue; - // `child.data?.estree` is widened by the hast augmentations; narrow it back to the shape - // we care about (an ESTree Program with ImportDeclaration bodies). - const body = (child.data?.estree as EstreeProgram | undefined)?.body; + // satteri and `mdast-util-mdxjs-esm` both augment `hast` for `mdxjsEsm` with + // conflicting `data` types; the one that wins doesn't model `estree`, so type + // the access here. The unified pipeline's nodes always carry the program. + const body = (child.data as { estree?: Program } | undefined)?.estree?.body; if (!body) continue; for (const ast of body) { diff --git a/packages/integrations/mdx/src/unified/rehype-optimize-static.ts b/packages/integrations/mdx/src/unified/rehype-optimize-static.ts index b4466896e111..ee9a091400fe 100644 --- a/packages/integrations/mdx/src/unified/rehype-optimize-static.ts +++ b/packages/integrations/mdx/src/unified/rehype-optimize-static.ts @@ -1,5 +1,5 @@ import type { RehypePlugin } from '@astrojs/markdown-remark'; -import type { Expression } from 'estree'; +import type { Expression, Program } from 'estree'; import { SKIP, visit } from 'estree-util-visit'; import type { Element, RootContent, RootContentMap } from 'hast'; import { toHtml } from 'hast-util-to-html'; @@ -255,7 +255,9 @@ function getExportConstComponentObjectKeys(node: RootContentMap['mdxjsEsm']) { let variableInit: Expression | undefined | null; // Find the initial value of `components` in the AST. - for (const part of (node.data?.estree as any)?.body || []) { + for (const part of (node.data as { + estree: Program; + })?.estree?.body || []) { if ( part.type !== 'ExportNamedDeclaration' || part.declaration?.type !== 'VariableDeclaration' diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts index f46e3e029ea5..ca007d152d3d 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts @@ -1,5 +1,4 @@ import type { SSRError } from 'astro'; -import { getAstroMetadata } from 'astro/jsx/rehype.js'; import type { MarkdownProcessorEntry, MdxRenderer } from 'astro/markdown'; import { VFile } from 'vfile'; import type { Plugin } from 'vite'; @@ -114,7 +113,9 @@ async function resolveMdxRenderer( }; } if (isUnifiedProcessor(processor)) { - const { createMdxProcessor: createRemarkMdxProcessor } = await import('./unified/index.js'); + const { createMdxProcessor: createRemarkMdxProcessor, getAstroMetadata } = await import( + './unified/index.js' + ); const remarkProcessor = createRemarkMdxProcessor(opts.mdxOptions, { sourcemap }); return { async process(content, filePath, frontmatter) { diff --git a/packages/internal-helpers/package.json b/packages/internal-helpers/package.json index b37c25b0d32d..d6fc080a6243 100644 --- a/packages/internal-helpers/package.json +++ b/packages/internal-helpers/package.json @@ -18,7 +18,8 @@ "./cli": "./dist/cli.js", "./create-filter": "./dist/create-filter.js", "./request": "./dist/request.js", - "./object": "./dist/object.js" + "./object": "./dist/object.js", + "./markdown": "./dist/markdown.js" }, "typesVersions": { "*": { @@ -28,6 +29,9 @@ "remote": [ "./dist/remote.d.ts" ], + "markdown": [ + "./dist/markdown.d.ts" + ], "fs": [ "./dist/fs.d.ts" ], @@ -53,7 +57,12 @@ "test": "astro-scripts test \"test/**/*.test.ts\"" }, "dependencies": { - "picomatch": "^4.0.4" + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "unified": "^11.0.5" }, "devDependencies": { "@types/picomatch": "^4.0.2", diff --git a/packages/markdown/remark/src/types.ts b/packages/internal-helpers/src/markdown.ts similarity index 58% rename from packages/markdown/remark/src/types.ts rename to packages/internal-helpers/src/markdown.ts index 44aa004f49af..8e5fb4e1d30a 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/internal-helpers/src/markdown.ts @@ -1,76 +1,76 @@ -import type { RemotePattern } from '@astrojs/internal-helpers/remote'; import type * as hast from 'hast'; import type * as mdast from 'mdast'; -import type { Options as RemarkRehypeOptions } from 'remark-rehype'; import type { Options as SmartypantsOptions } from 'retext-smartypants'; -import type { BuiltinTheme } from 'shiki'; -import type * as unified from 'unified'; -import type { CreateShikiHighlighterOptions, ShikiHighlighterHighlightOptions } from './shiki.js'; +import type { + BuiltinTheme, + HighlighterCoreOptions, + LanguageRegistration, + ShikiTransformer, + ThemeRegistration, + ThemeRegistrationRaw, +} from 'shiki'; +import type { Plugin } from 'unified'; +import type { RemotePattern } from './remote.js'; + +// Processor-agnostic markdown contract types, shared between `astro` and the +// markdown processor packages. They live here because `astro` depends on the +// processor packages, so the shared types must sit below all of them. -export type { Node } from 'unist'; +export type SyntaxHighlightConfigType = 'shiki' | 'prism'; + +export interface SyntaxHighlightConfig { + type: SyntaxHighlightConfigType; + excludeLangs?: string[]; +} + +type ThemePresets = BuiltinTheme | 'css-variables'; -declare module 'vfile' { - interface DataMap { - astro: { - headings?: MarkdownHeading[]; - localImagePaths?: string[]; - remoteImagePaths?: string[]; - frontmatter?: Record; - }; - } +export interface ShikiConfig { + langs?: LanguageRegistration[]; + theme?: ThemePresets | ThemeRegistration | ThemeRegistrationRaw; + themes?: Record; + langAlias?: HighlighterCoreOptions['langAlias']; + defaultColor?: 'light' | 'dark' | string | false; + wrap?: boolean | null; + transformers?: ShikiTransformer[]; } -export type RemarkPlugin = unified.Plugin< +export type Smartypants = SmartypantsOptions; + +// Plugin/option shapes for the deprecated `markdown.remarkPlugins` / +// `rehypePlugins` / `remarkRehype` config fields. + +export type RemarkPlugin = Plugin< PluginParameters, mdast.Root >; - export type RemarkPlugins = (string | [string, any] | RemarkPlugin | [RemarkPlugin, any])[]; - -export type RehypePlugin = unified.Plugin< +export type RehypePlugin = Plugin< PluginParameters, hast.Root >; - export type RehypePlugins = (string | [string, any] | RehypePlugin | [RehypePlugin, any])[]; +export type RemarkRehype = Record; -export type RemarkRehype = RemarkRehypeOptions; - -export type Smartypants = SmartypantsOptions; - -export type ThemePresets = BuiltinTheme | 'css-variables'; - -export type SyntaxHighlightConfigType = 'shiki' | 'prism'; - -export interface SyntaxHighlightConfig { - type: SyntaxHighlightConfigType; - excludeLangs?: string[]; +export interface MarkdownHeading { + depth: number; + slug: string; + text: string; } -export interface ShikiConfig - extends Pick, - Pick {} - -/** - * Configuration options that end up in the markdown section of AstroConfig - */ -export interface AstroMarkdownOptions { +export interface AstroMarkdownProcessorOptions { syntaxHighlight?: SyntaxHighlightConfig | SyntaxHighlightConfigType | false; shikiConfig?: ShikiConfig; + /** @deprecated Configure via the processor instead, e.g. `satteri({ features: { gfm: false } })`. */ + gfm?: boolean; + /** @deprecated Configure via the processor instead, e.g. `satteri({ features: { smartPunctuation: false } })`. */ + smartypants?: boolean | SmartypantsOptions; /** @deprecated Use `markdown.processor: unified({ remarkPlugins })` instead. */ remarkPlugins?: RemarkPlugins; /** @deprecated Use `markdown.processor: unified({ rehypePlugins })` instead. */ rehypePlugins?: RehypePlugins; /** @deprecated Use `markdown.processor: unified({ remarkRehype })` instead. */ remarkRehype?: RemarkRehype; - gfm?: boolean; - smartypants?: boolean | SmartypantsOptions; -} - -/** - * Extra configuration options from other parts of AstroConfig that get injected into this plugin - */ -export interface AstroMarkdownProcessorOptions extends AstroMarkdownOptions { image?: { domains?: string[]; remotePatterns?: RemotePattern[]; @@ -85,9 +85,7 @@ export interface MarkdownProcessor { } export interface MarkdownProcessorRenderOptions { - /** The URL of the file being rendered, used for resolving relative image paths */ fileURL?: URL; - /** Used for frontmatter injection plugins */ frontmatter?: Record; } @@ -100,9 +98,3 @@ export interface MarkdownProcessorRenderResult { frontmatter: Record; }; } - -export interface MarkdownHeading { - depth: number; - slug: string; - text: string; -} diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index 2d790cb26f91..cdd6b47b1d55 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -14,12 +14,43 @@ import { rehypePrism } from './rehype-prism.js'; import { rehypeShiki } from './rehype-shiki.js'; import { remarkCollectImages } from './remark-collect-images.js'; import type { - AstroMarkdownOptions, AstroMarkdownProcessorOptions, + MarkdownHeading, MarkdownProcessor, SyntaxHighlightConfig, -} from './types.js'; +} from '@astrojs/internal-helpers/markdown'; + +// `vfile.data.astro` is populated by the unified pipeline as it renders. +declare module 'vfile' { + interface DataMap { + astro: { + headings?: MarkdownHeading[]; + localImagePaths?: string[]; + remoteImagePaths?: string[]; + frontmatter?: Record; + }; + } +} +export type { Node } from 'unist'; +// The markdown contract types live in `@astrojs/internal-helpers/markdown`; +// re-exported so consumers keep a single import surface. +export type { + AstroMarkdownProcessorOptions, + MarkdownHeading, + MarkdownProcessor, + MarkdownProcessorRenderOptions, + MarkdownProcessorRenderResult, + RehypePlugin, + RehypePlugins, + RemarkPlugin, + RemarkPlugins, + RemarkRehype, + ShikiConfig, + Smartypants, + SyntaxHighlightConfig, + SyntaxHighlightConfigType, +} from '@astrojs/internal-helpers/markdown'; export { extractFrontmatter, isFrontmatterValid, @@ -37,7 +68,6 @@ export { type ShikiHighlighter, type ShikiHighlighterHighlightOptions, } from './shiki.js'; -export * from './types.js'; export { isUnifiedProcessor, type UnifiedProcessorDescriptor, @@ -50,7 +80,7 @@ export const syntaxHighlightDefaults: Required = { excludeLangs: defaultExcludeLanguages, }; -export const markdownConfigDefaults: Required = { +export const markdownConfigDefaults: Required> = { syntaxHighlight: syntaxHighlightDefaults, shikiConfig: { langs: [], diff --git a/packages/markdown/remark/src/processor.ts b/packages/markdown/remark/src/processor.ts index 2a260566959e..98ff4e12cf05 100644 --- a/packages/markdown/remark/src/processor.ts +++ b/packages/markdown/remark/src/processor.ts @@ -4,7 +4,7 @@ import type { RehypePlugins, RemarkPlugins, RemarkRehype, -} from './types.js'; +} from '@astrojs/internal-helpers/markdown'; export interface UnifiedProcessorOptions { remarkPlugins?: RemarkPlugins; diff --git a/packages/markdown/remark/src/rehype-collect-headings.ts b/packages/markdown/remark/src/rehype-collect-headings.ts index db0da42f0f00..bc1b64819dde 100644 --- a/packages/markdown/remark/src/rehype-collect-headings.ts +++ b/packages/markdown/remark/src/rehype-collect-headings.ts @@ -4,7 +4,7 @@ import type { MdxTextExpression } from 'mdast-util-mdx-expression'; import type { Node } from 'unist'; import { visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; -import type { MarkdownHeading, RehypePlugin } from './types.js'; +import type { MarkdownHeading, RehypePlugin } from '@astrojs/internal-helpers/markdown'; import { FORBIDDEN_PATH_KEYS } from '@astrojs/internal-helpers/object'; diff --git a/packages/markdown/remark/src/rehype-shiki.ts b/packages/markdown/remark/src/rehype-shiki.ts index 9f05d65b89b7..185f9310c5c4 100644 --- a/packages/markdown/remark/src/rehype-shiki.ts +++ b/packages/markdown/remark/src/rehype-shiki.ts @@ -2,7 +2,7 @@ import type { Root } from 'hast'; import type { Plugin } from 'unified'; import { highlightCodeBlocks } from './highlight.js'; import { createShikiHighlighter, type ShikiHighlighter } from './shiki.js'; -import type { ShikiConfig } from './types.js'; +import type { ShikiConfig } from '@astrojs/internal-helpers/markdown'; export const rehypeShiki: Plugin<[ShikiConfig, string[]?], Root> = (config, excludeLangs) => { let highlighterAsync: Promise | undefined; diff --git a/packages/markdown/remark/src/remark-collect-images.ts b/packages/markdown/remark/src/remark-collect-images.ts index 0fa946670823..32f77ec19cc8 100644 --- a/packages/markdown/remark/src/remark-collect-images.ts +++ b/packages/markdown/remark/src/remark-collect-images.ts @@ -3,7 +3,7 @@ import type { Root } from 'mdast'; import { definitions } from 'mdast-util-definitions'; import { visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; -import type { AstroMarkdownProcessorOptions } from './types.js'; +import type { AstroMarkdownProcessorOptions } from '@astrojs/internal-helpers/markdown'; export function remarkCollectImages(opts: AstroMarkdownProcessorOptions['image']) { const domains = opts?.domains ?? []; diff --git a/packages/markdown/remark/src/shiki.ts b/packages/markdown/remark/src/shiki.ts index 28d7124381b5..502b8d74a670 100644 --- a/packages/markdown/remark/src/shiki.ts +++ b/packages/markdown/remark/src/shiki.ts @@ -1,6 +1,7 @@ import type { Properties, Root } from 'hast'; import { type BuiltinLanguage, + type BuiltinTheme, type BundledLanguage, createCssVariablesTheme, createHighlighter, @@ -14,9 +15,10 @@ import { type ThemeRegistration, type ThemeRegistrationRaw, } from 'shiki'; -import type { ThemePresets } from './types.js'; import { loadShikiEngine } from '#shiki-engine'; +export type ThemePresets = BuiltinTheme | 'css-variables'; + export interface ShikiHighlighter { codeToHast( code: string, @@ -299,6 +301,3 @@ async function createShikiHighlighterInternal({ function normalizePropAsString(value: Properties[string]): string | null { return Array.isArray(value) ? value.join(' ') : (value as string | null); } - -// Re-export ThemePresets type for consumers -export type { ThemePresets }; diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index 8db164ea8817..c558b6064908 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -35,13 +35,12 @@ "dependencies": { "@astrojs/internal-helpers": "workspace:*", "github-slugger": "^2.0.0", - "satteri": "^0.6.1", + "satteri": "^0.6.2", "shiki": "^4.0.0" }, "devDependencies": { "@types/hast": "^3.0.4", - "astro-scripts": "workspace:*", - "retext-smartypants": "^6.2.0" + "astro-scripts": "workspace:*" }, "publishConfig": { "provenance": true diff --git a/packages/markdown/satteri/src/defaults.ts b/packages/markdown/satteri/src/defaults.ts index 7c4609947809..858290afde62 100644 --- a/packages/markdown/satteri/src/defaults.ts +++ b/packages/markdown/satteri/src/defaults.ts @@ -1,5 +1,5 @@ import { defaultExcludeLanguages } from './highlight.js'; -import type { ShikiConfig, SyntaxHighlightConfig } from './types.js'; +import type { ShikiConfig, SyntaxHighlightConfig } from '@astrojs/internal-helpers/markdown'; export const syntaxHighlightDefaults: Required = { type: 'shiki', diff --git a/packages/markdown/satteri/src/index.ts b/packages/markdown/satteri/src/index.ts index ffd5752c2044..9a1d41841254 100644 --- a/packages/markdown/satteri/src/index.ts +++ b/packages/markdown/satteri/src/index.ts @@ -24,14 +24,3 @@ export { type ShikiHighlighterHighlightOptions, type ThemePresets, } from './shiki.js'; -export type { - AstroMarkdownProcessorOptions, - MarkdownHeading, - MarkdownProcessor, - MarkdownProcessorRenderOptions, - MarkdownProcessorRenderResult, - ShikiConfig, - Smartypants, - SyntaxHighlightConfig, - SyntaxHighlightConfigType, -} from './types.js'; diff --git a/packages/markdown/satteri/src/processor.ts b/packages/markdown/satteri/src/processor.ts index 5330da116f51..e7651abaf863 100644 --- a/packages/markdown/satteri/src/processor.ts +++ b/packages/markdown/satteri/src/processor.ts @@ -1,13 +1,16 @@ import { createSatteriMarkdownProcessor } from './satteri-processor.js'; -import type { AstroMarkdownProcessorOptions, MarkdownProcessor } from './types.js'; +import type { + AstroMarkdownProcessorOptions, + MarkdownProcessor, +} from '@astrojs/internal-helpers/markdown'; import type { MdastPluginDefinition, HastPluginDefinition, Features } from 'satteri'; export interface SatteriProcessorOptions { - mdastPlugins?: import('satteri').MdastPluginDefinition[]; - hastPlugins?: import('satteri').HastPluginDefinition[]; - features?: import('satteri').Features; + mdastPlugins?: MdastPluginDefinition[]; + hastPlugins?: HastPluginDefinition[]; + features?: Features; } /** diff --git a/packages/markdown/satteri/src/satteri-processor.ts b/packages/markdown/satteri/src/satteri-processor.ts index 2caf3d82df06..f77eeb81d0c9 100644 --- a/packages/markdown/satteri/src/satteri-processor.ts +++ b/packages/markdown/satteri/src/satteri-processor.ts @@ -1,5 +1,6 @@ import { isRemoteAllowed } from '@astrojs/internal-helpers/remote'; import Slugger from 'github-slugger'; +import type { Features, HastNode, HastPluginDefinition, MdastPluginDefinition } from 'satteri'; import { satteriMarkdownDefaults, syntaxHighlightDefaults } from './defaults.js'; import { defaultExcludeLanguages } from './highlight.js'; import { createShikiHighlighter } from './shiki.js'; @@ -7,29 +8,24 @@ import type { AstroMarkdownProcessorOptions, MarkdownHeading, MarkdownProcessor, -} from './types.js'; +} from '@astrojs/internal-helpers/markdown'; type HighlightFn = (code: string, lang: string, meta?: string) => Promise; let satteri: typeof import('satteri') | undefined; +// Loaded lazily so the satteri Rust/WASM binary isn't pulled in unless the +// satteri processor actually runs. async function loadSatteri(): Promise { - if (satteri) return satteri; - try { - satteri = await import('satteri'); - return satteri; - } catch { - throw new Error( - 'The default `satteri()` markdown processor requires the `satteri` package. Install it with:\n npm install satteri\n\nOr opt into the legacy unified pipeline with `markdown.processor: unified({...})` from `@astrojs/markdown-remark`.', - ); - } + satteri ??= await import('satteri'); + return satteri; } export function createCollectImagesPlugin( localImagePaths: Set, remoteImagePaths: Set, image: AstroMarkdownProcessorOptions['image'] = {}, -): import('satteri').MdastPluginDefinition { +): MdastPluginDefinition { const domains = image?.domains ?? []; const remotePatterns = image?.remotePatterns ?? []; return { @@ -75,7 +71,7 @@ function resolveFrontmatterExpression( } export function collectHastText( - node: import('satteri').HastNode, + node: HastNode, frontmatter: Record | undefined, ): string { let text = ''; @@ -89,25 +85,25 @@ export function collectHastText( } if ('children' in node && node.children) { for (const child of node.children) { - text += collectHastText(child as import('satteri').HastNode, frontmatter); + text += collectHastText(child as HastNode, frontmatter); } } return text; } -export function makeFragmentNode(html: string): import('satteri').HastNode { +export function makeFragmentNode(html: string): HastNode { return { type: 'mdxJsxFlowElement', name: 'Fragment', attributes: [{ type: 'mdxJsxAttribute', name: 'set:html', value: html }], children: [], - } as unknown as import('satteri').HastNode; + } as unknown as HastNode; } export function createHeadingIdsPlugin( headings: MarkdownHeading[], frontmatter: Record | undefined, -): import('satteri').HastPluginDefinition { +): HastPluginDefinition { const slugger = new Slugger(); return { name: 'heading-ids', @@ -131,8 +127,8 @@ export function createHeadingIdsPlugin( }; } -function makeRawNode(html: string): import('satteri').HastNode { - return { type: 'raw', value: html } as unknown as import('satteri').HastNode; +function makeRawNode(html: string): HastNode { + return { type: 'raw', value: html } as unknown as HastNode; } const HAST_PRESERVED_PROPERTIES = new Set(['className', 'htmlFor']); @@ -141,7 +137,7 @@ const HAST_PRESERVED_PROPERTIES = new Set(['className', 'htmlFor']); export function createImageMarkerPlugin( localImagePaths: Set, remoteImagePaths: Set, -): import('satteri').HastPluginDefinition { +): HastPluginDefinition { const indexBySrc = new Map(); return { name: 'image-marker', @@ -182,7 +178,7 @@ export function createShikiPlugin( highlight: HighlightFn, excludeLangs: string[] | undefined, options?: { mdx?: boolean }, -): import('satteri').HastPluginDefinition { +): HastPluginDefinition { const wrapResult = options?.mdx ? makeFragmentNode : makeRawNode; return { name: 'shiki-highlight', @@ -190,8 +186,8 @@ export function createShikiPlugin( filter: ['pre'], async visit(node, ctx) { const codeChild = node.children?.find( - (c: import('satteri').HastNode) => c.type === 'element' && c.tagName === 'code', - ) as import('satteri').HastNode | undefined; + (c: HastNode) => c.type === 'element' && c.tagName === 'code', + ) as HastNode | undefined; if (!codeChild || codeChild.type !== 'element') return; const lang = (codeChild.data as any)?.lang ?? 'plaintext'; @@ -213,9 +209,9 @@ export function createShikiPlugin( } export interface SatteriMarkdownProcessorOptions extends AstroMarkdownProcessorOptions { - mdastPlugins?: import('satteri').MdastPluginDefinition[]; - hastPlugins?: import('satteri').HastPluginDefinition[]; - features?: import('satteri').Features; + mdastPlugins?: MdastPluginDefinition[]; + hastPlugins?: HastPluginDefinition[]; + features?: Features; } export async function createSatteriMarkdownProcessor( @@ -273,12 +269,12 @@ export async function createSatteriMarkdownProcessor( const remoteImagePaths = new Set(); const frontmatter = renderOpts?.frontmatter ?? {}; - const allMdastPlugins: import('satteri').MdastPluginDefinition[] = [ + const allMdastPlugins: MdastPluginDefinition[] = [ createCollectImagesPlugin(localImagePaths, remoteImagePaths, opts?.image), ...userMdastPlugins, ]; - const hastPlugins: import('satteri').HastPluginDefinition[] = []; + const hastPlugins: HastPluginDefinition[] = []; if (highlightFn) { hastPlugins.push(createShikiPlugin(highlightFn, syntaxHighlightExcludeLangs)); } diff --git a/packages/markdown/satteri/src/types.ts b/packages/markdown/satteri/src/types.ts deleted file mode 100644 index f2bdada05294..000000000000 --- a/packages/markdown/satteri/src/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { RemotePattern } from '@astrojs/internal-helpers/remote'; -import type { Options as SmartypantsOptions } from 'retext-smartypants'; -import type { CreateShikiHighlighterOptions, ShikiHighlighterHighlightOptions } from './shiki.js'; - -export type SyntaxHighlightConfigType = 'shiki' | 'prism'; - -export interface SyntaxHighlightConfig { - type: SyntaxHighlightConfigType; - excludeLangs?: string[]; -} - -export interface ShikiConfig - extends Pick, - Pick {} - -export type Smartypants = SmartypantsOptions; - -export interface MarkdownHeading { - depth: number; - slug: string; - text: string; -} - -export interface AstroMarkdownProcessorOptions { - syntaxHighlight?: SyntaxHighlightConfig | SyntaxHighlightConfigType | false; - shikiConfig?: ShikiConfig; - gfm?: boolean; - smartypants?: boolean | SmartypantsOptions; - image?: { - domains?: string[]; - remotePatterns?: RemotePattern[]; - }; -} - -export interface MarkdownProcessor { - render: ( - content: string, - opts?: MarkdownProcessorRenderOptions, - ) => Promise; -} - -export interface MarkdownProcessorRenderOptions { - fileURL?: URL; - frontmatter?: Record; -} - -export interface MarkdownProcessorRenderResult { - code: string; - metadata: { - headings: MarkdownHeading[]; - localImagePaths: string[]; - remoteImagePaths: string[]; - frontmatter: Record; - }; -} diff --git a/packages/markdown/satteri/test/highlight.test.ts b/packages/markdown/satteri/test/highlight.test.ts new file mode 100644 index 000000000000..9ff091fbc3c2 --- /dev/null +++ b/packages/markdown/satteri/test/highlight.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createSatteriMarkdownProcessor } from '../dist/index.js'; + +describe('satteri highlight', () => { + it('highlights using shiki by default', async () => { + const processor = await createSatteriMarkdownProcessor(); + const { code } = await processor.render('```js\nconsole.log("Hello, world!");\n```'); + assert.match(code, /background-color:/); + }); + + it('does not highlight math code blocks by default', async () => { + const processor = await createSatteriMarkdownProcessor(); + const { code } = await processor.render('```math\n\\frac{1}{2}\n```'); + assert.ok(!code.includes('background-color:')); + }); + + it('supports excludeLangs', async () => { + const processor = await createSatteriMarkdownProcessor({ + syntaxHighlight: { type: 'shiki', excludeLangs: ['mermaid'] }, + }); + const { code } = await processor.render('```mermaid\ngraph TD\nA --> B\n```'); + assert.ok(!code.includes('background-color:')); + }); + + it('rejects prism highlighting', async () => { + await assert.rejects( + createSatteriMarkdownProcessor({ syntaxHighlight: { type: 'prism' } }), + /Prism syntax highlighting is not supported/, + ); + }); +}); diff --git a/packages/markdown/satteri/test/markdown.test.ts b/packages/markdown/satteri/test/markdown.test.ts new file mode 100644 index 000000000000..949a00b06ac6 --- /dev/null +++ b/packages/markdown/satteri/test/markdown.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createSatteriMarkdownProcessor } from '../dist/index.js'; + +describe('satteri markdown', () => { + it('renders basic markdown', async () => { + const processor = await createSatteriMarkdownProcessor(); + const { code } = await processor.render('# Title\n\nSome **bold** text.'); + assert.match(code, /

Title<\/h1>/); + assert.match(code, /bold<\/strong>/); + }); + + it('collects headings into metadata', async () => { + const processor = await createSatteriMarkdownProcessor(); + const { metadata } = await processor.render('# One\n\n## Two'); + assert.deepEqual(metadata.headings, [ + { depth: 1, slug: 'one', text: 'One' }, + { depth: 2, slug: 'two', text: 'Two' }, + ]); + }); + + it('applies GFM by default', async () => { + const processor = await createSatteriMarkdownProcessor(); + const { code } = await processor.render('Visit https://example.com today'); + assert.match(code, / { + const processor = await createSatteriMarkdownProcessor({ gfm: false }); + const { code } = await processor.render('Visit https://example.com today'); + assert.ok(!code.includes(' { + const processor = await createSatteriMarkdownProcessor(); + const { code } = await processor.render('He said "hello"'); + assert.match(code, /“hello”/); + }); + + it('disables smart punctuation when `smartypants: false`', async () => { + const processor = await createSatteriMarkdownProcessor({ smartypants: false }); + const { code } = await processor.render('He said "hello"'); + assert.ok(code.includes('"hello"')); + }); + + it('collects local image paths into metadata', async () => { + const processor = await createSatteriMarkdownProcessor(); + const { metadata } = await processor.render('![alt](./local.png)'); + assert.deepEqual(metadata.localImagePaths, ['./local.png']); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05b954b3b652..e6cff3c9b0d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -743,12 +743,6 @@ importers: hono: specifier: ^4.12.14 version: 4.12.16 - mdast-util-mdx: - specifier: ^3.0.0 - version: 3.0.0 - mdast-util-mdx-jsx: - specifier: ^3.2.0 - version: 3.2.0 node-mocks-http: specifier: ^1.17.2 version: 1.17.2(@types/express@5.0.6)(@types/node@25.2.3) @@ -5163,6 +5157,9 @@ importers: packages/integrations/mdx: dependencies: + '@astrojs/internal-helpers': + specifier: workspace:* + version: link:../../internal-helpers '@astrojs/markdown-satteri': specifier: workspace:* version: link:../../markdown/satteri @@ -5194,8 +5191,8 @@ importers: specifier: ^3.0.2 version: 3.0.2 satteri: - specifier: ^0.6.1 - version: 0.6.1 + specifier: ^0.6.2 + version: 0.6.2 source-map: specifier: ^0.7.6 version: 0.7.6 @@ -6459,9 +6456,24 @@ importers: packages/internal-helpers: dependencies: + '@types/hast': + specifier: ^3.0.4 + version: 3.0.4 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 picomatch: specifier: ^4.0.4 version: 4.0.4 + retext-smartypants: + specifier: ^6.2.0 + version: 6.2.0 + shiki: + specifier: ^4.0.2 + version: 4.0.2 + unified: + specifier: ^11.0.5 + version: 11.0.5 devDependencies: '@types/picomatch': specifier: ^4.0.2 @@ -6849,8 +6861,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 satteri: - specifier: ^0.6.1 - version: 0.6.1 + specifier: ^0.6.2 + version: 0.6.2 shiki: specifier: ^4.0.0 version: 4.0.2 @@ -6861,9 +6873,6 @@ importers: astro-scripts: specifier: workspace:* version: link:../../../scripts - retext-smartypants: - specifier: ^6.2.0 - version: 6.2.0 packages/telemetry: dependencies: @@ -7496,29 +7505,29 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - '@bruits/satteri-darwin-arm64@0.6.1': - resolution: {integrity: sha512-NKLcnlY0AOmCTWUziyxRc0U2A/+q0399bdn/ljNWHhb0s8H9jKIgTNtqi1f/GcJUX3kmTPAprBCJm+tempB6Mg==} + '@bruits/satteri-darwin-arm64@0.6.2': + resolution: {integrity: sha512-z1rMMG2DG6SqSMGc1ke4uvdE/hCa8NGiobT+3oZ41asRcixlYytpTk8D5p78l2xXt/yzr60dezEK0/33BRG51Q==} cpu: [arm64] os: [darwin] - '@bruits/satteri-darwin-x64@0.6.1': - resolution: {integrity: sha512-b/cZLW/JkUreNHM7HebE7pStXU6MHHgdaYq8M4Kr+9n2BYtFky1AHSVlF5n4uUOcLZocP4g8On3vBcYwm/j8Mw==} + '@bruits/satteri-darwin-x64@0.6.2': + resolution: {integrity: sha512-x2yoD8ekF8u/KPciJRL272uVtCzlSJgUthgjrddRGRDnjNfRG8HBqxwziwBkkWh6u7oEfXcsSI0o2DJ0kmD/zQ==} cpu: [x64] os: [darwin] - '@bruits/satteri-linux-x64-gnu@0.6.1': - resolution: {integrity: sha512-70RLvQuy8wiujTd/1+jJv1Qghj6mDhUWYCsbZtuEvoZ06rlDboyyACjXb9KQluPUbTSVTPVMHvLTZ1W0wzcaZA==} + '@bruits/satteri-linux-x64-gnu@0.6.2': + resolution: {integrity: sha512-bEmN7bALdl7wEunhd1GinRvZDXxotZBh0hzYpJeMyHckz1DsiK9UdI/Tw5b/gOLIAWwSVh03l1d4039tAfw6oA==} cpu: [x64] os: [linux] libc: [glibc] - '@bruits/satteri-wasm32-wasi@0.6.1': - resolution: {integrity: sha512-NuzychCFthdqb+LJecvrn/qEtFVZWq7aNwddYDM3VsEJuFP2BWEvQrTN+jCrdGDg/H45nfuzGlP/rDO3i901iw==} + '@bruits/satteri-wasm32-wasi@0.6.2': + resolution: {integrity: sha512-VdHVeNwGBf7Ju3X/77tZmVshjgCCztTMZmwBHT/3ammnS+6mLQMsUJUI2lrgistr9bRb1i+N/2ZWVOQzqmYsYw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@bruits/satteri-win32-x64-msvc@0.6.1': - resolution: {integrity: sha512-hakBJQvKVD+dT4iChz/bF3NI58AJNu5LQsQLhaMqTd5FBu87No0+LKRhyAtApzVGEPlR4TfABH753s06c0C38g==} + '@bruits/satteri-win32-x64-msvc@0.6.2': + resolution: {integrity: sha512-4IiS3Tp3rAtVTPUHlSa9Bc5w3gFsKkypvofBeB8naWHKaF1lPEv4RvqvNntfpKzDhNjwY/8/sH+Ug6n9koFX8w==} cpu: [x64] os: [win32] @@ -15001,8 +15010,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - satteri@0.6.1: - resolution: {integrity: sha512-+18droHlUtbxUC1uH51LBlwiaSe41cV4kqJwcGwbS7scy7i0l37y6d13QS0YALnpYzA3QxEfw/WYtlB090k0rg==} + satteri@0.6.2: + resolution: {integrity: sha512-4u1hfE2vcPzUBg/rDkk7FKzpvEbQgQuizT442GIZsdf4qhg4crjhG+aSmrjVJvkxMEA6z4Htt85JQYRuTkSzBg==} sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} @@ -17360,23 +17369,23 @@ snapshots: '@borewit/text-codec@0.2.2': {} - '@bruits/satteri-darwin-arm64@0.6.1': + '@bruits/satteri-darwin-arm64@0.6.2': optional: true - '@bruits/satteri-darwin-x64@0.6.1': + '@bruits/satteri-darwin-x64@0.6.2': optional: true - '@bruits/satteri-linux-x64-gnu@0.6.1': + '@bruits/satteri-linux-x64-gnu@0.6.2': optional: true - '@bruits/satteri-wasm32-wasi@0.6.1': + '@bruits/satteri-wasm32-wasi@0.6.2': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optional: true - '@bruits/satteri-win32-x64-msvc@0.6.1': + '@bruits/satteri-win32-x64-msvc@0.6.2': optional: true '@capsizecss/unpack@4.0.0': @@ -25938,17 +25947,17 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 - satteri@0.6.1: + satteri@0.6.2: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 optionalDependencies: - '@bruits/satteri-darwin-arm64': 0.6.1 - '@bruits/satteri-darwin-x64': 0.6.1 - '@bruits/satteri-linux-x64-gnu': 0.6.1 - '@bruits/satteri-wasm32-wasi': 0.6.1 - '@bruits/satteri-win32-x64-msvc': 0.6.1 + '@bruits/satteri-darwin-arm64': 0.6.2 + '@bruits/satteri-darwin-x64': 0.6.2 + '@bruits/satteri-linux-x64-gnu': 0.6.2 + '@bruits/satteri-wasm32-wasi': 0.6.2 + '@bruits/satteri-win32-x64-msvc': 0.6.2 sax@1.6.0: {} From 7853a2fef9d4be3c74b423d0607fb816b06d5a30 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 21 May 2026 02:33:56 +0200 Subject: [PATCH 21/26] chore: clean up --- packages/astro/components/Code.astro | 2 +- .../astro/src/core/config/schemas/base.ts | 14 +- packages/integrations/mdx/src/index.ts | 9 +- .../integrations/mdx/src/satteri/index.ts | 2 +- packages/internal-helpers/package.json | 12 +- packages/internal-helpers/src/markdown.ts | 26 ++ .../src/shiki-engine-default.ts | 0 .../src/shiki-engine-workerd.ts | 0 .../remark => internal-helpers}/src/shiki.ts | 0 packages/markdown/remark/package.json | 12 +- packages/markdown/remark/src/highlight.ts | 3 +- packages/markdown/remark/src/index.ts | 32 +-- .../remark/src/rehype-collect-headings.ts | 8 +- packages/markdown/remark/src/rehype-shiki.ts | 2 +- packages/markdown/remark/test/shiki.test.ts | 9 +- packages/markdown/satteri/package.json | 9 +- packages/markdown/satteri/src/defaults.ts | 23 -- packages/markdown/satteri/src/highlight.ts | 2 - packages/markdown/satteri/src/index.ts | 10 - .../markdown/satteri/src/satteri-processor.ts | 15 +- .../satteri/src/shiki-engine-default.ts | 6 - .../satteri/src/shiki-engine-workerd.ts | 7 - packages/markdown/satteri/src/shiki.ts | 249 ------------------ pnpm-lock.yaml | 12 +- 24 files changed, 80 insertions(+), 384 deletions(-) rename packages/{markdown/remark => internal-helpers}/src/shiki-engine-default.ts (100%) rename packages/{markdown/remark => internal-helpers}/src/shiki-engine-workerd.ts (100%) rename packages/{markdown/remark => internal-helpers}/src/shiki.ts (100%) delete mode 100644 packages/markdown/satteri/src/defaults.ts delete mode 100644 packages/markdown/satteri/src/highlight.ts delete mode 100644 packages/markdown/satteri/src/shiki-engine-default.ts delete mode 100644 packages/markdown/satteri/src/shiki-engine-workerd.ts delete mode 100644 packages/markdown/satteri/src/shiki.ts diff --git a/packages/astro/components/Code.astro b/packages/astro/components/Code.astro index 846d54e4d14c..ee2e4589152d 100644 --- a/packages/astro/components/Code.astro +++ b/packages/astro/components/Code.astro @@ -1,5 +1,5 @@ --- -import { createShikiHighlighter, type ThemePresets } from '@astrojs/markdown-remark/shiki'; +import { createShikiHighlighter, type ThemePresets } from '@astrojs/internal-helpers/shiki'; import type { ShikiTransformer, ThemeRegistration, ThemeRegistrationRaw } from 'shiki'; import { bundledLanguages } from 'shiki/langs'; import type { CodeLanguage } from '../dist/types/public/common.js'; diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index deff78abe5ef..fec85f17bd49 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -1,5 +1,10 @@ -import type { ShikiConfig, Smartypants as _Smartypants } from '@astrojs/internal-helpers/markdown'; -import { satteri, satteriMarkdownDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-satteri'; +import { + markdownConfigDefaults, + type ShikiConfig, + type Smartypants as _Smartypants, + syntaxHighlightDefaults, +} from '@astrojs/internal-helpers/markdown'; +import { satteri } from '@astrojs/markdown-satteri'; import type { MarkdownProcessorEntry } from '../../../markdown/index.js'; import type { RehypePlugin as _RehypePlugin, @@ -89,10 +94,7 @@ export const ASTRO_CONFIG_DEFAULTS = { }, integrations: [], markdown: { - ...satteriMarkdownDefaults, - remarkPlugins: [], - rehypePlugins: [], - remarkRehype: {}, + ...markdownConfigDefaults, processor: satteri(), }, vite: {}, diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index 17e5447db483..a84299528087 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -1,8 +1,11 @@ import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import type { UnifiedProcessorDescriptor } from '@astrojs/markdown-remark'; -import type { AstroMarkdownProcessorOptions } from '@astrojs/internal-helpers/markdown'; -import { satteriMarkdownDefaults, type SatteriProcessorDescriptor } from '@astrojs/markdown-satteri'; +import { + type AstroMarkdownProcessorOptions, + markdownConfigDefaults, +} from '@astrojs/internal-helpers/markdown'; +import type { SatteriProcessorDescriptor } from '@astrojs/markdown-satteri'; import type { Features } from 'satteri'; import type { AstroIntegration, @@ -113,7 +116,7 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI const extendMarkdownConfig = partialMdxOptions.extendMarkdownConfig ?? defaultMdxOptions.extendMarkdownConfig; - const markdownConfig = extendMarkdownConfig ? config.markdown : satteriMarkdownDefaults; + const markdownConfig = extendMarkdownConfig ? config.markdown : markdownConfigDefaults; const resolvedMdxOptions = applyDefaultOptions({ options: partialMdxOptions, diff --git a/packages/integrations/mdx/src/satteri/index.ts b/packages/integrations/mdx/src/satteri/index.ts index a826ad6839d0..69be2b5eb04c 100644 --- a/packages/integrations/mdx/src/satteri/index.ts +++ b/packages/integrations/mdx/src/satteri/index.ts @@ -1,6 +1,6 @@ import type { MarkdownHeading } from '@astrojs/internal-helpers/markdown'; +import { createShikiHighlighter } from '@astrojs/internal-helpers/shiki'; import { - createShikiHighlighter, satteriCollectImagesPlugin, satteriHeadingIdsPlugin, satteriShikiPlugin, diff --git a/packages/internal-helpers/package.json b/packages/internal-helpers/package.json index d6fc080a6243..2c38b36ea31d 100644 --- a/packages/internal-helpers/package.json +++ b/packages/internal-helpers/package.json @@ -19,7 +19,14 @@ "./create-filter": "./dist/create-filter.js", "./request": "./dist/request.js", "./object": "./dist/object.js", - "./markdown": "./dist/markdown.js" + "./markdown": "./dist/markdown.js", + "./shiki": "./dist/shiki.js" + }, + "imports": { + "#shiki-engine": { + "workerd": "./dist/shiki-engine-workerd.js", + "default": "./dist/shiki-engine-default.js" + } }, "typesVersions": { "*": { @@ -32,6 +39,9 @@ "markdown": [ "./dist/markdown.d.ts" ], + "shiki": [ + "./dist/shiki.d.ts" + ], "fs": [ "./dist/fs.d.ts" ], diff --git a/packages/internal-helpers/src/markdown.ts b/packages/internal-helpers/src/markdown.ts index 8e5fb4e1d30a..66cc824442ae 100644 --- a/packages/internal-helpers/src/markdown.ts +++ b/packages/internal-helpers/src/markdown.ts @@ -98,3 +98,29 @@ export interface MarkdownProcessorRenderResult { frontmatter: Record; }; } + +// Languages never syntax-highlighted by default (e.g. `math`, handled elsewhere). +export const defaultExcludeLanguages = ['math']; + +export const syntaxHighlightDefaults: Required = { + type: 'shiki', + excludeLangs: defaultExcludeLanguages, +}; + +/** Default values for the markdown config, regardless of processor. */ +export const markdownConfigDefaults: Required> = { + syntaxHighlight: syntaxHighlightDefaults, + shikiConfig: { + langs: [], + theme: 'github-dark', + themes: {}, + wrap: false, + transformers: [], + langAlias: {}, + }, + gfm: true, + smartypants: true, + remarkPlugins: [], + rehypePlugins: [], + remarkRehype: {}, +}; diff --git a/packages/markdown/remark/src/shiki-engine-default.ts b/packages/internal-helpers/src/shiki-engine-default.ts similarity index 100% rename from packages/markdown/remark/src/shiki-engine-default.ts rename to packages/internal-helpers/src/shiki-engine-default.ts diff --git a/packages/markdown/remark/src/shiki-engine-workerd.ts b/packages/internal-helpers/src/shiki-engine-workerd.ts similarity index 100% rename from packages/markdown/remark/src/shiki-engine-workerd.ts rename to packages/internal-helpers/src/shiki-engine-workerd.ts diff --git a/packages/markdown/remark/src/shiki.ts b/packages/internal-helpers/src/shiki.ts similarity index 100% rename from packages/markdown/remark/src/shiki.ts rename to packages/internal-helpers/src/shiki.ts diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 247f62af7b34..3826b4769346 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -13,17 +13,12 @@ "homepage": "https://astro.build", "main": "./dist/index.js", "exports": { - ".": "./dist/index.js", - "./shiki": "./dist/shiki.js" + ".": "./dist/index.js" }, "imports": { "#import-plugin": { "browser": "./dist/import-plugin-browser.js", "default": "./dist/import-plugin-default.js" - }, - "#shiki-engine": { - "workerd": "./dist/shiki-engine-workerd.js", - "default": "./dist/shiki-engine-default.js" } }, "files": [ @@ -50,8 +45,6 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", - "retext-smartypants": "^6.2.0", - "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", @@ -67,7 +60,8 @@ "@types/unist": "^3.0.3", "astro-scripts": "workspace:*", "esbuild": "^0.28.0", - "mdast-util-mdx-expression": "^2.0.1" + "mdast-util-mdx-expression": "^2.0.1", + "shiki": "^4.0.0" }, "publishConfig": { "provenance": true diff --git a/packages/markdown/remark/src/highlight.ts b/packages/markdown/remark/src/highlight.ts index d4457c7d2015..dcde13e9fa69 100644 --- a/packages/markdown/remark/src/highlight.ts +++ b/packages/markdown/remark/src/highlight.ts @@ -1,3 +1,4 @@ +import { defaultExcludeLanguages } from '@astrojs/internal-helpers/markdown'; import type { Element, Parent, Root } from 'hast'; import { fromHtml } from 'hast-util-from-html'; import { toText } from 'hast-util-to-text'; @@ -11,8 +12,6 @@ type Highlighter = ( ) => Promise; const languagePattern = /\blanguage-(\S+)\b/; -// Don’t highlight math code blocks by default. -export const defaultExcludeLanguages = ['math']; /** * A hast utility to syntax highlight code blocks with a given syntax highlighter. diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index cdd6b47b1d55..da2963b461dd 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -1,3 +1,4 @@ +import { markdownConfigDefaults } from '@astrojs/internal-helpers/markdown'; import rehypeRaw from 'rehype-raw'; import rehypeStringify from 'rehype-stringify'; import remarkGfm from 'remark-gfm'; @@ -6,7 +7,6 @@ import remarkRehype from 'remark-rehype'; import remarkSmartypants from 'remark-smartypants'; import { unified } from 'unified'; import { VFile } from 'vfile'; -import { defaultExcludeLanguages } from './highlight.js'; import { loadPlugins } from './load-plugins.js'; import { rehypeHeadingIds } from './rehype-collect-headings.js'; import { rehypeImages } from './rehype-images.js'; @@ -17,7 +17,6 @@ import type { AstroMarkdownProcessorOptions, MarkdownHeading, MarkdownProcessor, - SyntaxHighlightConfig, } from '@astrojs/internal-helpers/markdown'; // `vfile.data.astro` is populated by the unified pipeline as it renders. @@ -62,40 +61,13 @@ export { rehypeHeadingIds } from './rehype-collect-headings.js'; export { rehypePrism } from './rehype-prism.js'; export { rehypeShiki } from './rehype-shiki.js'; export { remarkCollectImages } from './remark-collect-images.js'; -export { - type CreateShikiHighlighterOptions, - createShikiHighlighter, - type ShikiHighlighter, - type ShikiHighlighterHighlightOptions, -} from './shiki.js'; export { isUnifiedProcessor, type UnifiedProcessorDescriptor, type UnifiedProcessorOptions, unified, } from './processor.js'; - -export const syntaxHighlightDefaults: Required = { - type: 'shiki', - excludeLangs: defaultExcludeLanguages, -}; - -export const markdownConfigDefaults: Required> = { - syntaxHighlight: syntaxHighlightDefaults, - shikiConfig: { - langs: [], - theme: 'github-dark', - themes: {}, - wrap: false, - transformers: [], - langAlias: {}, - }, - remarkPlugins: [], - rehypePlugins: [], - remarkRehype: {}, - gfm: true, - smartypants: true, -}; +export { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/internal-helpers/markdown'; // Skip nonessential plugins during performance benchmark runs const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK); diff --git a/packages/markdown/remark/src/rehype-collect-headings.ts b/packages/markdown/remark/src/rehype-collect-headings.ts index bc1b64819dde..4e65c44f581e 100644 --- a/packages/markdown/remark/src/rehype-collect-headings.ts +++ b/packages/markdown/remark/src/rehype-collect-headings.ts @@ -40,9 +40,9 @@ export function rehypeHeadingIds(): ReturnType { return; } } - if (rawNodeTypes.has(child.type) && 'value' in child) { - if (isMDX || ('tagName' in parent && codeTagNames.has(parent.tagName))) { - let value = child.value as string; + if (rawNodeTypes.has(child.type)) { + if (isMDX || codeTagNames.has(parent.tagName)) { + let value = child.value; if (isMdxTextExpression(child) && frontmatter) { const frontmatterPath = getMdxFrontmatterVariablePath(child); if (Array.isArray(frontmatterPath) && frontmatterPath.length > 0) { @@ -57,7 +57,7 @@ export function rehypeHeadingIds(): ReturnType { } text += value; } else { - text += (child.value as string).replace(/\{/g, '${'); + text += child.value.replace(/\{/g, '${'); } } }); diff --git a/packages/markdown/remark/src/rehype-shiki.ts b/packages/markdown/remark/src/rehype-shiki.ts index 185f9310c5c4..e10137127874 100644 --- a/packages/markdown/remark/src/rehype-shiki.ts +++ b/packages/markdown/remark/src/rehype-shiki.ts @@ -1,7 +1,7 @@ import type { Root } from 'hast'; import type { Plugin } from 'unified'; import { highlightCodeBlocks } from './highlight.js'; -import { createShikiHighlighter, type ShikiHighlighter } from './shiki.js'; +import { createShikiHighlighter, type ShikiHighlighter } from '@astrojs/internal-helpers/shiki'; import type { ShikiConfig } from '@astrojs/internal-helpers/markdown'; export const rehypeShiki: Plugin<[ShikiConfig, string[]?], Root> = (config, excludeLangs) => { diff --git a/packages/markdown/remark/test/shiki.test.ts b/packages/markdown/remark/test/shiki.test.ts index bc6d0dcb2fe3..51c2048be196 100644 --- a/packages/markdown/remark/test/shiki.test.ts +++ b/packages/markdown/remark/test/shiki.test.ts @@ -2,13 +2,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { Element } from 'hast'; import type { LanguageRegistration, ThemeRegistration } from 'shiki'; -import { - createMarkdownProcessor, - createShikiHighlighter, - type ShikiHighlighter, -} from '../dist/index.js'; +import { type ShikiHighlighter, createShikiHighlighter } from '@astrojs/internal-helpers/shiki'; // @ts-expect-error: `clearShikiHighlighterCache` is marked `@internal` and stripped from the `.d.ts`, but still exists at runtime. -import { clearShikiHighlighterCache } from '../dist/shiki.js'; +import { clearShikiHighlighterCache } from '@astrojs/internal-helpers/shiki'; +import { createMarkdownProcessor } from '../dist/index.js'; describe('shiki syntax highlighting', () => { it('does not add is:raw to the output', async () => { diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index c558b6064908..551590764380 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -15,12 +15,6 @@ "exports": { ".": "./dist/index.js" }, - "imports": { - "#shiki-engine": { - "workerd": "./dist/shiki-engine-workerd.js", - "default": "./dist/shiki-engine-default.js" - } - }, "files": [ "dist" ], @@ -35,8 +29,7 @@ "dependencies": { "@astrojs/internal-helpers": "workspace:*", "github-slugger": "^2.0.0", - "satteri": "^0.6.2", - "shiki": "^4.0.0" + "satteri": "^0.6.2" }, "devDependencies": { "@types/hast": "^3.0.4", diff --git a/packages/markdown/satteri/src/defaults.ts b/packages/markdown/satteri/src/defaults.ts deleted file mode 100644 index 858290afde62..000000000000 --- a/packages/markdown/satteri/src/defaults.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defaultExcludeLanguages } from './highlight.js'; -import type { ShikiConfig, SyntaxHighlightConfig } from '@astrojs/internal-helpers/markdown'; - -export const syntaxHighlightDefaults: Required = { - type: 'shiki', - excludeLangs: defaultExcludeLanguages, -}; - -const shikiConfigDefaults: ShikiConfig = { - langs: [], - theme: 'github-dark', - themes: {}, - wrap: false, - transformers: [], - langAlias: {}, -}; - -export const satteriMarkdownDefaults = { - syntaxHighlight: syntaxHighlightDefaults, - shikiConfig: shikiConfigDefaults, - gfm: true, - smartypants: true, -}; diff --git a/packages/markdown/satteri/src/highlight.ts b/packages/markdown/satteri/src/highlight.ts deleted file mode 100644 index 36636616985d..000000000000 --- a/packages/markdown/satteri/src/highlight.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Don't highlight math code blocks by default. -export const defaultExcludeLanguages = ['math']; diff --git a/packages/markdown/satteri/src/index.ts b/packages/markdown/satteri/src/index.ts index 9a1d41841254..be5e2458de85 100644 --- a/packages/markdown/satteri/src/index.ts +++ b/packages/markdown/satteri/src/index.ts @@ -1,4 +1,3 @@ -export { defaultExcludeLanguages } from './highlight.js'; export { createCollectImagesPlugin as satteriCollectImagesPlugin, createHeadingIdsPlugin as satteriHeadingIdsPlugin, @@ -15,12 +14,3 @@ export { type SatteriProcessorDescriptor, type SatteriProcessorOptions, } from './processor.js'; -export { satteriMarkdownDefaults, syntaxHighlightDefaults } from './defaults.js'; -export { - clearShikiHighlighterCache, - createShikiHighlighter, - type CreateShikiHighlighterOptions, - type ShikiHighlighter, - type ShikiHighlighterHighlightOptions, - type ThemePresets, -} from './shiki.js'; diff --git a/packages/markdown/satteri/src/satteri-processor.ts b/packages/markdown/satteri/src/satteri-processor.ts index f77eeb81d0c9..a512b99a7571 100644 --- a/packages/markdown/satteri/src/satteri-processor.ts +++ b/packages/markdown/satteri/src/satteri-processor.ts @@ -1,9 +1,12 @@ import { isRemoteAllowed } from '@astrojs/internal-helpers/remote'; +import { + defaultExcludeLanguages, + markdownConfigDefaults, + syntaxHighlightDefaults, +} from '@astrojs/internal-helpers/markdown'; import Slugger from 'github-slugger'; import type { Features, HastNode, HastPluginDefinition, MdastPluginDefinition } from 'satteri'; -import { satteriMarkdownDefaults, syntaxHighlightDefaults } from './defaults.js'; -import { defaultExcludeLanguages } from './highlight.js'; -import { createShikiHighlighter } from './shiki.js'; +import { createShikiHighlighter } from '@astrojs/internal-helpers/shiki'; import type { AstroMarkdownProcessorOptions, MarkdownHeading, @@ -221,9 +224,9 @@ export async function createSatteriMarkdownProcessor( const { syntaxHighlight = syntaxHighlightDefaults, - shikiConfig = satteriMarkdownDefaults.shikiConfig, - gfm = satteriMarkdownDefaults.gfm, - smartypants = satteriMarkdownDefaults.smartypants, + shikiConfig = markdownConfigDefaults.shikiConfig, + gfm = markdownConfigDefaults.gfm, + smartypants = markdownConfigDefaults.smartypants, mdastPlugins: userMdastPlugins = [], hastPlugins: userHastPlugins = [], features: userFeatures, diff --git a/packages/markdown/satteri/src/shiki-engine-default.ts b/packages/markdown/satteri/src/shiki-engine-default.ts deleted file mode 100644 index dc597633f6fa..000000000000 --- a/packages/markdown/satteri/src/shiki-engine-default.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { RegexEngine } from 'shiki'; -import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; - -export function loadShikiEngine(): Promise { - return createOnigurumaEngine(import('shiki/wasm')); -} diff --git a/packages/markdown/satteri/src/shiki-engine-workerd.ts b/packages/markdown/satteri/src/shiki-engine-workerd.ts deleted file mode 100644 index 6d3b0dda61c1..000000000000 --- a/packages/markdown/satteri/src/shiki-engine-workerd.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { RegexEngine } from 'shiki'; -import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; - -export function loadShikiEngine(): Promise { - // @ts-ignore wasm type - return createOnigurumaEngine(import('shiki/onig.wasm')); -} diff --git a/packages/markdown/satteri/src/shiki.ts b/packages/markdown/satteri/src/shiki.ts deleted file mode 100644 index 7344f5d75ac1..000000000000 --- a/packages/markdown/satteri/src/shiki.ts +++ /dev/null @@ -1,249 +0,0 @@ -import type { Properties, Root } from 'hast'; -import { - type BuiltinLanguage, - type BuiltinTheme, - type BundledLanguage, - createCssVariablesTheme, - createHighlighter, - type HighlighterCoreOptions, - isSpecialLang, - type LanguageInput, - type LanguageRegistration, - type RegexEngine, - type ShikiTransformer, - type SpecialLanguage, - type ThemeRegistration, - type ThemeRegistrationRaw, -} from 'shiki'; -import { loadShikiEngine } from '#shiki-engine'; - -export type ThemePresets = BuiltinTheme | 'css-variables'; - -export interface ShikiHighlighter { - codeToHast( - code: string, - lang?: string, - options?: ShikiHighlighterHighlightOptions, - ): Promise; - codeToHtml( - code: string, - lang?: string, - options?: ShikiHighlighterHighlightOptions, - ): Promise; -} - -type ShikiLanguage = LanguageInput | BuiltinLanguage | SpecialLanguage; - -interface ShikiHighlighterInternal extends ShikiHighlighter { - loadLanguage(...langs: ShikiLanguage[]): Promise; - getLoadedLanguages(): string[]; -} - -export interface CreateShikiHighlighterOptions { - langs?: LanguageRegistration[]; - theme?: ThemePresets | ThemeRegistration | ThemeRegistrationRaw; - themes?: Record; - langAlias?: HighlighterCoreOptions['langAlias']; -} - -export interface ShikiHighlighterHighlightOptions { - inline?: boolean; - wrap?: boolean | null; - defaultColor?: 'light' | 'dark' | string | false; - transformers?: ShikiTransformer[]; - attributes?: Record; - meta?: string; -} - -let _cssVariablesTheme: ReturnType; -const cssVariablesTheme = () => - _cssVariablesTheme ?? - (_cssVariablesTheme = createCssVariablesTheme({ - variablePrefix: '--astro-code-', - })); - -const cachedHighlighters = new Map>(); - -/** @internal */ -export function clearShikiHighlighterCache(): void { - cachedHighlighters.clear(); -} - -export function createShikiHighlighter( - options?: CreateShikiHighlighterOptions, -): Promise { - const key: string = getCacheKey(options); - let highlighterPromise = cachedHighlighters.get(key); - if (!highlighterPromise) { - highlighterPromise = createShikiHighlighterInternal(options); - cachedHighlighters.set(key, highlighterPromise); - } - return ensureLanguagesLoaded(highlighterPromise, options?.langs); -} - -function getCacheKey(options?: CreateShikiHighlighterOptions): string { - const keyCache: unknown[] = []; - const { theme, themes, langAlias } = options ?? {}; - if (theme) { - keyCache.push(theme); - } - if (themes) { - keyCache.push(Object.entries(themes).sort()); - } - if (langAlias) { - keyCache.push(Object.entries(langAlias).sort()); - } - return keyCache.length > 0 ? JSON.stringify(keyCache) : ''; -} - -async function ensureLanguagesLoaded( - promise: Promise, - langs?: ShikiLanguage[], -): Promise { - const highlighter = await promise; - if (!langs) { - return highlighter; - } - const loadedLanguages = highlighter.getLoadedLanguages(); - for (const lang of langs) { - if (typeof lang === 'string' && (isSpecialLang(lang) || loadedLanguages.includes(lang))) { - continue; - } - await highlighter.loadLanguage(lang); - } - return highlighter; -} - -let shikiEngine: RegexEngine | undefined = undefined; - -async function createShikiHighlighterInternal({ - langs = [], - theme = 'github-dark', - themes = {}, - langAlias = {}, -}: CreateShikiHighlighterOptions = {}): Promise { - theme = theme === 'css-variables' ? cssVariablesTheme() : theme; - - if (shikiEngine === undefined) { - shikiEngine = await loadShikiEngine(); - } - - const highlighter = await createHighlighter({ - langs: ['plaintext', ...langs], - langAlias, - themes: Object.values(themes).length ? Object.values(themes) : [theme], - engine: shikiEngine, - }); - - async function highlight( - code: string, - lang = 'plaintext', - options: ShikiHighlighterHighlightOptions, - to: 'hast' | 'html', - ) { - const resolvedLang = langAlias[lang] ?? lang; - const loadedLanguages = highlighter.getLoadedLanguages(); - - if (!isSpecialLang(lang) && !loadedLanguages.includes(resolvedLang)) { - try { - await highlighter.loadLanguage(resolvedLang as BundledLanguage); - } catch (_err) { - const langStr = - lang === resolvedLang ? `"${lang}"` : `"${lang}" (aliased to "${resolvedLang}")`; - console.warn(`[Shiki] The language ${langStr} doesn't exist, falling back to "plaintext".`); - lang = 'plaintext'; - } - } - - code = code.replace(/(?:\r\n|\r|\n)$/, ''); - - const themeOptions = Object.values(themes).length ? { themes } : { theme }; - const inline = options?.inline ?? false; - - return highlighter[to === 'html' ? 'codeToHtml' : 'codeToHast'](code, { - ...themeOptions, - defaultColor: options.defaultColor, - lang, - meta: options?.meta ? { __raw: options?.meta } : undefined, - transformers: [ - { - pre(node) { - if (inline) { - node.tagName = 'code'; - } - - const { - class: attributesClass, - style: attributesStyle, - ...rest - } = options?.attributes ?? {}; - Object.assign(node.properties, rest); - - const classValue = - (normalizePropAsString(node.properties.class) ?? '') + - (attributesClass ? ` ${attributesClass}` : ''); - const styleValue = - (normalizePropAsString(node.properties.style) ?? '') + - (attributesStyle ? `; ${attributesStyle}` : ''); - - node.properties.class = classValue.replace(/shiki/g, 'astro-code'); - - node.properties.dataLanguage = lang; - - if (options.wrap === false || options.wrap === undefined) { - node.properties.style = styleValue + '; overflow-x: auto;'; - } else if (options.wrap === true) { - node.properties.style = - styleValue + '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'; - } - }, - line(node) { - if (resolvedLang === 'diff') { - const innerSpanNode = node.children[0]; - const innerSpanTextNode = - innerSpanNode?.type === 'element' && innerSpanNode.children?.[0]; - - if (innerSpanTextNode && innerSpanTextNode.type === 'text') { - const start = innerSpanTextNode.value[0]; - if (start === '+' || start === '-') { - innerSpanTextNode.value = innerSpanTextNode.value.slice(1); - innerSpanNode.children.unshift({ - type: 'element', - tagName: 'span', - properties: { style: 'user-select: none;' }, - children: [{ type: 'text', value: start }], - }); - } - } - } - }, - code(node) { - if (inline) { - return node.children[0] as typeof node; - } - }, - }, - ...(options.transformers ?? []), - ], - }); - } - - return { - codeToHast(code, lang, options = {}) { - return highlight(code, lang, options, 'hast') as Promise; - }, - codeToHtml(code, lang, options = {}) { - return highlight(code, lang, options, 'html') as Promise; - }, - loadLanguage(...newLangs) { - return highlighter.loadLanguage(...newLangs); - }, - getLoadedLanguages() { - return highlighter.getLoadedLanguages(); - }, - }; -} - -function normalizePropAsString(value: Properties[string]): string | null { - return Array.isArray(value) ? value.join(' ') : (value as string | null); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1387b2dc1af9..b8f6546b73a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6802,12 +6802,6 @@ importers: remark-smartypants: specifier: ^3.0.2 version: 3.0.2 - retext-smartypants: - specifier: ^6.2.0 - version: 6.2.0 - shiki: - specifier: ^4.0.0 - version: 4.0.2 smol-toml: specifier: ^1.6.0 version: 1.6.1 @@ -6851,6 +6845,9 @@ importers: mdast-util-mdx-expression: specifier: ^2.0.1 version: 2.0.1 + shiki: + specifier: ^4.0.0 + version: 4.0.2 packages/markdown/satteri: dependencies: @@ -6863,9 +6860,6 @@ importers: satteri: specifier: ^0.6.2 version: 0.6.2 - shiki: - specifier: ^4.0.0 - version: 4.0.2 devDependencies: '@types/hast': specifier: ^3.0.4 From 209b79b5bd065b875de8194003633b70f8af5227 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 21 May 2026 02:48:14 +0200 Subject: [PATCH 22/26] fix: build --- packages/integrations/markdoc/src/extensions/shiki.ts | 2 +- packages/markdown/satteri/package.json | 1 - pnpm-lock.yaml | 3 --- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/integrations/markdoc/src/extensions/shiki.ts b/packages/integrations/markdoc/src/extensions/shiki.ts index 1102242fdfe5..e68d52b5b0c5 100644 --- a/packages/integrations/markdoc/src/extensions/shiki.ts +++ b/packages/integrations/markdoc/src/extensions/shiki.ts @@ -1,4 +1,4 @@ -import { createShikiHighlighter } from '@astrojs/markdown-remark'; +import { createShikiHighlighter } from '@astrojs/internal-helpers/shiki'; import Markdoc from '@markdoc/markdoc'; import type { ShikiConfig } from 'astro'; import { unescapeHTML } from 'astro/runtime/server/index.js'; diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index 551590764380..d9d37dddd3a1 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -32,7 +32,6 @@ "satteri": "^0.6.2" }, "devDependencies": { - "@types/hast": "^3.0.4", "astro-scripts": "workspace:*" }, "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8f6546b73a6..e22350d6bd1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6861,9 +6861,6 @@ importers: specifier: ^0.6.2 version: 0.6.2 devDependencies: - '@types/hast': - specifier: ^3.0.4 - version: 3.0.4 astro-scripts: specifier: workspace:* version: link:../../../scripts From 7c5521f652d3a2fe93b671b10228c9588d15afa3 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 21 May 2026 02:57:23 +0200 Subject: [PATCH 23/26] chore: update satteri --- packages/integrations/mdx/package.json | 2 +- .../mdx/src/satteri/hast-astro-metadata.ts | 54 +++--------------- packages/markdown/satteri/package.json | 2 +- pnpm-lock.yaml | 55 ++++++++++--------- 4 files changed, 37 insertions(+), 76 deletions(-) diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index de2d38867439..391037a109d5 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -45,7 +45,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", - "satteri": "^0.6.2", + "satteri": "^0.6.3", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" diff --git a/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts b/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts index 88ddb95d424a..90f32542be3c 100644 --- a/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts +++ b/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts @@ -1,6 +1,8 @@ import { resolvePath } from 'astro/markdown'; +import type { Identifier, Literal } from 'estree'; import { defineHastPlugin, + type EstreeProgram, type HastPluginDefinition, type HastVisitorContext, } from 'satteri'; @@ -26,49 +28,8 @@ export interface AstroMetadata { type ImportSpecifier = { local: string; imported: string }; type MatchedImport = { name: string; path: string }; -// ESTree shapes we care about from the parseExpression() output on mdxjsEsm nodes. -interface EstreeIdentifier { - type: 'Identifier'; - name: string; -} - -interface EstreeStringLiteral { - type: 'Literal'; - value: string; -} - -type EstreeModuleExportName = EstreeIdentifier | EstreeStringLiteral; - -interface EstreeImportDeclaration { - type: 'ImportDeclaration'; - source: EstreeStringLiteral; - specifiers: Array< - EstreeImportSpecifier | EstreeImportDefaultSpecifier | EstreeImportNamespaceSpecifier - >; -} - -interface EstreeImportSpecifier { - type: 'ImportSpecifier'; - local: EstreeIdentifier; - imported: EstreeModuleExportName; -} - -interface EstreeImportDefaultSpecifier { - type: 'ImportDefaultSpecifier'; - local: EstreeIdentifier; -} - -interface EstreeImportNamespaceSpecifier { - type: 'ImportNamespaceSpecifier'; - local: EstreeIdentifier; -} - -interface EstreeProgram { - body: Array<{ type: string } & Record>; -} - -function exportNameToString(name: EstreeModuleExportName): string { - return name.type === 'Identifier' ? name.name : name.value; +function exportNameToString(name: Identifier | Literal): string { + return name.type === 'Identifier' ? name.name : String(name.value); } function collectImportsFromEsm( @@ -77,8 +38,7 @@ function collectImportsFromEsm( ): void { for (const stmt of program.body) { if (stmt.type !== 'ImportDeclaration') continue; - const decl = stmt as EstreeImportDeclaration; - const source = decl.source.value; + const source = String(stmt.source.value); let specSet = imports.get(source); if (!specSet) { @@ -86,7 +46,7 @@ function collectImportsFromEsm( imports.set(source, specSet); } - for (const spec of decl.specifiers) { + for (const spec of stmt.specifiers) { switch (spec.type) { case 'ImportDefaultSpecifier': specSet.add({ local: spec.local.name, imported: 'default' }); @@ -214,7 +174,7 @@ export function createAstroMetadataPlugin( return defineHastPlugin({ name: 'astro-metadata', mdxjsEsm(node) { - const program = node.parseExpression() as EstreeProgram | null; + const program = node.parseExpression(); if (program) { collectImportsFromEsm(program, imports); } diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index d9d37dddd3a1..3285ad05cf1c 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -29,7 +29,7 @@ "dependencies": { "@astrojs/internal-helpers": "workspace:*", "github-slugger": "^2.0.0", - "satteri": "^0.6.2" + "satteri": "^0.6.3" }, "devDependencies": { "astro-scripts": "workspace:*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e22350d6bd1e..74643f512bb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5191,8 +5191,8 @@ importers: specifier: ^3.0.2 version: 3.0.2 satteri: - specifier: ^0.6.2 - version: 0.6.2 + specifier: ^0.6.3 + version: 0.6.3 source-map: specifier: ^0.7.6 version: 0.7.6 @@ -6858,8 +6858,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 satteri: - specifier: ^0.6.2 - version: 0.6.2 + specifier: ^0.6.3 + version: 0.6.3 devDependencies: astro-scripts: specifier: workspace:* @@ -7496,29 +7496,29 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - '@bruits/satteri-darwin-arm64@0.6.2': - resolution: {integrity: sha512-z1rMMG2DG6SqSMGc1ke4uvdE/hCa8NGiobT+3oZ41asRcixlYytpTk8D5p78l2xXt/yzr60dezEK0/33BRG51Q==} + '@bruits/satteri-darwin-arm64@0.6.3': + resolution: {integrity: sha512-oKgMfpmNzQ8vaqmkE37PBu8tOyVjoOc4s+DV2tLpWvO6WO467qn/+Nbcirm/ceer7wUM8v4vMLGcZO0gkRzBEg==} cpu: [arm64] os: [darwin] - '@bruits/satteri-darwin-x64@0.6.2': - resolution: {integrity: sha512-x2yoD8ekF8u/KPciJRL272uVtCzlSJgUthgjrddRGRDnjNfRG8HBqxwziwBkkWh6u7oEfXcsSI0o2DJ0kmD/zQ==} + '@bruits/satteri-darwin-x64@0.6.3': + resolution: {integrity: sha512-9I5pbwZRWH5LvhoCtwpRr4rYSDe43/dLvps6zO70ipVF2XbH4rJ20T+EfvPcmou5jsWMsq9Ybn5GX3PwlSrBaw==} cpu: [x64] os: [darwin] - '@bruits/satteri-linux-x64-gnu@0.6.2': - resolution: {integrity: sha512-bEmN7bALdl7wEunhd1GinRvZDXxotZBh0hzYpJeMyHckz1DsiK9UdI/Tw5b/gOLIAWwSVh03l1d4039tAfw6oA==} + '@bruits/satteri-linux-x64-gnu@0.6.3': + resolution: {integrity: sha512-aFfw2DL2HpIcAQ8I3ZEtKuz+/GoF0H0sq387jAlvr00Q7buiiFbvARFuQlvTk00N7u3SJIh/3+YkKssTJDT+yQ==} cpu: [x64] os: [linux] libc: [glibc] - '@bruits/satteri-wasm32-wasi@0.6.2': - resolution: {integrity: sha512-VdHVeNwGBf7Ju3X/77tZmVshjgCCztTMZmwBHT/3ammnS+6mLQMsUJUI2lrgistr9bRb1i+N/2ZWVOQzqmYsYw==} + '@bruits/satteri-wasm32-wasi@0.6.3': + resolution: {integrity: sha512-DdpfnJ+04Mb4YtHaxAeETUvhdxFKg3URnroGY39FvweJEgeXx8cFNutuF5w904BhqaiblhmF76AoBnJwNLxsXg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@bruits/satteri-win32-x64-msvc@0.6.2': - resolution: {integrity: sha512-4IiS3Tp3rAtVTPUHlSa9Bc5w3gFsKkypvofBeB8naWHKaF1lPEv4RvqvNntfpKzDhNjwY/8/sH+Ug6n9koFX8w==} + '@bruits/satteri-win32-x64-msvc@0.6.3': + resolution: {integrity: sha512-J/CZqACnBbv77eImx/JeO5RmCuCyliihiC81u3M4VobA8eupsygaPen3/UFFqf3yfeHvMlE3myilouh/2iHMOA==} cpu: [x64] os: [win32] @@ -14993,8 +14993,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - satteri@0.6.2: - resolution: {integrity: sha512-4u1hfE2vcPzUBg/rDkk7FKzpvEbQgQuizT442GIZsdf4qhg4crjhG+aSmrjVJvkxMEA6z4Htt85JQYRuTkSzBg==} + satteri@0.6.3: + resolution: {integrity: sha512-iY5xd2tBDQveYRFkL1F0cegkabWSVoXHi64e1p49SiCs1bZDaqTQPGQI+PqEQIaWkz0iqK80CuQQUYvhsssviw==} sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} @@ -17352,23 +17352,23 @@ snapshots: '@borewit/text-codec@0.2.2': {} - '@bruits/satteri-darwin-arm64@0.6.2': + '@bruits/satteri-darwin-arm64@0.6.3': optional: true - '@bruits/satteri-darwin-x64@0.6.2': + '@bruits/satteri-darwin-x64@0.6.3': optional: true - '@bruits/satteri-linux-x64-gnu@0.6.2': + '@bruits/satteri-linux-x64-gnu@0.6.3': optional: true - '@bruits/satteri-wasm32-wasi@0.6.2': + '@bruits/satteri-wasm32-wasi@0.6.3': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optional: true - '@bruits/satteri-win32-x64-msvc@0.6.2': + '@bruits/satteri-win32-x64-msvc@0.6.3': optional: true '@capsizecss/unpack@4.0.0': @@ -25922,17 +25922,18 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 - satteri@0.6.2: + satteri@0.6.3: dependencies: + '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 optionalDependencies: - '@bruits/satteri-darwin-arm64': 0.6.2 - '@bruits/satteri-darwin-x64': 0.6.2 - '@bruits/satteri-linux-x64-gnu': 0.6.2 - '@bruits/satteri-wasm32-wasi': 0.6.2 - '@bruits/satteri-win32-x64-msvc': 0.6.2 + '@bruits/satteri-darwin-arm64': 0.6.3 + '@bruits/satteri-darwin-x64': 0.6.3 + '@bruits/satteri-linux-x64-gnu': 0.6.3 + '@bruits/satteri-wasm32-wasi': 0.6.3 + '@bruits/satteri-win32-x64-msvc': 0.6.3 sax@1.6.0: {} From 0afe01feddda1430e5f2a04d707b78eca0003334 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 21 May 2026 12:41:03 +0200 Subject: [PATCH 24/26] refactor: deduplicate code --- .changeset/seven-emus-knock.md | 14 +++ knip.js | 2 - packages/astro/src/content/content-layer.ts | 2 +- packages/astro/src/content/utils.ts | 2 +- .../astro/src/core/config/schemas/base.ts | 18 ++-- packages/astro/src/core/config/validate.ts | 35 +++++--- packages/astro/src/markdown/index.ts | 8 +- packages/astro/src/types/public/config.ts | 8 +- .../astro/src/vite-plugin-markdown/index.ts | 2 +- .../markdoc/src/content-entry-type.ts | 2 +- packages/integrations/mdx/src/index.ts | 18 +++- .../mdx/src/satteri/hast-astro-metadata.ts | 18 +--- .../src/satteri/hast-images-to-component.ts | 7 +- .../integrations/mdx/src/satteri/index.ts | 25 ++---- .../integrations/mdx/src/unified/index.ts | 3 +- .../unified/rehype-analyze-astro-metadata.ts | 30 +------ packages/integrations/mdx/src/utils.ts | 2 +- packages/internal-helpers/package.json | 7 ++ .../src}/frontmatter.ts | 5 +- packages/internal-helpers/src/markdown.ts | 36 +++++++- .../test/frontmatter.test.ts | 2 +- packages/markdown/remark/package.json | 3 - packages/markdown/remark/src/frontmatter.ts | 86 ------------------- packages/markdown/remark/src/index.ts | 7 +- packages/markdown/remark/src/processor.ts | 19 +++- pnpm-lock.yaml | 18 ++-- 26 files changed, 169 insertions(+), 210 deletions(-) rename packages/{astro/src/markdown => internal-helpers/src}/frontmatter.ts (93%) rename packages/{markdown/remark => internal-helpers}/test/frontmatter.test.ts (99%) delete mode 100644 packages/markdown/remark/src/frontmatter.ts diff --git a/.changeset/seven-emus-knock.md b/.changeset/seven-emus-knock.md index 613982cb4b19..7a5a2dfd8f4f 100644 --- a/.changeset/seven-emus-knock.md +++ b/.changeset/seven-emus-knock.md @@ -42,3 +42,17 @@ export default defineConfig({ ``` The top-level `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` options are deprecated. They'll continue to work when `@astrojs/markdown-remark` is installed for now, but this will be removed in the next major. + +The top-level `markdown.gfm` and `markdown.smartypants` options are also deprecated. Move them onto your processor instead — `satteri({ features: { gfm: false, smartPunctuation: false } })`, or `unified({ gfm: false, smartypants: false })`: + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import { unified } from '@astrojs/markdown-remark'; + +export default defineConfig({ + markdown: { + processor: unified({ gfm: false, smartypants: false }), + }, +}); +``` diff --git a/knip.js b/knip.js index 4bc4853c443a..600e677d4d44 100644 --- a/knip.js +++ b/knip.js @@ -92,8 +92,6 @@ export default { }, 'packages/markdown/satteri': { entry: [srcEntry, dtsEntry, testEntry], - // package.json#imports are not resolved at the moment - ignore: ['src/shiki-engine-default.ts', 'src/shiki-engine-workerd.ts'], }, 'packages/upgrade': { entry: ['src/index.ts', testEntry], diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts index 3cd5c9ac374e..da4aa710edc4 100644 --- a/packages/astro/src/content/content-layer.ts +++ b/packages/astro/src/content/content-layer.ts @@ -1,6 +1,6 @@ import { existsSync, promises as fs } from 'node:fs'; +import { parseFrontmatter } from '@astrojs/internal-helpers/frontmatter'; import type { MarkdownProcessor } from '@astrojs/internal-helpers/markdown'; -import { parseFrontmatter } from '../markdown/frontmatter.js'; import PQueue from 'p-queue'; import type { FSWatcher } from 'vite'; import xxhash from 'xxhash-wasm'; diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index 5b5d6046f661..39e77e2da96b 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -1,7 +1,7 @@ import fsMod from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { parseFrontmatter } from '../markdown/frontmatter.js'; +import { parseFrontmatter } from '@astrojs/internal-helpers/frontmatter'; import { slug as githubSlug } from 'github-slugger'; import colors from 'piccolore'; import type { RunnableDevEnvironment, Rolldown } from 'vite'; diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index fec85f17bd49..9e78e1b79b45 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -93,10 +93,7 @@ export const ASTRO_CONFIG_DEFAULTS = { allowedHosts: [], }, integrations: [], - markdown: { - ...markdownConfigDefaults, - processor: satteri(), - }, + markdown: markdownConfigDefaults, vite: {}, legacy: { collectionsBackwardsCompat: false, @@ -425,14 +422,18 @@ export const AstroConfigSchema = z.object({ remarkRehype: z .custom((data) => data instanceof Object && !Array.isArray(data)) .default(ASTRO_CONFIG_DEFAULTS.markdown.remarkRehype), - gfm: z.boolean().default(ASTRO_CONFIG_DEFAULTS.markdown.gfm), + // Deprecated: left undefined unless the user explicitly sets them, so the + // deprecation warning only fires when actually used. The active processor + // (`satteri()` / `unified()`) supplies the real default (`gfm`/smart + // punctuation on) when these are absent. + gfm: z.boolean().optional(), smartypants: z .union([z.boolean(), smartypantsOptionsSchema]) .transform((val): false | Smartypants => { if (val === true) return smartypantsOptionsSchema.parse({}); return val; }) - .prefault(ASTRO_CONFIG_DEFAULTS.markdown.smartypants), + .optional(), processor: z .object({ name: z.string(), @@ -452,7 +453,10 @@ export const AstroConfigSchema = z.object({ ) .optional(), }) - .default(ASTRO_CONFIG_DEFAULTS.markdown.processor), + // A factory (not a shared value) so every config gets its own descriptor — + // integrations extend the pipeline by mutating `processor.options`, which + // would otherwise leak across configs built in the same process. + .default(() => satteri()), }) .prefault({}), vite: z diff --git a/packages/astro/src/core/config/validate.ts b/packages/astro/src/core/config/validate.ts index 818f3f2f6b5f..0f050d14db3b 100644 --- a/packages/astro/src/core/config/validate.ts +++ b/packages/astro/src/core/config/validate.ts @@ -41,11 +41,12 @@ function warnDeprecatedMarkdownOptions( if (deprecated.length === 0) return; didWarnAboutDeprecatedMarkdownOptions = true; console.warn( - `[astro] \`markdown.${deprecated.join('\` and `markdown.')}\` are deprecated. Configure these via your processor instead (e.g. \`satteri({ features: { gfm: false } })\` or \`unified({ remarkPlugins: [...] })\`). They'll be removed in a future major.`, + `[astro] \`markdown.${deprecated.join('\` and `markdown.')}\` are deprecated. Move them onto your processor instead (e.g. \`unified({ gfm: false, smartypants: false })\` or \`satteri({ features: { gfm: false, smartPunctuation: false } })\`). They'll be removed in a future major.`, ); } let didWarnAboutLegacyMarkdownPlugins = false; +let didWarnAboutProcessorSwap = false; /** * Folds legacy `markdown.{remark,rehype}Plugins` / `remarkRehype` into a unified @@ -53,7 +54,9 @@ let didWarnAboutLegacyMarkdownPlugins = false; * Runs on every validate pass so integration-added legacy plugins are picked up. * Without this they'd land in `config.markdown.*` and be silently dropped. */ -async function coerceLegacyMarkdownPlugins(config: Pick): Promise { +async function coerceLegacyMarkdownPlugins( + config: Pick, +): Promise { const md = config?.markdown; if (!md) return; @@ -78,21 +81,33 @@ async function coerceLegacyMarkdownPlugins(config: Pick p.name === 'unified'; +// `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< - Pick ->; + Pick +> & + Pick; export type MdxOptions = SharedMarkdownOptions & { extendMarkdownConfig: boolean; @@ -157,6 +160,17 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI ...descriptor.options.remarkRehype, ...resolvedMdxOptions.remarkRehype, }; + // `gfm`/`smartypants` from `unified({...})` apply to `.mdx` too, unless + // `mdx({...})` set its own. + if (partialMdxOptions.gfm === undefined && descriptor.options.gfm !== undefined) { + resolvedMdxOptions.gfm = descriptor.options.gfm; + } + if ( + partialMdxOptions.smartypants === undefined && + descriptor.options.smartypants !== undefined + ) { + resolvedMdxOptions.smartypants = descriptor.options.smartypants; + } } // Third-party processors don't expose their plugins to MDX's built-in option // merging; they handle their own pipeline via `createMdxRenderer`. diff --git a/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts b/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts index 90f32542be3c..c6ddb95c0e98 100644 --- a/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts +++ b/packages/integrations/mdx/src/satteri/hast-astro-metadata.ts @@ -1,3 +1,4 @@ +import type { AstroMetadata } from '@astrojs/internal-helpers/markdown'; import { resolvePath } from 'astro/markdown'; import type { Identifier, Literal } from 'estree'; import { @@ -8,22 +9,7 @@ import { } from 'satteri'; import { findAttrValue, hasDirective, isComponent, type MdxJsxHastNode } from './jsx-utils.js'; -interface AstroComponentMetadata { - exportName: string; - localName: string; - specifier: string; - resolvedPath: string; -} - -export interface AstroMetadata { - hydratedComponents: AstroComponentMetadata[]; - clientOnlyComponents: AstroComponentMetadata[]; - serverComponents: AstroComponentMetadata[]; - scripts: never[]; - propagation: 'none'; - containsHead: false; - pageOptions: Record; -} +export type { AstroMetadata }; type ImportSpecifier = { local: string; imported: string }; type MatchedImport = { name: string; path: string }; diff --git a/packages/integrations/mdx/src/satteri/hast-images-to-component.ts b/packages/integrations/mdx/src/satteri/hast-images-to-component.ts index fbd50bd9934c..ec074451e6b1 100644 --- a/packages/integrations/mdx/src/satteri/hast-images-to-component.ts +++ b/packages/integrations/mdx/src/satteri/hast-images-to-component.ts @@ -40,8 +40,7 @@ export function createImageToComponentPlugin( if (key === 'widths' || key === 'densities') { attrs.push(makeJsxExprAttr(key, JSON.stringify(String(value).split(' ')))); } else { - const attrName = - key === 'className' ? 'class' : key === 'htmlFor' ? 'for' : key; + const attrName = key === 'className' ? 'class' : key === 'htmlFor' ? 'for' : key; attrs.push(makeJsxAttr(attrName, String(value))); } } @@ -69,8 +68,10 @@ export function createImageToComponentPlugin( // `_components['astro-image']`. The vite-plugin-mdx-postprocess wraps the // final component to map `astro-image` → `components.img ?? __AstroImage__`, // which lets a user's `export const components = { img: MyImage }` win. + // Match the unified pipeline, which emits a flow (block) element for + // markdown-derived images so MDX compiles them consistently. return { - type: 'mdxJsxTextElement', + type: 'mdxJsxFlowElement', name: ASTRO_IMAGE_ELEMENT, attributes: attrs, children: [], diff --git a/packages/integrations/mdx/src/satteri/index.ts b/packages/integrations/mdx/src/satteri/index.ts index 69be2b5eb04c..6e5907221fda 100644 --- a/packages/integrations/mdx/src/satteri/index.ts +++ b/packages/integrations/mdx/src/satteri/index.ts @@ -1,4 +1,7 @@ -import type { MarkdownHeading } from '@astrojs/internal-helpers/markdown'; +import { + createDefaultAstroMetadata, + type MarkdownHeading, +} from '@astrojs/internal-helpers/markdown'; import { createShikiHighlighter } from '@astrojs/internal-helpers/shiki'; import { satteriCollectImagesPlugin, @@ -11,17 +14,11 @@ import { type MdastPluginDefinition, type MdxCompileOptions, } from 'satteri'; -import { - ASTRO_IMAGE_IMPORT, - USES_ASTRO_IMAGE_FLAG, -} from '../image-constants.js'; +import { ASTRO_IMAGE_IMPORT, USES_ASTRO_IMAGE_FLAG } from '../image-constants.js'; import type { MdxOptions } from '../index.js'; import { shouldAddCharset } from './charset.js'; import { type AstroMetadata, createAstroMetadataPlugin } from './hast-astro-metadata.js'; -import { - createImageToComponentPlugin, - type ImageImportInfo, -} from './hast-images-to-component.js'; +import { createImageToComponentPlugin, type ImageImportInfo } from './hast-images-to-component.js'; type HighlightFn = (code: string, lang: string, meta?: string) => Promise; @@ -90,15 +87,7 @@ export function createMdxProcessor(mdxOptions: MdxOptions, ctx: CreateMdxProcess const localImagePaths = new Set(); const remoteImagePaths = new Set(); - const astroMetadata: AstroMetadata = { - hydratedComponents: [], - clientOnlyComponents: [], - serverComponents: [], - scripts: [], - propagation: 'none', - containsHead: false, - pageOptions: {}, - }; + const astroMetadata = createDefaultAstroMetadata(); const collectImages = satteriCollectImagesPlugin(localImagePaths, remoteImagePaths); const headingIds = satteriHeadingIdsPlugin(headings, frontmatter); diff --git a/packages/integrations/mdx/src/unified/index.ts b/packages/integrations/mdx/src/unified/index.ts index a7061fda18ef..baf3fc405bb1 100644 --- a/packages/integrations/mdx/src/unified/index.ts +++ b/packages/integrations/mdx/src/unified/index.ts @@ -46,7 +46,8 @@ function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList { let remarkPlugins: PluggableList = []; if (!isPerformanceBenchmark) { - if (mdxOptions.gfm) { + // `undefined` means "use the default" — gfm is on unless explicitly disabled. + if (mdxOptions.gfm !== false) { remarkPlugins.push(remarkGfm); } if (mdxOptions.smartypants !== false) { diff --git a/packages/integrations/mdx/src/unified/rehype-analyze-astro-metadata.ts b/packages/integrations/mdx/src/unified/rehype-analyze-astro-metadata.ts index 5010a0a422de..dc78e033747c 100644 --- a/packages/integrations/mdx/src/unified/rehype-analyze-astro-metadata.ts +++ b/packages/integrations/mdx/src/unified/rehype-analyze-astro-metadata.ts @@ -1,3 +1,4 @@ +import { createDefaultAstroMetadata, type AstroMetadata } from '@astrojs/internal-helpers/markdown'; import { resolvePath } from 'astro/markdown'; import type { Program } from 'estree'; import type { RootContent, Root as HastRoot } from 'hast'; @@ -13,34 +14,7 @@ import type { VFile } from 'vfile'; const ClientOnlyPlaceholder = 'astro-client-only'; -interface AstroComponentMetadata { - exportName: string; - localName: string; - specifier: string; - resolvedPath: string; -} - -export interface AstroMetadata { - hydratedComponents: AstroComponentMetadata[]; - clientOnlyComponents: AstroComponentMetadata[]; - serverComponents: AstroComponentMetadata[]; - scripts: never[]; - propagation: 'none'; - containsHead: false; - pageOptions: Record; -} - -function createDefaultAstroMetadata(): AstroMetadata { - return { - hydratedComponents: [], - clientOnlyComponents: [], - serverComponents: [], - scripts: [], - propagation: 'none', - containsHead: false, - pageOptions: {}, - }; -} +export type { AstroMetadata }; export const rehypeAnalyzeAstroMetadata: Plugin<[], HastRoot> = () => { return (tree, file) => { diff --git a/packages/integrations/mdx/src/utils.ts b/packages/integrations/mdx/src/utils.ts index 3a23fb8e8403..2bde89e43a57 100644 --- a/packages/integrations/mdx/src/utils.ts +++ b/packages/integrations/mdx/src/utils.ts @@ -1,7 +1,7 @@ +import { parseFrontmatter } from '@astrojs/internal-helpers/frontmatter'; import type { Options as AcornOpts } from 'acorn'; import { parse } from 'acorn'; import type { AstroConfig, AstroIntegrationLogger, SSRError } from 'astro'; -import { parseFrontmatter } from 'astro/markdown'; import type { MdxjsEsm } from 'mdast-util-mdx'; import colors from 'piccolore'; import type { PluggableList } from 'unified'; diff --git a/packages/internal-helpers/package.json b/packages/internal-helpers/package.json index 2c38b36ea31d..2a7a88871126 100644 --- a/packages/internal-helpers/package.json +++ b/packages/internal-helpers/package.json @@ -20,6 +20,7 @@ "./request": "./dist/request.js", "./object": "./dist/object.js", "./markdown": "./dist/markdown.js", + "./frontmatter": "./dist/frontmatter.js", "./shiki": "./dist/shiki.js" }, "imports": { @@ -39,6 +40,9 @@ "markdown": [ "./dist/markdown.d.ts" ], + "frontmatter": [ + "./dist/frontmatter.d.ts" + ], "shiki": [ "./dist/shiki.d.ts" ], @@ -69,12 +73,15 @@ "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", + "smol-toml": "^1.6.0", "unified": "^11.0.5" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/picomatch": "^4.0.2", "astro-scripts": "workspace:*" }, diff --git a/packages/astro/src/markdown/frontmatter.ts b/packages/internal-helpers/src/frontmatter.ts similarity index 93% rename from packages/astro/src/markdown/frontmatter.ts rename to packages/internal-helpers/src/frontmatter.ts index a71f8483a5db..9a26b7e3bba0 100644 --- a/packages/astro/src/markdown/frontmatter.ts +++ b/packages/internal-helpers/src/frontmatter.ts @@ -3,6 +3,7 @@ import * as toml from 'smol-toml'; export function isFrontmatterValid(frontmatter: Record) { try { + // ensure frontmatter is JSON-serializable JSON.stringify(frontmatter); } catch { return false; @@ -11,8 +12,8 @@ export function isFrontmatterValid(frontmatter: Record) { } // Capture frontmatter wrapped with `---` or `+++` near the top of the file — -// either at the very start (allowing BOM) or after leading whitespace, with the -// fence still starting on a new line. +// either at the very start (allowing a BOM) or after leading whitespace, with +// the fence still starting on a new line. const frontmatterRE = /(?:^\uFEFF?|^\s*\n)(?:---|\+\+\+)([\s\S]*?\n)(?:---|\+\+\+)/; const frontmatterTypeRE = /(?:^\uFEFF?|^\s*\n)(---|\+\+\+)/; diff --git a/packages/internal-helpers/src/markdown.ts b/packages/internal-helpers/src/markdown.ts index 66cc824442ae..600ce0cf94fe 100644 --- a/packages/internal-helpers/src/markdown.ts +++ b/packages/internal-helpers/src/markdown.ts @@ -61,9 +61,9 @@ export interface MarkdownHeading { export interface AstroMarkdownProcessorOptions { syntaxHighlight?: SyntaxHighlightConfig | SyntaxHighlightConfigType | false; shikiConfig?: ShikiConfig; - /** @deprecated Configure via the processor instead, e.g. `satteri({ features: { gfm: false } })`. */ + /** @deprecated Configure via the processor instead, e.g. `unified({ gfm: false })` or `satteri({ features: { gfm: false } })`. */ gfm?: boolean; - /** @deprecated Configure via the processor instead, e.g. `satteri({ features: { smartPunctuation: false } })`. */ + /** @deprecated Configure via the processor instead, e.g. `unified({ smartypants: false })` or `satteri({ features: { smartPunctuation: false } })`. */ smartypants?: boolean | SmartypantsOptions; /** @deprecated Use `markdown.processor: unified({ remarkPlugins })` instead. */ remarkPlugins?: RemarkPlugins; @@ -99,6 +99,38 @@ export interface MarkdownProcessorRenderResult { }; } +// MDX rendering metadata. Produced by `@astrojs/mdx`'s satteri and unified pipelines +// and surfaced on Vite's `meta.astro`; defined here so both pipelines share one shape. + +export interface AstroComponentMetadata { + exportName: string; + localName: string; + specifier: string; + resolvedPath: string; +} + +export interface AstroMetadata { + hydratedComponents: AstroComponentMetadata[]; + clientOnlyComponents: AstroComponentMetadata[]; + serverComponents: AstroComponentMetadata[]; + scripts: never[]; + propagation: 'none'; + containsHead: false; + pageOptions: Record; +} + +export function createDefaultAstroMetadata(): AstroMetadata { + return { + hydratedComponents: [], + clientOnlyComponents: [], + serverComponents: [], + scripts: [], + propagation: 'none', + containsHead: false, + pageOptions: {}, + }; +} + // Languages never syntax-highlighted by default (e.g. `math`, handled elsewhere). export const defaultExcludeLanguages = ['math']; diff --git a/packages/markdown/remark/test/frontmatter.test.ts b/packages/internal-helpers/test/frontmatter.test.ts similarity index 99% rename from packages/markdown/remark/test/frontmatter.test.ts rename to packages/internal-helpers/test/frontmatter.test.ts index ee365d19e4de..f9355035f2de 100644 --- a/packages/markdown/remark/test/frontmatter.test.ts +++ b/packages/internal-helpers/test/frontmatter.test.ts @@ -4,7 +4,7 @@ import { extractFrontmatter, parseFrontmatter, type ParseFrontmatterOptions, -} from '../dist/index.js'; +} from '../dist/frontmatter.js'; type FrontmatterStyle = ParseFrontmatterOptions['frontmatter']; diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 3826b4769346..39c59188ffac 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -37,7 +37,6 @@ "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", - "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", @@ -45,7 +44,6 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", - "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", @@ -55,7 +53,6 @@ "devDependencies": { "@types/estree": "^1.0.8", "@types/hast": "^3.0.4", - "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "@types/unist": "^3.0.3", "astro-scripts": "workspace:*", diff --git a/packages/markdown/remark/src/frontmatter.ts b/packages/markdown/remark/src/frontmatter.ts deleted file mode 100644 index 16c103f73598..000000000000 --- a/packages/markdown/remark/src/frontmatter.ts +++ /dev/null @@ -1,86 +0,0 @@ -import yaml from 'js-yaml'; -import * as toml from 'smol-toml'; - -export function isFrontmatterValid(frontmatter: Record) { - try { - // ensure frontmatter is JSON-serializable - JSON.stringify(frontmatter); - } catch { - return false; - } - return typeof frontmatter === 'object' && frontmatter !== null; -} - -// Capture frontmatter wrapped with `---` or `+++`, including any characters and new lines within it. -// Only capture if `---` or `+++` exists near the top of the file, including: -// 1. Start of file (including if has BOM encoding) -// 2. Start of file with any whitespace (but `---` or `+++` must still start on a new line) -const frontmatterRE = /(?:^\uFEFF?|^\s*\n)(?:---|\+\+\+)([\s\S]*?\n)(?:---|\+\+\+)/; -const frontmatterTypeRE = /(?:^\uFEFF?|^\s*\n)(---|\+\+\+)/; -export function extractFrontmatter(code: string): string | undefined { - return frontmatterRE.exec(code)?.[1]; -} - -function getFrontmatterParser(code: string): [string, (str: string) => unknown] { - return frontmatterTypeRE.exec(code)?.[1] === '+++' ? ['+++', toml.parse] : ['---', yaml.load]; -} -export interface ParseFrontmatterOptions { - /** - * How the frontmatter should be handled in the returned `content` string. - * - `preserve`: Keep the frontmatter. - * - `remove`: Remove the frontmatter. - * - `empty-with-spaces`: Replace the frontmatter with empty spaces. (preserves sourcemap line/col/offset) - * - `empty-with-lines`: Replace the frontmatter with empty line breaks. (preserves sourcemap line/col) - * - * @default 'remove' - */ - frontmatter: 'preserve' | 'remove' | 'empty-with-spaces' | 'empty-with-lines'; -} - -export interface ParseFrontmatterResult { - frontmatter: Record; - rawFrontmatter: string; - content: string; -} - -export function parseFrontmatter( - code: string, - options?: ParseFrontmatterOptions, -): ParseFrontmatterResult { - const rawFrontmatter = extractFrontmatter(code); - - if (rawFrontmatter == null) { - return { frontmatter: {}, rawFrontmatter: '', content: code }; - } - const [delims, parser] = getFrontmatterParser(code); - const parsed = parser(rawFrontmatter); - const frontmatter = (parsed && typeof parsed === 'object' ? parsed : {}) as Record; - - let content: string; - switch (options?.frontmatter ?? 'remove') { - case 'preserve': - content = code; - break; - case 'remove': - content = code.replace(`${delims}${rawFrontmatter}${delims}`, ''); - break; - case 'empty-with-spaces': - content = code.replace( - `${delims}${rawFrontmatter}${delims}`, - ` ${rawFrontmatter.replace(/[^\r\n]/g, ' ')} `, - ); - break; - case 'empty-with-lines': - content = code.replace( - `${delims}${rawFrontmatter}${delims}`, - rawFrontmatter.replace(/[^\r\n]/g, ''), - ); - break; - } - - return { - frontmatter, - rawFrontmatter, - content, - }; -} diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index da2963b461dd..807373d4c679 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -56,7 +56,7 @@ export { type ParseFrontmatterOptions, type ParseFrontmatterResult, parseFrontmatter, -} from './frontmatter.js'; +} from '@astrojs/internal-helpers/frontmatter'; export { rehypeHeadingIds } from './rehype-collect-headings.js'; export { rehypePrism } from './rehype-prism.js'; export { rehypeShiki } from './rehype-shiki.js'; @@ -67,7 +67,10 @@ export { type UnifiedProcessorOptions, unified, } from './processor.js'; -export { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/internal-helpers/markdown'; +export { + markdownConfigDefaults, + syntaxHighlightDefaults, +} from '@astrojs/internal-helpers/markdown'; // Skip nonessential plugins during performance benchmark runs const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK); diff --git a/packages/markdown/remark/src/processor.ts b/packages/markdown/remark/src/processor.ts index 98ff4e12cf05..7e363062b500 100644 --- a/packages/markdown/remark/src/processor.ts +++ b/packages/markdown/remark/src/processor.ts @@ -4,12 +4,17 @@ import type { RehypePlugins, RemarkPlugins, RemarkRehype, + Smartypants, } from '@astrojs/internal-helpers/markdown'; export interface UnifiedProcessorOptions { remarkPlugins?: RemarkPlugins; rehypePlugins?: RehypePlugins; remarkRehype?: RemarkRehype; + /** Enable GitHub-Flavored Markdown. Defaults to `true`. */ + gfm?: boolean; + /** Enable SmartyPants typography. Defaults to `true`; pass an object to configure it. */ + smartypants?: boolean | Smartypants; } /** @@ -22,6 +27,8 @@ export interface UnifiedProcessorDescriptor { remarkPlugins: RemarkPlugins; rehypePlugins: RehypePlugins; remarkRehype: RemarkRehype; + gfm?: boolean; + smartypants?: boolean | Smartypants; }; createRenderer(shared: AstroMarkdownProcessorOptions): Promise; } @@ -32,7 +39,9 @@ export function unified(opts: UnifiedProcessorOptions = {}): UnifiedProcessorDes options: { remarkPlugins: [...(opts.remarkPlugins ?? [])], rehypePlugins: [...(opts.rehypePlugins ?? [])], - remarkRehype: { ...(opts.remarkRehype) }, + remarkRehype: { ...opts.remarkRehype }, + gfm: opts.gfm, + smartypants: opts.smartypants, }, async createRenderer(shared) { // Lazy import to avoid a circular module load with `./index.js`. @@ -42,14 +51,16 @@ export function unified(opts: UnifiedProcessorOptions = {}): UnifiedProcessorDes remarkPlugins: descriptor.options.remarkPlugins, rehypePlugins: descriptor.options.rehypePlugins, remarkRehype: descriptor.options.remarkRehype, + // `unified({ gfm, smartypants })` wins; fall back to the deprecated + // top-level `markdown.gfm` / `markdown.smartypants` while they still exist. + gfm: descriptor.options.gfm ?? shared.gfm, + smartypants: descriptor.options.smartypants ?? shared.smartypants, }); }, }; return descriptor; } -export function isUnifiedProcessor(p: { - name: string; -}): p is UnifiedProcessorDescriptor { +export function isUnifiedProcessor(p: { name: string }): p is UnifiedProcessorDescriptor { return p.name === 'unified'; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74643f512bb8..5234ea1d8f94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6462,6 +6462,9 @@ importers: '@types/mdast': specifier: ^4.0.4 version: 4.0.4 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 picomatch: specifier: ^4.0.4 version: 4.0.4 @@ -6471,10 +6474,16 @@ importers: shiki: specifier: ^4.0.2 version: 4.0.2 + smol-toml: + specifier: ^1.6.0 + version: 1.6.1 unified: specifier: ^11.0.5 version: 11.0.5 devDependencies: + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/picomatch': specifier: ^4.0.2 version: 4.0.2 @@ -6778,9 +6787,6 @@ importers: hast-util-to-text: specifier: ^4.0.2 version: 4.0.2 - js-yaml: - specifier: ^4.1.1 - version: 4.1.1 mdast-util-definitions: specifier: ^6.0.0 version: 6.0.0 @@ -6802,9 +6808,6 @@ importers: remark-smartypants: specifier: ^3.0.2 version: 3.0.2 - smol-toml: - specifier: ^1.6.0 - version: 1.6.1 unified: specifier: ^11.0.5 version: 11.0.5 @@ -6827,9 +6830,6 @@ importers: '@types/hast': specifier: ^3.0.4 version: 3.0.4 - '@types/js-yaml': - specifier: ^4.0.9 - version: 4.0.9 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 From 6827983a10e3b3aca21b31a52745009a9bbd6704 Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 21 May 2026 13:09:26 +0200 Subject: [PATCH 25/26] fix: format --- packages/astro/e2e/nested-in-svelte.test.ts | 110 +++++++++--------- .../mdx/src/unified/rehype-optimize-static.ts | 8 +- packages/integrations/solid/src/index.ts | 5 +- packages/integrations/vue/src/index.ts | 6 +- packages/markdown/satteri/src/processor.ts | 10 +- 5 files changed, 72 insertions(+), 67 deletions(-) diff --git a/packages/astro/e2e/nested-in-svelte.test.ts b/packages/astro/e2e/nested-in-svelte.test.ts index 8c4e873fa19c..c57a8479612e 100644 --- a/packages/astro/e2e/nested-in-svelte.test.ts +++ b/packages/astro/e2e/nested-in-svelte.test.ts @@ -14,88 +14,88 @@ test.afterAll(async () => { }); test.describe('Nested Frameworks in Svelte', () => { - test('React counter', async ({ astro, page }) => { - await page.goto(astro.resolveUrl('/')); + test('React counter', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); - const counter = page.locator('#react-counter'); - await expect(counter, 'component is visible').toBeVisible(); + const counter = page.locator('#react-counter'); + await expect(counter, 'component is visible').toBeVisible(); - const count = counter.locator('#react-counter-count'); - await expect(count, 'initial count is 0').toHaveText('0'); + const count = counter.locator('#react-counter-count'); + await expect(count, 'initial count is 0').toHaveText('0'); - await waitForHydrate(page, counter); + await waitForHydrate(page, counter); - const increment = counter.locator('#react-counter-increment'); - await increment.click(); + const increment = counter.locator('#react-counter-increment'); + await increment.click(); - await expect(count, 'count incremented by 1').toHaveText('1'); - }); + await expect(count, 'count incremented by 1').toHaveText('1'); + }); - test('Preact counter', async ({ astro, page }) => { - await page.goto(astro.resolveUrl('/')); + test('Preact counter', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); - const counter = page.locator('#preact-counter'); - await expect(counter, 'component is visible').toBeVisible(); + const counter = page.locator('#preact-counter'); + await expect(counter, 'component is visible').toBeVisible(); - const count = counter.locator('#preact-counter-count'); - await expect(count, 'initial count is 0').toHaveText('0'); + const count = counter.locator('#preact-counter-count'); + await expect(count, 'initial count is 0').toHaveText('0'); - await waitForHydrate(page, counter); + await waitForHydrate(page, counter); - const increment = counter.locator('#preact-counter-increment'); - await increment.click(); + const increment = counter.locator('#preact-counter-increment'); + await increment.click(); - await expect(count, 'count incremented by 1').toHaveText('1'); - }); + await expect(count, 'count incremented by 1').toHaveText('1'); + }); - test('Solid counter', async ({ astro, page }) => { - await page.goto(astro.resolveUrl('/')); + test('Solid counter', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); - const counter = page.locator('#solid-counter'); - await expect(counter, 'component is visible').toBeVisible(); + const counter = page.locator('#solid-counter'); + await expect(counter, 'component is visible').toBeVisible(); - const count = counter.locator('#solid-counter-count'); - await expect(count, 'initial count is 0').toHaveText('0'); + const count = counter.locator('#solid-counter-count'); + await expect(count, 'initial count is 0').toHaveText('0'); - await waitForHydrate(page, counter); + await waitForHydrate(page, counter); - const increment = counter.locator('#solid-counter-increment'); - await increment.click(); + const increment = counter.locator('#solid-counter-increment'); + await increment.click(); - await expect(count, 'count incremented by 1').toHaveText('1'); - }); + await expect(count, 'count incremented by 1').toHaveText('1'); + }); - test('Vue counter', async ({ astro, page }) => { - await page.goto(astro.resolveUrl('/')); + test('Vue counter', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); - const counter = page.locator('#vue-counter'); - await expect(counter, 'component is visible').toBeVisible(); + const counter = page.locator('#vue-counter'); + await expect(counter, 'component is visible').toBeVisible(); - const count = counter.locator('#vue-counter-count'); - await expect(count, 'initial count is 0').toHaveText('0'); + const count = counter.locator('#vue-counter-count'); + await expect(count, 'initial count is 0').toHaveText('0'); - await waitForHydrate(page, counter); + await waitForHydrate(page, counter); - const increment = counter.locator('#vue-counter-increment'); - await increment.click(); + const increment = counter.locator('#vue-counter-increment'); + await increment.click(); - await expect(count, 'count incremented by 1').toHaveText('1'); - }); + await expect(count, 'count incremented by 1').toHaveText('1'); + }); - test('Svelte counter', async ({ astro, page }) => { - await page.goto(astro.resolveUrl('/')); + test('Svelte counter', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); - const counter = page.locator('#svelte-counter'); - await expect(counter, 'component is visible').toBeVisible(); + const counter = page.locator('#svelte-counter'); + await expect(counter, 'component is visible').toBeVisible(); - const count = counter.locator('#svelte-counter-count'); - await expect(count, 'initial count is 0').toHaveText('0'); + const count = counter.locator('#svelte-counter-count'); + await expect(count, 'initial count is 0').toHaveText('0'); - await waitForHydrate(page, counter); + await waitForHydrate(page, counter); - const increment = counter.locator('#svelte-counter-increment'); - await increment.click(); + const increment = counter.locator('#svelte-counter-increment'); + await increment.click(); - await expect(count, 'count incremented by 1').toHaveText('1'); - }); + await expect(count, 'count incremented by 1').toHaveText('1'); }); +}); diff --git a/packages/integrations/mdx/src/unified/rehype-optimize-static.ts b/packages/integrations/mdx/src/unified/rehype-optimize-static.ts index ee9a091400fe..81800f90a638 100644 --- a/packages/integrations/mdx/src/unified/rehype-optimize-static.ts +++ b/packages/integrations/mdx/src/unified/rehype-optimize-static.ts @@ -255,9 +255,11 @@ function getExportConstComponentObjectKeys(node: RootContentMap['mdxjsEsm']) { let variableInit: Expression | undefined | null; // Find the initial value of `components` in the AST. - for (const part of (node.data as { - estree: Program; - })?.estree?.body || []) { + for (const part of ( + node.data as { + estree: Program; + } + )?.estree?.body || []) { if ( part.type !== 'ExportNamedDeclaration' || part.declaration?.type !== 'VariableDeclaration' diff --git a/packages/integrations/solid/src/index.ts b/packages/integrations/solid/src/index.ts index 3ee9e391a870..91712fc8ccec 100644 --- a/packages/integrations/solid/src/index.ts +++ b/packages/integrations/solid/src/index.ts @@ -44,7 +44,10 @@ function getViteConfiguration( { include, exclude }: Options, devtoolsPlugin: DevtoolsPlugin | null, ) { - const plugins: PluginOption[] = [solid({ include, exclude, ssr: true }), configEnvironmentPlugin()]; + const plugins: PluginOption[] = [ + solid({ include, exclude, ssr: true }), + configEnvironmentPlugin(), + ]; if (devtoolsPlugin) { plugins.push(devtoolsPlugin({ autoname: true })); diff --git a/packages/integrations/vue/src/index.ts b/packages/integrations/vue/src/index.ts index 2e86b0d4cba7..6e8cbe3a4d63 100644 --- a/packages/integrations/vue/src/index.ts +++ b/packages/integrations/vue/src/index.ts @@ -121,7 +121,11 @@ async function getViteConfiguration( // The vue vite plugin may not manage to resolve it automatically vueOptions.compiler ??= await import('vue/compiler-sfc'); - const plugins: PluginOption[] = [vue(vueOptions), virtualAppEntrypoint(vueOptions), configEnvironmentPlugin()]; + const plugins: PluginOption[] = [ + vue(vueOptions), + virtualAppEntrypoint(vueOptions), + configEnvironmentPlugin(), + ]; if (options?.jsx) { const vueJsx = (await import('@vitejs/plugin-vue-jsx')).default; diff --git a/packages/markdown/satteri/src/processor.ts b/packages/markdown/satteri/src/processor.ts index e7651abaf863..bc668eafe516 100644 --- a/packages/markdown/satteri/src/processor.ts +++ b/packages/markdown/satteri/src/processor.ts @@ -3,9 +3,7 @@ import type { AstroMarkdownProcessorOptions, MarkdownProcessor, } from '@astrojs/internal-helpers/markdown'; -import type { - MdastPluginDefinition, HastPluginDefinition, Features -} from 'satteri'; +import type { MdastPluginDefinition, HastPluginDefinition, Features } from 'satteri'; export interface SatteriProcessorOptions { mdastPlugins?: MdastPluginDefinition[]; @@ -35,7 +33,7 @@ export function satteri(opts: SatteriProcessorOptions = {}): SatteriProcessorDes hastPlugins: [...(opts.hastPlugins ?? [])], // Default to `{}` so integrations can write `options.features.gfm = false` // without an `??=` check. - features: { ...(opts.features) }, + features: { ...opts.features }, }, createRenderer(shared) { return createSatteriMarkdownProcessor({ @@ -49,8 +47,6 @@ export function satteri(opts: SatteriProcessorOptions = {}): SatteriProcessorDes return descriptor; } -export function isSatteriProcessor(p: { - name: string; -}): p is SatteriProcessorDescriptor { +export function isSatteriProcessor(p: { name: string }): p is SatteriProcessorDescriptor { return p.name === 'satteri'; } From 1f15ab6bb1a88c38a6a5d6630b5b1ef1edb2e00f Mon Sep 17 00:00:00 2001 From: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 21 May 2026 13:33:32 +0200 Subject: [PATCH 26/26] fix: address comments --- .changeset/internal-helpers-content-modules.md | 5 +++++ packages/astro/src/core/config/validate.ts | 7 +++++-- packages/integrations/mdx/src/satteri/charset.ts | 2 +- packages/integrations/mdx/src/satteri/index.ts | 8 +++++++- packages/integrations/mdx/src/satteri/jsx-utils.ts | 2 +- packages/integrations/mdx/src/vite-plugin-mdx.ts | 4 ++++ packages/internal-helpers/src/markdown.ts | 2 +- packages/markdown/satteri/src/satteri-processor.ts | 4 ++-- pnpm-workspace.yaml | 1 - 9 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 .changeset/internal-helpers-content-modules.md diff --git a/.changeset/internal-helpers-content-modules.md b/.changeset/internal-helpers-content-modules.md new file mode 100644 index 000000000000..76917f5a26cf --- /dev/null +++ b/.changeset/internal-helpers-content-modules.md @@ -0,0 +1,5 @@ +--- +'@astrojs/internal-helpers': minor +--- + +Adds `markdown`, `frontmatter`, and `shiki` helper modules, shared by Astro's content pipeline. diff --git a/packages/astro/src/core/config/validate.ts b/packages/astro/src/core/config/validate.ts index 0f050d14db3b..91c1ad165735 100644 --- a/packages/astro/src/core/config/validate.ts +++ b/packages/astro/src/core/config/validate.ts @@ -40,8 +40,11 @@ function warnDeprecatedMarkdownOptions( const deprecated = (['gfm', 'smartypants'] as const).filter((k) => md[k] !== undefined); if (deprecated.length === 0) return; didWarnAboutDeprecatedMarkdownOptions = true; + + const names = deprecated.map((key) => `\`markdown.${key}\``).join(' and '); + const isPlural = deprecated.length > 1; console.warn( - `[astro] \`markdown.${deprecated.join('\` and `markdown.')}\` are deprecated. Move them onto your processor instead (e.g. \`unified({ gfm: false, smartypants: false })\` or \`satteri({ features: { gfm: false, smartPunctuation: false } })\`). They'll be removed in a future major.`, + `[astro] ${names} ${isPlural ? 'are' : 'is'} deprecated. Move ${isPlural ? 'them' : 'it'} onto your processor instead (e.g. \`unified({ gfm: false, smartypants: false })\` or \`satteri({ features: { gfm: false, smartPunctuation: false } })\`). Will be removed in a future major.`, ); } @@ -94,7 +97,7 @@ async function coerceLegacyMarkdownPlugins( ); } } else { - // satteri and third-party processors can't run remark/rehype plugins, so legacy + // Sätteri and third-party processors can't run remark/rehype plugins, so legacy // plugins force a swap to `unified()`. Warn loudly — this changes the active // Markdown processor, e.g. away from the default native pipeline. if (!didWarnAboutProcessorSwap) { diff --git a/packages/integrations/mdx/src/satteri/charset.ts b/packages/integrations/mdx/src/satteri/charset.ts index ce3f36589bb6..c36cd70c629e 100644 --- a/packages/integrations/mdx/src/satteri/charset.ts +++ b/packages/integrations/mdx/src/satteri/charset.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'; const exportConstPartialTrueRe = /export\s+const\s+partial\s*=\s*true/; const leadingComponentRe = /^\s*<\s*([A-Za-z][A-Za-z0-9]*)\b/; -// Scans MDX source directly because satteri exposes no root-level visitor. +// Scans MDX source directly because Sätteri exposes no root-level visitor. // Skips imports/exports/blank lines and bails when the first content line is a // capitalized JSX element (treated as a wrapping layout). export function shouldAddCharset(content: string, filePath: string, srcDir: URL): boolean { diff --git a/packages/integrations/mdx/src/satteri/index.ts b/packages/integrations/mdx/src/satteri/index.ts index 6e5907221fda..02860d9b6924 100644 --- a/packages/integrations/mdx/src/satteri/index.ts +++ b/packages/integrations/mdx/src/satteri/index.ts @@ -53,6 +53,12 @@ export function createMdxProcessor(mdxOptions: MdxOptions, ctx: CreateMdxProcess ? syntaxHighlight.type : undefined; + if (syntaxHighlightType === 'prism') { + throw new Error( + 'Prism syntax highlighting is not supported by the `satteri()` markdown processor. Use shiki instead, or switch to `markdown.processor: unified({...})`.', + ); + } + if (syntaxHighlightType === 'shiki') { const shikiConfig = mdxOptions.shikiConfig ?? {}; initPromise = createShikiHighlighter({ @@ -113,7 +119,7 @@ export function createMdxProcessor(mdxOptions: MdxOptions, ctx: CreateMdxProcess const hastPlugins: HastPluginDefinition[] = []; if (highlightFn) { // `mdx: true` wraps the highlighted HTML in a JSX `` node - // rather than a raw HTML node, since the satteri pipeline is compiling to JSX. + // rather than a raw HTML node, since the Sätteri pipeline is compiling to JSX. hastPlugins.push(satteriShikiPlugin(highlightFn, excludeLangs, { mdx: true })); } hastPlugins.push(headingIds, imageToComponent, astroMeta); diff --git a/packages/integrations/mdx/src/satteri/jsx-utils.ts b/packages/integrations/mdx/src/satteri/jsx-utils.ts index 2d592d0cded9..b82ad012fb3e 100644 --- a/packages/integrations/mdx/src/satteri/jsx-utils.ts +++ b/packages/integrations/mdx/src/satteri/jsx-utils.ts @@ -4,7 +4,7 @@ import type { MdxJsxAttributeNode, MdxJsxFlowElementHast, MdxJsxTextElementHast // `makeFragmentNode`, `collectHastText`) live in `@astrojs/markdown-satteri` // because they're shared with the markdown render pipeline. -// satteri hands plugin visitors readonly nodes; these helpers only read them. +// Sätteri hands plugin visitors readonly nodes; these helpers only read them. export type MdxJsxHastNode = Readonly; const nonAlphaRe = /[^a-zA-Z]/; diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts index ca007d152d3d..d690567c2cdf 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts @@ -68,13 +68,17 @@ export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin { map: result.map ?? null, meta: { astro: result.astroMetadata, + // `lang: 'ts'` makes Vite resolve `.js` import specifiers to `.ts` files. vite: { lang: 'ts' }, }, }; } catch (e: any) { const err: SSRError = e; + // Surface compile failures as a dedicated MDX error with a source + // location so the dev overlay can point at the offending file. err.name = 'MDXError'; err.loc = { file: id, line: e.line, column: e.column }; + // Compiler errors may arrive without a JS stack; capture one here. Error.captureStackTrace(err); throw err; } diff --git a/packages/internal-helpers/src/markdown.ts b/packages/internal-helpers/src/markdown.ts index 600ce0cf94fe..bb21c274fe09 100644 --- a/packages/internal-helpers/src/markdown.ts +++ b/packages/internal-helpers/src/markdown.ts @@ -99,7 +99,7 @@ export interface MarkdownProcessorRenderResult { }; } -// MDX rendering metadata. Produced by `@astrojs/mdx`'s satteri and unified pipelines +// MDX rendering metadata. Produced by `@astrojs/mdx`'s Sätteri and unified pipelines // and surfaced on Vite's `meta.astro`; defined here so both pipelines share one shape. export interface AstroComponentMetadata { diff --git a/packages/markdown/satteri/src/satteri-processor.ts b/packages/markdown/satteri/src/satteri-processor.ts index a512b99a7571..c5694f4f4956 100644 --- a/packages/markdown/satteri/src/satteri-processor.ts +++ b/packages/markdown/satteri/src/satteri-processor.ts @@ -17,8 +17,8 @@ type HighlightFn = (code: string, lang: string, meta?: string) => Promise { satteri ??= await import('satteri'); return satteri; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f4b679d148b3..95a77a55d91c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,7 +31,6 @@ minimumReleaseAgeExclude: - '@astrojs/*' - satteri - '@bruits/satteri-*' - - '@napi-rs/wasm-runtime' peerDependencyRules: allowAny: - 'astro'