From 908c2638cbe0ba9a21571275d8d8cf975f528c8c Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 24 Jun 2022 15:11:17 -0500 Subject: [PATCH] Add *private* built-in JSX renderer (#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 --- packages/astro/package.json | 3 + packages/astro/src/core/config.ts | 11 +- packages/astro/src/jsx-runtime/index.ts | 76 ++++++++++ packages/astro/src/jsx/babel.ts | 131 ++++++++++++++++++ packages/astro/src/jsx/renderer.ts | 15 ++ packages/astro/src/jsx/server.ts | 37 +++++ packages/astro/src/runtime/server/index.ts | 18 ++- packages/astro/src/runtime/server/jsx.ts | 63 +++++++++ packages/astro/src/vite-plugin-jsx/index.ts | 5 +- .../astro/test/fixtures/jsx/astro.config.mjs | 25 ++++ packages/astro/test/fixtures/jsx/package.json | 21 +++ .../jsx/src/components/Frameworks.jsx | 28 ++++ .../jsx/src/components/PreactCounter.tsx | 20 +++ .../jsx/src/components/ReactCounter.jsx | 19 +++ .../jsx/src/components/SolidCounter.jsx | 19 +++ .../jsx/src/components/SvelteCounter.svelte | 21 +++ .../test/fixtures/jsx/src/components/Test.jsx | 5 + .../jsx/src/components/VueCounter.vue | 27 ++++ .../fixtures/jsx/src/pages/component.astro | 6 + .../fixtures/jsx/src/pages/frameworks.astro | 13 ++ packages/astro/test/jsx.test.js | 62 +++++++++ pnpm-lock.yaml | 70 ++++++++++ 22 files changed, 685 insertions(+), 10 deletions(-) create mode 100644 packages/astro/src/jsx-runtime/index.ts create mode 100644 packages/astro/src/jsx/babel.ts create mode 100644 packages/astro/src/jsx/renderer.ts create mode 100644 packages/astro/src/jsx/server.ts create mode 100644 packages/astro/src/runtime/server/jsx.ts create mode 100644 packages/astro/test/fixtures/jsx/astro.config.mjs create mode 100644 packages/astro/test/fixtures/jsx/package.json create mode 100644 packages/astro/test/fixtures/jsx/src/components/Frameworks.jsx create mode 100644 packages/astro/test/fixtures/jsx/src/components/PreactCounter.tsx create mode 100644 packages/astro/test/fixtures/jsx/src/components/ReactCounter.jsx create mode 100644 packages/astro/test/fixtures/jsx/src/components/SolidCounter.jsx create mode 100644 packages/astro/test/fixtures/jsx/src/components/SvelteCounter.svelte create mode 100644 packages/astro/test/fixtures/jsx/src/components/Test.jsx create mode 100644 packages/astro/test/fixtures/jsx/src/components/VueCounter.vue create mode 100644 packages/astro/test/fixtures/jsx/src/pages/component.astro create mode 100644 packages/astro/test/fixtures/jsx/src/pages/frameworks.astro create mode 100644 packages/astro/test/jsx.test.js diff --git a/packages/astro/package.json b/packages/astro/package.json index 49ddc48b0a3b..256b299c642f 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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", @@ -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", diff --git a/packages/astro/src/core/config.ts b/packages/astro/src/core/config.ts index 44ec20912d60..f4e097ff6a7d 100644 --- a/packages/astro/src/core/config.ts +++ b/packages/astro/src/core/config.ts @@ -51,7 +51,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = { vite: {}, experimental: { ssr: false, - integrations: false, + integrations: false }, }; @@ -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 && diff --git a/packages/astro/src/jsx-runtime/index.ts b/packages/astro/src/jsx-runtime/index.ts new file mode 100644 index 000000000000..ee8660742b35 --- /dev/null +++ b/packages/astro/src/jsx-runtime/index.ts @@ -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; +} + +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 = {}; + 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) { + 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 +} diff --git a/packages/astro/src/jsx/babel.ts b/packages/astro/src/jsx/babel.ts new file mode 100644 index 000000000000..33bd8652beee --- /dev/null +++ b/packages/astro/src/jsx/babel.ts @@ -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!`); + } + }, + } + }; +}; diff --git a/packages/astro/src/jsx/renderer.ts b/packages/astro/src/jsx/renderer.ts new file mode 100644 index 000000000000..94a63b5fe4c7 --- /dev/null +++ b/packages/astro/src/jsx/renderer.ts @@ -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; diff --git a/packages/astro/src/jsx/server.ts b/packages/astro/src/jsx/server.ts new file mode 100644 index 000000000000..c75135b90c54 --- /dev/null +++ b/packages/astro/src/jsx/server.ts @@ -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 = {}; + 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 = {}; + 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, +}; diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index c55328c16189..33d41ef5d08a 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -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; @@ -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; } @@ -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}! @@ -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') { @@ -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, '')); @@ -496,7 +500,7 @@ function internalSpreadAttributes(values: Record, shouldEscape = true) // Adds support for ` export function spreadAttributes( values: Record, - name: string, + name?: string, { class: scopedClassName }: { class?: string } = {} ) { let output = ''; diff --git a/packages/astro/src/runtime/server/jsx.ts b/packages/astro/src/runtime/server/jsx.ts new file mode 100644 index 000000000000..dc4df923a878 --- /dev/null +++ b/packages/astro/src/runtime/server/jsx.ts @@ -0,0 +1,63 @@ +import { HTMLString, markHTMLString, escapeHTML, Fragment, renderComponent, spreadAttributes, voidElementNames } from './index.js'; +import { AstroJSX, isVNode } from '../../jsx-runtime/index.js'; + +export async function renderJSX(result: any, vnode: any): Promise { + switch (true) { + case (vnode instanceof HTMLString): return vnode; + case (typeof vnode === 'string'): return markHTMLString(escapeHTML(vnode)); + case (!vnode && vnode !== 0): return ''; + case (vnode.type === Fragment): return renderJSX(result, vnode.props.children); + case (Array.isArray(vnode)): return markHTMLString((await Promise.all(vnode.map((v: any) => renderJSX(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 (!!vnode.type) { + try { + // TODO: silence Invalid hook call warning from React + const output = await vnode.type(vnode.props ?? {}); + if (output && output[AstroJSX]) { + return await renderJSX(result, output); + } else if (!output) { + return await renderJSX(result, output); + } + } catch (e) {} + + const { children = null, ...props } = vnode.props ?? {}; + const slots: Record = { + default: [] + } + function extractSlots(child: any): any { + if (Array.isArray(child)) { + return child.map(c => extractSlots(c)); + } + if (!isVNode(child)) { + return slots.default.push(child); + } + if ('slot' in child.props) { + slots[child.props.slot] = [...(slots[child.props.slot] ?? []), child] + delete child.props.slot; + return; + } + slots.default.push(child); + } + extractSlots(children); + for (const [key, value] of Object.entries(slots)) { + slots[key] = () => renderJSX(result, value); + } + return markHTMLString(await renderComponent(result, vnode.type.name, vnode.type, props, slots)); + } + } + // numbers, plain objects, etc + return markHTMLString(`${vnode}`); +} + +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 renderJSX(result, children)}` + )}`); +} diff --git a/packages/astro/src/vite-plugin-jsx/index.ts b/packages/astro/src/vite-plugin-jsx/index.ts index 22df96cb480e..421c3622c12a 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 'astro/jsx-runtime'", }; // A code snippet to inject into JS files to prevent esbuild reference bugs. @@ -167,9 +168,9 @@ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin if (!importSource) { const multiline = code.match(/\/\*\*[\S\s]*\*\//gm) || []; for (const comment of multiline) { - const [_, lib] = comment.match(/@jsxImportSource\s*(\S+)/) || []; + const [_, lib] = comment.slice(0, -2).match(/@jsxImportSource\s*(\S+)/) || []; if (lib) { - importSource = lib; + importSource = lib.trim(); break; } } diff --git a/packages/astro/test/fixtures/jsx/astro.config.mjs b/packages/astro/test/fixtures/jsx/astro.config.mjs new file mode 100644 index 000000000000..5b84d23a8f20 --- /dev/null +++ b/packages/astro/test/fixtures/jsx/astro.config.mjs @@ -0,0 +1,25 @@ +import { defineConfig } from 'astro/config'; +import renderer from 'astro/jsx/renderer.js'; +import preact from '@astrojs/preact'; +import react from '@astrojs/react'; +import svelte from '@astrojs/svelte'; +import vue from '@astrojs/vue'; +import solid from '@astrojs/solid-js'; + +export default defineConfig({ + integrations: [ + { + name: '@astrojs/test-jsx', + hooks: { + 'astro:config:setup': ({ addRenderer }) => { + addRenderer(renderer); + } + } + }, + preact(), + react(), + svelte(), + vue(), + solid(), + ] +}) diff --git a/packages/astro/test/fixtures/jsx/package.json b/packages/astro/test/fixtures/jsx/package.json new file mode 100644 index 000000000000..eb2c75d6931a --- /dev/null +++ b/packages/astro/test/fixtures/jsx/package.json @@ -0,0 +1,21 @@ +{ + "name": "@test/jsx", + "version": "0.0.0", + "private": true, + "devDependencies": { + "@astrojs/preact": "workspace:*", + "@astrojs/react": "workspace:*", + "@astrojs/solid-js": "workspace:*", + "@astrojs/svelte": "workspace:*", + "@astrojs/vue": "workspace:*", + "astro": "workspace:*" + }, + "dependencies": { + "preact": "^10.7.3", + "react": "^18.1.0", + "react-dom": "^18.1.0", + "solid-js": "^1.4.3", + "svelte": "^3.48.0", + "vue": "^3.2.36" + } +} diff --git a/packages/astro/test/fixtures/jsx/src/components/Frameworks.jsx b/packages/astro/test/fixtures/jsx/src/components/Frameworks.jsx new file mode 100644 index 000000000000..2cc17596457c --- /dev/null +++ b/packages/astro/test/fixtures/jsx/src/components/Frameworks.jsx @@ -0,0 +1,28 @@ +import 'astro/jsx-runtime'; +import { Test } from "./Test"; + +import PreactCounter from "./PreactCounter"; +import ReactCounter from "./ReactCounter"; +import SolidCounter from "./SolidCounter"; +import SvelteCounter from "./SvelteCounter.svelte"; +import VueCounter from "./VueCounter.vue"; + +export function Preact() { + return +} + +export function React() { + return +} + +export function Solid() { + return +} + +export function Svelte() { + return +} + +export function Vue() { + return +} diff --git a/packages/astro/test/fixtures/jsx/src/components/PreactCounter.tsx b/packages/astro/test/fixtures/jsx/src/components/PreactCounter.tsx new file mode 100644 index 000000000000..cdb368377d19 --- /dev/null +++ b/packages/astro/test/fixtures/jsx/src/components/PreactCounter.tsx @@ -0,0 +1,20 @@ +import { h, Fragment } from 'preact'; +import { useState } from 'preact/hooks'; + +/** a counter written in Preact */ +export default function PreactCounter() { + const [count, setCount] = useState(0); + const add = () => setCount((i) => i + 1); + const subtract = () => setCount((i) => i - 1); + + return ( +
+
+ +
{count}
+ +
+
Preact
+
+ ); +} diff --git a/packages/astro/test/fixtures/jsx/src/components/ReactCounter.jsx b/packages/astro/test/fixtures/jsx/src/components/ReactCounter.jsx new file mode 100644 index 000000000000..5c5a001e8863 --- /dev/null +++ b/packages/astro/test/fixtures/jsx/src/components/ReactCounter.jsx @@ -0,0 +1,19 @@ +import React, { useState } from 'react'; + +/** a counter written in React */ +export default function ReactCounter() { + const [count, setCount] = useState(0); + const add = () => setCount((i) => i + 1); + const subtract = () => setCount((i) => i - 1); + + return ( +
+
+ +
{count}
+ +
+
React
+
+ ); +} diff --git a/packages/astro/test/fixtures/jsx/src/components/SolidCounter.jsx b/packages/astro/test/fixtures/jsx/src/components/SolidCounter.jsx new file mode 100644 index 000000000000..9cfd85d02f3c --- /dev/null +++ b/packages/astro/test/fixtures/jsx/src/components/SolidCounter.jsx @@ -0,0 +1,19 @@ +import { createSignal } from 'solid-js'; + +/** a counter written with Solid */ +export default function SolidCounter() { + const [count, setCount] = createSignal(0); + const add = () => setCount(count() + 1); + const subtract = () => setCount(count() - 1); + + return ( +
+
+ +
{count()}
+ +
+
Solid
+
+ ); +} diff --git a/packages/astro/test/fixtures/jsx/src/components/SvelteCounter.svelte b/packages/astro/test/fixtures/jsx/src/components/SvelteCounter.svelte new file mode 100644 index 000000000000..3d6f1b2bd2be --- /dev/null +++ b/packages/astro/test/fixtures/jsx/src/components/SvelteCounter.svelte @@ -0,0 +1,21 @@ + + + +
+
+ +
{count}
+ +
+
Svelte
+
diff --git a/packages/astro/test/fixtures/jsx/src/components/Test.jsx b/packages/astro/test/fixtures/jsx/src/components/Test.jsx new file mode 100644 index 000000000000..007c8f6172cd --- /dev/null +++ b/packages/astro/test/fixtures/jsx/src/components/Test.jsx @@ -0,0 +1,5 @@ +import 'astro'; + +export function Test({ case: id, ...slots }) { + return
{Object.values(slots)}
+} diff --git a/packages/astro/test/fixtures/jsx/src/components/VueCounter.vue b/packages/astro/test/fixtures/jsx/src/components/VueCounter.vue new file mode 100644 index 000000000000..f7492911ec47 --- /dev/null +++ b/packages/astro/test/fixtures/jsx/src/components/VueCounter.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/astro/test/fixtures/jsx/src/pages/component.astro b/packages/astro/test/fixtures/jsx/src/pages/component.astro new file mode 100644 index 000000000000..7ee06da8463f --- /dev/null +++ b/packages/astro/test/fixtures/jsx/src/pages/component.astro @@ -0,0 +1,6 @@ +--- +import { Test } from '../components/Test' +--- + +Basic +Named diff --git a/packages/astro/test/fixtures/jsx/src/pages/frameworks.astro b/packages/astro/test/fixtures/jsx/src/pages/frameworks.astro new file mode 100644 index 000000000000..ede0f542c3cd --- /dev/null +++ b/packages/astro/test/fixtures/jsx/src/pages/frameworks.astro @@ -0,0 +1,13 @@ +--- +import * as Framework from '../components/Frameworks' +--- + + + + + + + + + + diff --git a/packages/astro/test/jsx.test.js b/packages/astro/test/jsx.test.js new file mode 100644 index 000000000000..bf05d35c0af4 --- /dev/null +++ b/packages/astro/test/jsx.test.js @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; + +describe('jsx-runtime', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/jsx/' + }); + await fixture.build(); + }); + + it('Can load simple JSX components', async () => { + const html = await fixture.readFile('/component/index.html'); + const $ = cheerio.load(html); + + expect($('#basic').text()).to.equal('Basic'); + expect($('#named').text()).to.equal('Named'); + }); + + it('Can load Preact component inside Astro JSX', async () => { + const html = await fixture.readFile('/frameworks/index.html'); + const $ = cheerio.load(html); + + expect($('#has-preact #preact').length).to.equal(1); + expect($('#preact').text()).to.include('Preact'); + }); + + it('Can load React component inside Astro JSX', async () => { + const html = await fixture.readFile('/frameworks/index.html'); + const $ = cheerio.load(html); + + expect($('#has-react #react').length).to.equal(1); + expect($('#react').text()).to.include('React'); + }); + + it('Can load Solid component inside Astro JSX', async () => { + const html = await fixture.readFile('/frameworks/index.html'); + const $ = cheerio.load(html); + + expect($('#has-solid #solid').length).to.equal(1); + expect($('#solid').text()).to.include('Solid'); + }); + + it('Can load Svelte component inside Astro JSX', async () => { + const html = await fixture.readFile('/frameworks/index.html'); + const $ = cheerio.load(html); + + expect($('#has-svelte #svelte').length).to.equal(1); + expect($('#svelte').text()).to.include('Svelte'); + }); + + it('Can load Vue component inside Astro JSX', async () => { + const html = await fixture.readFile('/frameworks/index.html'); + const $ = cheerio.load(html); + + expect($('#has-vue #vue').length).to.equal(1); + expect($('#vue').text()).to.include('Vue'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ec530eb66b0..8c22fd695281 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -472,6 +472,7 @@ importers: '@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 '@babel/types': ^7.18.4 '@playwright/test': ^1.22.2 @@ -555,6 +556,7 @@ importers: '@babel/core': 7.18.2 '@babel/generator': 7.18.2 '@babel/parser': 7.18.4 + '@babel/plugin-transform-react-jsx': 7.17.12_@babel+core@7.18.2 '@babel/traverse': 7.18.2 '@proload/core': 0.3.2 '@proload/plugin-tsm': 0.2.1_@proload+core@0.3.2 @@ -1467,6 +1469,35 @@ importers: dependencies: astro: link:../../.. + packages/astro/test/fixtures/jsx: + specifiers: + '@astrojs/preact': workspace:* + '@astrojs/react': workspace:* + '@astrojs/solid-js': workspace:* + '@astrojs/svelte': workspace:* + '@astrojs/vue': workspace:* + astro: workspace:* + preact: ^10.7.3 + react: ^18.1.0 + react-dom: ^18.1.0 + solid-js: ^1.4.3 + svelte: ^3.48.0 + vue: ^3.2.36 + dependencies: + preact: 10.7.3 + react: 18.1.0 + react-dom: 18.1.0_react@18.1.0 + solid-js: 1.4.3 + svelte: 3.48.0 + vue: 3.2.37 + devDependencies: + '@astrojs/preact': link:../../../../integrations/preact + '@astrojs/react': link:../../../../integrations/react + '@astrojs/solid-js': link:../../../../integrations/solid + '@astrojs/svelte': link:../../../../integrations/svelte + '@astrojs/vue': link:../../../../integrations/vue + astro: link:../../.. + packages/astro/test/fixtures/legacy-build: specifiers: '@astrojs/vue': workspace:* @@ -3131,6 +3162,19 @@ packages: '@babel/helper-plugin-utils': 7.17.12 dev: false + /@babel/plugin-syntax-jsx/7.17.12_@babel+core@7.18.2: + resolution: {integrity: sha512-spyY3E3AURfxh/RHtjx5j6hs8am5NbUBGfcZ2vB3uShSpZdQyXSf5rR5Mk76vbtlAZOelyVQ71Fg0x9SG4fsog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + dependencies: + '@babel/core': 7.18.2 + '@babel/helper-plugin-utils': 7.17.12 + dev: false + /@babel/plugin-syntax-logical-assignment-operators/7.10.4_@babel+core@7.18.2: resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} peerDependencies: @@ -3583,6 +3627,23 @@ packages: '@babel/types': 7.18.4 dev: false + /@babel/plugin-transform-react-jsx/7.17.12_@babel+core@7.18.2: + resolution: {integrity: sha512-Lcaw8bxd1DKht3thfD4A12dqo1X16he1Lm8rIv8sTwjAYNInRS1qHa9aJoqvzpscItXvftKDCfaEQzwoVyXpEQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + dependencies: + '@babel/core': 7.18.2 + '@babel/helper-annotate-as-pure': 7.16.7 + '@babel/helper-module-imports': 7.16.7 + '@babel/helper-plugin-utils': 7.17.12 + '@babel/plugin-syntax-jsx': 7.17.12_@babel+core@7.18.2 + '@babel/types': 7.18.4 + dev: false + /@babel/plugin-transform-regenerator/7.18.0_@babel+core@7.18.2: resolution: {integrity: sha512-C8YdRw9uzx25HSIzwA7EM7YP0FhCe5wNvJbZzjVNHHPGVcDJ3Aie+qGYYdS1oVQgn+B3eAIJbWFLrJ4Jipv7nw==} engines: {node: '>=6.9.0'} @@ -8485,6 +8546,11 @@ packages: /debug/3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true dependencies: ms: 2.1.3 dev: false @@ -11366,6 +11432,8 @@ packages: debug: 3.2.7 iconv-lite: 0.4.24 sax: 1.2.4 + transitivePeerDependencies: + - supports-color dev: false /netmask/2.0.2: @@ -11449,6 +11517,8 @@ packages: rimraf: 2.7.1 semver: 5.7.1 tar: 4.4.19 + transitivePeerDependencies: + - supports-color dev: false /node-releases/2.0.5: