wip: add @astrojs/jsx package

This commit is contained in:
Nate Moore 2022-05-16 10:44:06 -05:00 committed by Nate Moore
parent 8ce5b824ab
commit dbe8d7e89d
10 changed files with 294 additions and 7 deletions

View file

@ -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/*",

View file

@ -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<any> {
export async function _render(child: any): Promise<any> {
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));
}
}

View file

@ -17,6 +17,7 @@ const IMPORT_STATEMENTS: Record<string, string> = {
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.

View file

@ -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"
}
}

View file

@ -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)
}
},
}
};
};

View file

@ -0,0 +1,5 @@
import { h, render } from 'preact';
export default (element) => (Component, props, children) => {
throw new Error("Unable to hydrate Astro JSX!");
}

View file

@ -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(),
});
},
},
};
}

View file

@ -0,0 +1,24 @@
import { Fragment } from 'astro/server';
const AstroJSX = Symbol('@astrojs/jsx');
function createVNode(type: any, props: Record<string, any>, 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
}

View file

@ -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<any> {
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<string, any>) {
return markHTMLString(`<${tag}${spreadAttributes(props)}${markHTMLString(
(children == null || children == '') && voidElementNames.test(tag)
? `/>`
: `>${children == null ? '' : await render(result, children)}</${tag}>`
)}`);
}
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,
};

View file

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"allowJs": true,
"module": "ES2020",
"outDir": "./dist",
"target": "ES2020"
}
}