wip: add @astrojs/jsx package
This commit is contained in:
parent
8ce5b824ab
commit
dbe8d7e89d
10 changed files with 294 additions and 7 deletions
|
@ -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/*",
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
44
packages/integrations/jsx/package.json
Normal file
44
packages/integrations/jsx/package.json
Normal 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"
|
||||
}
|
||||
}
|
105
packages/integrations/jsx/src/babel/index.ts
Normal file
105
packages/integrations/jsx/src/babel/index.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
};
|
5
packages/integrations/jsx/src/client.ts
Normal file
5
packages/integrations/jsx/src/client.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { h, render } from 'preact';
|
||||
|
||||
export default (element) => (Component, props, children) => {
|
||||
throw new Error("Unable to hydrate Astro JSX!");
|
||||
}
|
40
packages/integrations/jsx/src/index.ts
Normal file
40
packages/integrations/jsx/src/index.ts
Normal 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(),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
24
packages/integrations/jsx/src/jsx-runtime/index.ts
Normal file
24
packages/integrations/jsx/src/jsx-runtime/index.ts
Normal 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
|
||||
}
|
55
packages/integrations/jsx/src/server.ts
Normal file
55
packages/integrations/jsx/src/server.ts
Normal 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,
|
||||
};
|
10
packages/integrations/jsx/tsconfig.json
Normal file
10
packages/integrations/jsx/tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"module": "ES2020",
|
||||
"outDir": "./dist",
|
||||
"target": "ES2020"
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue