From dbe8d7e89ddfd0d277833c1d16463f5151ef1776 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Mon, 16 May 2022 10:44:06 -0500 Subject: [PATCH] wip: add @astrojs/jsx package --- packages/astro/package.json | 5 +- packages/astro/src/runtime/server/index.ts | 12 +- packages/astro/src/vite-plugin-jsx/index.ts | 1 + packages/integrations/jsx/package.json | 44 ++++++++ packages/integrations/jsx/src/babel/index.ts | 105 ++++++++++++++++++ packages/integrations/jsx/src/client.ts | 5 + packages/integrations/jsx/src/index.ts | 40 +++++++ .../integrations/jsx/src/jsx-runtime/index.ts | 24 ++++ packages/integrations/jsx/src/server.ts | 55 +++++++++ packages/integrations/jsx/tsconfig.json | 10 ++ 10 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 packages/integrations/jsx/package.json create mode 100644 packages/integrations/jsx/src/babel/index.ts create mode 100644 packages/integrations/jsx/src/client.ts create mode 100644 packages/integrations/jsx/src/index.ts create mode 100644 packages/integrations/jsx/src/jsx-runtime/index.ts create mode 100644 packages/integrations/jsx/src/server.ts create mode 100644 packages/integrations/jsx/tsconfig.json diff --git a/packages/astro/package.json b/packages/astro/package.json index 782f439154..e60c961991 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -20,6 +20,9 @@ ], "app/*": [ "./dist/types/core/app/*" + ], + "server": [ + "./dist/types/runtime/server/index" ] } }, @@ -28,7 +31,6 @@ "./env": "./env.d.ts", "./astro-jsx": "./astro-jsx.d.ts", "./config": "./config.mjs", - "./internal": "./internal.js", "./app": "./dist/core/app/index.js", "./app/node": "./dist/core/app/node.js", "./client/*": "./dist/runtime/client/*", @@ -38,6 +40,7 @@ "./internal/*": "./dist/runtime/server/*", "./package.json": "./package.json", "./runtime/*": "./dist/runtime/*", + "./server": "./dist/runtime/server/index.js", "./server/*": "./dist/runtime/server/*", "./vite-plugin-astro": "./dist/vite-plugin-astro/index.js", "./vite-plugin-astro/*": "./dist/vite-plugin-astro/*", diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 539bfad63b..a6c2e9d34b 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -21,11 +21,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 } 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; @@ -40,7 +40,7 @@ const svgEnumAttributes = /^(autoReverse|externalResourcesRequired|focusable|pre // INVESTIGATE: Can we have more specific types both for the argument and output? // If these are intentional, add comments that these are intention and why. // Or maybe type UserValue = any; ? -async function _render(child: any): Promise { +export async function _render(child: any): Promise { child = await child; if (child instanceof HTMLString) { return child; @@ -215,7 +215,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; } @@ -281,7 +281,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}! @@ -300,7 +300,7 @@ 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)); } } diff --git a/packages/astro/src/vite-plugin-jsx/index.ts b/packages/astro/src/vite-plugin-jsx/index.ts index 22df96cb48..f358868d67 100644 --- a/packages/astro/src/vite-plugin-jsx/index.ts +++ b/packages/astro/src/vite-plugin-jsx/index.ts @@ -17,6 +17,7 @@ const IMPORT_STATEMENTS: Record = { react: "import React from 'react'", preact: "import { h } from 'preact'", 'solid-js': "import 'solid-js/web'", + astro: "import '@astrojs/jsx'", }; // A code snippet to inject into JS files to prevent esbuild reference bugs. diff --git a/packages/integrations/jsx/package.json b/packages/integrations/jsx/package.json new file mode 100644 index 0000000000..6d6f5e86b4 --- /dev/null +++ b/packages/integrations/jsx/package.json @@ -0,0 +1,44 @@ +{ + "name": "@astrojs/jsx", + "description": "Use generic JSX components within Astro", + "version": "0.0.1", + "type": "module", + "types": "./dist/index.d.ts", + "author": "withastro", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/withastro/astro.git", + "directory": "packages/integrations/jsx" + }, + "keywords": [ + "astro-component", + "renderer", + "jsx" + ], + "bugs": "https://github.com/withastro/astro/issues", + "homepage": "https://astro.build", + "exports": { + ".": "./dist/index.js", + "./babel": "./dist/babel/index.js", + "./jsx-runtime": "./dist/jsx-runtime/index.js", + "./client.js": "./dist/client.js", + "./server.js": "./dist/server.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "astro-scripts build \"src/**/*.ts\" && tsc", + "build:ci": "astro-scripts build \"src/**/*.ts\"", + "dev": "astro-scripts dev \"src/**/*.ts\"" + }, + "dependencies": { + "astro": "workspace:*", + "@babel/plugin-transform-react-jsx": "^7.17.3" + }, + "devDependencies": { + "astro-scripts": "workspace:*" + }, + "engines": { + "node": "^14.15.0 || >=16.0.0" + } +} diff --git a/packages/integrations/jsx/src/babel/index.ts b/packages/integrations/jsx/src/babel/index.ts new file mode 100644 index 0000000000..6c24740594 --- /dev/null +++ b/packages/integrations/jsx/src/babel/index.ts @@ -0,0 +1,105 @@ +import * as t from "@babel/types"; +import type { PluginObj, NodePath } 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 addClientMetadata(node: t.JSXElement, meta: { path: string, name: string }) { + const componentPath = t.jsxAttribute( + t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-path')), + !meta.path.startsWith('.') ? t.stringLiteral(meta.path) : t.jsxExpressionContainer(t.memberExpression(t.newExpression(t.identifier('URL'), [t.stringLiteral(meta.path), t.identifier('import.meta.url')]), t.identifier('pathname'))), + ); + const componentExport = t.jsxAttribute( + t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-export')), + t.stringLiteral(meta.name), + ); + const staticMarker = t.jsxAttribute( + t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-hydration')), + ) + node.openingElement.attributes.push( + componentPath, + componentExport, + staticMarker + ) +} + +export default function astroJSX(): PluginObj { + return { + visitor: { + ImportDeclaration(path, state) { + const source = path.node.source.value; + if (source.startsWith('@astrojs/jsx')) 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; + + for (const [source, specs] of state.get('imports')) { + for (const { imported } of specs) { + const reference = path.referencesImport(source, imported); + if (reference) { + 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) + } + }, + } + }; +}; diff --git a/packages/integrations/jsx/src/client.ts b/packages/integrations/jsx/src/client.ts new file mode 100644 index 0000000000..39ca817605 --- /dev/null +++ b/packages/integrations/jsx/src/client.ts @@ -0,0 +1,5 @@ +import { h, render } from 'preact'; + +export default (element) => (Component, props, children) => { + throw new Error("Unable to hydrate Astro JSX!"); +} diff --git a/packages/integrations/jsx/src/index.ts b/packages/integrations/jsx/src/index.ts new file mode 100644 index 0000000000..7ce31d4a5d --- /dev/null +++ b/packages/integrations/jsx/src/index.ts @@ -0,0 +1,40 @@ +import { AstroIntegration } from 'astro'; + +function getRenderer() { + return { + name: '@astrojs/jsx', + clientEntrypoint: '@astrojs/jsx/client.js', + serverEntrypoint: '@astrojs/jsx/server.js', + jsxImportSource: '@astrojs/jsx', + jsxTransformOptions: async () => { + const { default: { default: jsx } } = await import('@babel/plugin-transform-react-jsx'); + const { default: astroJSX } = await import('./babel/index.js'); + return { + plugins: [astroJSX(), jsx({ }, { throwIfNamespace: false, runtime: 'automatic', importSource: '@astrojs/jsx' })], + }; + }, + }; +} + +function getViteConfiguration() { + return { + optimizeDeps: { + include: ['@astrojs/jsx/client.js'], + exclude: ['@astrojs/jsx/server.js'], + }, + }; +} + +export default function (): AstroIntegration { + return { + name: '@astrojs/jsx', + hooks: { + 'astro:config:setup': ({ addRenderer, updateConfig }) => { + addRenderer(getRenderer()); + updateConfig({ + vite: getViteConfiguration(), + }); + }, + }, + }; +} diff --git a/packages/integrations/jsx/src/jsx-runtime/index.ts b/packages/integrations/jsx/src/jsx-runtime/index.ts new file mode 100644 index 0000000000..a1e15887c5 --- /dev/null +++ b/packages/integrations/jsx/src/jsx-runtime/index.ts @@ -0,0 +1,24 @@ +import { Fragment } from 'astro/server'; + +const AstroJSX = Symbol('@astrojs/jsx'); + +function createVNode(type: any, props: Record, key?: string, __self?: string, __source?: string) { + const vnode = { + [AstroJSX]: true, + type, + props: props ?? {}, + key, + __source, + __self, + }; + return vnode; +} + + +export { + AstroJSX, + createVNode as jsx, + createVNode as jsxs, + createVNode as jsxDEV, + Fragment +} diff --git a/packages/integrations/jsx/src/server.ts b/packages/integrations/jsx/src/server.ts new file mode 100644 index 0000000000..82ee285e8c --- /dev/null +++ b/packages/integrations/jsx/src/server.ts @@ -0,0 +1,55 @@ +import { Fragment, renderComponent, spreadAttributes, markHTMLString, voidElementNames } from 'astro/server'; +import { AstroJSX, jsx } from './jsx-runtime'; + +async function render(result: any, vnode: any): Promise { + switch (true) { + case (typeof vnode === 'string'): return markHTMLString(vnode); + case (vnode.type === Fragment): return render(result, vnode.props.children); + case (Array.isArray(vnode)): return markHTMLString((await Promise.all(vnode.map((v: any) => render(result, v)))).join('')); + } + if (vnode[AstroJSX]) { + if (!vnode.type && vnode.type !== 0) return ''; + if (typeof vnode.type === 'string') { + return await renderElement(result, vnode.type, vnode.props ?? {}); + } + if (typeof vnode.type === 'function') { + try { + const output = await vnode.type(vnode.props ?? {}); + return await render(result, output); + } catch (e) {} + } + } + return markHTMLString(await renderComponent(result, vnode.type.name, vnode.type, vnode.props ?? {})); +} + +async function renderElement(result: any, tag: string, { children, ...props }: Record) { + return markHTMLString(`<${tag}${spreadAttributes(props)}${markHTMLString( + (children == null || children == '') && voidElementNames.test(tag) + ? `/>` + : `>${children == null ? '' : await render(result, children)}` + )}`); +} + +export async function check(Component, props, children) { + if (typeof Component !== 'function') return false; + try { + const result = await Component({ ...props, children }); + return result[AstroJSX]; + } catch (e) {}; + return false; +} + +export async function renderToStaticMarkup(this: any, Component, props = {}, children = null) { + const { result } = this; + try { + const html = await render(result, jsx(Component, { children, ...props })); + return { html }; + } catch (e) { + console.log(e); + } +} + +export default { + check, + renderToStaticMarkup, +}; diff --git a/packages/integrations/jsx/tsconfig.json b/packages/integrations/jsx/tsconfig.json new file mode 100644 index 0000000000..44baf375c8 --- /dev/null +++ b/packages/integrations/jsx/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "allowJs": true, + "module": "ES2020", + "outDir": "./dist", + "target": "ES2020" + } +}