Handle metadata for MDX files during build (#3915)

* fix: metadata handling for MDX files

* chore: add changeset

* chore: update mdx example

* fix: protect against infinite loops in jsx-runtime, properly hook console.error

* chore: remove unused import

* feat(mdx): support `client:only`

* fix: prefer Symbol.for

* fix(jsx): handle vnode check properly

* chore: appease ts

Co-authored-by: Nate Moore <nate@astro.build>
This commit is contained in:
Nate Moore 2022-07-15 10:25:29 -05:00 committed by GitHub
parent 31f9c0bf02
commit f5d4ebf0e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 260 additions and 73 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Fix metadata handling for building MDX files

View file

@ -14,4 +14,4 @@ Written by: {new Intl.ListFormat('en').format(authors.map(d => d.name))}.
Published on: {new Intl.DateTimeFormat('en', {dateStyle: 'long'}).format(published)}. Published on: {new Intl.DateTimeFormat('en', {dateStyle: 'long'}).format(published)}.
<Counter>## This is a counter!</Counter> <Counter client:idle>This is a **counter**!</Counter>

View file

@ -3,9 +3,9 @@ import { Fragment, markHTMLString } from '../runtime/server/index.js';
const AstroJSX = 'astro:jsx'; const AstroJSX = 'astro:jsx';
const Empty = Symbol('empty'); const Empty = Symbol('empty');
interface AstroVNode { export interface AstroVNode {
[AstroJSX]: boolean; [AstroJSX]: boolean;
type: string | ((...args: any) => any) | typeof Fragment; type: string | ((...args: any) => any);
props: Record<string, any>; props: Record<string, any>;
} }

View file

@ -1,5 +1,8 @@
import type { PluginObj } from '@babel/core'; import type { PluginMetadata } from '../vite-plugin-astro/types';
import type { PluginObj, NodePath } from '@babel/core';
import * as t from '@babel/types'; import * as t from '@babel/types';
import { pathToFileURL } from 'node:url'
import { ClientOnlyPlaceholder } from '../runtime/server/index.js';
function isComponent(tagName: string) { function isComponent(tagName: string) {
return ( return (
@ -18,6 +21,15 @@ function hasClientDirective(node: t.JSXElement) {
return false; return false;
} }
function isClientOnlyComponent(node: t.JSXElement) {
for (const attr of node.openingElement.attributes) {
if (attr.type === 'JSXAttribute' && attr.name.type === 'JSXNamespacedName') {
return jsxAttributeToString(attr) === 'client:only';
}
}
return false;
}
function getTagName(tag: t.JSXElement) { function getTagName(tag: t.JSXElement) {
const jsxName = tag.openingElement.name; const jsxName = tag.openingElement.name;
return jsxElementNameToString(jsxName); return jsxElementNameToString(jsxName);
@ -40,28 +52,55 @@ function jsxAttributeToString(attr: t.JSXAttribute): string {
return `${attr.name.name}`; return `${attr.name.name}`;
} }
function addClientMetadata(node: t.JSXElement, meta: { path: string; name: string }) { function addClientMetadata(node: t.JSXElement, meta: { resolvedPath: string; path: string; name: string }) {
const existingAttributes = node.openingElement.attributes.map((attr) => const existingAttributes = node.openingElement.attributes.map((attr) =>
t.isJSXAttribute(attr) ? jsxAttributeToString(attr) : null t.isJSXAttribute(attr) ? jsxAttributeToString(attr) : null
); );
if (!existingAttributes.find((attr) => attr === 'client:component-path')) { if (!existingAttributes.find((attr) => attr === 'client:component-path')) {
const componentPath = t.jsxAttribute( const componentPath = t.jsxAttribute(
t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-path')), t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-path')),
!meta.path.startsWith('.') t.stringLiteral(meta.resolvedPath)
? t.stringLiteral(meta.path) );
: t.jsxExpressionContainer( node.openingElement.attributes.push(componentPath);
t.binaryExpression( }
'+', if (!existingAttributes.find((attr) => attr === 'client:component-export')) {
t.stringLiteral('/@fs'), if (meta.name === '*') {
t.memberExpression( meta.name = getTagName(node).split('.').at(1)!;
t.newExpression(t.identifier('URL'), [ }
t.stringLiteral(meta.path), const componentExport = t.jsxAttribute(
t.identifier('import.meta.url'), t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-export')),
]), t.stringLiteral(meta.name)
t.identifier('pathname') );
) node.openingElement.attributes.push(componentExport);
) }
) if (!existingAttributes.find((attr) => attr === 'client:component-hydration')) {
const staticMarker = t.jsxAttribute(
t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-hydration'))
);
node.openingElement.attributes.push(staticMarker);
}
}
function addClientOnlyMetadata(node: t.JSXElement, meta: { resolvedPath: string; path: string; name: string }) {
const tagName = getTagName(node);
node.openingElement = t.jsxOpeningElement(t.jsxIdentifier(ClientOnlyPlaceholder), node.openingElement.attributes)
if (node.closingElement) {
node.closingElement = t.jsxClosingElement(t.jsxIdentifier(ClientOnlyPlaceholder))
}
const existingAttributes = node.openingElement.attributes.map((attr) =>
t.isJSXAttribute(attr) ? jsxAttributeToString(attr) : null
);
if (!existingAttributes.find((attr) => attr === 'client:display-name')) {
const displayName = t.jsxAttribute(
t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('display-name')),
t.stringLiteral(tagName)
);
node.openingElement.attributes.push(displayName);
}
if (!existingAttributes.find((attr) => attr === 'client:component-path')) {
const componentPath = t.jsxAttribute(
t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-path')),
t.stringLiteral(meta.resolvedPath)
); );
node.openingElement.attributes.push(componentPath); node.openingElement.attributes.push(componentPath);
} }
@ -86,7 +125,15 @@ function addClientMetadata(node: t.JSXElement, meta: { path: string; name: strin
export default function astroJSX(): PluginObj { export default function astroJSX(): PluginObj {
return { return {
visitor: { visitor: {
Program(path) { Program: {
enter(path, state) {
if (!(state.file.metadata as PluginMetadata).astro) {
(state.file.metadata as PluginMetadata).astro = {
clientOnlyComponents: [],
hydratedComponents: [],
scripts: [],
}
}
path.node.body.splice( path.node.body.splice(
0, 0,
0, 0,
@ -95,6 +142,7 @@ export default function astroJSX(): PluginObj {
t.stringLiteral('astro/jsx-runtime') t.stringLiteral('astro/jsx-runtime')
) )
); );
}
}, },
ImportDeclaration(path, state) { ImportDeclaration(path, state) {
const source = path.node.source.value; const source = path.node.source.value;
@ -127,9 +175,11 @@ export default function astroJSX(): PluginObj {
const tagName = getTagName(parentNode); const tagName = getTagName(parentNode);
if (!isComponent(tagName)) return; if (!isComponent(tagName)) return;
if (!hasClientDirective(parentNode)) return; if (!hasClientDirective(parentNode)) return;
const isClientOnly = isClientOnlyComponent(parentNode);
if (tagName === ClientOnlyPlaceholder) return;
const imports = state.get('imports') ?? new Map(); const imports = state.get('imports') ?? new Map();
const namespace = getTagName(parentNode).split('.'); const namespace = tagName.split('.');
for (const [source, specs] of imports) { for (const [source, specs] of imports) {
for (const { imported, local } of specs) { for (const { imported, local } of specs) {
const reference = path.referencesImport(source, imported); const reference = path.referencesImport(source, imported);
@ -143,10 +193,38 @@ export default function astroJSX(): PluginObj {
} }
} }
} }
// TODO: map unmatched identifiers back to imports if possible
const meta = path.getData('import'); const meta = path.getData('import');
if (meta) { if (meta) {
let resolvedPath: string;
if (meta.path.startsWith('.')) {
const fileURL = pathToFileURL(state.filename!);
resolvedPath = `/@fs${new URL(meta.path, fileURL).pathname}`;
if (resolvedPath.endsWith('.jsx')) {
resolvedPath = resolvedPath.slice(0, -4);
}
} else {
resolvedPath = meta.path;
}
if (isClientOnly) {
(state.file.metadata as PluginMetadata).astro.clientOnlyComponents.push({
exportName: meta.name,
specifier: meta.name,
resolvedPath
})
meta.resolvedPath = resolvedPath;
addClientOnlyMetadata(parentNode, meta);
} else {
(state.file.metadata as PluginMetadata).astro.hydratedComponents.push({
exportName: meta.name,
specifier: meta.name,
resolvedPath
})
meta.resolvedPath = resolvedPath;
addClientMetadata(parentNode, meta); addClientMetadata(parentNode, meta);
}
} else { } else {
throw new Error( throw new Error(
`Unable to match <${getTagName( `Unable to match <${getTagName(

View file

@ -34,10 +34,8 @@ export async function renderToStaticMarkup(
} }
const { result } = this; const { result } = this;
try {
const html = await renderJSX(result, jsx(Component, { ...props, ...slots, children })); const html = await renderJSX(result, jsx(Component, { ...props, ...slots, children }));
return { html }; return { html };
} catch (e) {}
} }
export default { export default {

View file

@ -58,6 +58,9 @@ export function extractDirectives(inputProps: Record<string | number, any>): Ext
case 'client:component-hydration': { case 'client:component-hydration': {
break; break;
} }
case 'client:display-name': {
break;
}
default: { default: {
extracted.hydration.directive = key.split(':')[1]; extracted.hydration.directive = key.split(':')[1];
extracted.hydration.value = value; extracted.hydration.value = value;

View file

@ -155,7 +155,8 @@ export function mergeSlots(...slotted: unknown[]) {
return slots; return slots;
} }
export const Fragment = Symbol('Astro.Fragment'); export const Fragment = Symbol.for('astro:fragment');
export const ClientOnlyPlaceholder = 'astro-client-only';
function guessRenderers(componentUrl?: string): string[] { function guessRenderers(componentUrl?: string): string[] {
const extname = componentUrl?.split('.').pop(); const extname = componentUrl?.split('.').pop();

View file

@ -1,7 +1,9 @@
/* eslint-disable no-console */
import { SSRResult } from '../../@types/astro.js';
import { AstroJSX, isVNode } from '../../jsx-runtime/index.js'; import { AstroJSX, isVNode } from '../../jsx-runtime/index.js';
import { import {
escapeHTML, escapeHTML,
Fragment, ClientOnlyPlaceholder,
HTMLString, HTMLString,
markHTMLString, markHTMLString,
renderComponent, renderComponent,
@ -10,7 +12,11 @@ import {
voidElementNames, voidElementNames,
} from './index.js'; } from './index.js';
export async function renderJSX(result: any, vnode: any): Promise<any> { const skipAstroJSXCheck = new WeakSet();
let originalConsoleError: any;
let consoleFilterRefs = 0;
export async function renderJSX(result: SSRResult, vnode: any): Promise<any> {
switch (true) { switch (true) {
case vnode instanceof HTMLString: case vnode instanceof HTMLString:
return vnode; return vnode;
@ -18,13 +24,16 @@ export async function renderJSX(result: any, vnode: any): Promise<any> {
return markHTMLString(escapeHTML(vnode)); return markHTMLString(escapeHTML(vnode));
case !vnode && vnode !== 0: case !vnode && vnode !== 0:
return ''; return '';
case vnode.type === Fragment:
return renderJSX(result, vnode.props.children);
case Array.isArray(vnode): case Array.isArray(vnode):
return markHTMLString( return markHTMLString(
(await Promise.all(vnode.map((v: any) => renderJSX(result, v)))).join('') (await Promise.all(vnode.map((v: any) => renderJSX(result, v)))).join('')
); );
case vnode.type.isAstroComponentFactory: { }
if (isVNode(vnode)) {
switch (true) {
case (vnode.type as any) === Symbol.for('astro:fragment'):
return renderJSX(result, vnode.props.children);
case (vnode.type as any).isAstroComponentFactory: {
let props: Record<string, any> = {}; let props: Record<string, any> = {};
let slots: Record<string, any> = {}; let slots: Record<string, any> = {};
for (const [key, value] of Object.entries(vnode.props ?? {})) { for (const [key, value] of Object.entries(vnode.props ?? {})) {
@ -37,24 +46,34 @@ export async function renderJSX(result: any, vnode: any): Promise<any> {
props[key] = value; props[key] = value;
} }
} }
return await renderToString(result, vnode.type, props, slots); return await renderToString(result, vnode.type as any, props, slots);
} }
case !vnode.type && (vnode.type as any) !== 0:
return '';
case typeof vnode.type === 'string' && vnode.type !== ClientOnlyPlaceholder:
return await renderElement(result, vnode.type as string, vnode.props ?? {});
} }
if (vnode[AstroJSX]) {
if (!vnode.type && vnode.type !== 0) return ''; if (vnode.type) {
if (typeof vnode.type === 'string') { if (typeof vnode.type === 'function' && vnode.props['server:root']) {
return await renderElement(result, vnode.type, vnode.props ?? {}); const output = await vnode.type(vnode.props ?? {});
return await renderJSX(result, output);
} }
if (!!vnode.type) { if (typeof vnode.type === 'function' && !skipAstroJSXCheck.has(vnode.type)) {
useConsoleFilter();
try { try {
// TODO: silence Invalid hook call warning from React
const output = await vnode.type(vnode.props ?? {}); const output = await vnode.type(vnode.props ?? {});
if (output && output[AstroJSX]) { if (output && output[AstroJSX]) {
return await renderJSX(result, output); return await renderJSX(result, output);
} else if (!output) { } else if (!output) {
return await renderJSX(result, output); return await renderJSX(result, output);
} }
} catch (e) {} } catch (e) {
skipAstroJSXCheck.add(vnode.type);
} finally {
finishUsingConsoleFilter();
}
}
const { children = null, ...props } = vnode.props ?? {}; const { children = null, ...props } = vnode.props ?? {};
const slots: Record<string, any> = { const slots: Record<string, any> = {
@ -78,9 +97,20 @@ export async function renderJSX(result: any, vnode: any): Promise<any> {
for (const [key, value] of Object.entries(slots)) { for (const [key, value] of Object.entries(slots)) {
slots[key] = () => renderJSX(result, value); slots[key] = () => renderJSX(result, value);
} }
return markHTMLString(
await renderComponent(result, vnode.type.name, vnode.type, props, slots) let output: string | AsyncIterable<string>;
if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) {
output = await renderComponent(
result,
vnode.props['client:display-name'] ?? '',
null,
props,
slots
); );
} else {
output = await renderComponent(result, typeof vnode.type === 'function' ? vnode.type.name : vnode.type, vnode.type, props, slots);
}
return markHTMLString(output);
} }
} }
// numbers, plain objects, etc // numbers, plain objects, etc
@ -100,3 +130,57 @@ async function renderElement(
)}` )}`
); );
} }
/**
* Reduces console noise by filtering known non-problematic errors.
*
* Performs reference counting to allow parallel usage from async code.
*
* To stop filtering, please ensure that there always is a matching call
* to `finishUsingConsoleFilter` afterwards.
*/
function useConsoleFilter() {
consoleFilterRefs++;
if (!originalConsoleError) {
// eslint-disable-next-line no-console
originalConsoleError = console.error;
try {
// eslint-disable-next-line no-console
console.error = filteredConsoleError;
} catch (error) {
// If we're unable to hook `console.error`, just accept it
}
}
}
/**
* Indicates that the filter installed by `useConsoleFilter`
* is no longer needed by the calling code.
*/
function finishUsingConsoleFilter() {
consoleFilterRefs--;
// Note: Instead of reverting `console.error` back to the original
// when the reference counter reaches 0, we leave our hook installed
// to prevent potential race conditions once `check` is made async
}
/**
* Hook/wrapper function for the global `console.error` function.
*
* Ignores known non-problematic errors while any code is using the console filter.
* Otherwise, simply forwards all arguments to the original function.
*/
function filteredConsoleError(msg: any, ...rest: any[]) {
if (consoleFilterRefs > 0 && typeof msg === 'string') {
// In `check`, we attempt to render JSX components through Preact.
// When attempting this on a React component, React may output
// the following error, which we can safely filter out:
const isKnownReactHookError =
msg.includes('Warning: Invalid hook call.') &&
msg.includes('https://reactjs.org/link/invalid-hook-call');
if (isKnownReactHookError) return;
}
}

View file

@ -2,6 +2,7 @@ import type { TransformResult } from 'rollup';
import type { Plugin, ResolvedConfig } from 'vite'; import type { Plugin, ResolvedConfig } from 'vite';
import type { AstroConfig, AstroRenderer } from '../@types/astro'; import type { AstroConfig, AstroRenderer } from '../@types/astro';
import type { LogOptions } from '../core/logger/core.js'; import type { LogOptions } from '../core/logger/core.js';
import type { PluginMetadata } from '../vite-plugin-astro/types';
import babel from '@babel/core'; import babel from '@babel/core';
import * as eslexer from 'es-module-lexer'; import * as eslexer from 'es-module-lexer';
@ -70,6 +71,23 @@ async function transformJSX({
// TODO: Be more strict about bad return values here. // TODO: Be more strict about bad return values here.
// Should we throw an error instead? Should we never return `{code: ""}`? // Should we throw an error instead? Should we never return `{code: ""}`?
if (!result) return null; if (!result) return null;
if (renderer.name === 'astro:jsx') {
const { astro } = result.metadata as unknown as PluginMetadata;
return {
code: result.code || '',
map: result.map,
meta: {
astro,
vite: {
// Setting this vite metadata to `ts` causes Vite to resolve .js
// extensions to .ts files.
lang: 'ts',
},
},
};
}
return { return {
code: result.code || '', code: result.code || '',
map: result.map, map: result.map,