From eae58cd5cb72890c810622066f315ad33999cddb Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 29 Mar 2021 16:11:08 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Bundling!=20=F0=9F=A4=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/snowpack/astro.config.mjs | 1 + examples/snowpack/astro/pages/guides.astro | 1 + package-lock.json | 6 +- package.json | 1 + src/@types/astro.ts | 2 + src/build.ts | 98 ++++++--- src/build/bundle.ts | 244 +++++++++++++++++++++ src/build/static.ts | 26 +++ src/compiler/codegen.ts | 4 +- src/config.ts | 3 + src/dev.ts | 2 +- src/runtime.ts | 19 +- 12 files changed, 362 insertions(+), 45 deletions(-) create mode 100644 src/build/bundle.ts create mode 100644 src/build/static.ts diff --git a/examples/snowpack/astro.config.mjs b/examples/snowpack/astro.config.mjs index b8665c917296..5339e88ed42e 100644 --- a/examples/snowpack/astro.config.mjs +++ b/examples/snowpack/astro.config.mjs @@ -2,6 +2,7 @@ export default { projectRoot: '.', astroRoot: './astro', dist: './_site', + public: './public', extensions: { '.jsx': 'preact', }, diff --git a/examples/snowpack/astro/pages/guides.astro b/examples/snowpack/astro/pages/guides.astro index f3571d231460..60828324349e 100644 --- a/examples/snowpack/astro/pages/guides.astro +++ b/examples/snowpack/astro/pages/guides.astro @@ -73,6 +73,7 @@ let communityGuides;
{communityGuides.map((post) => )} + ; } diff --git a/src/build.ts b/src/build.ts index 32b8bcc843a4..3ccc71aa70b5 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,34 +1,65 @@ import type { AstroConfig } from './@types/astro'; -import { defaultLogOptions, LogOptions } from './logger'; +import type { LogOptions } from './logger'; +import type { LoadResult } from './runtime'; -import { loadConfiguration, startServer as startSnowpackServer, build as snowpackBuild } from 'snowpack'; import { promises as fsPromises } from 'fs'; import { relative as pathRelative } from 'path'; import { defaultLogDestination, error } from './logger.js'; import { createRuntime } from './runtime.js'; +import { bundle, collectDynamicImports } from './build/bundle.js'; +import { collectStatics } from './build/static.js'; -const { mkdir, readdir, stat, writeFile } = fsPromises; +const { mkdir, readdir, readFile, stat, writeFile } = fsPromises; const logging: LogOptions = { level: 'debug', dest: defaultLogDestination, }; -async function* allPages(root: URL): AsyncGenerator { +async function* recurseFiles(root: URL): AsyncGenerator { for (const filename of await readdir(root)) { const fullpath = new URL(filename, root); const info = await stat(fullpath); if (info.isDirectory()) { - yield* allPages(new URL(fullpath + '/')); + yield* recurseFiles(new URL(fullpath + '/')); } else { - if (/\.(astro|md)$/.test(fullpath.pathname)) { - yield fullpath; - } + yield fullpath; + } + } +} + +async function* allPages(root: URL): AsyncGenerator { + for await(const filename of recurseFiles(root)) { + if(/\.(astro|md)$/.test(filename.pathname)) { + yield filename; } } } +function mergeSet(a: Set, b: Set) { + for(let str of b) { + a.add(str); + } + return a; +} + +async function writeFilep(outPath: URL, bytes: string | Buffer, encoding: 'utf-8' | null) { + const outFolder = new URL('./', outPath); + await mkdir(outFolder, { recursive: true }); + await writeFile(outPath, bytes, encoding || 'binary'); +} + +async function writeResult(result: LoadResult, outPath: URL, encoding: null | 'utf-8') { + if(result.statusCode !== 200) { + error(logging, 'build', result.error || result.statusCode); + //return 1; + } else { + const bytes = result.contents; + await writeFilep(outPath, bytes, encoding); + } +} + export async function build(astroConfig: AstroConfig): Promise<0 | 1> { const { projectRoot, astroRoot } = astroConfig; const pageRoot = new URL('./pages/', astroRoot); @@ -39,18 +70,13 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { dest: defaultLogDestination, }; - const runtime = await createRuntime(astroConfig, { logging: runtimeLogging, env: 'build' }); - const { snowpackConfig } = runtime.runtimeConfig; - - try { - const result = await snowpackBuild({ - config: snowpackConfig, - lockfile: null, - }); - } catch (err) { - error(logging, 'build', err); - return 1; - } + const runtime = await createRuntime(astroConfig, { logging: runtimeLogging }); + const { runtimeConfig } = runtime; + const { snowpack } = runtimeConfig; + const resolve = (pkgName: string) => snowpack.getUrlForPackage(pkgName) + + const imports = new Set(); + const statics = new Set(); for await (const filepath of allPages(pageRoot)) { const rel = pathRelative(astroRoot.pathname + '/pages', filepath.pathname); // pages/index.astro @@ -58,20 +84,38 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { try { const outPath = new URL('./' + rel.replace(/\.(astro|md)/, '.html'), dist); - const outFolder = new URL('./', outPath); const result = await runtime.load(pagePath); - if (result.statusCode !== 200) { - error(logging, 'generate', result.error || result.statusCode); - //return 1; - } else { - await mkdir(outFolder, { recursive: true }); - await writeFile(outPath, result.contents, 'utf-8'); + await writeResult(result, outPath, 'utf-8'); + if(result.statusCode === 200) { + mergeSet(statics, collectStatics(result.contents.toString('utf-8'))); } } catch (err) { error(logging, 'generate', err); return 1; } + + mergeSet(imports, await collectDynamicImports(filepath, astroConfig, resolve)); + } + + await bundle(imports, {dist, runtime, astroConfig}); + + for(let url of statics) { + const outPath = new URL('.' + url, dist); + const result = await runtime.load(url); + + await writeResult(result, outPath, null); + } + + if(astroConfig.public) { + const pub = astroConfig.public; + for await(const filename of recurseFiles(pub)) { + const rel = pathRelative(pub.pathname, filename.pathname); + const outUrl = new URL('./' + rel, dist); + + const bytes = await readFile(filename); + await writeFilep(outUrl, bytes, null); + } } await runtime.shutdown(); diff --git a/src/build/bundle.ts b/src/build/bundle.ts new file mode 100644 index 000000000000..c448a4c09285 --- /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, resolve: (s: string) => Promise): Promise { + 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> = { + '.jsx': 'react', + '.svelte': 'svelte', + '.vue': 'vue' +}; + +export async function collectDynamicImports(filename: URL, astroConfig: AstroConfig, resolve: (s: string) => Promise) { + const imports = new Set(); + + // 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 = {}; + const plugins = new Set(); + + 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, { 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 diff --git a/src/build/static.ts b/src/build/static.ts new file mode 100644 index 000000000000..f7518c7beb86 --- /dev/null +++ b/src/build/static.ts @@ -0,0 +1,26 @@ +import type {Element} from 'domhandler'; +import cheerio from 'cheerio'; + +export function collectStatics(html: string) { + const statics = new Set(); + + const $ = cheerio.load(html); + + const append = (el: Element, attr: string) => { + const value: string = $(el).attr(attr)!; + if(value.startsWith('http')) { + return; + } + statics.add(value); + } + + $('link[href]').each((i, el) => { + append(el, 'href'); + }); + + $('img[src]').each((i, el) => { + append(el, 'src'); + }); + + return statics; +} \ No newline at end of file diff --git a/src/compiler/codegen.ts b/src/compiler/codegen.ts index f42968b1af88..febb0a514796 100644 --- a/src/compiler/codegen.ts +++ b/src/compiler/codegen.ts @@ -9,9 +9,7 @@ import path from 'path'; import { walk } from 'estree-walker'; import babelParser from '@babel/parser'; import _babelGenerator from '@babel/generator'; -import traverse from '@babel/traverse'; -import { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier, VariableDeclaration } from '@babel/types'; -import { type } from 'node:os'; +import { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier } from '@babel/types'; const babelGenerator: typeof _babelGenerator = // @ts-ignore diff --git a/src/config.ts b/src/config.ts index 1a80d4a15452..dfe590baef04 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,5 +18,8 @@ export async function loadConfig(rawRoot: string | undefined): Promise { const result = await runtime.load(req.url); diff --git a/src/runtime.ts b/src/runtime.ts index 0cc55ecc7775..8e4c8635dcc2 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -106,12 +106,17 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro } } +export interface AstroRuntime { + runtimeConfig: RuntimeConfig; + load: (rawPathname: string | undefined) => Promise; + shutdown: () => Promise; +} + interface RuntimeOptions { logging: LogOptions; - env: 'dev' | 'build'; } -export async function createRuntime(astroConfig: AstroConfig, { env, logging }: RuntimeOptions) { +export async function createRuntime(astroConfig: AstroConfig, { logging }: RuntimeOptions): Promise { const { projectRoot, astroRoot, extensions } = astroConfig; const internalPath = new URL('./frontend/', import.meta.url); @@ -122,16 +127,8 @@ export async function createRuntime(astroConfig: AstroConfig, { env, logging }: extensions?: Record; } = { extensions, - resolve: env === 'dev' ? async (pkgName: string) => snowpack.getUrlForPackage(pkgName) : async (pkgName: string) => `/_snowpack/pkg/${pkgName}.js`, + resolve: async (pkgName: string) => snowpack.getUrlForPackage(pkgName) }; - /*if (existsSync(new URL('./package-lock.json', projectRoot))) { - const pkgLockStr = await readFile(new URL('./package-lock.json', projectRoot), 'utf-8'); - const pkgLock = JSON.parse(pkgLockStr); - astroPlugOptions.resolve = (pkgName: string) => { - const ver = pkgLock.dependencies[pkgName].version; - return `/_snowpack/pkg/${pkgName}.v${ver}.js`; - }; - }*/ const snowpackConfig = await loadConfiguration({ root: projectRoot.pathname, From 4aa93d637c95806d58e272787953d7e66b99be18 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 30 Mar 2021 09:22:44 -0400 Subject: [PATCH 2/2] Finalize build changes --- src/@types/astro.ts | 4 ++-- src/build.ts | 34 +++++++++++++++++++++------------- src/config.ts | 6 +++--- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/@types/astro.ts b/src/@types/astro.ts index a4b3228a3721..ee6a79eabac4 100644 --- a/src/@types/astro.ts +++ b/src/@types/astro.ts @@ -2,7 +2,7 @@ export interface AstroConfigRaw { dist: string; projectRoot: string; astroRoot: string; - public?: string; + public: string; jsx?: string; } @@ -12,7 +12,7 @@ export interface AstroConfig { dist: string; projectRoot: URL; astroRoot: URL; - public?: URL; + public: URL; extensions?: Record; } diff --git a/src/build.ts b/src/build.ts index 3ccc71aa70b5..dccc432d83f5 100644 --- a/src/build.ts +++ b/src/build.ts @@ -2,8 +2,9 @@ import type { AstroConfig } from './@types/astro'; import type { LogOptions } from './logger'; import type { LoadResult } from './runtime'; -import { promises as fsPromises } from 'fs'; +import { existsSync, promises as fsPromises } from 'fs'; import { relative as pathRelative } from 'path'; +import {fdir} from 'fdir'; import { defaultLogDestination, error } from './logger.js'; import { createRuntime } from './runtime.js'; import { bundle, collectDynamicImports } from './build/bundle.js'; @@ -29,12 +30,11 @@ async function* recurseFiles(root: URL): AsyncGenerator { } } -async function* allPages(root: URL): AsyncGenerator { - for await(const filename of recurseFiles(root)) { - if(/\.(astro|md)$/.test(filename.pathname)) { - yield filename; - } - } +async function allPages(root: URL) { + const api = new fdir().filter(p => /\.(astro|md)$/.test(p)) + .withFullPaths().crawl(root.pathname); + const files = await api.withPromise(); + return files as string[]; } function mergeSet(a: Set, b: Set) { @@ -78,12 +78,18 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { const imports = new Set(); const statics = new Set(); - for await (const filepath of allPages(pageRoot)) { + for (const pathname of await allPages(pageRoot)) { + const filepath = new URL(`file://${pathname}`); const rel = pathRelative(astroRoot.pathname + '/pages', filepath.pathname); // pages/index.astro const pagePath = `/${rel.replace(/\.(astro|md)/, '')}`; try { - const outPath = new URL('./' + rel.replace(/\.(astro|md)/, '.html'), dist); + let relPath = './' + rel.replace(/\.(astro|md)$/, '.html'); + if(!relPath.endsWith('index.html')) { + relPath = relPath.replace(/\.html$/, '/index.html'); + } + + const outPath = new URL(relPath, dist); const result = await runtime.load(pagePath); await writeResult(result, outPath, 'utf-8'); @@ -107,13 +113,15 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { await writeResult(result, outPath, null); } - if(astroConfig.public) { + if(existsSync(astroConfig.public)) { const pub = astroConfig.public; - for await(const filename of recurseFiles(pub)) { - const rel = pathRelative(pub.pathname, filename.pathname); + const publicFiles = (await new fdir().withFullPaths().crawl(pub.pathname).withPromise()) as string[]; + for(const filepath of publicFiles) { + const fileUrl = new URL(`file://${filepath}`) + const rel = pathRelative(pub.pathname, fileUrl.pathname); const outUrl = new URL('./' + rel, dist); - const bytes = await readFile(filename); + const bytes = await readFile(fileUrl); await writeFilep(outUrl, bytes, null); } } diff --git a/src/config.ts b/src/config.ts index dfe590baef04..96c4e92d43c4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,8 +18,8 @@ export async function loadConfig(rawRoot: string | undefined): Promise