diff options
Diffstat (limited to 'packages/astro/src/build/bundle/css.ts')
-rw-r--r-- | packages/astro/src/build/bundle/css.ts | 139 |
1 files changed, 139 insertions, 0 deletions
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)}]`); +} |