Skip to content

Commit

Permalink
Add *private* built-in JSX renderer (withastro#3697)
Browse files Browse the repository at this point in the history
* feat: add private `addPageExtensions` hook

* feat: experimental JSX support

* chore: remove experimental.jsx option from config

* chore: remove automatic astro JSX runtime detection

* fix: throw warning when client:* directive is used but no client entrypoint is found

* feat: add slot support to renderer

* chore: remove client entrypoint from jsx renderer

* test: add barebones JSX test

* test: add frameworks-in-jsx test

* feat: improve error message when no matching import is found

* feat: support slots

* fix: do not strip `astro-slot` when using JSX renderer

* fix: handle null values in isVNode

* fix: do not transform slots for elements

Co-authored-by: Nate Moore <nate@astro.build>
  • Loading branch information
natemoo-re and natemoo-re authored Jun 24, 2022
1 parent 67b5aa4 commit 908c263
Show file tree
Hide file tree
Showing 22 changed files with 685 additions and 10 deletions.
3 changes: 3 additions & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
".": "./astro.js",
"./env": "./env.d.ts",
"./astro-jsx": "./astro-jsx.d.ts",
"./jsx/*": "./dist/jsx/*",
"./jsx-runtime": "./dist/jsx-runtime/index.js",
"./config": "./config.mjs",
"./internal": "./internal.js",
"./app": "./dist/core/app/index.js",
Expand Down Expand Up @@ -87,6 +89,7 @@
"@babel/core": "^7.18.2",
"@babel/generator": "^7.18.2",
"@babel/parser": "^7.18.4",
"@babel/plugin-transform-react-jsx": "^7.17.12",
"@babel/traverse": "^7.18.2",
"@proload/core": "^0.3.2",
"@proload/plugin-tsm": "^0.2.1",
Expand Down
11 changes: 10 additions & 1 deletion packages/astro/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
vite: {},
experimental: {
ssr: false,
integrations: false,
integrations: false
},
};

Expand Down Expand Up @@ -346,6 +346,15 @@ export async function validateConfig(
adapter: undefined,
},
};
if (
// TODO: expose @astrojs/mdx package
result.integrations.find(integration => integration.name === '@astrojs/mdx')
) {
// Enable default JSX integration
const { default: jsxRenderer } = await import('../jsx/renderer.js');
(result._ctx.renderers as any[]).push(jsxRenderer);
}

// Final-Pass Validation (perform checks that require the full config object)
if (
!result.experimental?.integrations &&
Expand Down
76 changes: 76 additions & 0 deletions packages/astro/src/jsx-runtime/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Fragment, markHTMLString } from '../runtime/server/index.js';

const AstroJSX = Symbol('@astrojs/jsx');
const Empty = Symbol('empty');

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

const toSlotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());

export function isVNode(vnode: any): vnode is AstroVNode {
return vnode && typeof vnode === 'object' && vnode[AstroJSX];
}

export function transformSlots(vnode: AstroVNode) {
if (typeof vnode.type === 'string') return vnode;
if (!Array.isArray(vnode.props.children)) return;
const slots: Record<string, any> = {};
vnode.props.children = vnode.props.children.map(child => {
if (!isVNode(child)) return child;
if (!('slot' in child.props)) return child;
const name = toSlotName(child.props.slot)
if (Array.isArray(slots[name])) {
slots[name].push(child);
} else {
slots[name] = [child];
}
delete child.props.slot;
return Empty;
}).filter(v => v !== Empty);
Object.assign(vnode.props, slots);
}

function markRawChildren(child: any): any {
if (typeof child === 'string') return markHTMLString(child);
if (Array.isArray(child)) return child.map(c => markRawChildren(c));
return child;
}

function transformSetDirectives(vnode: AstroVNode) {
if (!('set:html' in vnode.props || 'set:text' in vnode.props)) return;
if ('set:html' in vnode.props) {
const children = markRawChildren(vnode.props['set:html']);
delete vnode.props['set:html'];
Object.assign(vnode.props, { children });
return;
}
if ('set:text' in vnode.props) {
const children = vnode.props['set:text'];
delete vnode.props['set:text'];
Object.assign(vnode.props, { children });
return;
}
}

function createVNode(type: any, props: Record<string, any>) {
const vnode: AstroVNode = {
[AstroJSX]: true,
type,
props: props ?? {},
};
transformSetDirectives(vnode);
transformSlots(vnode);
return vnode;
}

export {
AstroJSX,
createVNode as jsx,
createVNode as jsxs,
createVNode as jsxDEV,
Fragment
}
131 changes: 131 additions & 0 deletions packages/astro/src/jsx/babel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import * as t from "@babel/types";
import type { PluginObj } from '@babel/core';

function isComponent(tagName: string) {
return (
(tagName[0] && tagName[0].toLowerCase() !== tagName[0]) ||
tagName.includes(".") ||
/[^a-zA-Z]/.test(tagName[0])
);
}

function hasClientDirective(node: t.JSXElement) {
for (const attr of node.openingElement.attributes) {
if (attr.type === 'JSXAttribute' && attr.name.type === 'JSXNamespacedName') {
return attr.name.namespace.name === 'client'
}
}
return false;
}

function getTagName(tag: t.JSXElement) {
const jsxName = tag.openingElement.name;
return jsxElementNameToString(jsxName);
}

function jsxElementNameToString(node: t.JSXOpeningElement['name']): string {
if (t.isJSXMemberExpression(node)) {
return `${jsxElementNameToString(node.object)}.${node.property.name}`;
}
if (t.isJSXIdentifier(node) || t.isIdentifier(node)) {
return node.name;
}
return `${node.namespace.name}:${node.name.name}`;
}

function jsxAttributeToString(attr: t.JSXAttribute): string {
if (t.isJSXNamespacedName(attr.name)) {
return `${attr.name.namespace.name}:${attr.name.name.name}`
}
return `${attr.name.name}`;
}

function addClientMetadata(node: t.JSXElement, meta: { 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')))),
);
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);
}
}

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'))));
},
ImportDeclaration(path, state) {
const source = path.node.source.value;
if (source.startsWith('astro/jsx-runtime')) return;
const specs = path.node.specifiers.map(spec => {
if (t.isImportDefaultSpecifier(spec)) return { local: spec.local.name, imported: 'default' }
if (t.isImportNamespaceSpecifier(spec)) return { local: spec.local.name, imported: '*' }
if (t.isIdentifier(spec.imported)) return { local: spec.local.name, imported: spec.imported.name };
return { local: spec.local.name, imported: spec.imported.value };
});
const imports = state.get('imports') ?? new Map();
for (const spec of specs) {
if (imports.has(source)) {
const existing = imports.get(source);
existing.add(spec);
imports.set(source, existing)
} else {
imports.set(source, new Set([spec]))
}
}
state.set('imports', imports);
},
JSXIdentifier(path, state) {
const isAttr = path.findParent(n => t.isJSXAttribute(n));
if (isAttr) return;
const parent = path.findParent(n => t.isJSXElement(n))!;
const parentNode = parent.node as t.JSXElement;
const tagName = getTagName(parentNode);
if (!isComponent(tagName)) return;
if (!hasClientDirective(parentNode)) return;

const imports = state.get('imports') ?? new Map();
const namespace = getTagName(parentNode).split('.');
for (const [source, specs] of imports) {
for (const { imported, local } of specs) {
const reference = path.referencesImport(source, imported);
if (reference) {
path.setData('import', { name: imported, path: source });
break;
}
if (namespace.at(0) === local) {
path.setData('import', { name: imported, path: source });
break;
}
}
}
// TODO: map unmatched identifiers back to imports if possible
const meta = path.getData('import');
if (meta) {
addClientMetadata(parentNode, meta)
} else {
throw new Error(`Unable to match <${getTagName(parentNode)}> with client:* directive to an import statement!`);
}
},
}
};
};
15 changes: 15 additions & 0 deletions packages/astro/src/jsx/renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const renderer = {
name: 'astro:jsx',
serverEntrypoint: 'astro/jsx/server.js',
jsxImportSource: 'astro',
jsxTransformOptions: async () => {
// @ts-ignore
const { default: { default: jsx } } = await import('@babel/plugin-transform-react-jsx');
const { default: astroJSX } = await import('./babel.js');
return {
plugins: [astroJSX(), jsx({}, { throwIfNamespace: false, runtime: 'automatic', importSource: 'astro' })],
};
},
}

export default renderer;
37 changes: 37 additions & 0 deletions packages/astro/src/jsx/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { renderJSX } from '../runtime/server/jsx.js';
import { AstroJSX, jsx } from '../jsx-runtime/index.js';

const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());

export async function check(Component: any, props: any, { default: children = null, ...slotted } = {}) {
if (typeof Component !== 'function') return false;
const slots: Record<string, any> = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = value;
}
try {
const result = await Component({ ...props, ...slots, children });
return result[AstroJSX];
} catch (e) {};
return false;
}

export async function renderToStaticMarkup(this: any, Component: any, props = {}, { default: children = null, ...slotted } = {}) {
const slots: Record<string, any> = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = value;
}

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

export default {
check,
renderToStaticMarkup,
};
18 changes: 11 additions & 7 deletions packages/astro/src/runtime/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ import { serializeProps } from './serialize.js';
import { shorthash } from './shorthash.js';
import { serializeListValue } from './util.js';

export { markHTMLString, markHTMLString as unescapeHTML } from './escape.js';
export { markHTMLString, markHTMLString as unescapeHTML, HTMLString, escapeHTML } from './escape.js';
export type { Metadata } from './metadata';
export { createMetadata } from './metadata.js';

const voidElementNames =
export const voidElementNames =
/^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i;
const htmlBooleanAttributes =
/^(allowfullscreen|async|autofocus|autoplay|controls|default|defer|disabled|disablepictureinpicture|disableremoteplayback|formnovalidate|hidden|loop|nomodule|novalidate|open|playsinline|readonly|required|reversed|scoped|seamless|itemscope)$/i;
Expand Down Expand Up @@ -233,7 +233,7 @@ Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`')
let error;
for (const r of renderers) {
try {
if (await r.ssr.check(Component, props, children)) {
if (await r.ssr.check.call({ result }, Component, props, children)) {
renderer = r;
break;
}
Expand Down Expand Up @@ -299,7 +299,7 @@ Did you mean to enable ${formatList(probableRendererNames.map((r) => '`' + r + '
// We already know that renderer.ssr.check() has failed
// but this will throw a much more descriptive error!
renderer = matchingRenderers[0];
({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children, metadata));
({ html } = await renderer.ssr.renderToStaticMarkup.call({ result }, Component, props, children, metadata));
} else {
throw new Error(`Unable to render ${metadata.displayName}!
Expand All @@ -318,10 +318,14 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
if (metadata.hydrate === 'only') {
html = await renderSlot(result, slots?.fallback);
} else {
({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children, metadata));
({ html } = await renderer.ssr.renderToStaticMarkup.call({ result }, Component, props, children, metadata));
}
}

if (renderer && !renderer.clientEntrypoint && metadata.hydrate) {
throw new Error(`${metadata.displayName} component has a \`client:${metadata.hydrate}\` directive, but no client entrypoint was provided by ${renderer.name}!`);
}

// This is a custom element without a renderer. Because of that, render it
// as a string and the user is responsible for adding a script tag for the component definition.
if (!html && typeof Component === 'string') {
Expand All @@ -340,7 +344,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
}

if (!hydration) {
if (isPage) {
if (isPage || renderer?.name === 'astro:jsx') {
return html;
}
return markHTMLString(html.replace(/\<\/?astro-slot\>/g, ''));
Expand Down Expand Up @@ -496,7 +500,7 @@ function internalSpreadAttributes(values: Record<any, any>, shouldEscape = true)
// Adds support for `<Component {...value} />
export function spreadAttributes(
values: Record<any, any>,
name: string,
name?: string,
{ class: scopedClassName }: { class?: string } = {}
) {
let output = '';
Expand Down
Loading

0 comments on commit 908c263

Please sign in to comment.