diff options
author | 2021-05-06 10:38:53 -0600 | |
---|---|---|
committer | 2021-05-06 10:38:53 -0600 | |
commit | b81abd5b2c0e68f05c99eaad54eb5b9a6bc092db (patch) | |
tree | e21c6c1318b5bf2cf7c6cb74290a6262fdab6e22 | |
parent | 64f4f74fb641194c12edf3888a55a3359c9f594f (diff) | |
download | astro-b81abd5b2c0e68f05c99eaad54eb5b9a6bc092db.tar.gz astro-b81abd5b2c0e68f05c99eaad54eb5b9a6bc092db.tar.zst astro-b81abd5b2c0e68f05c99eaad54eb5b9a6bc092db.zip |
Add CSS bundling (#172)
* Add CSS bundling
* Add Changeset
* Update build script
* Count better
* Fix stats
* Cleanup
* Add test
* Show profile ms under 750ms
36 files changed, 891 insertions, 570 deletions
diff --git a/.changeset/great-cats-train.md b/.changeset/great-cats-train.md new file mode 100644 index 000000000..35ce4a4df --- /dev/null +++ b/.changeset/great-cats-train.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Add CSS bundling diff --git a/examples/blog/package.json b/examples/blog/package.json index 15b0209b1..20f97487c 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -8,7 +8,7 @@ "astro-dev": "nodemon --delay 0.5 -w ../../packages/astro/dist -x '../../packages/astro/astro.mjs dev'" }, "devDependencies": { - "astro": "0.0.11", + "astro": "^0.0.12", "nodemon": "^2.0.7" }, "snowpack": { diff --git a/examples/kitchen-sink/package.json b/examples/kitchen-sink/package.json index ba35948fb..c2a99adb7 100644 --- a/examples/kitchen-sink/package.json +++ b/examples/kitchen-sink/package.json @@ -8,7 +8,7 @@ "astro-dev": "nodemon --delay 0.5 -w ../../packages/astro/dist -x '../../packages/astro/astro.mjs dev'" }, "devDependencies": { - "astro": "0.0.11", + "astro": "^0.0.12", "nodemon": "^2.0.7" }, "snowpack": { diff --git a/examples/portfolio/package.json b/examples/portfolio/package.json index b3567282d..ffe44b97a 100644 --- a/examples/portfolio/package.json +++ b/examples/portfolio/package.json @@ -8,7 +8,7 @@ "astro-dev": "nodemon --delay 0.5 -w ../../packages/astro/dist -x '../../packages/astro/astro.mjs dev'" }, "devDependencies": { - "astro": "0.0.11" + "astro": "^0.0.12" }, "snowpack": { "workspaceRoot": "../.." diff --git a/examples/snowpack/package.json b/examples/snowpack/package.json index 0b7dbfd90..8a095b13f 100644 --- a/examples/snowpack/package.json +++ b/examples/snowpack/package.json @@ -12,7 +12,7 @@ "lint": "prettier --check \"src/**/*.js\"" }, "dependencies": { - "astro": "0.0.11", + "astro": "^0.0.12", "date-fns": "^2.19.0", "deepmerge": "^4.2.2", "docsearch.js": "^2.6.3", @@ -26,7 +26,7 @@ "@11ty/eleventy-plugin-syntaxhighlight": "^3.0.4", "@contentful/rich-text-html-renderer": "^14.1.2", "@contentful/rich-text-types": "^14.1.2", - "astro": "0.0.11", + "astro": "^0.0.12", "eleventy-plugin-nesting-toc": "^1.2.0", "luxon": "^1.25.0", "markdown-it": "^12.0.2", diff --git a/examples/tailwindcss/package.json b/examples/tailwindcss/package.json index 7889890db..c00350964 100644 --- a/examples/tailwindcss/package.json +++ b/examples/tailwindcss/package.json @@ -8,7 +8,7 @@ "astro-dev": "nodemon --delay 0.5 -w ../../packages/astro/dist -x '../../packages/astro/astro.mjs dev'" }, "devDependencies": { - "astro": "0.0.11", + "astro": "^0.0.12", "tailwindcss": "^2.1.1" }, "snowpack": { diff --git a/package.json b/package.json index 314e216fd..9ad0ee961 100644 --- a/package.json +++ b/package.json @@ -32,18 +32,18 @@ "devDependencies": { "@typescript-eslint/eslint-plugin": "^4.22.0", "@typescript-eslint/parser": "^4.18.0", - "cheerio": "^1.0.0-rc.5", + "cheerio": "^1.0.0-rc.6", "cheerio-select-tmp": "^0.1.1", "del": "^6.0.0", + "esbuild": "^0.11.17", "eslint": "^7.25.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", "execa": "^5.0.0", "lerna": "^4.0.0", "prettier": "^2.2.1", - "tiny-glob": "^0.2.8", - "esbuild": "^0.11.17", "svelte": "^3.38.0", + "tiny-glob": "^0.2.8", "typescript": "^4.2.4", "uvu": "^0.5.1" }, diff --git a/packages/astro/package.json b/packages/astro/package.json index 81c5e0645..59505a3f9 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -43,8 +43,8 @@ "astro-parser": "0.0.9", "astro-prism": "0.0.2", "autoprefixer": "^10.2.5", - "cheerio": "^1.0.0-rc.5", - "domhandler": "^4.1.0", + "cheerio": "^1.0.0-rc.6", + "del": "^6.0.0", "es-module-lexer": "^0.4.1", "esbuild": "^0.10.1", "estree-walker": "^3.0.0", @@ -62,6 +62,7 @@ "micromark-extension-gfm": "^0.3.3", "micromark-extension-mdx-expression": "^0.3.2", "micromark-extension-mdx-jsx": "^0.3.3", + "mime": "^2.5.2", "moize": "^6.0.1", "node-fetch": "^2.6.1", "picomatch": "^2.2.3", @@ -76,6 +77,7 @@ "rollup": "^2.43.1", "rollup-plugin-terser": "^7.0.2", "sass": "^1.32.8", + "shorthash": "^0.0.2", "snowpack": "^3.3.7", "source-map-support": "^0.5.19", "string-width": "^5.0.0", @@ -92,6 +94,7 @@ "@types/babel__traverse": "^7.11.1", "@types/estree": "0.0.46", "@types/github-slugger": "^1.3.0", + "@types/mime": "^2.0.3", "@types/node": "^14.14.31", "@types/react": "^17.0.3", "@types/react-dom": "^17.0.2", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 049105970..df7dbc4d6 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -63,6 +63,36 @@ export type RuntimeMode = 'development' | 'production'; export type Params = Record<string, string | number>; +/** 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'; +} + +/** 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<string>; + /** CSS needed for page, whether imported via <link>, JS, or Astro component. */ + css: Set<string>; + /** Images needed for page. Can be loaded via CSS, <link>, or otherwise. */ + images: Set<string>; +} + export interface CreateCollection<T = any> { data: ({ params }: { params: Params }) => T[]; routes?: Params[]; diff --git a/packages/astro/src/@types/shorthash.d.ts b/packages/astro/src/@types/shorthash.d.ts new file mode 100644 index 000000000..02eb5ba51 --- /dev/null +++ b/packages/astro/src/@types/shorthash.d.ts @@ -0,0 +1,5 @@ +declare module 'shorthash' { + function unique(string: string): string; + + export default { unique }; +} diff --git a/packages/astro/src/build.ts b/packages/astro/src/build.ts index a09043db7..9661b5cc4 100644 --- a/packages/astro/src/build.ts +++ b/packages/astro/src/build.ts @@ -1,40 +1,24 @@ import 'source-map-support/register.js'; -import type { AstroConfig, RuntimeMode } from './@types/astro'; +import type { AstroConfig, BundleMap, BuildOutput, RuntimeMode, PageDependencies } from './@types/astro'; import type { LogOptions } from './logger'; -import type { AstroRuntime, LoadResult } from './runtime'; -import { existsSync, promises as fsPromises } from 'fs'; -import { bold, green, yellow } from 'kleur/colors'; +import fs from 'fs'; import path from 'path'; -import cheerio from 'cheerio'; import { fileURLToPath } from 'url'; +import { performance } from 'perf_hooks'; +import cheerio from 'cheerio'; +import del from 'del'; +import { bold, green, yellow } from 'kleur/colors'; +import mime from 'mime'; import { fdir } from 'fdir'; -import { defaultLogDestination, error, info, trapWarn } from './logger.js'; -import { createRuntime } from './runtime.js'; -import { bundle, collectDynamicImports } from './build/bundle.js'; -import { generateRSS } from './build/rss.js'; +import { bundleCSS } from './build/bundle/css.js'; +import { bundleJS, collectJSImports } from './build/bundle/js'; +import { buildCollectionPage, buildStaticPage, getPageType } from './build/page.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; - -interface PageBuildOptions { - astroRoot: URL; - dist: URL; - filepath: URL; - runtime: AstroRuntime; - site?: string; - sitemap: boolean; - statics: Set<string>; -} - -interface PageResult { - canonicalURLs: string[]; - rss?: string; - statusCode: number; -} +import { logURLStats, collectBundleStats, mapBundleStatsToURLStats } from './build/stats.js'; +import { getDistPath, sortSet, stopTimer } from './build/util.js'; +import { debug, defaultLogDestination, error, info, trapWarn } from './logger.js'; +import { createRuntime } from './runtime.js'; const logging: LogOptions = { level: 'debug', @@ -51,145 +35,20 @@ async function allPages(root: URL) { return files as string[]; } -/** Utility for merging two Set()s */ -function mergeSet(a: Set<string>, b: Set<string>) { - for (let str of b) { - a.add(str); - } - return a; -} - -/** Utility for writing to file (async) */ -async function writeFilep(outPath: URL, bytes: string | Buffer, encoding: 'utf8' | null) { - const outFolder = new URL('./', outPath); - await mkdir(outFolder, { recursive: true }); - await writeFile(outPath, bytes, encoding || 'binary'); -} - -interface WriteResultOptions { - srcPath: string; - result: LoadResult; - outPath: URL; - encoding: null | 'utf8'; -} - -/** Utility for writing a build result to disk */ -async function writeResult({ srcPath, result, outPath, encoding }: WriteResultOptions) { - if (result.statusCode === 500 || result.statusCode === 404) { - error(logging, 'build', ` Failed to build ${srcPath}\n${' '.repeat(9)}`, result.error?.message ?? `Unexpected load result (${result.statusCode})`); - } else if (result.statusCode !== 200) { - error(logging, 'build', ` Failed to build ${srcPath}\n${' '.repeat(9)}`, `Unexpected load result (${result.statusCode}) for ${fileURLToPath(outPath)}`); - } else { - const bytes = result.contents; - await writeFilep(outPath, bytes, encoding); - } -} - -/** Collection utility */ -function getPageType(filepath: URL): 'collection' | 'static' { - if (/\$[^.]+.astro$/.test(filepath.pathname)) return 'collection'; - return 'static'; -} - -/** Build collection */ -async function buildCollectionPage({ astroRoot, dist, filepath, runtime, site, statics }: PageBuildOptions): Promise<PageResult> { - const rel = path.relative(fileURLToPath(astroRoot) + '/pages', fileURLToPath(filepath)); // pages/index.astro - const pagePath = `/${rel.replace(/\$([^.]+)\.astro$/, '$1')}`; - const srcPath = fileURLToPath(new URL('pages/' + rel, astroRoot)); - const builtURLs = new Set<string>(); // !important: internal cache that prevents building the same URLs - - /** Recursively build collection URLs */ - async function loadCollection(url: string): Promise<LoadResult | undefined> { - if (builtURLs.has(url)) return; // this stops us from recursively building the same pages over and over - const result = await runtime.load(url); - builtURLs.add(url); - if (result.statusCode === 200) { - const outPath = new URL('./' + url + '/index.html', dist); - await writeResult({ srcPath, result, outPath, encoding: 'utf8' }); - mergeSet(statics, collectStatics(result.contents.toString('utf8'))); - } - return result; - } - - const result = (await loadCollection(pagePath)) as LoadResult; - - if (result.statusCode >= 500) { - throw new Error((result as any).error); - } - if (result.statusCode === 200 && !result.collectionInfo) { - throw new Error(`[${rel}]: Collection page must export createCollection() function`); - } - - let rss: string | undefined; - - // note: for pages that require params (/tag/:tag), we will get a 404 but will still get back collectionInfo that tell us what the URLs should be - if (result.collectionInfo) { - // build subsequent pages - await Promise.all( - [...result.collectionInfo.additionalURLs].map(async (url) => { - // for the top set of additional URLs, we render every new URL generated - const addlResult = await loadCollection(url); - builtURLs.add(url); - if (addlResult && addlResult.collectionInfo) { - // believe it or not, we may still have a few unbuilt pages left. this is our last crawl: - await Promise.all([...addlResult.collectionInfo.additionalURLs].map(async (url2) => loadCollection(url2))); - } - }) - ); - - if (result.collectionInfo.rss) { - if (!site) throw new Error(`[${rel}] createCollection() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`); - rss = generateRSS({ ...(result.collectionInfo.rss as any), site }, rel.replace(/\$([^.]+)\.astro$/, '$1')); - } - } - - return { - canonicalURLs: [...builtURLs].filter((url) => !url.endsWith('/1')), // note: canonical URLs are controlled by the collection, so these are canonical (but exclude "/1" pages as those are duplicates of the index) - statusCode: result.statusCode, - rss, - }; -} - -/** Build static page */ -async function buildStaticPage({ astroRoot, dist, filepath, runtime, sitemap, statics }: PageBuildOptions): Promise<PageResult> { - const rel = path.relative(fileURLToPath(astroRoot) + '/pages', fileURLToPath(filepath)); // pages/index.astro - const pagePath = `/${rel.replace(/\.(astro|md)$/, '')}`; - let canonicalURLs: string[] = []; - - let relPath = './' + rel.replace(/\.(astro|md)$/, '.html'); - if (!relPath.endsWith('index.html')) { - relPath = relPath.replace(/\.html$/, '/index.html'); - } - - const srcPath = fileURLToPath(new URL('pages/' + rel, astroRoot)); - const outPath = new URL(relPath, dist); - const result = await runtime.load(pagePath); - - await writeResult({ srcPath, result, outPath, encoding: 'utf8' }); - - if (result.statusCode === 200) { - mergeSet(statics, collectStatics(result.contents.toString('utf8'))); - - // get Canonical URL (if user has specified one manually, use that) - if (sitemap) { - const $ = cheerio.load(result.contents); - const canonicalTag = $('link[rel="canonical"]'); - canonicalURLs.push(canonicalTag.attr('href') || pagePath.replace(/index$/, '')); - } - } - - return { - canonicalURLs, - statusCode: result.statusCode, - }; +/** Is this URL remote? */ +function isRemote(url: string) { + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) return true; + return false; } /** The primary build action */ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { const { projectRoot, astroRoot } = astroConfig; - const pageRoot = new URL('./pages/', astroRoot); - const componentRoot = new URL('./components/', astroRoot); const dist = new URL(astroConfig.dist + '/', projectRoot); + const pageRoot = new URL('./pages/', astroRoot); + const buildState: BuildOutput = {}; + const depTree: BundleMap = {}; + const timer: Record<string, number> = {}; const runtimeLogging: LogOptions = { level: 'error', @@ -200,63 +59,34 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { const runtime = await createRuntime(astroConfig, { mode, logging: runtimeLogging }); const { runtimeConfig } = runtime; const { backendSnowpack: snowpack } = runtimeConfig; - const resolvePackageUrl = (pkgName: string) => snowpack.getUrlForPackage(pkgName); - - const imports = new Set<string>(); - const statics = new Set<string>(); - const collectImportsOptions = { astroConfig, logging, resolvePackageUrl, mode }; - - let builtURLs: string[] = []; - let urlStats = createURLStats(); - let importsToUrl = new Map<string, Set<string>>(); const pages = await allPages(pageRoot); + // 0. erase build directory + await del(fileURLToPath(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(); try { info(logging, 'build', yellow('! building pages...')); - // Vue also console.warns, this silences it. - const release = trapWarn(); + const release = trapWarn(); // Vue also console.warns, this silences it. await Promise.all( pages.map(async (pathname) => { const filepath = new URL(`file://${pathname}`); - - 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 - .relative(fileURLToPath(astroRoot) + '/pages', pathname) - .replace(/^\$/, '') - .replace(/\.astro$/, ''); - await writeFilep(new URL(`file://${path.join(fileURLToPath(dist), 'feed', basename + '.xml')}`), rss, 'utf8'); - } - } else { - const { canonicalURLs } = await buildStaticPage(pageOptions); - urls = canonicalURLs; - builtURLs.push(...canonicalURLs); - } - - 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<string>()); - } - mergeSet(importsToUrl.get(imp)!, new Set(urls)); - } + const buildPage = getPageType(filepath) === 'collection' ? buildCollectionPage : buildStaticPage; + await buildPage({ + astroConfig, + buildState, + filepath, + logging, + mode, + resolvePackageUrl: (pkgName: string) => snowpack.getUrlForPackage(pkgName), + runtime, + site: astroConfig.buildOptions.site, + }); }) ); info(logging, 'build', green('✔'), 'pages built.'); @@ -266,65 +96,182 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> { await runtime.shutdown(); return 1; } - - info(logging, 'build', yellow('! scanning pages...')); - for (const pathname of await allPages(componentRoot)) { - mergeSet(imports, await collectDynamicImports(new URL(`file://${pathname}`), collectImportsOptions)); - } - info(logging, 'build', green('✔'), 'pages scanned.'); - - if (imports.size > 0) { - try { - info(logging, 'build', yellow('! bundling client-side code.')); - const bundleStats = await bundle(imports, { dist, runtime, astroConfig }); - mapBundleStatsToURLStats(urlStats, importsToUrl, bundleStats); - info(logging, 'build', green('✔'), 'bundling complete.'); - } catch (err) { - error(logging, 'build', err); - await runtime.shutdown(); - return 1; + debug(logging, 'build', `built pages [${stopTimer(timer.build)}]`); + + // after pages are built, build depTree + timer.deps = performance.now(); + const scanPromises: Promise<void>[] = []; + 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, + }); + 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( + runtime.load(url).then((result) => { + if (result.statusCode !== 200) { + throw new Error((result as any).error); // there shouldn’t be a build error here + } + buildState[url] = { + srcPath: new URL(url, projectRoot), + contents: result.contents, + contentType: result.contentType || mime.getType(url) || '', + }; + }) + ); } } - - for (let url of statics) { - const outPath = new URL('.' + url, dist); - const result = await runtime.load(url); - - await writeResult({ srcPath: url, result, outPath, encoding: null }); + 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.prebundle = performance.now(); + await Promise.all([ + bundleCSS({ buildState, astroConfig, logging, depTree }).then(() => { + debug(logging, 'build', `bundled CSS [${stopTimer(timer.prebundle)}]`); + }), + // 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', 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)}]`); + } else if (astroConfig.buildOptions.sitemap) { + info(logging, 'tip', `Set "buildOptions.site" in astro.config.mjs to generate a sitemap.xml, or set "buildOptions.sitemap: false" to disable this message.`); } - if (existsSync(astroConfig.public)) { + timer.write = performance.now(); + + // write to disk and free up memory + await Promise.all( + Object.keys(buildState).map(async (id) => { + const outPath = new URL(`.${id}`, dist); + const parentDir = path.posix.dirname(fileURLToPath(outPath)); + await fs.promises.mkdir(parentDir, { recursive: true }); + await fs.promises.writeFile(outPath, buildState[id].contents, buildState[id].encoding); + 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 pub = astroConfig.public; const publicFiles = (await new fdir().withFullPaths().crawl(fileURLToPath(pub)).withPromise()) as string[]; - for (const filepath of publicFiles) { - const fileUrl = new URL(`file://${filepath}`); - const rel = path.relative(pub.pathname, fileUrl.pathname); - const outUrl = new URL('./' + rel, dist); - - const bytes = await readFile(fileUrl); - await writeFilep(outUrl, bytes, null); - } + await Promise.all( + publicFiles.map(async (filepath) => { + const fileUrl = new URL(`file://${filepath}`); + const rel = path.relative(fileURLToPath(pub), fileURLToPath(fileUrl)); + const outPath = new URL(path.join('.', rel), dist); + await fs.promises.mkdir(path.dirname(fileURLToPath(outPath)), { recursive: true }); + await fs.promises.copyFile(fileUrl, outPath); + }) + ); + 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...`)); } } - // build sitemap - if (astroConfig.buildOptions.sitemap && astroConfig.buildOptions.site) { - info(logging, 'build', yellow('! creating a sitemap...')); - const sitemap = generateSitemap(builtURLs.map((url) => ({ canonicalURL: canonicalURL(url, astroConfig.buildOptions.site) }))); - await writeFile(new URL('./sitemap.xml', dist), sitemap, 'utf8'); - info(logging, 'build', green('✔'), 'sitemap built.'); - } else if (astroConfig.buildOptions.sitemap) { - info(logging, 'tip', `Set "buildOptions.site" in astro.config.mjs to generate a sitemap.xml`); - } - // Log in a table-like view. - logURLStats(logging, urlStats, builtURLs); + /** + * 5. Bundling 2nd pass: On disk + * Bundle JS, which requires hard files to optimize + */ + info(logging, 'build', yellow(`! bundling...`)); + if (jsImports.size > 0) { + try { + timer.bundleJS = performance.now(); + const jsStats = await bundleJS(jsImports, { dist: new URL(dist + '/', projectRoot), runtime }); + mapBundleStatsToURLStats({ urlStats, depTree, bundleStats: jsStats }); + debug(logging, 'build', `bundled JS [${stopTimer(timer.bundleJS)}]`); + info(logging, 'build', green(`✔`), 'bundling complete.'); + } catch (err) { + error(logging, 'build', err); + await runtime.shutdown(); + return 1; + } + } + /** + * 6. Print stats + */ + logURLStats(logging, urlStats); await runtime.shutdown(); info(logging, 'build', bold(green('▶ Build Complete!'))); return 0; } + +/** Given an HTML string, collect <link> and <img> tags */ +export function findDeps(html: string, { astroConfig, srcPath }: { astroConfig: AstroConfig; srcPath: URL }): PageDependencies { + const pageDeps: PageDependencies = { + js: new Set<string>(), + css: new Set<string>(), + images: new Set<string>(), + }; + + const $ = cheerio.load(html); + + $('script').each((i, el) => { + const src = $(el).attr('src'); + if (src && !isRemote(src)) { + pageDeps.js.add(getDistPath(src, { astroConfig, srcPath })); + } + }); + + $('link[href]').each((i, el) => { + const href = $(el).attr('href'); + if (href && !isRemote(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 && !isRemote(src)) { + pageDeps.images.add(getDistPath(src, { astroConfig, srcPath })); + } + }); + + // sort (makes things a bit more predictable) + pageDeps.js = sortSet(pageDeps.js); + pageDeps.css = sortSet(pageDeps.css); + pageDeps.images = sortSet(pageDeps.images); + + return pageDeps; +} diff --git a/packages/astro/src/build/bundle/css.ts b/packages/astro/src/build/bundle/css.ts new file mode 100644 index 000000000..11f978140 --- /dev/null +++ b/packages/astro/src/build/bundle/css.ts @@ -0,0 +1,139 @@ +import type { AstroConfig, BuildOutput, BundleMap } from '../../@types/astro'; +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, 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<void> { + const timer: Record<string, number> = {}; + const cssMap = new Map<string, string>(); + + // 1. organize CSS into common or page-specific CSS + timer.bundle = performance.now(); + for (const [pageUrl, { css }] of Object.entries(depTree)) { + for (const cssUrl of css.keys()) { + 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 + timer.bundle = performance.now(); + await Promise.all( + Object.keys(buildState).map(async (id) => { + if (buildState[id].contentType !== 'text/css') return; + + const newUrl = cssMap.get(id); + if (!newUrl) return; + + // 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 as string, { + 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 <link> tags with final hashed URLs + timer.hashes = performance.now(); + const cssHashes = new Map<string, string>(); + 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 <link> 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 pageCSS = new Set<string>(); // keep track of page-specific CSS so we 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) { + // note: link[href] will select too much, however, remote CSS and non-CSS link tags won’t be in cssMap + if (pageCSS.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) + pageCSS.add(newHref); + } + // bonus: add [rel] and [type]. not necessary, but why not? + $(el).attr('rel', 'stylesheet'); + $(el).attr('type', 'text/css'); + } + }); + (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/build/bundle/js.ts b/packages/astro/src/build/bundle/js.ts new file mode 100644 index 000000000..0112f024a --- /dev/null +++ b/packages/astro/src/build/bundle/js.ts @@ -0,0 +1,95 @@ +import type { InputOptions, OutputOptions, OutputChunk } from 'rollup'; +import type { BuildOutput } from '../../@types/astro'; +import type { AstroRuntime } from '../../runtime'; + +import { fileURLToPath } from 'url'; +import { rollup } from 'rollup'; +import { terser } from 'rollup-plugin-terser'; +import { createBundleStats, addBundleStats, BundleStatsMap } from '../stats.js'; + +interface BundleOptions { + dist: URL; + runtime: AstroRuntime; +} + +/** Collect JS imports from build output */ +export function collectJSImports(buildState: BuildOutput): Set<string> { + const imports = new Set<string>(); + for (const id of Object.keys(buildState)) { + if (buildState[id].contentType === 'application/javascript') imports.add(id); + } + return imports; +} + +/** Bundle JS action */ +export async function bundleJS(imports: Set<string>, { runtime, dist }: BundleOptions): Promise<BundleStatsMap> { + 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: fileURLToPath(dist), + format: 'esm', + exports: 'named', + entryFileNames(chunk) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return chunk.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/build/bundle.ts b/packages/astro/src/build/page.ts index c8bc37ece..89e2ac2a5 100644 --- a/packages/astro/src/build/bundle.ts +++ b/packages/astro/src/build/page.ts @@ -1,28 +1,139 @@ -import type { AstroConfig, RuntimeMode, ValidExtensionPlugins } from '../@types/astro'; import type { ImportDeclaration } from '@babel/types'; -import type { InputOptions, OutputOptions, OutputChunk } from 'rollup'; -import type { AstroRuntime } from '../runtime'; +import type { AstroConfig, BuildOutput, RuntimeMode, ValidExtensionPlugins } from '../@types/astro'; +import type { AstroRuntime, LoadResult } from '../runtime'; import type { LogOptions } from '../logger'; -import esbuild from 'esbuild'; -import { promises as fsPromises } from 'fs'; +import fs from 'fs'; +import path from 'path'; +import mime from 'mime'; import { fileURLToPath } from 'url'; +import babelParser from '@babel/parser'; import { parse } from 'astro-parser'; -import { transform } from '../compiler/transform/index.js'; -import { convertMdToAstroSource } from '../compiler/index.js'; -import { getAttrValue } from '../ast.js'; +import esbuild from 'esbuild'; import { walk } from 'estree-walker'; -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; +import { generateRSS } from './rss.js'; +import { getAttrValue } from '../ast.js'; +import { convertMdToAstroSource } from '../compiler/index.js'; +import { transform } from '../compiler/transform/index.js'; type DynamicImportMap = Map<'vue' | 'react' | 'react-dom' | 'preact' | 'svelte', string>; +interface PageBuildOptions { + astroConfig: AstroConfig; + buildState: BuildOutput; + logging: LogOptions; + filepath: URL; + mode: RuntimeMode; + resolvePackageUrl: (s: string) => Promise<string>; + runtime: AstroRuntime; + site?: string; +} + +/** Collection utility */ +export function getPageType(filepath: URL): 'collection' | 'static' { + if (/\$[^.]+.astro$/.test(filepath.pathname)) return 'collection'; + return 'static'; +} + +/** Build collection */ +export async function buildCollectionPage({ astroConfig, filepath, logging, mode, runtime, site, resolvePackageUrl, buildState }: PageBuildOptions): Promise<void> { + const rel = path.relative(fileURLToPath(astroConfig.astroRoot) + '/pages', fileURLToPath(filepath)); // pages/index.astro + const pagePath = `/${rel.replace(/\$([^.]+)\.astro$/, '$1')}`; + const srcPath = new URL('pages/' + rel, astroConfig.astroRoot); + const builtURLs = new Set<string>(); // !important: internal cache that prevents building the same URLs + + /** Recursively build collection URLs */ + async function loadCollection(url: string): Promise<LoadResult | undefined> { + if (builtURLs.has(url)) return; // this stops us from recursively building the same pages over and over + const result = await runtime.load(url); + builtURLs.add(url); + if (result.statusCode === 200) { + const outPath = path.posix.join('/', url, 'index.html'); + buildState[outPath] = { + srcPath, + contents: result.contents, + contentType: 'text/html', + encoding: 'utf8', + }; + } + return result; + } + + const [result] = await Promise.all([ + loadCollection(pagePath) as Promise<LoadResult>, // first run will always return a result so assert type here + gatherRuntimes({ astroConfig, buildState, filepath, logging, resolvePackageUrl, mode, runtime }), + ]); + + if (result.statusCode >= 500) { + throw new Error((result as any).error); + } + if (result.statusCode === 200 && !result.collectionInfo) { + throw new Error(`[${rel}]: Collection page must export createCollection() function`); + } + + // note: for pages that require params (/tag/:tag), we will get a 404 but will still get back collectionInfo that tell us what the URLs should be + if (result.collectionInfo) { + // build subsequent pages + await Promise.all( + [...result.collectionInfo.additionalURLs].map(async (url) => { + // for the top set of additional URLs, we render every new URL generated + const addlResult = await loadCollection(url); + builtURLs.add(url); + if (addlResult && addlResult.collectionInfo) { + // believe it or not, we may still have a few unbuilt pages left. this is our last crawl: + await Promise.all([...addlResult.collectionInfo.additionalURLs].map(async (url2) => loadCollection(url2))); + } + }) + ); + + if (result.collectionInfo.rss) { + if (!site) throw new Error(`[${rel}] createCollection() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`); + const rss = generateRSS({ ...(result.collectionInfo.rss as any), site }, rel.replace(/\$([^.]+)\.astro$/, '$1')); + const feedURL = path.posix.join('/feed', `${pagePath}.xml`); + buildState[feedURL] = { + srcPath, + contents: rss, + contentType: 'application/rss+xml', + encoding: 'utf8', + }; + } + } +} + +/** Build static page */ +export async function buildStaticPage({ astroConfig, buildState, filepath, logging, mode, resolvePackageUrl, runtime }: PageBuildOptions): Promise<void> { + const rel = path.relative(fileURLToPath(astroConfig.astroRoot) + '/pages', fileURLToPath(filepath)); // pages/index.astro + const pagePath = `/${rel.replace(/\.(astro|md)$/, '')}`; + + let relPath = path.posix.join('/', rel.replace(/\.(astro|md)$/, '.html')); + if (!relPath.endsWith('index.html')) { + relPath = relPath.replace(/\.html$/, '/index.html'); + } + + const srcPath = new URL('pages/' + rel, astroConfig.astroRoot); + + // build page in parallel with gathering runtimes + await Promise.all([ + runtime.load(pagePath).then((result) => { + if (result.statusCode === 200) { + buildState[relPath] = { srcPath, contents: result.contents, contentType: 'text/html', encoding: 'utf8' }; + } + }), + gatherRuntimes({ astroConfig, buildState, filepath, logging, resolvePackageUrl, mode, runtime }), + ]); +} + +/** Evaluate mustache expression (safely) */ +function compileExpressionSafe(raw: string): string { + let { code } = esbuild.transformSync(raw, { + loader: 'tsx', + jsxFactory: 'h', + jsxFragment: 'Fragment', + charset: 'utf8', + }); + return code; +} + /** Add framework runtimes when needed */ async function acquireDynamicComponentImports(plugins: Set<ValidExtensionPlugins>, resolvePackageUrl: (s: string) => Promise<string>): Promise<DynamicImportMap> { const importMap: DynamicImportMap = new Map(); @@ -50,17 +161,6 @@ async function acquireDynamicComponentImports(plugins: Set<ValidExtensionPlugins return importMap; } -/** Evaluate mustache expression (safely) */ -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', '.tsx': 'react', @@ -68,39 +168,30 @@ const defaultExtensions: Readonly<Record<string, ValidExtensionPlugins>> = { '.vue': 'vue', }; -interface CollectDynamic { - astroConfig: AstroConfig; - resolvePackageUrl: (s: string) => Promise<string>; - logging: LogOptions; - mode: RuntimeMode; -} - -/** Gather necessary framework runtimes for dynamic components */ -export async function collectDynamicImports(filename: URL, { astroConfig, logging, resolvePackageUrl, mode }: CollectDynamic) { +/** Gather necessary framework runtimes (React, Vue, Svelte, etc.) for dynamic components */ +async function gatherRuntimes({ astroConfig, buildState, filepath, logging, resolvePackageUrl, mode, runtime }: PageBuildOptions) { const imports = new Set<string>(); // Only astro files - if (!filename.pathname.endsWith('.astro') && !filename.pathname.endsWith('.md')) { + if (!filepath.pathname.endsWith('.astro') && !filepath.pathname.endsWith('.md')) { return imports; } const extensions = astroConfig.extensions || defaultExtensions; - let source = await readFile(filename, 'utf-8'); - if (filename.pathname.endsWith('.md')) { + let source = await fs.promises.readFile(filepath, 'utf8'); + if (filepath.pathname.endsWith('.md')) { source = await convertMdToAstroSource(source); } - const ast = parse(source, { - filename, - }); + const ast = parse(source, { filepath }); if (!ast.module) { return imports; } await transform(ast, { - filename: fileURLToPath(filename), + filename: fileURLToPath(filepath), fileID: '', compileOptions: { astroConfig, @@ -224,13 +315,13 @@ export async function collectDynamicImports(filename: URL, { astroConfig, loggin } for (const foundImport of matches.reverse()) { const name = foundImport[1]; - appendImports(name, filename); + appendImports(name, filepath); } break; } case 'InlineComponent': { if (/^[A-Z]/.test(node.name)) { - appendImports(node.name, filename); + appendImports(node.name, filepath); return; } @@ -240,82 +331,21 @@ export async function collectDynamicImports(filename: URL, { astroConfig, loggin }, }); - return imports; -} - -interface BundleOptions { - runtime: AstroRuntime; - dist: URL; - astroConfig: AstroConfig; -} - -/** The primary bundling/optimization action */ -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: fileURLToPath(dist), - format: 'esm', - exports: 'named', - entryFileNames(chunk) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return chunk.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; + // add all imports to build output + [...imports].map(async (url) => { + // don’t end up in an infinite loop building same URLs over and over + const alreadyBuilt = buildState[url]; + if (alreadyBuilt) return; + + // add new results to buildState + const result = await runtime.load(url); + if (result.statusCode === 200) { + buildState[url] = { + srcPath: filepath, + contents: result.contents, + contentType: result.contentType || mime.getType(url) || '', + encoding: 'utf8', + }; + } + }); } diff --git a/packages/astro/src/build/sitemap.ts b/packages/astro/src/build/sitemap.ts index 1cb3f3e40..5095019c7 100644 --- a/packages/astro/src/build/sitemap.ts +++ b/packages/astro/src/build/sitemap.ts @@ -1,14 +1,26 @@ -export interface PageMeta { - /** (required) The canonical URL of the page */ - canonicalURL: string; -} +import type { BuildOutput } from '../@types/astro'; + +import { canonicalURL } from './util'; /** Construct sitemap.xml given a set of URLs */ -export function generateSitemap(pages: PageMeta[]): string { +export function generateSitemap(buildState: BuildOutput, site: string): string { + const pages: string[] = []; + + // TODO: find way to respect <link rel="canonical"> URLs here + // TODO: find way to exclude pages from sitemap + + // look through built pages, only add HTML + for (const id of Object.keys(buildState)) { + if (buildState[id].contentType !== 'text/html' || id.endsWith('/1/index.html')) continue; // note: exclude auto-generated "page 1" pages (duplicates of index) + let url = canonicalURL(id.replace(/index\.html$/, ''), site); + pages.push(url); + } + + pages.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); // sort alphabetically so sitemap is same each time + let sitemap = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`; - pages.sort((a, b) => a.canonicalURL.localeCompare(b.canonicalURL, 'en', { numeric: true })); // sort alphabetically for (const page of pages) { - sitemap += `<url><loc>${page.canonicalURL}</loc></url>`; + sitemap += `<url><loc>${page}</loc></url>`; } sitemap += `</urlset>\n`; return sitemap; diff --git a/packages/astro/src/build/static.ts b/packages/astro/src/build/static.ts deleted file mode 100644 index af99c33cb..000000000 --- a/packages/astro/src/build/static.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Element } from 'domhandler'; -import cheerio from 'cheerio'; - -/** Given an HTML string, collect <link> and <img> tags */ -export function collectStatics(html: string) { - const statics = new Set<string>(); - - const $ = cheerio.load(html); - - const append = (el: Element, attr: string) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const value: string = $(el).attr(attr)!; - if (value.startsWith('http') || $(el).attr('rel') === 'alternate') { - return; - } - statics.add(value); - }; - - $('link[href]').each((i, el) => { - append(el, 'href'); - }); - - $('img[src]').each((i, el) => { - append(el, 'src'); - }); - - return statics; -} diff --git a/packages/astro/src/build/stats.ts b/packages/astro/src/build/stats.ts index e29409994..909994a4a 100644 --- a/packages/astro/src/build/stats.ts +++ b/packages/astro/src/build/stats.ts @@ -1,3 +1,4 @@ +import type { BuildOutput, BundleMap } from '../@types/astro'; import type { LogOptions } from '../logger'; import { info, table } from '../logger.js'; @@ -30,19 +31,48 @@ export async function addBundleStats(bundleStatsMap: BundleStatsMap, code: strin bundleStatsMap.set(filename, { size: Buffer.byteLength(code), - gzipSize: gzsize + gzipSize: gzsize, }); } -export function mapBundleStatsToURLStats(urlStats: URLStatsMap, importsToUrl: Map<string, Set<string>>, bundleStats: BundleStatsMap) { - for(let [imp, stats] of bundleStats) { - for(let url of importsToUrl.get('/' + imp) || []) { - urlStats.get(url)?.stats.push(stats); +export function mapBundleStatsToURLStats({ urlStats, depTree, bundleStats }: { urlStats: URLStatsMap; depTree: BundleMap; bundleStats: BundleStatsMap }) { + for (let [srcPath, stats] of bundleStats) { + for (let url of urlStats.keys()) { + if (depTree[url] && depTree[url].js.has('/' + srcPath)) { + urlStats.get(url)?.stats.push(stats); + } } } } -export function logURLStats(logging: LogOptions, urlStats: URLStatsMap, builtURLs: string[]) { +export async function collectBundleStats(buildState: BuildOutput, depTree: BundleMap): Promise<URLStatsMap> { + const urlStats = createURLStats(); + + await Promise.all( + Object.keys(buildState).map(async (id) => { + if (!depTree[id]) return; + const stats = await Promise.all( + [...depTree[id].js, ...depTree[id].css, ...depTree[id].images].map(async (url) => { + if (!buildState[url]) return undefined; + const stat = { + size: Buffer.byteLength(buildState[url].contents), + gzipSize: await gzipSize(buildState[url].contents), + }; + return stat; + }) + ); + urlStats.set(id, { + dynamicImports: new Set<string>(), + stats: stats.filter((s) => !!s) as any, + }); + }) + ); + + return urlStats; +} + +export function logURLStats(logging: LogOptions, urlStats: URLStatsMap) { + const builtURLs = [...urlStats.keys()].map((url) => url.replace(/index\.html$/, '')); builtURLs.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); info(logging, null, ''); const log = table(logging, [60, 20]); @@ -51,11 +81,11 @@ export function logURLStats(logging: LogOptions, urlStats: URLStatsMap, builtURL const lastIndex = builtURLs.length - 1; builtURLs.forEach((url, index) => { const sep = index === 0 ? '┌' : index === lastIndex ? '└' : '├'; - const urlPart = (' ' + sep + ' ') + (url === '/' ? url : url + '/'); + const urlPart = ' ' + sep + ' ' + url; - const bytes = urlStats.get(url)?.stats.map(s => s.gzipSize).reduce((a, b) => a + b, 0) || 0; + const bytes = (urlStats.get(url) || urlStats.get(url + 'index.html'))?.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/build/util.ts b/packages/astro/src/build/util.ts index 505e6f183..c22216388 100644 --- a/packages/astro/src/build/util.ts +++ b/packages/astro/src/build/util.ts @@ -1,4 +1,7 @@ +import type { AstroConfig } from '../@types/astro'; + import path from 'path'; +import { fileURLToPath, URL } from 'url'; /** Normalize URL to its canonical form */ export function canonicalURL(url: string, base?: string): string { @@ -7,3 +10,39 @@ export function canonicalURL(url: string, base?: string): string { base ).href; } + +/** Sort a Set */ +export function sortSet(set: Set<string>): Set<string> { + return new Set([...set].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }))); +} + +/** Resolve final output URL */ +export function getDistPath(specifier: string, { astroConfig, srcPath }: { astroConfig: AstroConfig; srcPath: URL }): string { + if (specifier[0] === '/') return specifier; // assume absolute URLs are correct + + const fileLoc = path.posix.join(path.posix.dirname(fileURLToPath(srcPath)), specifier); + const projectLoc = path.posix.relative(fileURLToPath(astroConfig.astroRoot), fileLoc); + const pagesDir = fileURLToPath(new URL('/pages', astroConfig.astroRoot)); + // if this lives above src/pages, return that URL + if (fileLoc.includes(pagesDir)) { + const [, publicURL] = projectLoc.split(pagesDir); + return publicURL || '/index.html'; // if this is missing, this is the root + } + // otherwise, return /_astro/* url + return '/_astro/' + projectLoc; +} + +/** Given a final output URL, guess at src path (may be inaccurate) */ +export function getSrcPath(url: string, { astroConfig }: { astroConfig: AstroConfig }): URL { + if (url.startsWith('/_astro/')) { + return new URL(url.replace(/^\/_astro\//, ''), astroConfig.astroRoot); + } + let srcFile = url.replace(/^\//, '').replace(/\/index.html$/, '.astro'); + return new URL('./pages/' + srcFile, astroConfig.astroRoot); +} + +/** Stop timer & format time for profiling */ +export function stopTimer(start: number): string { + const diff = performance.now() - start; + return diff < 750 ? `${Math.round(diff)}ms` : `${(diff / 1000).toFixed(1)}s`; +} diff --git a/packages/astro/test/astro-css-bundling.test.js b/packages/astro/test/astro-css-bundling.test.js new file mode 100644 index 000000000..fe8d82c98 --- /dev/null +++ b/packages/astro/test/astro-css-bundling.test.js @@ -0,0 +1,50 @@ +import cheerio from 'cheerio'; +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { setupBuild } from './helpers.js'; + +const CSSBundling = suite('CSS Bundling'); + +setupBuild(CSSBundling, './fixtures/astro-css-bundling'); + +// note: the hashes should be deterministic, but updating the file contents will change hashes +// be careful not to test that the HTML simply contains CSS, because it always will! filename and quanity matter here (bundling). +const EXPECTED_CSS = { + '/index.html': ['/_astro/common-ZVuUT3.css', '/_astro/index-Z2jH7pc.css'], + '/one/index.html': ['/_astro/common-ZVuUT3.css', '/_astro/one/index-2qFtfN.css'], + '/two/index.html': ['/_astro/common-ZVuUT3.css', '/_astro/two/index-2jKE68.css'], +}; +const UNEXPECTED_CSS = ['/_astro/components/nav.css', '../css/typography.css', '../css/colors.css', '../css/page-index.css', '../css/page-one.css', '../css/page-two.css']; + +CSSBundling('Bundles CSS', async (context) => { + await context.build(); + + const builtCSS = new Set(); + + // for all HTML files… + for (const [filepath, css] of Object.entries(EXPECTED_CSS)) { + const html = await context.readFile(filepath); + const $ = cheerio.load(html); + + // test 1: assert new bundled CSS is present + for (const href of css) { + builtCSS.add(href); + const link = $(`link[href="${href}"]`); + assert.equal(link.length, 1); + } + + // test 2: assert old CSS was removed + for (const href of UNEXPECTED_CSS) { + const link = $(`link[href="${href}"]`); + assert.equal(link.length, 0); + } + } + + // test 3: assert all bundled CSS was built and contains CSS + for (const url of builtCSS.keys()) { + const css = await context.readFile(url); + assert.ok(css, true); + } +}); + +CSSBundling.run(); diff --git a/packages/astro/test/astro-rss.test.js b/packages/astro/test/astro-rss.test.js index 1fc70a9a7..055150362 100644 --- a/packages/astro/test/astro-rss.test.js +++ b/packages/astro/test/astro-rss.test.js @@ -11,14 +11,8 @@ const snapshot = ']]></description><pubDate>Tue, 19 Oct 1999 00:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>259</itunes:duration><itunes:explicit>true</itunes:explicit></item></channel></rss>'; RSS('Generates RSS correctly', async (context) => { - let rss; - try { - await context.build(); - rss = await context.readFile('/feed/episodes.xml'); - assert.ok(true, 'Build successful'); - } catch (err) { - assert.ok(false, 'Build threw'); - } + await context.build(); + let rss = await context.readFile('/feed/episodes.xml'); assert.match(rss, snapshot); }); diff --git a/packages/astro/test/astro-sitemap.test.js b/packages/astro/test/astro-sitemap.test.js index 5e47c5d81..778816929 100644 --- a/packages/astro/test/astro-sitemap.test.js +++ b/packages/astro/test/astro-sitemap.test.js @@ -6,18 +6,12 @@ const Sitemap = suite('Sitemap Generation'); setupBuild(Sitemap, './fixtures/astro-rss'); -const snapshot = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://mysite.dev/episode/fazers/</loc></url><url><loc>https://mysite.dev/episode/rap-snitch-knishes/</loc></url><url><loc>https://mysite.dev/episode/rhymes-like-dimes/</loc></url><url><loc>https://mysite.dev/episodes/</loc></url></urlset>`; +const snapshot = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://mysite.dev/episode/fazers/</loc></url><url><loc>https://mysite.dev/episode/rap-snitch-knishes/</loc></url><url><loc>https://mysite.dev/episode/rhymes-like-dimes/</loc></url><url><loc>https://mysite.dev/episodes/</loc></url></urlset>\n`; Sitemap('Generates Sitemap correctly', async (context) => { - let rss; - try { - await context.build(); - rss = await context.readFile('/sitemap.xml'); - assert.ok(true, 'Build successful'); - } catch (err) { - assert.ok(false, 'Build threw'); - } - assert.match(rss, snapshot); + await context.build(); + let sitemap = await context.readFile('/sitemap.xml'); + assert.match(sitemap, snapshot); }); Sitemap.run(); diff --git a/packages/astro/test/fixtures/astro-css-bundling/src/components/Nav.astro b/packages/astro/test/fixtures/astro-css-bundling/src/components/Nav.astro new file mode 100644 index 000000000..deafe668c --- /dev/null +++ b/packages/astro/test/fixtures/astro-css-bundling/src/components/Nav.astro @@ -0,0 +1,9 @@ +<style> +.nav { + display: block; +} +</style> + +<nav class=".nav"> +<a href="/">Home</a> +</nav>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-css-bundling/src/css/colors.css b/packages/astro/test/fixtures/astro-css-bundling/src/css/colors.css new file mode 100644 index 000000000..27bafe566 --- /dev/null +++ b/packages/astro/test/fixtures/astro-css-bundling/src/css/colors.css @@ -0,0 +1,4 @@ +:root { + --brown: burlywood; + --red: crimson; +} diff --git a/packages/astro/test/fixtures/astro-css-bundling/src/css/page-index.css b/packages/astro/test/fixtures/astro-css-bundling/src/css/page-index.css new file mode 100644 index 000000000..b553e8e49 --- /dev/null +++ b/packages/astro/test/fixtures/astro-css-bundling/src/css/page-index.css @@ -0,0 +1,3 @@ +.page__index { + background: var(--red); +} diff --git a/packages/astro/test/fixtures/astro-css-bundling/src/css/page-one.css b/packages/astro/test/fixtures/astro-css-bundling/src/css/page-one.css new file mode 100644 index 000000000..3e09a847f --- /dev/null +++ b/packages/astro/test/fixtures/astro-css-bundling/src/css/page-one.css @@ -0,0 +1,3 @@ +.page__one { + background: paleturquoise; +} diff --git a/packages/astro/test/fixtures/astro-css-bundling/src/css/page-two.css b/packages/astro/test/fixtures/astro-css-bundling/src/css/page-two.css new file mode 100644 index 000000000..dbef8bf34 --- /dev/null +++ b/packages/astro/test/fixtures/astro-css-bundling/src/css/page-two.css @@ -0,0 +1,3 @@ +.page__two { + background: var(--brown); +} diff --git a/packages/astro/test/fixtures/astro-css-bundling/src/css/typography.css b/packages/astro/test/fixtures/astro-css-bundling/src/css/typography.css new file mode 100644 index 000000000..810acd74a --- /dev/null +++ b/packages/astro/test/fixtures/astro-css-bundling/src/css/typography.css @@ -0,0 +1,6 @@ +/* Typography.css */ + +body { + font-size: 16px; + font-family: sans-serif; +} diff --git a/packages/astro/test/fixtures/astro-css-bundling/src/pages/index.astro b/packages/astro/test/fixtures/astro-css-bundling/src/pages/index.astro new file mode 100644 index 000000000..9479981c8 --- /dev/null +++ b/packages/astro/test/fixtures/astro-css-bundling/src/pages/index.astro @@ -0,0 +1,15 @@ +--- +import Nav from '../components/Nav.astro'; +--- + +<html> + <head> + <link rel="stylesheet" href="../css/typography.css" /> + <link rel="stylesheet" href="../css/colors.css" /> + <link rel="stylesheet" href="../css/page-index.css" /> + </head> + <body> + <Nav /> + <h1>Index page</h1> + </body> +</html> diff --git a/packages/astro/test/fixtures/astro-css-bundling/src/pages/one.astro b/packages/astro/test/fixtures/astro-css-bundling/src/pages/one.astro new file mode 100644 index 000000000..cd47f0b27 --- /dev/null +++ b/packages/astro/test/fixtures/astro-css-bundling/src/pages/one.astro @@ -0,0 +1,14 @@ +--- +import Nav from '../components/Nav.astro'; +--- + +<html> + <head> + <link rel="stylesheet" href="../css/typography.css" /> + <link rel="stylesheet" href="../css/page-one.css" /> + </head> + <body> + <Nav /> + <h1>Page One</h1> + </body> +</html> diff --git a/packages/astro/test/fixtures/astro-css-bundling/src/pages/two.astro b/packages/astro/test/fixtures/astro-css-bundling/src/pages/two.astro new file mode 100644 index 000000000..cc730f0d1 --- /dev/null +++ b/packages/astro/test/fixtures/astro-css-bundling/src/pages/two.astro @@ -0,0 +1,15 @@ +--- +import Nav from '../components/Nav.astro'; +--- + +<html> + <head> + <link rel="stylesheet" href="../css/typography.css" /> + <link rel="stylesheet" href="../css/colors.css" /> + <link rel="stylesheet" href="../css/page-two.css" /> + </head> + <body> + <Nav /> + <h1>Page Two</h1> + </body> +</html> diff --git a/packages/astro/test/helpers.js b/packages/astro/test/helpers.js index 3e8ed6e54..108383d2e 100644 --- a/packages/astro/test/helpers.js +++ b/packages/astro/test/helpers.js @@ -52,7 +52,7 @@ export function setupBuild(Suite, fixturePath) { context.build = build; context.readFile = async (path) => { const resolved = fileURLToPath(new URL(`${fixturePath}/${astroConfig.dist}${path}`, import.meta.url)); - return readFile(resolved).then((r) => r.toString('utf-8')); + return readFile(resolved).then((r) => r.toString('utf8')); }; }); diff --git a/packages/create-astro/src/templates/blank/package.json b/packages/create-astro/src/templates/blank/package.json index e04205726..478970d63 100644 --- a/packages/create-astro/src/templates/blank/package.json +++ b/packages/create-astro/src/templates/blank/package.json @@ -6,6 +6,6 @@ "build": "astro build" }, "devDependencies": { - "astro": "0.0.9" + "astro": "^0.0.12" } } diff --git a/packages/create-astro/src/templates/starter/package.json b/packages/create-astro/src/templates/starter/package.json index e04205726..478970d63 100644 --- a/packages/create-astro/src/templates/starter/package.json +++ b/packages/create-astro/src/templates/starter/package.json @@ -6,6 +6,6 @@ "build": "astro build" }, "devDependencies": { - "astro": "0.0.9" + "astro": "^0.0.12" } } diff --git a/tools/astro-languageserver/package.json b/tools/astro-languageserver/package.json index 7572c450a..11f8b3a88 100644 --- a/tools/astro-languageserver/package.json +++ b/tools/astro-languageserver/package.json @@ -15,7 +15,7 @@ "dev": "astro-scripts dev 'src/index.ts'" }, "devDependencies": { - "astro": "0.0.9", + "astro": "^0.0.12", "astro-scripts": "0.0.1" }, "dependencies": { diff --git a/www/package.json b/www/package.json index 958448d89..06d8484fa 100644 --- a/www/package.json +++ b/www/package.json @@ -6,6 +6,6 @@ "build": "astro build ." }, "devDependencies": { - "astro": "0.0.11" + "astro": "^0.0.12" } } @@ -1529,6 +1529,11 @@ dependencies: "@types/unist" "*" +"@types/mime@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a" + integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q== + "@types/minimatch@*", "@types/minimatch@^3.0.3": version "3.0.4" resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz" @@ -2204,117 +2209,6 @@ astral-regex@^2.0.0: resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== -astro@0.0.11: - version "0.0.11" - resolved "https://registry.yarnpkg.com/astro/-/astro-0.0.11.tgz#a028fdab35f05cf53309865facaf4b686435b4c5" - integrity sha512-cTS1isXyeQct3G/PFgImhzVuJ6GzjKjjZCFVHfpWFRSF/nIH1kToU91VnAKuRbgMaOlPPQM7BI3LOvmwkxYMCA== - dependencies: - "@babel/code-frame" "^7.12.13" - "@babel/generator" "^7.13.9" - "@babel/parser" "^7.13.15" - "@babel/traverse" "^7.13.15" - "@snowpack/plugin-sass" "^1.4.0" - "@snowpack/plugin-svelte" "^3.6.1" - "@snowpack/plugin-vue" "^2.4.0" - "@vue/server-renderer" "^3.0.10" - acorn "^7.4.0" - astro-parser "0.0.9" - astro-prism "0.0.2" - autoprefixer "^10.2.5" - cheerio "^1.0.0-rc.5" - domhandler "^4.1.0" - es-module-lexer "^0.4.1" - esbuild "^0.10.1" - estree-walker "^3.0.0" - fast-xml-parser "^3.19.0" - fdir "^5.0.0" - 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" - magic-string "^0.25.3" - micromark "^2.11.4" - micromark-extension-gfm "^0.3.3" - micromark-extension-mdx-expression "^0.3.2" - micromark-extension-mdx-jsx "^0.3.3" - moize "^6.0.1" - node-fetch "^2.6.1" - picomatch "^2.2.3" - postcss "^8.2.8" - postcss-icss-keyframes "^0.2.1" - preact "^10.5.13" - preact-render-to-string "^5.1.18" - prismjs "^1.23.0" - react "^17.0.1" - react-dom "^17.0.1" - rehype-parse "^7.0.1" - rollup "^2.43.1" - rollup-plugin-terser "^7.0.2" - sass "^1.32.8" - snowpack "^3.3.7" - source-map-support "^0.5.19" - string-width "^5.0.0" - svelte "^3.35.0" - unified "^9.2.1" - vue "^3.0.10" - yargs-parser "^20.2.7" - -astro@0.0.9: - version "0.0.9" - resolved "https://registry.yarnpkg.com/astro/-/astro-0.0.9.tgz#c69e05e4d9ecd61f029833738548cd57d3d41933" - integrity sha512-D+HEH854M22syvp57JT9CLpO2kU35pdYgttpYQiuAFTmZ0N+8r4QqEdOT3PaXAdue6JY0dybVTskVMQ9mK1QbA== - dependencies: - "@babel/code-frame" "^7.12.13" - "@babel/generator" "^7.13.9" - "@babel/parser" "^7.13.15" - "@babel/traverse" "^7.13.15" - "@snowpack/plugin-sass" "^1.4.0" - "@snowpack/plugin-svelte" "^3.6.1" - "@snowpack/plugin-vue" "^2.4.0" - "@vue/server-renderer" "^3.0.10" - acorn "^7.4.0" - autoprefixer "^10.2.5" - cheerio "^1.0.0-rc.5" - es-module-lexer "^0.4.1" - esbuild "^0.10.1" - estree-walker "^3.0.0" - fast-xml-parser "^3.19.0" - fdir "^5.0.0" - find-up "^5.0.0" - github-slugger "^1.3.0" - gray-matter "^4.0.2" - hast-to-hyperscript "^9.0.1" - kleur "^4.1.4" - locate-character "^2.0.5" - magic-string "^0.25.3" - micromark "^2.11.4" - micromark-extension-gfm "^0.3.3" - micromark-extension-mdx-expression "^0.3.2" - micromark-extension-mdx-jsx "^0.3.3" - moize "^6.0.1" - node-fetch "^2.6.1" - picomatch "^2.2.3" - postcss "^8.2.8" - postcss-icss-keyframes "^0.2.1" - preact "^10.5.13" - preact-render-to-string "^5.1.18" - prismjs "^1.23.0" - react "^17.0.1" - react-dom "^17.0.1" - rehype-parse "^7.0.1" - rollup "^2.43.1" - rollup-plugin-terser "^7.0.2" - sass "^1.32.8" - snowpack "^3.3.7" - svelte "^3.35.0" - tiny-glob "^0.2.8" - unified "^9.2.1" - vue "^3.0.10" - yargs-parser "^20.2.7" - async-each-series@0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/async-each-series/-/async-each-series-0.1.1.tgz" @@ -3000,7 +2894,7 @@ cheerio-select@^1.3.0: domhandler "^4.2.0" domutils "^2.6.0" -cheerio@^1.0.0-rc.3, cheerio@^1.0.0-rc.5: +cheerio@^1.0.0-rc.3, cheerio@^1.0.0-rc.6: version "1.0.0-rc.6" resolved "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.6.tgz" integrity sha512-hjx1XE1M/D5pAtMgvWwE21QClmAEeGHOIDfycgmndisdNgI6PE1cGRQkMGBcsbUbmEQyWu5PJLUcAOjtQS8DWw== @@ -3877,7 +3771,7 @@ del@^2.2.0: del@^6.0.0: version "6.0.0" - resolved "https://registry.npmjs.org/del/-/del-6.0.0.tgz" + resolved "https://registry.yarnpkg.com/del/-/del-6.0.0.tgz#0b40d0332cea743f1614f818be4feb717714c952" integrity sha512-1shh9DQ23L16oXSZKB2JxpL7iMy2E0S9d517ptA1P8iw0alkPtQcrKH7ru31rYtKwF499HkTu+DRzq3TCKDFRQ== dependencies: globby "^11.0.1" @@ -5556,7 +5450,7 @@ hash-sum@^2.0.0: resolved "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz" integrity sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg== -hast-to-hyperscript@^9.0.1, hast-to-hyperscript@~9.0.0: +hast-to-hyperscript@~9.0.0: version "9.0.1" resolved "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz" integrity sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA== @@ -7519,6 +7413,11 @@ mime@1.4.1: resolved "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz" integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ== +mime@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" + integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" @@ -10194,6 +10093,11 @@ shelljs@^0.8.3: interpret "^1.0.0" rechoir "^0.6.2" +shorthash@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/shorthash/-/shorthash-0.0.2.tgz#59b268eecbde59038b30da202bcfbddeb2c4a4eb" + integrity sha1-WbJo7sveWQOLMNogK8+93rLEpOs= + side-channel@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz" |