summaryrefslogtreecommitdiff
path: root/src/build/bundle.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/build/bundle.ts')
-rw-r--r--src/build/bundle.ts244
1 files changed, 244 insertions, 0 deletions
diff --git a/src/build/bundle.ts b/src/build/bundle.ts
new file mode 100644
index 000000000..c448a4c09
--- /dev/null
+++ b/src/build/bundle.ts
@@ -0,0 +1,244 @@
+import type { AstroConfig, ValidExtensionPlugins } from '../@types/astro';
+import type { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier, VariableDeclaration } from '@babel/types';
+import type { InputOptions, OutputOptions } from 'rollup';
+import type { AstroRuntime } from '../runtime';
+
+import esbuild from 'esbuild';
+import { promises as fsPromises } from 'fs';
+import { parse } from '../parser/index.js';
+import { walk } from 'estree-walker';
+import babelParser from '@babel/parser';
+import path from 'path';
+import {rollup} from 'rollup';
+
+const { transformSync } = esbuild;
+const { readFile } = fsPromises;
+
+type DynamicImportMap = Map<'vue' | 'react' | 'react-dom' | 'preact', string>;
+
+async function acquireDynamicComponentImports(plugins: Set<ValidExtensionPlugins>, resolve: (s: string) => Promise<string>): Promise<DynamicImportMap> {
+ const importMap: DynamicImportMap = new Map();
+ for (let plugin of plugins) {
+ switch (plugin) {
+ case 'vue': {
+ importMap.set('vue', await resolve('vue'));
+ break;
+ }
+ case 'react': {
+ importMap.set('react', await resolve('react'));
+ importMap.set('react-dom', await resolve('react-dom'));
+ break;
+ }
+ case 'preact': {
+ importMap.set('preact', await resolve('preact'));
+ break;
+ }
+ }
+ }
+ return importMap;
+}
+
+function compileExpressionSafe(raw: string): string {
+ let { code } = transformSync(raw, {
+ loader: 'tsx',
+ jsxFactory: 'h',
+ jsxFragment: 'Fragment',
+ charset: 'utf8',
+ });
+ return code;
+}
+
+const defaultExtensions: Readonly<Record<string, ValidExtensionPlugins>> = {
+ '.jsx': 'react',
+ '.svelte': 'svelte',
+ '.vue': 'vue'
+};
+
+export async function collectDynamicImports(filename: URL, astroConfig: AstroConfig, resolve: (s: string) => Promise<string>) {
+ const imports = new Set<string>();
+
+ // No markdown for now
+ if(filename.pathname.endsWith('md')) {
+ return imports;
+ }
+
+ const extensions = astroConfig.extensions || defaultExtensions;
+ const source = await readFile(filename, 'utf-8');
+ const ast = parse(source, {
+ filename
+ });
+
+ if(!ast.module) {
+ return imports;
+ }
+
+ const componentImports: ImportDeclaration[] = [];
+ const components: Record<string, { plugin: ValidExtensionPlugins; type: string; specifier: string; }> = {};
+ const plugins = new Set<ValidExtensionPlugins>();
+
+ const program = babelParser.parse(ast.module.content, {
+ sourceType: 'module',
+ plugins: ['jsx', 'typescript', 'topLevelAwait'],
+ }).program;
+
+ const { body } = program;
+ let i = body.length;
+ while (--i >= 0) {
+ const node = body[i];
+ if (node.type === 'ImportDeclaration') {
+ componentImports.push(node);
+ }
+ }
+
+ for (const componentImport of componentImports) {
+ const importUrl = componentImport.source.value;
+ const componentType = path.posix.extname(importUrl);
+ const componentName = path.posix.basename(importUrl, componentType);
+ const plugin = extensions[componentType] || defaultExtensions[componentType];
+ plugins.add(plugin);
+ components[componentName] = {
+ plugin,
+ type: componentType,
+ specifier: importUrl,
+ };
+ }
+
+ const dynamic = await acquireDynamicComponentImports(plugins, resolve);
+
+ function appendImports(rawName: string, filename: URL, astroConfig: AstroConfig) {
+ const [componentName, componentType] = rawName.split(':');
+ if(!componentType) {
+ return;
+ }
+
+ if (!components[componentName]) {
+ throw new Error(`Unknown Component: ${componentName}`);
+ }
+
+ const defn = components[componentName];
+ const fileUrl = new URL(defn.specifier, filename);
+ let rel = path.posix.relative(astroConfig.astroRoot.pathname, fileUrl.pathname);
+
+ switch(defn.plugin) {
+ case 'preact': {
+ imports.add(dynamic.get('preact')!);
+ rel = rel.replace(/\.[^.]+$/, '.js');
+ break;
+ }
+ case 'react': {
+ imports.add(dynamic.get('react')!);
+ imports.add(dynamic.get('react-dom')!);
+ rel = rel.replace(/\.[^.]+$/, '.js');
+ break;
+ }
+ case 'vue': {
+ imports.add(dynamic.get('vue')!);
+ rel = rel.replace(/\.[^.]+$/, '.vue.js');
+ break;
+ }
+ }
+
+ imports.add(`/_astro/${rel}`);
+ }
+
+ walk(ast.html, {
+ enter(node) {
+ switch (node.type) {
+ case 'MustacheTag': {
+ let code: string;
+ try {
+ code = compileExpressionSafe(node.content);
+ } catch {
+ return;
+ }
+
+ let matches: RegExpExecArray[] = [];
+ let match: RegExpExecArray | null | undefined;
+ const H_COMPONENT_SCANNER = /h\(['"]?([A-Z].*?)['"]?,/gs;
+ const regex = new RegExp(H_COMPONENT_SCANNER);
+ while ((match = regex.exec(code))) {
+ matches.push(match);
+ }
+ for (const match of matches.reverse()) {
+ const name = match[1];
+ appendImports(name, filename, astroConfig);
+ }
+ break;
+ }
+ case 'InlineComponent': {
+ if(/^[A-Z]/.test(node.name)) {
+ appendImports(node.name, filename, astroConfig);
+ return;
+ }
+
+ break;
+ }
+ }
+ }
+ });
+
+ return imports;
+}
+
+interface BundleOptions {
+ runtime: AstroRuntime;
+ dist: URL;
+ astroConfig: AstroConfig;
+}
+
+export async function bundle(imports: Set<string>, { runtime, dist }: BundleOptions) {
+ const ROOT = 'astro:root';
+ const root = `
+ ${[...imports].map(url => `import '${url}';`).join('\n')}
+ `;
+
+ const inputOptions: InputOptions = {
+ input: [...imports],
+ plugins: [
+ {
+ name: 'astro:build',
+ resolveId(source: string, imported?: string) {
+ if(source === ROOT) {
+ return source;
+ }
+ if(source.startsWith('/')) {
+ return source;
+ }
+
+ if(imported) {
+ const outUrl = new URL(source, 'http://example.com' + imported);
+ return outUrl.pathname;
+ }
+
+ return null;
+ },
+ async load(id: string) {
+ if(id === ROOT) {
+ return root;
+ }
+
+ const result = await runtime.load(id);
+
+ if(result.statusCode !== 200) {
+ return null;
+ }
+
+ return result.contents.toString('utf-8');
+ }
+ }
+ ]
+ }
+
+ const build = await rollup(inputOptions);
+
+ const outputOptions: OutputOptions = {
+ dir: dist.pathname,
+ format: 'esm',
+ exports: 'named',
+ entryFileNames(chunk) {
+ return chunk.facadeModuleId!.substr(1);
+ }
+ };
+
+ await build.write(outputOptions);
+} \ No newline at end of file