diff options
Diffstat (limited to 'packages/astro')
11 files changed, 276 insertions, 17 deletions
diff --git a/packages/astro/package.json b/packages/astro/package.json index d700adfe0..4aac0a8c1 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -14,6 +14,7 @@ ".": "./astro.cjs", "./package.json": "./package.json", "./snowpack-plugin": "./snowpack-plugin.cjs", + "./snowpack-plugin-jsx": "./snowpack-plugin-jsx.cjs", "./components": "./components/index.js", "./components/*": "./components/*", "./runtime/svelte": "./dist/frontend/runtime/svelte.js", @@ -49,8 +50,10 @@ "@astrojs/renderer-svelte": "0.1.1", "@astrojs/renderer-vue": "0.1.3", "@babel/code-frame": "^7.12.13", + "@babel/core": "^7.14.6", "@babel/generator": "^7.13.9", "@babel/parser": "^7.13.15", + "@babel/plugin-transform-react-jsx": "^7.14.5", "@babel/traverse": "^7.13.15", "@snowpack/plugin-postcss": "^1.4.3", "@snowpack/plugin-sass": "^1.4.0", @@ -58,11 +61,12 @@ "astring": "^1.7.4", "autoprefixer": "^10.2.5", "camel-case": "^4.1.2", + "babel-plugin-module-resolver": "^4.1.0", "cheerio": "^1.0.0-rc.6", "ci-info": "^3.2.0", "del": "^6.0.0", "es-module-lexer": "^0.4.1", - "esbuild": "^0.10.1", + "esbuild": "^0.12.12", "estree-util-value-to-estree": "^1.2.0", "estree-walker": "^3.0.0", "fast-xml-parser": "^3.19.0", @@ -89,7 +93,7 @@ "semver": "^7.3.5", "shorthash": "^0.0.2", "slash": "^4.0.0", - "snowpack": "^3.8.1", + "snowpack": "^3.8.3", "string-width": "^5.0.0", "tiny-glob": "^0.2.8", "unified": "^9.2.1", diff --git a/packages/astro/snowpack-plugin-jsx.cjs b/packages/astro/snowpack-plugin-jsx.cjs new file mode 100644 index 000000000..f7b31b76f --- /dev/null +++ b/packages/astro/snowpack-plugin-jsx.cjs @@ -0,0 +1,189 @@ +const esbuild = require('esbuild'); +const colors = require('kleur/colors'); +const loggerPromise = import('./dist/logger.js'); +const { promises: fs } = require('fs'); + +const babel = require('@babel/core') +const eslexer = require('es-module-lexer'); +let error = (...args) => {}; + +/** + * @typedef {Object} PluginOptions - creates a new type named 'SpecialType' + * @prop {import('./src/config_manager').ConfigManager} configManager + * @prop {'development' | 'production'} mode + */ + +/** + * Returns esbuild loader for a given file + * @param filePath {string} + * @returns {import('esbuild').Loader} + */ +function getLoader(fileExt) { + /** @type {any} */ + return fileExt.substr(1); +} + +/** + * @type {import('snowpack').SnowpackPluginFactory<PluginOptions>} + */ +module.exports = function jsxPlugin(config, options = {}) { + const { + configManager, + logging, + } = options; + + let didInit = false; + return { + name: '@astrojs/snowpack-plugin-jsx', + resolve: { + input: ['.jsx', '.tsx'], + output: ['.js'], + }, + async load({ filePath, fileExt, ...transformContext }) { + if (!didInit) { + const logger = await loggerPromise; + error = logger.error; + await eslexer.init; + didInit = true; + } + + const contents = await fs.readFile(filePath, 'utf8'); + const loader = getLoader(fileExt); + + const { code, warnings } = await esbuild.transform(contents, { + loader, + jsx: 'preserve', + sourcefile: filePath, + sourcemap: config.buildOptions.sourcemap ? 'inline' : undefined, + charset: 'utf8', + sourcesContent: config.mode !== 'production', + }); + for (const warning of warnings) { + error(logging, 'renderer', `${colors.bold('!')} ${filePath} + ${warning.text}`); + } + + let renderers = await configManager.getRenderers(); + const importSources = new Set(renderers.map(({ jsxImportSource }) => jsxImportSource).filter(i => i)); + const getRenderer = (importSource) => renderers.find(({ jsxImportSource }) => jsxImportSource === importSource); + const getTransformOptions = async (importSource) => { + const { name } = getRenderer(importSource); + const { default: renderer } = await import(name); + return renderer.jsxTransformOptions(transformContext); + } + + if (importSources.size === 0) { + error(logging, 'renderer', `${colors.yellow(filePath)} +Unable to resolve a renderer that handles JSX transforms! Please include a \`renderer\` plugin which supports JSX in your \`astro.config.mjs\` file.`); + + return { + '.js': { + code: `(() => { + throw new Error("Hello world!"); + })()` + }, + } + } + + // If we only have a single renderer, we can skip a bunch of work! + if (importSources.size === 1) { + const result = transform(code, filePath, await getTransformOptions(Array.from(importSources)[0])) + + return { + '.js': { + code: result.code || '' + }, + }; + } + + // we need valid JS to scan for imports + // so let's just use `h` and `Fragment` as placeholders + const { code: codeToScan } = await esbuild.transform(code, { + loader: 'jsx', + jsx: 'transform', + jsxFactory: 'h', + jsxFragment: 'Fragment', + }); + + let imports = []; + if (/import/.test(codeToScan)) { + let [i] = eslexer.parse(codeToScan); + // @ts-ignore + imports = i; + } + + let importSource; + + if (imports.length > 0) { + for (let { n: name } of imports) { + if (name.indexOf('/') > -1) name = name.split('/')[0]; + if (importSources.has(name)) { + importSource = name; + break; + } + } + } + + if (!importSource) { + const multiline = contents.match(/\/\*\*[\S\s]*\*\//gm) || []; + + for (const comment of multiline) { + const [_, lib] = comment.match(/@jsxImportSource\s*(\S+)/) || []; + if (lib) { + importSource = lib; + break; + } + } + } + + if (!importSource) { + const importStatements = { + 'react': "import React from 'react'", + 'preact': "import { h } from 'preact'", + 'solid-js': "import 'solid-js/web'" + } + if (importSources.size > 1) { + const defaultRenderer = Array.from(importSources)[0]; + error(logging, 'renderer', `${colors.yellow(filePath)} +Unable to resolve a renderer that handles this file! With more than one renderer enabled, you should include an import or use a pragma comment. +Add ${colors.cyan(importStatements[defaultRenderer] || `import '${defaultRenderer}';`)} or ${colors.cyan(`/* jsxImportSource: ${defaultRenderer} */`)} to this file. +`); + } + + return { + '.js': { + code: contents + }, + } + } + + const result = transform(code, filePath, await getTransformOptions(importSource)); + + return { + '.js': { + code: result.code || '' + }, + }; + }, + cleanup() {}, + }; +} + +/** + * + * @param code {string} + * @param id {string} + * @param opts {{ plugins?: import('@babel/core').PluginItem[], presets?: import('@babel/core').PluginItem[] }|undefined} + */ +const transform = (code, id, { alias, plugins = [], presets = [] } = {}) => + babel.transformSync(code, { + presets, + plugins: [...plugins, alias ? ['babel-plugin-module-resolver', { root: process.cwd(), alias }] : undefined].filter(v => v), + cwd: process.cwd(), + filename: id, + ast: false, + compact: false, + sourceMaps: false, + configFile: false, + babelrc: false, + }); diff --git a/packages/astro/src/config_manager.ts b/packages/astro/src/config_manager.ts index 8087f58c8..e63b126f5 100644 --- a/packages/astro/src/config_manager.ts +++ b/packages/astro/src/config_manager.ts @@ -1,4 +1,4 @@ -import type { ServerRuntime as SnowpackServerRuntime } from 'snowpack'; +import type { ServerRuntime as SnowpackServerRuntime, PluginLoadOptions } from 'snowpack'; import type { AstroConfig } from './@types/astro'; import { posix as path } from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; @@ -17,6 +17,8 @@ interface RendererInstance { external: string[] | undefined; polyfills: string[]; hydrationPolyfills: string[]; + jsxImportSource?: string; + jsxTransformOptions?: (transformContext: Omit<PluginLoadOptions, 'filePath'|'fileExt'>) => undefined|{ plugins?: any[], presets?: any[] }|Promise<{ plugins?: any[], presets?: any[] }> } const CONFIG_MODULE_BASE_NAME = '__astro_config.js'; @@ -119,12 +121,18 @@ export class ConfigManager { external: raw.external, polyfills: polyfillsNormalized, hydrationPolyfills: hydrationPolyfillsNormalized, + jsxImportSource: raw.jsxImportSource }; }); return rendererInstances; } + async getRenderers(): Promise<RendererInstance[]> { + const renderers = await this.buildRendererInstances(); + return renderers; + } + async buildSource(contents: string): Promise<string> { const renderers = await this.buildRendererInstances(); const rendererServerPackages = renderers.map(({ server }) => server); diff --git a/packages/astro/src/runtime.ts b/packages/astro/src/runtime.ts index e1feeeeea..6bf099b4a 100644 --- a/packages/astro/src/runtime.ts +++ b/packages/astro/src/runtime.ts @@ -302,6 +302,7 @@ export interface RuntimeOptions { } interface CreateSnowpackOptions { + logging: LogOptions; mode: RuntimeMode; resolvePackageUrl: (pkgName: string) => Promise<string>; } @@ -309,7 +310,7 @@ interface CreateSnowpackOptions { /** Create a new Snowpack instance to power Astro */ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackOptions) { const { projectRoot, src } = astroConfig; - const { mode, resolvePackageUrl } = options; + const { mode, logging, resolvePackageUrl } = options; const frontendPath = new URL('./frontend/', import.meta.url); const resolveDependency = (dep: string) => resolve.sync(dep, { basedir: fileURLToPath(projectRoot) }); @@ -324,10 +325,12 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO astroConfig: AstroConfig; hmrPort?: number; mode: RuntimeMode; + logging: LogOptions, configManager: ConfigManager; } = { astroConfig, mode, + logging, resolvePackageUrl, configManager, }; @@ -370,6 +373,7 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO mount: mountOptions, mode, plugins: [ + [fileURLToPath(new URL('../snowpack-plugin-jsx.cjs', import.meta.url)), astroPluginOptions], [fileURLToPath(new URL('../snowpack-plugin.cjs', import.meta.url)), astroPluginOptions], ...rendererSnowpackPlugins, resolveDependency('@snowpack/plugin-sass'), @@ -440,6 +444,7 @@ export async function createRuntime(astroConfig: AstroConfig, { mode, logging }: snowpackConfig, configManager, } = await createSnowpack(astroConfig, { + logging, mode, resolvePackageUrl, }); diff --git a/packages/astro/test/fixtures/preact-component/src/components/ArrowFunction.jsx b/packages/astro/test/fixtures/preact-component/src/components/ArrowFunction.jsx index e9bd13b21..ba6854bad 100644 --- a/packages/astro/test/fixtures/preact-component/src/components/ArrowFunction.jsx +++ b/packages/astro/test/fixtures/preact-component/src/components/ArrowFunction.jsx @@ -1,5 +1,5 @@ -import { h, Component } from 'preact'; +import { h } from 'preact'; export default () => { - return <div id="arrow-fn-component"></div>; -}
\ No newline at end of file + return <div id="arrow-fn-component"></div> +} diff --git a/packages/astro/test/fixtures/preact-component/src/components/PragmaComment.jsx b/packages/astro/test/fixtures/preact-component/src/components/PragmaComment.jsx new file mode 100644 index 000000000..3340e3f8e --- /dev/null +++ b/packages/astro/test/fixtures/preact-component/src/components/PragmaComment.jsx @@ -0,0 +1,5 @@ +/** @jsxImportSource preact */ + +export default function() { + return <div id="pragma-comment">Hello world</div>; +} diff --git a/packages/astro/test/fixtures/preact-component/src/pages/pragma-comment.astro b/packages/astro/test/fixtures/preact-component/src/pages/pragma-comment.astro new file mode 100644 index 000000000..91db69830 --- /dev/null +++ b/packages/astro/test/fixtures/preact-component/src/pages/pragma-comment.astro @@ -0,0 +1,10 @@ +--- +import PragmaComponent from '../components/PragmaComment.jsx'; +--- + +<html> +<head> + <title>Preact component works with Pragma comment</title> +</head> +<body><PragmaComponent client:load/></body> +</html> diff --git a/packages/astro/test/fixtures/react-component/src/components/ArrowFunction.jsx b/packages/astro/test/fixtures/react-component/src/components/ArrowFunction.jsx index 21b8ca173..16fac5bb6 100644 --- a/packages/astro/test/fixtures/react-component/src/components/ArrowFunction.jsx +++ b/packages/astro/test/fixtures/react-component/src/components/ArrowFunction.jsx @@ -2,4 +2,4 @@ import React from 'react'; export default () => { return <div id="arrow-fn-component"></div>; -}
\ No newline at end of file +} diff --git a/packages/astro/test/fixtures/react-component/src/components/ForgotImport.jsx b/packages/astro/test/fixtures/react-component/src/components/ForgotImport.jsx index d7dfc29f7..9ee27faca 100644 --- a/packages/astro/test/fixtures/react-component/src/components/ForgotImport.jsx +++ b/packages/astro/test/fixtures/react-component/src/components/ForgotImport.jsx @@ -1,5 +1,3 @@ - - export default function ({}) { return <h2>oops</h2>; -}
\ No newline at end of file +} diff --git a/packages/astro/test/preact-component.test.js b/packages/astro/test/preact-component.test.js index 3b3a37dfc..55debc8ad 100644 --- a/packages/astro/test/preact-component.test.js +++ b/packages/astro/test/preact-component.test.js @@ -1,11 +1,12 @@ import { suite } from 'uvu'; import * as assert from 'uvu/assert'; import { doc } from './test-utils.js'; -import { setup } from './helpers.js'; +import { setup, setupBuild } from './helpers.js'; const PreactComponent = suite('Preact component test'); setup(PreactComponent, './fixtures/preact-component'); +setupBuild(PreactComponent, './fixtures/preact-component'); PreactComponent('Can load class component', async ({ runtime }) => { const result = await runtime.load('/class'); @@ -40,4 +41,31 @@ PreactComponent('Can export a Fragment', async ({ runtime }) => { assert.equal($('body').children().length, 0, "nothing rendered but it didn't throw."); }); +PreactComponent('Can use a pragma comment', async ({ runtime }) => { + const result = await runtime.load('/pragma-comment'); + assert.ok(!result.error, `build error: ${result.error}`); + + const $ = doc(result.contents); + assert.equal($('#pragma-comment').length, 1, "rendered the PragmaComment component."); +}); + + +PreactComponent('Uses the new JSX transform', async ({ runtime }) => { + const result = await runtime.load('/pragma-comment'); + + // Grab the imports + const exp = /import\("(.+?)"\)/g; + let match, componentUrl; + while ((match = exp.exec(result.contents))) { + if (match[1].includes('PragmaComment.js')) { + componentUrl = match[1]; + break; + } + } + const component = await runtime.load(componentUrl); + const jsxRuntime = component.imports.filter(i => i.specifier.includes('jsx-runtime')); + + assert.ok(jsxRuntime, 'preact/jsx-runtime is used for the component'); +}); + PreactComponent.run(); diff --git a/packages/astro/test/react-component.test.js b/packages/astro/test/react-component.test.js index 48c985be1..90b864925 100644 --- a/packages/astro/test/react-component.test.js +++ b/packages/astro/test/react-component.test.js @@ -72,11 +72,23 @@ React('Can load Vue', async () => { assert.equal($('#vue-h2').text(), 'Hasta la vista, baby'); }); -React('Get good error message when react import is forgotten', async () => { - const result = await runtime.load('/forgot-import'); +React('uses the new JSX transform', async () => { + const result = await runtime.load('/'); + assert.ok(!result.error, `build error: ${result.error}`); - assert.ok(result.error instanceof ReferenceError); - assert.equal(result.error.message, 'React is not defined'); -}); + // Grab the imports + const exp = /import\("(.+?)"\)/g; + let match, componentUrl; + while ((match = exp.exec(result.contents))) { + if (match[1].includes('Research.js')) { + componentUrl = match[1]; + break; + } + } + const component = await runtime.load(componentUrl); + const jsxRuntime = component.imports.filter(i => i.specifier.includes('jsx-runtime')); + + assert.ok(jsxRuntime, 'react/jsx-runtime is used for the component'); +}) React.run(); |