Skip to content

Commit

Permalink
Handle metadata for MDX files during build (#3915)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
natemoo-re and natemoo-re authored Jul 15, 2022
1 parent 31f9c0b commit f5d4ebf
Show file tree
Hide file tree
Showing 9 changed files with 257 additions and 70 deletions.
5 changes: 5 additions & 0 deletions .changeset/purple-flowers-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fix metadata handling for building MDX files
2 changes: 1 addition & 1 deletion examples/with-mdx/src/pages/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}.

<Counter>## This is a counter!</Counter>
<Counter client:idle>This is a **counter**!</Counter>
4 changes: 2 additions & 2 deletions packages/astro/src/jsx-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { Fragment, markHTMLString } from '../runtime/server/index.js';
const AstroJSX = 'astro:jsx';
const Empty = Symbol('empty');

interface AstroVNode {
export interface AstroVNode {
[AstroJSX]: boolean;
type: string | ((...args: any) => any) | typeof Fragment;
type: string | ((...args: any) => any);
props: Record<string, any>;
}

Expand Down
136 changes: 107 additions & 29 deletions packages/astro/src/jsx/babel.ts
Original file line number Diff line number Diff line change
@@ -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 { pathToFileURL } from 'node:url'
import { ClientOnlyPlaceholder } from '../runtime/server/index.js';

function isComponent(tagName: string) {
return (
Expand All @@ -18,6 +21,15 @@ function hasClientDirective(node: t.JSXElement) {
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) {
const jsxName = tag.openingElement.name;
return jsxElementNameToString(jsxName);
Expand All @@ -40,28 +52,55 @@ function jsxAttributeToString(attr: t.JSXAttribute): string {
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) =>
t.isJSXAttribute(attr) ? jsxAttributeToString(attr) : null
);
if (!existingAttributes.find((attr) => attr === 'client:component-path')) {
const componentPath = t.jsxAttribute(
t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-path')),
!meta.path.startsWith('.')
? t.stringLiteral(meta.path)
: t.jsxExpressionContainer(
t.binaryExpression(
'+',
t.stringLiteral('/@fs'),
t.memberExpression(
t.newExpression(t.identifier('URL'), [
t.stringLiteral(meta.path),
t.identifier('import.meta.url'),
]),
t.identifier('pathname')
)
)
)
t.stringLiteral(meta.resolvedPath)
);
node.openingElement.attributes.push(componentPath);
}
if (!existingAttributes.find((attr) => attr === 'client:component-export')) {
if (meta.name === '*') {
meta.name = getTagName(node).split('.').at(1)!;
}
const componentExport = t.jsxAttribute(
t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-export')),
t.stringLiteral(meta.name)
);
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);
}
Expand All @@ -86,15 +125,24 @@ function addClientMetadata(node: t.JSXElement, meta: { path: string; name: strin
export default function astroJSX(): PluginObj {
return {
visitor: {
Program(path) {
path.node.body.splice(
0,
0,
t.importDeclaration(
[t.importSpecifier(t.identifier('Fragment'), t.identifier('Fragment'))],
t.stringLiteral('astro/jsx-runtime')
)
);
Program: {
enter(path, state) {
if (!(state.file.metadata as PluginMetadata).astro) {
(state.file.metadata as PluginMetadata).astro = {
clientOnlyComponents: [],
hydratedComponents: [],
scripts: [],
}
}
path.node.body.splice(
0,
0,
t.importDeclaration(
[t.importSpecifier(t.identifier('Fragment'), t.identifier('Fragment'))],
t.stringLiteral('astro/jsx-runtime')
)
);
}
},
ImportDeclaration(path, state) {
const source = path.node.source.value;
Expand Down Expand Up @@ -127,9 +175,11 @@ export default function astroJSX(): PluginObj {
const tagName = getTagName(parentNode);
if (!isComponent(tagName)) return;
if (!hasClientDirective(parentNode)) return;
const isClientOnly = isClientOnlyComponent(parentNode);
if (tagName === ClientOnlyPlaceholder) return;

const imports = state.get('imports') ?? new Map();
const namespace = getTagName(parentNode).split('.');
const namespace = tagName.split('.');
for (const [source, specs] of imports) {
for (const { imported, local } of specs) {
const reference = path.referencesImport(source, imported);
Expand All @@ -143,10 +193,38 @@ export default function astroJSX(): PluginObj {
}
}
}
// TODO: map unmatched identifiers back to imports if possible

const meta = path.getData('import');
if (meta) {
addClientMetadata(parentNode, 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);
}
} else {
throw new Error(
`Unable to match <${getTagName(
Expand Down
6 changes: 2 additions & 4 deletions packages/astro/src/jsx/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,8 @@ export async function renderToStaticMarkup(
}

const { result } = this;
try {
const html = await renderJSX(result, jsx(Component, { ...props, ...slots, children }));
return { html };
} catch (e) {}
const html = await renderJSX(result, jsx(Component, { ...props, ...slots, children }));
return { html };
}

export default {
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/runtime/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export function extractDirectives(inputProps: Record<string | number, any>): Ext
case 'client:component-hydration': {
break;
}
case 'client:display-name': {
break;
}
default: {
extracted.hydration.directive = key.split(':')[1];
extracted.hydration.value = value;
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/runtime/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ export function mergeSlots(...slotted: unknown[]) {
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[] {
const extname = componentUrl?.split('.').pop();
Expand Down
Loading

0 comments on commit f5d4ebf

Please sign in to comment.