diff --git a/packages/astro/package.json b/packages/astro/package.json index 2d6cf0c63..d3c1eee51 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -88,6 +88,7 @@ "shorthash": "^0.0.2", "slash": "^4.0.0", "sourcemap-codec": "^1.4.8", + "srcset-parse": "^1.1.0", "string-width": "^5.0.0", "strip-ansi": "^7.0.1", "strip-indent": "^4.0.0", diff --git a/packages/astro/src/@types/astro-build.ts b/packages/astro/src/@types/astro-build.ts new file mode 100644 index 000000000..27fa2dd00 --- /dev/null +++ b/packages/astro/src/@types/astro-build.ts @@ -0,0 +1,35 @@ +import type { ScriptInfo } from './astro-core'; + +/** Entire output of `astro build`, stored in memory */ +export interface BuildOutput { + [dist: string]: BuildFile; +} + +export interface BuildFile { + /** The original location. Needed for code frame errors. */ + srcPath: URL; + /** File contents */ + contents: string | Buffer; + /** File content type (to determine encoding, etc) */ + contentType: string; + /** Encoding */ + encoding?: 'utf8'; + /** Extracted scripts */ + hoistedScripts?: ScriptInfo[]; +} + +/** Mapping of every URL and its required assets. All URLs are absolute relative to the project. */ +export type BundleMap = { + [pageUrl: string]: PageDependencies; +}; + +export interface PageDependencies { + /** JavaScript files needed for page. No distinction between blocking/non-blocking or sync/async. */ + js: Set; + /** CSS needed for page, whether imported via , JS, or Astro component. */ + css: Set; + /** Images needed for page. Can be loaded via CSS, , or otherwise. */ + images: Set; + /** Async hoisted Javascript */ + hoistedJS: Map; +} \ No newline at end of file diff --git a/packages/astro/src/core/build/bundle/css.ts b/packages/astro/src/core/build/bundle/css.ts new file mode 100644 index 000000000..e92003dd3 --- /dev/null +++ b/packages/astro/src/core/build/bundle/css.ts @@ -0,0 +1,157 @@ +import type { AstroConfig } from '../../../@types/astro-core'; +import type { BuildOutput, BundleMap } from '../../../@types/astro-build'; +import type { LogOptions } from '../../logger.js'; + +import { performance } from 'perf_hooks'; +import shorthash from 'shorthash'; +import cheerio from 'cheerio'; +import esbuild from 'esbuild'; +import { getDistPath, getSrcPath, IS_ASTRO_FILE_URL, stopTimer } from '../util.js'; +import { debug } from '../../logger.js'; + +// config +const COMMON_URL = `/_astro/common-[HASH].css`; // [HASH] will be replaced + +/** + * Bundle CSS + * For files within dep tree, find ways to combine them. + * Current logic: + * - If CSS appears across multiple pages, combine into `/_astro/common.css` bundle + * - Otherwise, combine page CSS into one request as `/_astro/[page].css` bundle + * + * This operation _should_ be relatively-safe to do in parallel with other bundling, + * assuming other bundling steps don’t touch CSS. While this step does modify HTML, + * it doesn’t keep anything in local memory so other processes may modify HTML too. + * + * This operation mutates the original references of the buildOutput not only for + * safety (prevents possible conflicts), but for efficiency. + */ +export async function bundleCSS({ + astroConfig, + buildState, + logging, + depTree, +}: { + astroConfig: AstroConfig; + buildState: BuildOutput; + logging: LogOptions; + depTree: BundleMap; +}): Promise { + const timer: Record = {}; + const cssMap = new Map(); + + // 1. organize CSS into common or page-specific CSS + timer.bundle = performance.now(); + const sortedPages = Object.keys(depTree); // these were scanned in parallel; sort to create somewhat deterministic order + sortedPages.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); + for (const pageUrl of sortedPages) { + const { css } = depTree[pageUrl]; + for (const cssUrl of css.keys()) { + if (!IS_ASTRO_FILE_URL.test(cssUrl)) { + // do not add to cssMap, leave as-is. + } else if (cssMap.has(cssUrl)) { + // scenario 1: if multiple URLs require this CSS, upgrade to common chunk + cssMap.set(cssUrl, COMMON_URL); + } else { + // scenario 2: otherwise, assume this CSS is page-specific + cssMap.set(cssUrl, '/_astro' + pageUrl.replace(/.html$/, '').replace(/^\./, '') + '-[HASH].css'); + } + } + } + + // 2. bundle (note: assume cssMap keys are in specific, correct order; assume buildState[] keys are in different order each time) + timer.bundle = performance.now(); + // note: don’t parallelize here otherwise CSS may end up in random order + for (const id of cssMap.keys()) { + const newUrl = cssMap.get(id) as string; + + // if new bundle, create + if (!buildState[newUrl]) { + buildState[newUrl] = { + srcPath: getSrcPath(id, { astroConfig }), // this isn’t accurate, but we can at least reference a file in the bundle + contents: '', + contentType: 'text/css', + encoding: 'utf8', + }; + } + + // append to bundle, delete old file + (buildState[newUrl] as any).contents += Buffer.isBuffer(buildState[id].contents) ? buildState[id].contents.toString('utf8') : buildState[id].contents; + delete buildState[id]; + } + debug(logging, 'css', `bundled [${stopTimer(timer.bundle)}]`); + + // 3. minify + timer.minify = performance.now(); + await Promise.all( + Object.keys(buildState).map(async (id) => { + if (buildState[id].contentType !== 'text/css') return; + const { code } = await esbuild.transform(buildState[id].contents.toString(), { + loader: 'css', + minify: true, + }); + buildState[id].contents = code; + }) + ); + debug(logging, 'css', `minified [${stopTimer(timer.minify)}]`); + + // 4. determine hashes based on CSS content (deterministic), and update HTML tags with final hashed URLs + timer.hashes = performance.now(); + const cssHashes = new Map(); + for (const id of Object.keys(buildState)) { + if (!id.includes('[HASH].css')) continue; // iterate through buildState, looking to replace [HASH] + + const hash = shorthash.unique(buildState[id].contents as string); + const newID = id.replace(/\[HASH\]/, hash); + cssHashes.set(id, newID); + buildState[newID] = buildState[id]; // copy ref without cloning to save memory + delete buildState[id]; // delete old ref + } + debug(logging, 'css', `built hashes [${stopTimer(timer.hashes)}]`); + + // 5. update HTML tags with final hashed URLs + timer.html = performance.now(); + await Promise.all( + Object.keys(buildState).map(async (id) => { + if (buildState[id].contentType !== 'text/html') return; + + const $ = cheerio.load(buildState[id].contents); + const stylesheets = new Set(); // keep track of page-specific CSS so we remove dupes + const preloads = new Set(); // list of stylesheets preloads, to remove dupes + + $('link[href]').each((i, el) => { + const srcPath = getSrcPath(id, { astroConfig }); + const oldHref = getDistPath($(el).attr('href') || '', { astroConfig, srcPath }); // note: this may be a relative URL; transform to absolute to find a buildOutput match + const newHref = cssMap.get(oldHref); + + if (!newHref) { + return; + } + + if (el.attribs?.rel === 'preload') { + if (preloads.has(newHref)) { + $(el).remove(); + } else { + $(el).attr('href', cssHashes.get(newHref) || ''); + preloads.add(newHref); + } + return; + } + + if (stylesheets.has(newHref)) { + $(el).remove(); // this is a dupe; remove + } else { + $(el).attr('href', cssHashes.get(newHref) || ''); // new CSS; update href (important! use cssHashes, not cssMap) + + // bonus: add [rel] and [type]. not necessary, but why not? + $(el).attr('rel', 'stylesheet'); + $(el).attr('type', 'text/css'); + + stylesheets.add(newHref); + } + }); + (buildState[id] as any).contents = $.html(); // save updated HTML in global buildState + }) + ); + debug(logging, 'css', `parsed html [${stopTimer(timer.html)}]`); +} diff --git a/packages/astro/src/core/build/bundle/js.ts b/packages/astro/src/core/build/bundle/js.ts new file mode 100644 index 000000000..7516a573c --- /dev/null +++ b/packages/astro/src/core/build/bundle/js.ts @@ -0,0 +1,256 @@ +import type { InputOptions, OutputOptions, OutputChunk } from 'rollup'; +import type { AstroConfig, ScriptInfo } from '../../../@types/astro-core'; +//import type { AstroConfig, BundleMap, BuildOutput, ScriptInfo, InlineScriptInfo } from '../../../@types/astro-build'; +import type { AstroRuntime } from '../../runtime'; +import type { LogOptions } from '../../logger.js'; + +import { fileURLToPath } from 'url'; +import { rollup } from 'rollup'; +import { terser } from 'rollup-plugin-terser'; +import { createBundleStats, addBundleStats, BundleStatsMap } from '../stats.js'; +import { IS_ASTRO_FILE_URL } from '../util.js'; +import cheerio from 'cheerio'; +import path from 'path'; + +interface BundleOptions { + dist: URL; + astroRuntime: AstroRuntime; +} + +/** Collect JS imports from build output */ +export function collectJSImports(buildState: BuildOutput): Set { + const imports = new Set(); + for (const id of Object.keys(buildState)) { + if (buildState[id].contentType === 'application/javascript') imports.add(id); + } + return imports; +} + +function pageUrlToVirtualJSEntry(pageUrl: string) { + return 'astro-virtual:' + pageUrl.replace(/.html$/, '').replace(/^\./, '') + '.js'; +} + +export async function bundleHoistedJS({ + buildState, + astroConfig, + logging, + depTree, + dist, + runtime, +}: { + astroConfig: AstroConfig; + buildState: BuildOutput; + logging: LogOptions; + depTree: BundleMap; + dist: URL; + runtime: AstroRuntime; +}) { + const sortedPages = Object.keys(depTree); // these were scanned in parallel; sort to create somewhat deterministic order + sortedPages.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); + + /** + * 1. Go over sorted pages and create a virtual module for all of its dependencies + */ + const entryImports: string[] = []; + const virtualScripts = new Map(); + const pageToEntryMap = new Map(); + + for (let pageUrl of sortedPages) { + const hoistedJS = depTree[pageUrl].hoistedJS; + if (hoistedJS.size) { + for (let [url, scriptInfo] of hoistedJS) { + if (virtualScripts.has(url) || !url.startsWith('astro-virtual:')) continue; + virtualScripts.set(url, scriptInfo); + } + const entryURL = pageUrlToVirtualJSEntry(pageUrl); + const entryJS = Array.from(hoistedJS.keys()) + .map((url) => `import '${url}';`) + .join('\n'); + virtualScripts.set(entryURL, { + content: entryJS, + }); + entryImports.push(entryURL); + pageToEntryMap.set(pageUrl, entryURL); + } + } + + if (!entryImports.length) { + // There are no hoisted scripts, bail + return; + } + + /** + * 2. Run the bundle to bundle each pages JS into a single bundle (with shared content) + */ + const inputOptions: InputOptions = { + input: entryImports, + plugins: [ + { + name: 'astro:build', + resolveId(source: string, imported?: string) { + if (virtualScripts.has(source)) { + 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 (virtualScripts.has(id)) { + let info = virtualScripts.get(id) as InlineScriptInfo; + return info.content; + } + + 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: fileURLToPath(dist), + format: 'esm', + exports: 'named', + entryFileNames(chunk) { + const { facadeModuleId } = chunk; + if (!facadeModuleId) throw new Error(`facadeModuleId missing: ${chunk.name}`); + return facadeModuleId.substr('astro-virtual:/'.length, facadeModuleId.length - 'astro-virtual:/'.length - 3 /* .js */) + '-[hash].js'; + }, + plugins: [ + // We are using terser for the demo, but might switch to something else long term + // Look into that rather than adding options here. + terser(), + ], + }; + + const { output } = await build.write(outputOptions); + + /** + * 3. Get a mapping of the virtual filename to the chunk file name + */ + const entryToChunkFileName = new Map(); + output.forEach((chunk) => { + const { fileName, facadeModuleId, isEntry } = chunk as OutputChunk; + if (!facadeModuleId || !isEntry) return; + entryToChunkFileName.set(facadeModuleId, fileName); + }); + + /** + * 4. Update the original HTML with the new chunk scripts + */ + Object.keys(buildState).forEach((id) => { + if (buildState[id].contentType !== 'text/html') return; + + const entryVirtualURL = pageUrlToVirtualJSEntry(id); + let hasHoisted = false; + const $ = cheerio.load(buildState[id].contents); + $('script[data-astro="hoist"]').each((i, el) => { + hasHoisted = true; + if (i === 0) { + let chunkName = entryToChunkFileName.get(entryVirtualURL); + if (!chunkName) return; + let chunkPathname = '/' + chunkName; + let relLink = path.relative(path.dirname(id), chunkPathname); + $(el).attr('src', relLink.startsWith('.') ? relLink : './' + relLink); + $(el).removeAttr('data-astro'); + $(el).html(''); + } else { + $(el).remove(); + } + }); + + if (hasHoisted) { + (buildState[id] as any).contents = $.html(); // save updated HTML in global buildState + } + }); +} + +/** Bundle JS action */ +export async function bundleJS(imports: Set, { astroRuntime, dist }: BundleOptions): Promise { + const ROOT = 'astro:root'; + const validImports = [...imports].filter((url) => IS_ASTRO_FILE_URL.test(url)); + const root = ` + ${validImports.map((url) => `import '${url}';`).join('\n')} +`; + + const inputOptions: InputOptions = { + input: validImports, + 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 astroRuntime.load(id); + + if (result.statusCode !== 200) { + return null; + } + + return result.contents.toString('utf-8'); + }, + }, + ], + }; + + const build = await rollup(inputOptions); + + const outputOptions: OutputOptions = { + dir: fileURLToPath(dist), + format: 'esm', + exports: 'named', + entryFileNames(chunk) { + const { facadeModuleId } = chunk; + if (!facadeModuleId) throw new Error(`facadeModuleId missing: ${chunk.name}`); + return facadeModuleId.substr(1); + }, + plugins: [ + // We are using terser for the demo, but might switch to something else long term + // Look into that rather than adding options here. + terser(), + ], + }; + + const stats = createBundleStats(); + const { output } = await build.write(outputOptions); + await Promise.all( + output.map(async (chunk) => { + const code = (chunk as OutputChunk).code || ''; + await addBundleStats(stats, code, chunk.fileName); + }) + ); + + return stats; +} diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 674748bb0..71c4303fc 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -1,221 +1,395 @@ -import type { InputHTMLOptions } from '@web/rollup-plugin-html'; -import type { AstroConfig, ComponentInstance, GetStaticPathsResult, ManifestData, RouteCache, RouteData, RSSResult } from '../../@types/astro-core'; -import type { LogOptions } from '../logger'; +import type { AstroConfig, RouteData, RuntimeMode, ScriptInfo } from '../../@types/astro-core'; +import type { BuildOutput, BundleMap, PageDependencies } from '../../@types/astro-build'; -import { rollupPluginHTML } from '@web/rollup-plugin-html'; +import cheerio from 'cheerio'; +import del from 'del'; +import eslexer from 'es-module-lexer'; import fs from 'fs'; -import { bold, cyan, green, dim } from 'kleur/colors'; +import { bold, green, red, underline, yellow } from 'kleur/colors'; +import mime from 'mime'; +import path from 'path'; import { performance } from 'perf_hooks'; -import vite, { ViteDevServer } from '../vite.js'; +import glob from 'tiny-glob'; +import hash from 'shorthash'; +import srcsetParse from 'srcset-parse'; import { fileURLToPath } from 'url'; -import { createVite } from '../create-vite.js'; -import { pad } from '../dev/util.js'; -import { debug, defaultLogOptions, levels, timerMessage, warn } from '../logger.js'; -import { ssr } from '../ssr/index.js'; -import { generatePaginateFunction } from '../ssr/paginate.js'; -import { createRouteManifest, validateGetStaticPathsModule, validateGetStaticPathsResult } from '../ssr/routing.js'; -import { generateRssFunction } from '../ssr/rss.js'; -import { generateSitemap } from '../ssr/sitemap.js'; -import { kb, profileHTML, profileJS } from './stats.js'; -export interface BuildOptions { - mode?: string; - logging: LogOptions; +import { bundleCSS } from './bundle/css.js'; +import { bundleJS, bundleHoistedJS, collectJSImports } from './bundle/js.js'; +import { buildStaticPage, getStaticPathsForPage } from './page.js'; +import { generateSitemap } from './sitemap.js'; +import { collectBundleStats, logURLStats, mapBundleStatsToURLStats } from './stats.js'; +import { getDistPath, stopTimer } from './util.js'; +import type { LogOptions } from '../logger'; +import { debug, defaultLogDestination, defaultLogLevel, error, info, warn } from '../logger.js'; +import { createVite } from '../create-vite.js'; +import vite, { ViteDevServer } from '../vite.js'; +import { createRouteManifest, validateGetStaticPathsModule, validateGetStaticPathsResult } from '../ssr/routing.js'; + +// This package isn't real ESM, so have to coerce it +const matchSrcset: typeof srcsetParse = (srcsetParse as any).default; + +const defaultLogging: LogOptions = { + level: defaultLogLevel, + dest: defaultLogDestination, +}; + +/** Is this URL remote or embedded? */ +function isRemoteOrEmbedded(url: string) { + return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//') || url.startsWith('data:'); } +/** The primary build action */ +export async function build(astroConfig: AstroConfig, logging: LogOptions = defaultLogging): Promise<0 | 1> { + const { projectRoot } = astroConfig; + const buildState: BuildOutput = {}; + const depTree: BundleMap = {}; + const timer: Record = {}; + + const runtimeLogging: LogOptions = { + level: 'error', + dest: defaultLogDestination, + }; + + // warn users if missing config item in build that may result in broken SEO (can’t disable, as they should provide this) + if (!astroConfig.buildOptions.site) { + warn(logging, 'config', `Set "buildOptions.site" to generate correct canonical URLs and sitemap`); + } + + const mode: RuntimeMode = 'production'; + + const viteConfig = await createVite( + { + mode, + server: { + hmr: { overlay: false }, + middlewareMode: 'ssr', + }, + ...(astroConfig.vite || {}), + }, + { astroConfig, logging } + ); + const viteServer = await vite.createServer(viteConfig); + + const manifest = createRouteManifest({ config: astroConfig }); + + //const astroRuntime = await createRuntime(astroConfig, { mode, logging: runtimeLogging }); + //const { runtimeConfig } = astroRuntime; + //const { snowpackRuntime } = runtimeConfig; + + try { + // 0. erase build directory + await del(fileURLToPath(astroConfig.dist)); + + /** + * 1. Build Pages + * Source files are built in parallel and stored in memory. Most assets are also gathered here, too. + */ + timer.build = performance.now(); + info(logging, 'build', yellow('! building pages...')); + const allRoutesAndPaths = await Promise.all( + manifest.routes.map(async (route): Promise<[RouteData, string[]]> => { + if (route.pathname) { + return [route, [route.pathname]]; + } else { + const result = await getStaticPathsForPage({ + astroConfig, + route, + logging, + viteServer + }); + if (result.rss.xml) { + if (buildState[result.rss.url]) { + throw new Error(`[getStaticPaths] RSS feed ${result.rss.url} already exists.\nUse \`rss(data, {url: '...'})\` to choose a unique, custom URL. (${route.component})`); + } + buildState[result.rss.url] = { + srcPath: new URL(result.rss.url, projectRoot), + contents: result.rss.xml, + contentType: 'text/xml', + encoding: 'utf8', + }; + } + return [route, result.paths]; + } + }) + ); + try { + await Promise.all( + allRoutesAndPaths.map(async ([route, paths]: [RouteData, string[]]) => { + for (const p of paths) { + await buildStaticPage({ + astroConfig, + buildState, + route, + path: p, + astroRuntime, + }); + } + }) + ); + } catch (e: any) { + if (e.filename) { + let stack = e.stack + .replace(/Object\.__render \(/gm, '') + .replace(/\/_astro\/(.+)\.astro\.js\:\d+\:\d+\)/gm, (_: string, $1: string) => 'file://' + fileURLToPath(projectRoot) + $1 + '.astro') + .split('\n'); + stack.splice(1, 0, ` at file://${e.filename}`); + stack = stack.join('\n'); + error( + logging, + 'build', + `${red(`Unable to render ${underline(e.filename.replace(fileURLToPath(projectRoot), ''))}`)} + +${stack} +` + ); + } else { + error(logging, 'build', e.message); + } + error(logging, 'build', red('✕ building pages failed!')); + + await viteServer.close(); + return 1; + } + info(logging, 'build', green('✔'), 'pages built.'); + debug(logging, 'build', `built pages [${stopTimer(timer.build)}]`); + + // after pages are built, build depTree + timer.deps = performance.now(); + const scanPromises: Promise[] = []; + + await eslexer.init; + for (const id of Object.keys(buildState)) { + if (buildState[id].contentType !== 'text/html') continue; // only scan HTML files + const pageDeps = findDeps(buildState[id].contents as string, { + astroConfig, + srcPath: buildState[id].srcPath, + id, + }); + depTree[id] = pageDeps; + + // while scanning we will find some unbuilt files; make sure those are all built while scanning + for (const url of [...pageDeps.js, ...pageDeps.css, ...pageDeps.images]) { + if (!buildState[url]) + scanPromises.push( + astroRuntime.load(url).then((result: LoadResult) => { + if (result.statusCode === 404) { + if (url.startsWith('/_astro/')) { + throw new Error(`${buildState[id].srcPath.href}: could not find file "${url}".`); + } + warn(logging, 'build', `${buildState[id].srcPath.href}: could not find file "${url}". Marked as external.`); + return; + } + if (result.statusCode !== 200) { + // there shouldn’t be a build error here + throw (result as any).error || new Error(`unexpected ${result.statusCode} response from "${url}".`); + } + buildState[url] = { + srcPath: new URL(url, projectRoot), + contents: result.contents, + contentType: result.contentType || mime.getType(url) || '', + }; + }) + ); + } + } + await Promise.all(scanPromises); + debug(logging, 'build', `scanned deps [${stopTimer(timer.deps)}]`); + + /** + * 2. Bundling 1st Pass: In-memory + * Bundle CSS, and anything else that can happen in memory (for now, JS bundling happens after writing to disk) + */ + info(logging, 'build', yellow('! optimizing css...')); + timer.prebundleCSS = performance.now(); + await Promise.all([ + bundleCSS({ buildState, astroConfig, logging, depTree }).then(() => { + debug(logging, 'build', `bundled CSS [${stopTimer(timer.prebundleCSS)}]`); + }), + bundleHoistedJS({ buildState, astroConfig, logging, depTree, runtime: astroRuntime, dist: astroConfig.dist }), + // TODO: optimize images? + ]); + // TODO: minify HTML? + info(logging, 'build', green('✔'), 'css optimized.'); + + /** + * 3. Write to disk + * Also clear in-memory bundle + */ + // collect stats output + const urlStats = await collectBundleStats(buildState, depTree); + + // collect JS imports for bundling + const jsImports = await collectJSImports(buildState); + + // write sitemap + if (astroConfig.buildOptions.sitemap && astroConfig.buildOptions.site) { + timer.sitemap = performance.now(); + info(logging, 'build', yellow('! creating sitemap...')); + const sitemap = generateSitemap(buildState, astroConfig.buildOptions.site); + const sitemapPath = new URL('sitemap.xml', astroConfig.dist); + await fs.promises.mkdir(path.dirname(fileURLToPath(sitemapPath)), { recursive: true }); + await fs.promises.writeFile(sitemapPath, sitemap, 'utf8'); + info(logging, 'build', green('✔'), 'sitemap built.'); + debug(logging, 'build', `built sitemap [${stopTimer(timer.sitemap)}]`); + } + + // write to disk and free up memory + timer.write = performance.now(); + for (const id of Object.keys(buildState)) { + const outPath = new URL(`.${id}`, astroConfig.dist); + const parentDir = path.dirname(fileURLToPath(outPath)); + await fs.promises.mkdir(parentDir, { recursive: true }); + const handle = await fs.promises.open(outPath, 'w'); + await fs.promises.writeFile(handle, buildState[id].contents, buildState[id].encoding); + + // Ensure the file handle is not left hanging which will + // result in the garbage collector loggin errors in the console + // when it eventually has to close them. + await handle.close(); + + delete buildState[id]; + delete depTree[id]; + } + debug(logging, 'build', `wrote files to disk [${stopTimer(timer.write)}]`); + + /** + * 4. Copy Public Assets + */ + if (fs.existsSync(astroConfig.public)) { + info(logging, 'build', yellow(`! copying public folder...`)); + timer.public = performance.now(); + const cwd = fileURLToPath(astroConfig.public); + const publicFiles = await glob('**/*', { cwd, filesOnly: true }); + await Promise.all( + publicFiles.map(async (filepath) => { + const srcPath = new URL(filepath, astroConfig.public); + const distPath = new URL(filepath, astroConfig.dist); + await fs.promises.mkdir(path.dirname(fileURLToPath(distPath)), { recursive: true }); + await fs.promises.copyFile(srcPath, distPath); + }) + ); + debug(logging, 'build', `copied public folder [${stopTimer(timer.public)}]`); + info(logging, 'build', green('✔'), 'public folder copied.'); + } else { + if (path.basename(astroConfig.public.toString()) !== 'public') { + info(logging, 'tip', yellow(`! no public folder ${astroConfig.public} found...`)); + } + } + + /** + * 5. Bundling 2nd Pass: On disk + * Bundle JS, which requires hard files to optimize + */ + info(logging, 'build', yellow(`! bundling...`)); + if (jsImports.size > 0) { + timer.bundleJS = performance.now(); + const jsStats = await bundleJS(jsImports, { dist: astroConfig.dist, astroRuntime }); + mapBundleStatsToURLStats({ urlStats, depTree, bundleStats: jsStats }); + debug(logging, 'build', `bundled JS [${stopTimer(timer.bundleJS)}]`); + info(logging, 'build', green(`✔`), 'bundling complete.'); + } + + /** + * 6. Print stats + */ + logURLStats(logging, urlStats); + await astroRuntime.shutdown(); + info(logging, 'build', bold(green('▶ Build Complete!'))); + return 0; + } catch (err) { + error(logging, 'build', err.message); + await astroRuntime.shutdown(); + return 1; + } +} + +/** Given an HTML string, collect and tags */ +export function findDeps(html: string, { astroConfig, srcPath }: { astroConfig: AstroConfig; srcPath: URL; id: string }): PageDependencies { + const pageDeps: PageDependencies = { + js: new Set(), + css: new Set(), + images: new Set(), + hoistedJS: new Map(), + }; + + const $ = cheerio.load(html); + + $('script').each((_i, el) => { + const src = $(el).attr('src'); + const hoist = $(el).attr('data-astro') === 'hoist'; + if (hoist) { + if (src) { + pageDeps.hoistedJS.set(src, { + src, + }); + } else { + let content = $(el).html() || ''; + pageDeps.hoistedJS.set(`astro-virtual:${hash.unique(content)}`, { + content, + }); + } + } else if (src) { + if (isRemoteOrEmbedded(src)) return; + pageDeps.js.add(getDistPath(src, { astroConfig, srcPath })); + } else { + const text = $(el).html(); + if (!text) return; + const [imports] = eslexer.parse(text); + for (const spec of imports) { + const importSrc = spec.n; + if (importSrc && !isRemoteOrEmbedded(importSrc)) { + pageDeps.js.add(getDistPath(importSrc, { astroConfig, srcPath })); + } + } + } + }); + + $('link[href]').each((_i, el) => { + const href = $(el).attr('href'); + if (href && !isRemoteOrEmbedded(href) && ($(el).attr('rel') === 'stylesheet' || $(el).attr('type') === 'text/css' || href.endsWith('.css'))) { + const dist = getDistPath(href, { astroConfig, srcPath }); + pageDeps.css.add(dist); + } + }); + + $('img[src]').each((_i, el) => { + const src = $(el).attr('src'); + if (src && !isRemoteOrEmbedded(src)) { + pageDeps.images.add(getDistPath(src, { astroConfig, srcPath })); + } + }); + + $('img[srcset]').each((_i, el) => { + const srcset = $(el).attr('srcset') || ''; + for (const src of matchSrcset(srcset)) { + if (!isRemoteOrEmbedded(src.url)) { + pageDeps.images.add(getDistPath(src.url, { astroConfig, srcPath })); + } + } + }); + + // Add in srcset check for + $('source[srcset]').each((_i, el) => { + const srcset = $(el).attr('srcset') || ''; + for (const src of matchSrcset(srcset)) { + if (!isRemoteOrEmbedded(src.url)) { + pageDeps.images.add(getDistPath(src.url, { astroConfig, srcPath })); + } + } + }); + + // important: preserve the scan order of deps! order matters on pages + + return pageDeps; +} + + + + /** `astro build` */ -export default async function build(config: AstroConfig, options: BuildOptions = { logging: defaultLogOptions }): Promise { +/*export default async function build(config: AstroConfig, options: BuildOptions = { logging: defaultLogOptions }): Promise { const builder = new AstroBuilder(config, options); await builder.build(); } - -class AstroBuilder { - private config: AstroConfig; - private logging: LogOptions; - private mode = 'production'; - private origin: string; - private routeCache: RouteCache = {}; - private manifest: ManifestData; - private viteServer?: ViteDevServer; - - constructor(config: AstroConfig, options: BuildOptions) { - if (!config.buildOptions.site && config.buildOptions.sitemap !== false) { - warn(options.logging, 'config', `Set "buildOptions.site" to generate correct canonical URLs and sitemap`); - } - - if (options.mode) this.mode = options.mode; - this.config = config; - const port = config.devOptions.port; // no need to save this (don’t rely on port in builder) - this.logging = options.logging; - this.origin = config.buildOptions.site ? new URL(config.buildOptions.site).origin : `http://localhost:${port}`; - this.manifest = createRouteManifest({ config }); - } - - async build() { - const { logging, origin } = this; - const timer: Record = { viteStart: performance.now() }; - const viteConfig = await createVite( - { - mode: this.mode, - server: { - hmr: { overlay: false }, - middlewareMode: 'ssr', - }, - ...(this.config.vite || {}), - }, - { astroConfig: this.config, logging } - ); - const viteServer = await vite.createServer(viteConfig); - this.viteServer = viteServer; - debug(logging, 'build', timerMessage('Vite started', timer.viteStart)); - - timer.renderStart = performance.now(); - const assets: Record = {}; - const allPages: Record = {}; - // Collect all routes ahead-of-time, before we start the build. - // NOTE: This enforces that `getStaticPaths()` is only called once per route, - // and is then cached across all future SSR builds. In the past, we've had trouble - // with parallelized builds without guaranteeing that this is called first. - await Promise.all( - this.manifest.routes.map(async (route) => { - // static route: - if (route.pathname) { - allPages[route.component] = { ...route, paths: [route.pathname] }; - return; - } - // dynamic route: - const result = await this.getStaticPathsForRoute(route); - if (result.rss?.xml) { - const rssFile = new URL(result.rss.url.replace(/^\/?/, './'), this.config.dist); - if (assets[fileURLToPath(rssFile)]) { - throw new Error(`[getStaticPaths] RSS feed ${result.rss.url} already exists.\nUse \`rss(data, {url: '...'})\` to choose a unique, custom URL. (${route.component})`); - } - assets[fileURLToPath(rssFile)] = result.rss.xml; - } - allPages[route.component] = { ...route, paths: result.paths }; - }) - ); - - // After all routes have been collected, start building them. - // TODO: test parallel vs. serial performance. Promise.all() may be - // making debugging harder without any perf gain. If parallel is best, - // then we should set a max number of parallel builds. - const input: InputHTMLOptions[] = []; - await Promise.all( - Object.entries(allPages).map(([component, route]) => - Promise.all( - route.paths.map(async (pathname) => { - input.push({ - html: await ssr({ - astroConfig: this.config, - filePath: new URL(`./${component}`, this.config.projectRoot), - logging, - mode: 'production', - origin, - pathname, - route, - routeCache: this.routeCache, - viteServer, - }), - name: pathname.replace(/\/?$/, '/index.html').replace(/^\//, ''), - }); - }) - ) - ) - ); - debug(logging, 'build', timerMessage('All pages rendered', timer.renderStart)); - - // Bundle the assets in your final build: This currently takes the HTML output - // of every page (stored in memory) and bundles the assets pointed to on those pages. - timer.buildStart = performance.now(); - await vite.build({ - logLevel: 'error', - mode: 'production', - build: { - emptyOutDir: true, - minify: 'esbuild', // significantly faster than "terser" but may produce slightly-bigger bundles - outDir: fileURLToPath(this.config.dist), - rollupOptions: { - input: [], - output: { format: 'esm' }, - }, - target: 'es2020', // must match an esbuild target - }, - plugins: [ - rollupPluginHTML({ - rootDir: viteConfig.root, - input, - extractAssets: false, - }) as any, // "any" needed for CI; also we don’t need typedefs for this anyway - ...(viteConfig.plugins || []), - ], - publicDir: viteConfig.publicDir, - root: viteConfig.root, - server: viteConfig.server, - }); - debug(logging, 'build', timerMessage('Vite build finished', timer.buildStart)); - - // Write any additionally generated assets to disk. - timer.assetsStart = performance.now(); - Object.keys(assets).map((k) => { - if (!assets[k]) return; - const filePath = new URL(`file://${k}`); - fs.mkdirSync(new URL('./', filePath), { recursive: true }); - fs.writeFileSync(filePath, assets[k], 'utf8'); - delete assets[k]; // free up memory - }); - debug(logging, 'build', timerMessage('Additional assets copied', timer.assetsStart)); - - // Build your final sitemap. - timer.sitemapStart = performance.now(); - if (this.config.buildOptions.sitemap && this.config.buildOptions.site) { - const sitemapStart = performance.now(); - const sitemap = generateSitemap(input.map(({ name }) => new URL(`/${name}`, this.config.buildOptions.site).href)); - const sitemapPath = new URL('./sitemap.xml', this.config.dist); - await fs.promises.mkdir(new URL('./', sitemapPath), { recursive: true }); - await fs.promises.writeFile(sitemapPath, sitemap, 'utf8'); - } - debug(logging, 'build', timerMessage('Sitemap built', timer.sitemapStart)); - - // You're done! Time to clean up. - await viteServer.close(); - if (logging.level && levels[logging.level] <= levels['info']) { - await this.printStats({ cwd: this.config.dist, pageCount: input.length }); - } - } - - /** Extract all static paths from a dynamic route */ - private async getStaticPathsForRoute(route: RouteData): Promise<{ paths: string[]; rss?: RSSResult }> { - if (!this.viteServer) throw new Error(`vite.createServer() not called!`); - const filePath = new URL(`./${route.component}`, this.config.projectRoot); - const mod = (await this.viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; - validateGetStaticPathsModule(mod); - const rss = generateRssFunction(this.config.buildOptions.site, route); - const staticPaths: GetStaticPathsResult = (await mod.getStaticPaths!({ paginate: generatePaginateFunction(route), rss: rss.generator })).flat(); - this.routeCache[route.component] = staticPaths; - validateGetStaticPathsResult(staticPaths, this.logging); - return { - paths: staticPaths.map((staticPath) => staticPath.params && route.generate(staticPath.params)).filter(Boolean), - rss: rss.rss, - }; - } - - /** Stats */ - private async printStats({ cwd, pageCount }: { cwd: URL; pageCount: number }) { - const [js, html] = await Promise.all([profileJS({ cwd, entryHTML: new URL('./index.html', cwd) }), profileHTML({ cwd })]); - - /* eslint-disable no-console */ - console.log(`${bold(cyan('Done'))} -Pages (${pageCount} total) - ${green(`✔ All pages under ${kb(html.maxSize)}`)} -JS - ${pad('initial load', 50)}${pad(kb(js.entryHTML || 0), 8, 'left')} - ${pad('total size', 50)}${pad(kb(js.total), 8, 'left')} -CSS - ${pad('initial load', 50)}${pad('0 kB', 8, 'left')} - ${pad('total size', 50)}${pad('0 kB', 8, 'left')} -Images - ${green(`✔ All images under 50 kB`)} -`); - } -} +*/ \ No newline at end of file diff --git a/packages/astro/src/core/build/page.ts b/packages/astro/src/core/build/page.ts new file mode 100644 index 000000000..f8d6ec9dd --- /dev/null +++ b/packages/astro/src/core/build/page.ts @@ -0,0 +1,89 @@ +import type { AstroConfig, RouteData } from '../../@types/astro-core'; +import type { BuildOutput } from '../../@types/astro-build'; +import type { ViteDevServer } from '../vite'; +import _path from 'path'; +import { fileURLToPath } from 'url'; +import { LogOptions } from '../logger'; +import { loadModule } from '../ssr/index.js'; +import { validateGetStaticPathsModule, validateGetStaticPathsResult } from '../ssr/routing.js'; +import { generatePaginateFunction } from './paginate.js'; +import { generateRssFunction } from './rss.js'; + +interface PageLocation { + fileURL: URL; + snowpackURL: string; +} + +function convertMatchToLocation(routeMatch: RouteData, astroConfig: AstroConfig): PageLocation { + const url = new URL(`./${routeMatch.component}`, astroConfig.projectRoot); + return { + fileURL: url, + snowpackURL: `/_astro/${routeMatch.component}.js`, + }; +} + +interface PageBuildOptions { + astroConfig: AstroConfig; + buildState: BuildOutput; + path: string; + route: RouteData; +} + +/** Build dynamic page */ +export async function getStaticPathsForPage({ + astroConfig, + route, + logging, + viteServer, +}: { + astroConfig: AstroConfig; + route: RouteData; + logging: LogOptions; + viteServer: ViteDevServer; +}): Promise<{ paths: string[]; rss: any }> { + const location = convertMatchToLocation(route, astroConfig); + //const mod = await snowpackRuntime.importModule(location.snowpackURL); + const mod = await loadModule(location.fileURL, viteServer); + validateGetStaticPathsModule(mod); + const [rssFunction, rssResult] = generateRssFunction(astroConfig.buildOptions.site, route); + const staticPaths = mod.getStaticPaths!({ + paginate: generatePaginateFunction(route), + rss: rssFunction, + }); + validateGetStaticPathsResult(staticPaths, logging); + return { + paths: staticPaths.map((staticPath) => staticPath.params && route.generate(staticPath.params)).filter(Boolean), + rss: rssResult, + }; +} + +function formatOutFile(path: string, pageUrlFormat: AstroConfig['buildOptions']['pageUrlFormat']) { + if (path === '/404') { + return '/404.html'; + } + if (path === '/') { + return '/index.html'; + } + if (pageUrlFormat === 'directory') { + return _path.posix.join(path, '/index.html'); + } + return `${path}.html`; +} +/** Build static page */ +export async function buildStaticPage({ astroConfig, buildState, path, route, astroRuntime }: PageBuildOptions): Promise { + const location = convertMatchToLocation(route, astroConfig); + const normalizedPath = astroConfig.devOptions.trailingSlash === 'never' ? path : path.endsWith('/') ? path : `${path}/`; + const result = await astroRuntime.load(normalizedPath); + if (result.statusCode !== 200) { + let err = (result as any).error; + if (!(err instanceof Error)) err = new Error(err); + err.filename = fileURLToPath(location.fileURL); + throw err; + } + buildState[formatOutFile(path, astroConfig.buildOptions.pageUrlFormat)] = { + srcPath: location.fileURL, + contents: result.contents, + contentType: 'text/html', + encoding: 'utf8', + }; +} diff --git a/packages/astro/src/core/build/paginate.ts b/packages/astro/src/core/build/paginate.ts new file mode 100644 index 000000000..97a66cc2f --- /dev/null +++ b/packages/astro/src/core/build/paginate.ts @@ -0,0 +1,63 @@ +import { GetStaticPathsResult, PaginatedCollectionProp, PaginateFunction, Params, Props, RouteData } from '../@types/astro'; + +// return filters.map((filter) => { +// const filteredRecipes = allRecipes.filter((recipe) => +// filterKeys.some((key) => recipe[key] === filter) +// ); +// return paginate(filteredRecipes, { +// params: { slug: slugify(filter) }, +// props: { filter }, +// }); +// }); + +export function generatePaginateFunction(routeMatch: RouteData): PaginateFunction { + return function paginateUtility(data: any[], args: { pageSize?: number; params?: Params; props?: Props } = {}) { + let { pageSize: _pageSize, params: _params, props: _props } = args; + const pageSize = _pageSize || 10; + const paramName = 'page'; + const additoonalParams = _params || {}; + const additoonalProps = _props || {}; + let includesFirstPageNumber: boolean; + if (routeMatch.params.includes(`...${paramName}`)) { + includesFirstPageNumber = false; + } else if (routeMatch.params.includes(`${paramName}`)) { + includesFirstPageNumber = true; + } else { + throw new Error( + `[paginate()] page number param \`${paramName}\` not found in your filepath.\nRename your file to \`[...page].astro\` or customize the param name via the \`paginate([], {param: '...'}\` option.` + ); + } + const lastPage = Math.max(1, Math.ceil(data.length / pageSize)); + + const result: GetStaticPathsResult = [...Array(lastPage).keys()].map((num) => { + const pageNum = num + 1; + const start = pageSize === Infinity ? 0 : (pageNum - 1) * pageSize; // currentPage is 1-indexed + const end = Math.min(start + pageSize, data.length); + const params = { + ...additoonalParams, + [paramName]: includesFirstPageNumber || pageNum > 1 ? String(pageNum) : undefined, + }; + return { + params, + props: { + ...additoonalProps, + page: { + data: data.slice(start, end), + start, + end: end - 1, + size: pageSize, + total: data.length, + currentPage: pageNum, + lastPage: lastPage, + url: { + current: routeMatch.generate({ ...params }), + next: pageNum === lastPage ? undefined : routeMatch.generate({ ...params, page: String(pageNum + 1) }), + prev: pageNum === 1 ? undefined : routeMatch.generate({ ...params, page: !includesFirstPageNumber && pageNum - 1 === 1 ? undefined : String(pageNum - 1) }), + }, + } as PaginatedCollectionProp, + }, + }; + }); + return result; + }; +} diff --git a/packages/astro/src/core/build/rss.ts b/packages/astro/src/core/build/rss.ts new file mode 100644 index 000000000..b058970aa --- /dev/null +++ b/packages/astro/src/core/build/rss.ts @@ -0,0 +1,85 @@ +import type { RSSFunctionArgs, RouteData } from '../@types/astro'; +import parser from 'fast-xml-parser'; +import { canonicalURL } from './util.js'; + +/** Validates getStaticPaths.rss */ +export function validateRSS(args: GenerateRSSArgs): void { + const { rssData, srcFile } = args; + if (!rssData.title) throw new Error(`[${srcFile}] rss.title required`); + if (!rssData.description) throw new Error(`[${srcFile}] rss.description required`); + if ((rssData as any).item) throw new Error(`[${srcFile}] \`item: Function\` should be \`items: Item[]\``); + if (!Array.isArray(rssData.items)) throw new Error(`[${srcFile}] rss.items should be an array of items`); +} + +type GenerateRSSArgs = { site: string; rssData: RSSFunctionArgs; srcFile: string; feedURL: string }; + +/** Generate RSS 2.0 feed */ +export function generateRSS(args: GenerateRSSArgs): string { + validateRSS(args); + const { srcFile, feedURL, rssData, site } = args; + if ((rssData as any).item) throw new Error(`[${srcFile}] rss() \`item()\` function was deprecated, and is now \`items: object[]\`.`); + + let xml = ``; + xml += ``; + + // title, description, customData + xml += `<![CDATA[${rssData.title}]]>`; + xml += ``; + xml += `${canonicalURL(feedURL, site).href}`; + if (typeof rssData.customData === 'string') xml += rssData.customData; + // items + for (const result of rssData.items) { + xml += ``; + // validate + if (typeof result !== 'object') throw new Error(`[${srcFile}] rss.items expected an object. got: "${JSON.stringify(result)}"`); + if (!result.title) throw new Error(`[${srcFile}] rss.items required "title" property is missing. got: "${JSON.stringify(result)}"`); + if (!result.link) throw new Error(`[${srcFile}] rss.items required "link" property is missing. got: "${JSON.stringify(result)}"`); + xml += `<![CDATA[${result.title}]]>`; + xml += `${canonicalURL(result.link, site).href}`; + if (result.description) xml += ``; + if (result.pubDate) { + // note: this should be a Date, but if user provided a string or number, we can work with that, too. + if (typeof result.pubDate === 'number' || typeof result.pubDate === 'string') { + result.pubDate = new Date(result.pubDate); + } else if (result.pubDate instanceof Date === false) { + throw new Error('[${filename}] rss.item().pubDate must be a Date'); + } + xml += `${result.pubDate.toUTCString()}`; + } + if (typeof result.customData === 'string') xml += result.customData; + xml += ``; + } + + xml += ``; + + // validate user’s inputs to see if it’s valid XML + const isValid = parser.validate(xml); + if (isValid !== true) { + // If valid XML, isValid will be `true`. Otherwise, this will be an error object. Throw. + throw new Error(isValid as any); + } + + return xml; +} + +export function generateRssFunction(site: string | undefined, routeMatch: RouteData): [(args: any) => void, { url?: string; xml?: string }] { + let result: { url?: string; xml?: string } = {}; + function rssUtility(args: any) { + if (!site) { + throw new Error(`[${routeMatch.component}] rss() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`); + } + const { dest, ...rssData } = args; + const feedURL = dest || '/rss.xml'; + result.url = feedURL; + result.xml = generateRSS({ rssData, site, srcFile: routeMatch.component, feedURL }); + } + return [rssUtility, result]; +} diff --git a/packages/astro/src/core/build/sitemap.ts b/packages/astro/src/core/build/sitemap.ts new file mode 100644 index 000000000..d1e15636f --- /dev/null +++ b/packages/astro/src/core/build/sitemap.ts @@ -0,0 +1,27 @@ +import type { BuildOutput } from '../@types/astro'; +import { canonicalURL } from './util.js'; + +/** Construct sitemap.xml given a set of URLs */ +export function generateSitemap(buildState: BuildOutput, site: string): string { + const uniqueURLs = new Set(); + + // TODO: find way to respect URLs here + // TODO: find way to exclude pages from sitemap (currently only skips 404 pages) + + // look through built pages, only add HTML + for (const id of Object.keys(buildState)) { + if (buildState[id].contentType !== 'text/html') continue; + if (id === '/404.html') continue; + uniqueURLs.add(canonicalURL(id, site).href); + } + + const pages = [...uniqueURLs]; + pages.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); // sort alphabetically so sitemap is same each time + + let sitemap = ``; + for (const page of pages) { + sitemap += `${page}`; + } + sitemap += `\n`; + return sitemap; +} diff --git a/packages/astro/src/core/build/stats.ts b/packages/astro/src/core/build/stats.ts index 853f91e9d..eeff0af73 100644 --- a/packages/astro/src/core/build/stats.ts +++ b/packages/astro/src/core/build/stats.ts @@ -1,144 +1,92 @@ -import * as eslexer from 'es-module-lexer'; -import fetch from 'node-fetch'; -import fs from 'fs'; -import slash from 'slash'; -import glob from 'tiny-glob'; -import { fileURLToPath } from 'url'; +import type { BuildOutput, BundleMap } from '../@types/astro'; +import type { LogOptions } from '../logger'; -type FileSizes = { [file: string]: number }; +import { info, table } from '../logger.js'; +import { underline, bold } from 'kleur/colors'; +import gzipSize from 'gzip-size'; +import prettyBytes from 'pretty-bytes'; -// Feel free to modify output to whatever’s needed in display. If it’s not needed, kill it and improve stat speeds! - -/** JS: prioritize entry HTML, but also show total */ -interface JSOutput { - /** breakdown of JS per-file */ - js: FileSizes; - /** weight of index.html */ - entryHTML?: number; - /** total bytes of [js], added for convenience */ - total: number; +interface BundleStats { + size: number; + gzipSize: number; } -/** HTML: total isn’t important, because those are broken up requests. However, surface any anomalies / bloated HTML */ -interface HTMLOutput { - /** breakdown of HTML per-file */ - html: FileSizes; - /** biggest HTML file */ - maxSize: number; +interface URLStats { + dynamicImports: Set; + stats: BundleStats[]; } -/** Scan any directory */ -async function scan(cwd: URL, pattern: string): Promise { - const results = await glob(pattern, { cwd: fileURLToPath(cwd) }); - return results.map((filepath) => new URL(slash(filepath), cwd)); +export type BundleStatsMap = Map; +export type URLStatsMap = Map; + +export function createURLStats(): URLStatsMap { + return new Map(); } -/** get total HTML size */ -export async function profileHTML({ cwd }: { cwd: URL }): Promise { - const sizes: FileSizes = {}; - const html = await scan(cwd, '**/*.html'); - let maxSize = 0; - await Promise.all( - html.map(async (file) => { - const relPath = file.pathname.replace(cwd.pathname, ''); - const size = (await fs.promises.stat(file)).size; - sizes[relPath] = size; - if (size > maxSize) maxSize = size; - }) - ); - return { - html: sizes, - maxSize, - }; +export function createBundleStats(): BundleStatsMap { + return new Map(); } -/** get total JS size (note: .wasm counts as JS!) */ -export async function profileJS({ cwd, entryHTML }: { cwd: URL; entryHTML?: URL }): Promise { - const sizes: FileSizes = {}; - let htmlSize = 0; +export async function addBundleStats(bundleStatsMap: BundleStatsMap, code: string, filename: string) { + const gzsize = await gzipSize(code); - // profile HTML entry (do this first, before all JS in a project is scanned) - if (entryHTML) { - let entryScripts: URL[] = []; - let visitedEntry = false; // note: a quirk of Vite is that the entry file is async-loaded. Count that, but don’t count subsequent async loads + bundleStatsMap.set(filename, { + size: Buffer.byteLength(code), + gzipSize: gzsize, + }); +} - // Note: this function used cheerio to scan HTML, read deps, and build - // an accurate, “production-ready” benchmark for how much HTML, JS, and CSS - // you shipped. Disabled for now, because we have a post-merge cleanup item - // to revisit these build stats. - // - // let $ = cheerio.load(await fs.promises.readFile(entryHTML)); - // scan