diff --git a/.changeset/public-ducks-wave.md b/.changeset/public-ducks-wave.md new file mode 100644 index 000000000000..81ff8e8b9e1d --- /dev/null +++ b/.changeset/public-ducks-wave.md @@ -0,0 +1,5 @@ +--- +'@astrojs/markdown-satteri': patch +--- + +Fixes missing provenance information on the publish diff --git a/.changeset/restore-jsx-rehype.md b/.changeset/restore-jsx-rehype.md new file mode 100644 index 000000000000..f49cfac28141 --- /dev/null +++ b/.changeset/restore-jsx-rehype.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Restores the `astro/jsx/rehype.js` entry point so that older versions of `@astrojs/mdx` continue to work when used with Astro 6.x. This entry point will be removed in Astro 7.0. diff --git a/packages/astro/package.json b/packages/astro/package.json index d3cb91d79f90..777eb656ae7c 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -39,6 +39,7 @@ "./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" @@ -211,6 +212,8 @@ "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", @@ -240,6 +243,7 @@ "./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/jsx/rehype.ts b/packages/astro/src/jsx/rehype.ts new file mode 100644 index 000000000000..fe6b378aaff0 --- /dev/null +++ b/packages/astro/src/jsx/rehype.ts @@ -0,0 +1,377 @@ +// TODO: Remove this file in Astro 7.0. It now lives directly in the MDX integration at packages/integrations/mdx/src/rehype-analyze-astro-metadata.ts + +import type { RehypePlugin } from '@astrojs/internal-helpers/markdown'; +import type { RootContent } from 'hast'; +import type {} from 'mdast-util-mdx'; +import type { + MdxJsxAttribute, + MdxJsxFlowElementHast, + MdxJsxTextElementHast, +} from 'mdast-util-mdx-jsx'; +import { visit } from 'unist-util-visit'; +import type { VFile } from 'vfile'; +import { AstroError } from '../core/errors/errors.js'; +import { AstroErrorData } from '../core/errors/index.js'; +import { resolvePath } from '../core/viteUtils.js'; +import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js'; +import type { PluginMetadata } from '../vite-plugin-astro/types.js'; + +const ClientOnlyPlaceholder = 'astro-client-only'; + +export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => { + return (tree, file) => { + // Initial metadata for this MDX file, it will be mutated as we traverse the tree + const metadata = createDefaultAstroMetadata(); + + // Parse imports in this file. This is used to match components with their import source + const imports = parseImports(tree.children); + + visit(tree, (node) => { + if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return; + + const tagName = node.name; + if ( + !tagName || + !isComponent(tagName) || + !(hasClientDirective(node) || hasServerDeferDirective(node)) + ) + return; + + // From this point onwards, `node` is confirmed to be an island component + + // Match this component with its import source + const matchedImport = findMatchingImport(tagName, imports); + if (!matchedImport) { + throw new AstroError({ + ...AstroErrorData.NoMatchingImport, + message: AstroErrorData.NoMatchingImport.message(node.name!), + }); + } + + // If this is an Astro component, that means the `client:` directive is misused as it doesn't + // work on Astro components as it's server-side only. Warn the user about this. + if (matchedImport.path.endsWith('.astro')) { + const clientAttribute = node.attributes.find( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name.startsWith('client:'), + ) as MdxJsxAttribute | undefined; + if (clientAttribute) { + console.warn( + `You are attempting to render <${node.name!} ${ + clientAttribute.name + } />, but ${node.name!} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`, + ); + } + } + + const resolvedPath = resolvePath(matchedImport.path, file.path); + + if (hasClientOnlyDirective(node)) { + // Add this component to the metadata + metadata.clientOnlyComponents.push({ + exportName: matchedImport.name, + localName: '', + specifier: tagName, + resolvedPath, + }); + // Mutate node with additional island attributes + addClientOnlyMetadata(node, matchedImport, resolvedPath); + } else if (hasClientDirective(node)) { + // Add this component to the metadata + metadata.hydratedComponents.push({ + exportName: '*', + localName: '', + specifier: tagName, + resolvedPath, + }); + // Mutate node with additional island attributes + addClientMetadata(node, matchedImport, resolvedPath); + } else if (hasServerDeferDirective(node)) { + metadata.serverComponents.push({ + exportName: matchedImport.name, + localName: tagName, + specifier: matchedImport.path, + resolvedPath, + }); + // Mutate node with additional island attributes + addServerDeferMetadata(node, matchedImport, resolvedPath); + } + }); + + // Attach final metadata here, which can later be retrieved by `getAstroMetadata` + file.data.__astroMetadata = metadata; + }; +}; + +export function getAstroMetadata(file: VFile) { + return file.data.__astroMetadata as PluginMetadata['astro'] | undefined; +} + +type ImportSpecifier = { local: string; imported: string }; + +/** + * ``` + * import Foo from './Foo.jsx' + * import { Bar } from './Bar.jsx' + * import { Baz as Wiz } from './Bar.jsx' + * import * as Waz from './BaWazz.jsx' + * + * // => Map { + * // "./Foo.jsx" => Set { { local: "Foo", imported: "default" } }, + * // "./Bar.jsx" => Set { + * // { local: "Bar", imported: "Bar" } + * // { local: "Wiz", imported: "Baz" }, + * // }, + * // "./Waz.jsx" => Set { { local: "Waz", imported: "*" } }, + * // } + * ``` + */ +function parseImports(children: RootContent[]) { + // Map of import source to its imported specifiers + const imports = new Map>(); + + for (const child of children) { + if (child.type !== 'mdxjsEsm') continue; + + const body = child.data?.estree?.body; + if (!body) continue; + + for (const ast of body) { + if (ast.type !== 'ImportDeclaration') continue; + + const source = ast.source.value as string; + const specs: ImportSpecifier[] = ast.specifiers.map((spec) => { + switch (spec.type) { + case 'ImportDefaultSpecifier': + return { local: spec.local.name, imported: 'default' }; + case 'ImportNamespaceSpecifier': + return { local: spec.local.name, imported: '*' }; + case 'ImportSpecifier': { + return { + local: spec.local.name, + imported: + spec.imported.type === 'Identifier' + ? spec.imported.name + : String(spec.imported.value), + }; + } + default: + throw new Error('Unknown import declaration specifier: ' + spec); + } + }); + + // Get specifiers set from source or initialize a new one + let specSet = imports.get(source); + if (!specSet) { + specSet = new Set(); + imports.set(source, specSet); + } + + for (const spec of specs) { + specSet.add(spec); + } + } + } + + return imports; +} + +function isComponent(tagName: string) { + return ( + (tagName[0] && tagName[0].toLowerCase() !== tagName[0]) || + tagName.includes('.') || + /[^a-zA-Z]/.test(tagName[0]) + ); +} + +function hasClientDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) { + return node.attributes.some( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name.startsWith('client:'), + ); +} + +function hasServerDeferDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) { + return node.attributes.some( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'server:defer', + ); +} + +function hasClientOnlyDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) { + return node.attributes.some( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'client:only', + ); +} + +type MatchedImport = { name: string; path: string }; + +/** + * ``` + * import Button from './Button.jsx' + *