diff --git a/packages/astro/package.json b/packages/astro/package.json index 03a07666f..1979ebdd1 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -54,6 +54,7 @@ "find-up": "^5.0.0", "github-slugger": "^1.3.0", "gray-matter": "^4.0.2", + "gzip-size": "^6.0.0", "hast-to-hyperscript": "~9.0.0", "kleur": "^4.1.4", "locate-character": "^2.0.5", @@ -77,6 +78,7 @@ "rollup-plugin-terser": "^7.0.2", "sass": "^1.32.8", "snowpack": "^3.3.7", + "string-width": "^5.0.0", "source-map-support": "^0.5.19", "svelte": "^3.35.0", "unified": "^9.2.1", diff --git a/packages/astro/src/build.ts b/packages/astro/src/build.ts index b7cca32e5..a09043db7 100644 --- a/packages/astro/src/build.ts +++ b/packages/astro/src/build.ts @@ -4,7 +4,7 @@ import type { LogOptions } from './logger'; import type { AstroRuntime, LoadResult } from './runtime'; import { existsSync, promises as fsPromises } from 'fs'; -import { bold, green, yellow, underline } from 'kleur/colors'; +import { bold, green, yellow } from 'kleur/colors'; import path from 'path'; import cheerio from 'cheerio'; import { fileURLToPath } from 'url'; @@ -16,6 +16,7 @@ import { generateRSS } from './build/rss.js'; import { generateSitemap } from './build/sitemap.js'; import { collectStatics } from './build/static.js'; import { canonicalURL } from './build/util.js'; +import { createURLStats, mapBundleStatsToURLStats, logURLStats } from './build/stats.js'; const { mkdir, readFile, writeFile } = fsPromises; @@ -205,8 +206,11 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { const statics = new Set(); const collectImportsOptions = { astroConfig, logging, resolvePackageUrl, mode }; - const pages = await allPages(pageRoot); let builtURLs: string[] = []; + let urlStats = createURLStats(); + let importsToUrl = new Map>(); + + const pages = await allPages(pageRoot); try { info(logging, 'build', yellow('! building pages...')); @@ -218,8 +222,10 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { const pageType = getPageType(filepath); const pageOptions: PageBuildOptions = { astroRoot, dist, filepath, runtime, site: astroConfig.buildOptions.site, sitemap: astroConfig.buildOptions.sitemap, statics }; + let urls: string[]; if (pageType === 'collection') { const { canonicalURLs, rss } = await buildCollectionPage(pageOptions); + urls = canonicalURLs; builtURLs.push(...canonicalURLs); if (rss) { const basename = path @@ -230,10 +236,27 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { } } else { const { canonicalURLs } = await buildStaticPage(pageOptions); + urls = canonicalURLs; builtURLs.push(...canonicalURLs); } - mergeSet(imports, await collectDynamicImports(filepath, collectImportsOptions)); + const dynamicImports = await collectDynamicImports(filepath, collectImportsOptions); + mergeSet(imports, dynamicImports); + + // Keep track of urls and dynamic imports for stats. + for(const url of urls) { + urlStats.set(url, { + dynamicImports, + stats: [] + }); + } + + for(let imp of dynamicImports) { + if(!importsToUrl.has(imp)) { + importsToUrl.set(imp, new Set()); + } + mergeSet(importsToUrl.get(imp)!, new Set(urls)); + } }) ); info(logging, 'build', green('✔'), 'pages built.'); @@ -253,7 +276,8 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { if (imports.size > 0) { try { info(logging, 'build', yellow('! bundling client-side code.')); - await bundle(imports, { dist, runtime, astroConfig }); + const bundleStats = await bundle(imports, { dist, runtime, astroConfig }); + mapBundleStatsToURLStats(urlStats, importsToUrl, bundleStats); info(logging, 'build', green('✔'), 'bundling complete.'); } catch (err) { error(logging, 'build', err); @@ -297,13 +321,8 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { info(logging, 'tip', `Set "buildOptions.site" in astro.config.mjs to generate a sitemap.xml`); } - builtURLs.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); - info(logging, 'build', underline('Pages')); - const lastIndex = builtURLs.length - 1; - builtURLs.forEach((url, index) => { - const sep = index === 0 ? '┌' : index === lastIndex ? '└' : '├'; - info(logging, null, ' ' + sep, url === '/' ? url : url + '/'); - }); + // Log in a table-like view. + logURLStats(logging, urlStats, builtURLs); await runtime.shutdown(); info(logging, 'build', bold(green('▶ Build Complete!'))); diff --git a/packages/astro/src/build/bundle.ts b/packages/astro/src/build/bundle.ts index 0191e8c09..c8bc37ece 100644 --- a/packages/astro/src/build/bundle.ts +++ b/packages/astro/src/build/bundle.ts @@ -1,6 +1,6 @@ import type { AstroConfig, RuntimeMode, ValidExtensionPlugins } from '../@types/astro'; import type { ImportDeclaration } from '@babel/types'; -import type { InputOptions, OutputOptions } from 'rollup'; +import type { InputOptions, OutputOptions, OutputChunk } from 'rollup'; import type { AstroRuntime } from '../runtime'; import type { LogOptions } from '../logger'; @@ -16,6 +16,7 @@ import babelParser from '@babel/parser'; import path from 'path'; import { rollup } from 'rollup'; import { terser } from 'rollup-plugin-terser'; +import { createBundleStats, addBundleStats } from './stats.js'; const { transformSync } = esbuild; const { readFile } = fsPromises; @@ -309,5 +310,12 @@ export async function bundle(imports: Set, { runtime, dist }: BundleOpti ], }; - await build.write(outputOptions); + 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/build/stats.ts b/packages/astro/src/build/stats.ts new file mode 100644 index 000000000..e29409994 --- /dev/null +++ b/packages/astro/src/build/stats.ts @@ -0,0 +1,61 @@ +import type { LogOptions } from '../logger'; + +import { info, table } from '../logger.js'; +import { underline } from 'kleur/colors'; +import gzipSize from 'gzip-size'; + +interface BundleStats { + size: number; + gzipSize: number; +} + +interface URLStats { + dynamicImports: Set; + stats: BundleStats[]; +} + +export type BundleStatsMap = Map; +export type URLStatsMap = Map; + +export function createURLStats(): URLStatsMap { + return new Map(); +} + +export function createBundleStats(): BundleStatsMap { + return new Map(); +} + +export async function addBundleStats(bundleStatsMap: BundleStatsMap, code: string, filename: string) { + const gzsize = await gzipSize(code); + + bundleStatsMap.set(filename, { + size: Buffer.byteLength(code), + gzipSize: gzsize + }); +} + +export function mapBundleStatsToURLStats(urlStats: URLStatsMap, importsToUrl: Map>, bundleStats: BundleStatsMap) { + for(let [imp, stats] of bundleStats) { + for(let url of importsToUrl.get('/' + imp) || []) { + urlStats.get(url)?.stats.push(stats); + } + } +} + +export function logURLStats(logging: LogOptions, urlStats: URLStatsMap, builtURLs: string[]) { + builtURLs.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); + info(logging, null, ''); + const log = table(logging, [60, 20]); + log(info, ' ' + underline('Pages'), underline('GZip Size')); + + const lastIndex = builtURLs.length - 1; + builtURLs.forEach((url, index) => { + const sep = index === 0 ? '┌' : index === lastIndex ? '└' : '├'; + const urlPart = (' ' + sep + ' ') + (url === '/' ? url : url + '/'); + + const bytes = urlStats.get(url)?.stats.map(s => s.gzipSize).reduce((a, b) => a + b, 0) || 0; + const kb = (bytes * 0.001).toFixed(2); + const sizePart = kb + ' kB'; + log(info, urlPart, sizePart); + }); +} \ No newline at end of file diff --git a/packages/astro/src/logger.ts b/packages/astro/src/logger.ts index a97482459..d5d4fac89 100644 --- a/packages/astro/src/logger.ts +++ b/packages/astro/src/logger.ts @@ -3,6 +3,7 @@ import type { CompileError } from 'astro-parser'; import { bold, blue, red, grey, underline } from 'kleur/colors'; import { Writable } from 'stream'; import { format as utilFormat } from 'util'; +import stringWidth from 'string-width'; type ConsoleStream = Writable & { fd: 1 | 2; @@ -102,6 +103,15 @@ export function error(opts: LogOptions, type: string | null, ...messages: Array< return log(opts, 'error', type, ...messages); } +type LogFn = typeof debug | typeof info | typeof warn | typeof error; + +export function table(opts: LogOptions, columns: number[]) { + return function logTable(logFn: LogFn, ...input: Array) { + const messages = columns.map((len, i) => padStr(input[i].toString(), len)); + logFn(opts, null, ...messages); + }; +} + /** Pretty format error for display */ export function parseError(opts: LogOptions, err: CompileError) { let frame = err.frame @@ -142,3 +152,14 @@ export function trapWarn(cb: (...args: any[]) => void = () => {}) { }; return () => (console.warn = warn); } + + + +function padStr(str: string, len: number) { + const strLen = stringWidth(str); + if(strLen > len) { + return str.substring(0, len - 3) + '...'; + } + const spaces = Array.from({ length: len - strLen }, () => ' ').join(''); + return str + spaces; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0a80fe6f9..515ddf0ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2019,6 +2019,11 @@ ansi-regex@^5.0.0: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz" integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== +ansi-regex@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.0.tgz#ecc7f5933cbe5ac7b33e209a5ff409ab1669c6b2" + integrity sha512-tAaOSrWCHF+1Ear1Z4wnJCXA9GGox4K6Ic85a5qalES2aeEwQGr7UC93mwef49536PkCYjzkp0zIxfFvexJ6zQ== + ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz" @@ -3972,7 +3977,7 @@ duplexer3@^0.1.4: resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz" integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= -duplexer@^0.1.1: +duplexer@^0.1.1, duplexer@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== @@ -4059,6 +4064,11 @@ emoji-regex@^8.0.0: resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + emojis-list@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz" @@ -5263,6 +5273,13 @@ gray-matter@^4.0.2: section-matter "^1.0.0" strip-bom-string "^1.0.0" +gzip-size@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" + integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== + dependencies: + duplexer "^0.1.2" + hamljs@^0.6.2: version "0.6.2" resolved "https://registry.npmjs.org/hamljs/-/hamljs-0.6.2.tgz" @@ -6054,6 +6071,11 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-fullwidth-code-point@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88" + integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== + is-glob@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz" @@ -10453,6 +10475,15 @@ string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2 is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" +string-width@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.0.0.tgz#19191f152f937b96f4ec54ba0986a5656660c5a2" + integrity sha512-zwXcRmLUdiWhMPrHz6EXITuyTgcEnUqDzspTkCLhQovxywWz6NP9VHgqfVg20V/1mUg0B95AKbXxNT+ALRmqCw== + dependencies: + emoji-regex "^9.2.2" + is-fullwidth-code-point "^4.0.0" + strip-ansi "^7.0.0" + string.prototype.trimend@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz" @@ -10521,6 +10552,13 @@ strip-ansi@^6.0.0: dependencies: ansi-regex "^5.0.0" +strip-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.0.tgz#1dc49b980c3a4100366617adac59327eefdefcb0" + integrity sha512-UhDTSnGF1dc0DRbUqr1aXwNoY3RgVkSWG8BrpnuFIxhP57IqbS7IRta2Gfiavds4yCxc5+fEAVVOgBZWnYkvzg== + dependencies: + ansi-regex "^6.0.0" + strip-ansi@~0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.1.1.tgz"