forked from laptou/astro
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add *private* built-in JSX renderer (withastro#3697)
* 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
1 parent
67b5aa4
commit 908c263
Showing
22 changed files
with
685 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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!`); | ||
} | ||
}, | ||
} | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.