astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
552 lines (551 loc) • 25.9 kB
JavaScript
import { isCSSRequest } from "vite";
import { ASTRO_VITE_ENVIRONMENT_NAMES } from "../../constants.js";
import { isPropagatedAssetBoundary } from "../../head-propagation/boundary.js";
import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from "../../../vite-plugin-pages/const.js";
import { getParentExtendedModuleInfos, getParentModuleInfos, moduleIsTopLevelPage } from "../graph.js";
import { getPageDataByViteID, getPageDatasByClientOnlyID } from "../internal.js";
import { normalizeEntryId } from "./plugin-component-entry.js";
import { shouldInlineAsset } from "./util.js";
/***** ASTRO PLUGIN *****/
export function pluginCSS(options, internals) {
return rollupPluginAstroBuildCSS({
buildOptions: options,
internals
});
}
function isBuildCssBoundary(id, ctx) {
if (isPropagatedAssetBoundary(id)) return true;
const info = ctx.getModuleInfo(id);
if (!info || !moduleIsTopLevelPage(info)) return false;
const allImporters = info.importers.concat(info.dynamicImporters);
const hasNonVirtualPageImporter = allImporters.some((importer) => !importer.includes(VIRTUAL_PAGE_RESOLVED_MODULE_ID));
// Pages imported by non-virtual modules (e.g. partials imported by other pages)
// should propagate CSS transitively instead of acting as hard page boundaries.
return !hasNonVirtualPageImporter;
}
function rollupPluginAstroBuildCSS(options) {
const { internals, buildOptions } = options;
const { settings } = buildOptions;
let resolvedConfig;
// stylesheet filenames are kept in here until "post", when they are rendered and ready to be inlined
const pagesToCss = {};
// Map of module Ids (usually something like `/Users/...blog.mdx?astroPropagatedAssets`) to its imported CSS
const moduleIdToPropagatedCss = {};
// Map of page module specifiers to the CSS module IDs assigned to them by the SSR/prerender builds
const pageToSsrCssModules = {};
const cssBuildPlugin = {
name: "astro:rollup-plugin-build-css",
applyToEnvironment(environment) {
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client || environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr || environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender;
},
async generateBundle(_outputOptions, bundle) {
// Collect CSS modules that were bundled during SSR build for deduplication in client build
if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr || this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender) {
for (const [, chunk] of Object.entries(bundle)) {
if (chunk.type !== "chunk") continue;
// Track all CSS modules that are bundled during SSR
// so we can avoid creating separate CSS files for them in client build
for (const moduleId of Object.keys(chunk.modules || {})) {
if (isCSSRequest(moduleId)) {
internals.cssModuleToChunkIdMap.set(moduleId, chunk.fileName);
}
}
// Deduplicate CSS assets between the prerender and SSR environments.
// Both environments bundle shared layouts separately, which emits one CSS
// asset per environment for the same CSS source modules (e.g. `index.X.css`
// and `_..Y.css`). The prerender build runs first and records its CSS asset
// filenames keyed by the chunk's CSS module set; the SSR build then renames
// its matching assets to the prerender filenames, so both end up as a single
// file once assets are moved to the client directory.
// Keyed by module identity rather than content hash because plugins that
// scan the environment's module graph (e.g. Tailwind) can produce slightly
// different content for the same CSS source in each environment.
const cssModuleIds = Object.keys(chunk.modules || {}).filter(isCSSRequest);
const importedCss = chunk.viteMetadata?.importedCss;
if (cssModuleIds.length > 0 && importedCss?.size === 1) {
const moduleKey = cssModuleIds.sort().join("\n");
const [assetFileName] = importedCss;
if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender) {
internals.prerenderCssAssetByModuleKey.set(moduleKey, assetFileName);
} else {
const prerenderFileName = internals.prerenderCssAssetByModuleKey.get(moduleKey);
if (prerenderFileName && prerenderFileName !== assetFileName) {
renameBundleAsset(bundle, assetFileName, prerenderFileName);
}
}
}
// Track which component exports were rendered during SSR.
// This is used by the client build to determine if cssScopeTo CSS
// was tree-shaken (component not rendered in SSR) vs included.
for (const [moduleId, moduleInfo] of Object.entries(chunk.modules || {})) {
if (moduleInfo.renderedExports.length > 0) {
const existing = internals.ssrRenderedExports?.get(moduleId);
if (existing) {
for (const exp of moduleInfo.renderedExports) {
existing.add(exp);
}
} else {
internals.ssrRenderedExports ??= new Map();
internals.ssrRenderedExports.set(moduleId, new Set(moduleInfo.renderedExports));
}
}
}
}
}
// In the client build, collect which component modules have their exports rendered
// and which pages/entries contain them. This is used to handle CSS with cssScopeTo
// metadata for conditionally rendered components.
const renderedComponentExports = new Map();
// Map from component module ID to the pages that include it (via facadeModuleId)
const componentToPages = new Map();
// CSS assets marked for removal during client-build deduplication. They stay
// in the bundle until the end of this hook, so the cssScopeTo recovery code
// below can keep the ones containing styles for conditionally rendered
// components. Rolldown ignores assignments to `bundle`, so a deleted asset
// cannot be added back. https://github.com/withastro/astro/issues/18337
const cssAssetsToDelete = new Map();
// CSS asset IDs that the cssScopeTo recovery code added to pagesToCss.
// Only these assets should be kept — the normal parent walk
// also adds CSS IDs marked for deletion to pagesToCss, but those represent CSS that
// is already on the page from the SSR build.
const cssScopeToAddedCss = new Set();
// Mark CSS files in the client bundle that were already bundled with pages during SSR
if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.client) {
for (const [, item] of Object.entries(bundle)) {
if (item.type !== "chunk") continue;
for (const [moduleId, moduleRenderedInfo] of Object.entries(item.modules)) {
if (moduleRenderedInfo.renderedExports.length > 0) {
renderedComponentExports.set(moduleId, moduleRenderedInfo.renderedExports);
// Track which entry/page this component belongs to
if (item.facadeModuleId) {
let pages = componentToPages.get(moduleId);
if (!pages) {
pages = new Set();
componentToPages.set(moduleId, pages);
}
pages.add(item.facadeModuleId);
}
}
}
if ("viteMetadata" in item === false) continue;
const meta = item.viteMetadata;
// Check if this chunk contains CSS modules that were already in SSR
const allModules = Object.keys(item.modules || {});
const cssModules = allModules.filter((m) => isCSSRequest(m));
if (cssModules.length > 0) {
// Check if ALL CSS modules in this chunk were already bundled in SSR
const allCssInSSR = cssModules.every((moduleId) => internals.cssModuleToChunkIdMap.has(moduleId));
if (allCssInSSR && shouldDeleteCSSChunk(allModules, internals)) {
for (const cssId of meta.importedCss) {
if (bundle[cssId]) {
cssAssetsToDelete.set(cssId, bundle[cssId]);
}
}
}
}
}
}
for (const [, chunk] of Object.entries(bundle)) {
if (chunk.type !== "chunk") continue;
if ("viteMetadata" in chunk === false) continue;
const meta = chunk.viteMetadata;
// Skip if the chunk has no CSS, we want to handle CSS chunks only
if (meta.importedCss.size < 1) continue;
// For the client build, client:only styles need to be mapped
// over to their page. For this chunk, determine if it's a child of a
// client:only component and if so, add its CSS to the page it belongs to.
if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.client) {
const chunkCssModules = Object.keys(chunk.modules).filter((m) => isCSSRequest(m));
for (const id of Object.keys(chunk.modules)) {
// Only walk from CSS modules to find client:only parents. When Rollup
// merges unrelated modules into the same chunk, walking from every module
// would incorrectly attribute the chunk's CSS to pages reached through
// modules that have no CSS dependency.
if (!isCSSRequest(id)) continue;
for (const pageData of getParentClientOnlys(id, this, internals)) {
// The page already has these styles when the component is also rendered
// on the server, e.g. a static instance alongside a client:only one.
const ssrCssModules = pageToSsrCssModules[pageData.moduleSpecifier];
if (ssrCssModules && chunkCssModules.every((m) => ssrCssModules.has(m))) continue;
for (const importedCssImport of meta.importedCss) {
const cssToInfoRecord = pagesToCss[pageData.moduleSpecifier] ??= {};
cssToInfoRecord[importedCssImport] = {
depth: -1,
order: -1
};
// When a CSS asset was marked for deletion (because it was already
// bundled in SSR for another page), it may not survive to the
// inlineStylesheetsPlugin. Directly add the CSS to pageData.styles
// so client:only component child styles are not lost.
if (cssAssetsToDelete.has(importedCssImport)) {
const cssAsset = cssAssetsToDelete.get(importedCssImport);
if (cssAsset.type === "asset" && typeof cssAsset.source === "string" && cssAsset.source.length > 0) {
const sheet = {
type: "inline",
content: cssAsset.source
};
const alreadyAdded = pageData.styles.some((s) => s.sheet.type === "inline" && s.sheet.content === sheet.content);
if (!alreadyAdded) {
pageData.styles.push({
depth: -1,
order: -1,
sheet
});
}
}
}
}
}
}
// Handle CSS with cssScopeTo metadata for conditionally rendered components.
// These components may not be in the server build (due to conditional rendering)
// but are in the client build. We need to ensure their CSS is included.
for (const id of Object.keys(chunk.modules)) {
const moduleInfo = this.getModuleInfo(id);
const cssScopeTo = moduleInfo?.meta?.vite?.cssScopeTo;
if (cssScopeTo) {
const [scopedToModule, scopedToExport] = cssScopeTo;
const renderedExports = renderedComponentExports.get(scopedToModule);
// If the component's export is rendered in the client build,
// ensure its CSS is associated with the pages that use it
if (renderedExports?.includes(scopedToExport)) {
// Walk up from the scoped-to module to find pages or scripts
const parentModuleInfos = getParentExtendedModuleInfos(scopedToModule, this, (moduleId) => isBuildCssBoundary(moduleId, this));
for (const { info: pageInfo, depth, order } of parentModuleInfos) {
if (moduleIsTopLevelPage(pageInfo)) {
const pageData = getPageDataByViteID(internals, pageInfo.id);
if (pageData) {
appendCSSToPage(pageData, meta, pagesToCss, depth, order, this.environment?.name);
}
}
// For hydrated components, check if this parent is a script/component entry
// that's tracked in pagesByScriptId
const pageDatas = internals.pagesByScriptId.get(pageInfo.id);
if (pageDatas) {
for (const pageData of pageDatas) {
appendCSSToPage(pageData, meta, pagesToCss, -1, order, this.environment?.name);
}
}
}
// If we couldn't find a page through normal traversal,
// check if any parent in the chain is a hydrated component and
// use the pagesByHydratedComponent mapping from the server build.
let addedToAnyPage = false;
for (const importedCssImport of meta.importedCss) {
for (const pageData of internals.pagesByKeys.values()) {
const cssToInfoRecord = pagesToCss[pageData.moduleSpecifier];
if (cssToInfoRecord && importedCssImport in cssToInfoRecord) {
addedToAnyPage = true;
break;
}
}
}
if (!addedToAnyPage) {
// Walk up the parent chain and check if any parent is a hydrated component
for (const { info: parentInfo } of parentModuleInfos) {
const normalizedParent = normalizeEntryId(parentInfo.id);
// Check if this parent is tracked as a hydrated component
const pages = internals.pagesByHydratedComponent.get(normalizedParent);
if (pages) {
for (const pageData of pages) {
appendCSSToPage(pageData, meta, pagesToCss, -1, -1, this.environment?.name);
}
}
}
}
// Only flag CSS marked for deletion to be kept when the
// component's export was NOT rendered during SSR. If it was
// rendered in SSR, the page already has these styles and the
// client CSS is truly redundant.
const ssrExports = internals.ssrRenderedExports?.get(scopedToModule);
if (!ssrExports || !ssrExports.has(scopedToExport)) {
for (const cssId of meta.importedCss) {
if (cssAssetsToDelete.has(cssId)) {
cssScopeToAddedCss.add(cssId);
}
}
}
}
}
}
}
// For this CSS chunk, walk parents until you find a page. Add the CSS to that page.
for (const id of Object.keys(chunk.modules)) {
// Only walk up for dependencies that are CSS
if (!isCSSRequest(id)) continue;
const parentModuleInfos = getParentExtendedModuleInfos(id, this, (importer) => isBuildCssBoundary(importer, this));
for (const { info: pageInfo, depth, order } of parentModuleInfos) {
if (isPropagatedAssetBoundary(pageInfo.id)) {
const propagatedCss = moduleIdToPropagatedCss[pageInfo.id] ??= new Set();
for (const css of meta.importedCss) {
propagatedCss.add(css);
}
} else if (moduleIsTopLevelPage(pageInfo)) {
const pageViteID = pageInfo.id;
const pageData = getPageDataByViteID(internals, pageViteID);
if (pageData) {
appendCSSToPage(pageData, meta, pagesToCss, depth, order, this.environment?.name);
if (isRenderedByServerEnvironment(pageData, this.environment?.name)) {
(pageToSsrCssModules[pageData.moduleSpecifier] ??= new Set()).add(id);
}
}
} else if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.client) {
// For scripts, walk parents until you find a page, and add the CSS to that page.
const pageDatas = internals.pagesByScriptId.get(pageInfo.id);
if (pageDatas) {
for (const pageData of pageDatas) {
appendCSSToPage(pageData, meta, pagesToCss, -1, order, this.environment?.name);
}
}
}
}
}
}
// Keep the CSS assets that the cssScopeTo recovery code added to pages.
// Only assets explicitly flagged by cssScopeToAddedCss are kept — CSS
// added by the normal parent walk represents styles already present on
// the page from the SSR build and should be deleted.
for (const cssId of cssScopeToAddedCss) {
cssAssetsToDelete.delete(cssId);
}
// Vite builds the preload list of dynamic imports from `importedCss`, so a
// deleted asset left there is requested at runtime and 404s.
// https://github.com/withastro/astro/issues/18191
for (const cssId of cssAssetsToDelete.keys()) {
delete bundle[cssId];
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk") {
chunk.viteMetadata?.importedCss?.delete(cssId);
}
}
}
}
};
const singleCssPlugin = {
name: "astro:rollup-plugin-single-css",
enforce: "post",
applyToEnvironment(environment) {
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client || environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr || environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender;
},
configResolved(config) {
resolvedConfig = config;
},
generateBundle(_, bundle) {
// If user disable css code-splitting, search for Vite's hardcoded
// `style.css` and add it as css for each page.
// Ref: https://github.com/vitejs/vite/blob/b2c0ee04d4db4a0ef5a084c50f49782c5f88587c/packages/vite/src/node/plugins/html.ts#L690-L705
if (resolvedConfig.build.cssCodeSplit) return;
const cssChunk = Object.values(bundle).find((chunk) => chunk.type === "asset" && chunk.name === "style.css");
if (cssChunk === undefined) return;
for (const pageData of internals.pagesByKeys.values()) {
const cssToInfoMap = pagesToCss[pageData.moduleSpecifier] ??= {};
cssToInfoMap[cssChunk.fileName] = {
depth: -1,
order: -1
};
}
}
};
let assetsInlineLimit;
const inlineStylesheetsPlugin = {
name: "astro:rollup-plugin-inline-stylesheets",
enforce: "post",
applyToEnvironment(environment) {
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client || environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr || environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender;
},
configResolved(config) {
assetsInlineLimit = config.build.assetsInlineLimit;
},
async generateBundle(_outputOptions, bundle) {
const inlineConfig = settings.config.build.inlineStylesheets;
Object.entries(bundle).forEach(([id, stylesheet]) => {
if (stylesheet.type !== "asset" || stylesheet.name?.endsWith(".css") !== true || typeof stylesheet.source !== "string") return;
// Delete empty CSS chunks. In prerender these are likely duplicates
// from SSR.
if (stylesheet.source.length === 0) {
delete bundle[id];
return;
}
const toBeInlined = inlineConfig === "always" ? true : inlineConfig === "never" ? false : shouldInlineAsset(stylesheet.source, stylesheet.fileName, assetsInlineLimit);
// there should be a single js object for each stylesheet,
// allowing the single reference to be shared and checked for duplicates
const sheet = toBeInlined ? {
type: "inline",
content: stylesheet.source
} : {
type: "external",
src: stylesheet.fileName
};
let sheetAddedToPage = false;
internals.pagesByKeys.forEach((pageData) => {
const orderingInfo = pagesToCss[pageData.moduleSpecifier]?.[stylesheet.fileName];
if (orderingInfo !== undefined) {
// Check if this stylesheet was already added to this page.
// We check both inline (by content) and external (by src) styles to prevent
// duplicates that can occur when CSS is imported from both a page's frontmatter
// and a component's script tag, or when the same plugin runs in both SSR and client builds.
const alreadyAdded = pageData.styles.some((s) => {
if (s.sheet.type === "external" && sheet.type === "external") {
return s.sheet.src === sheet.src;
}
if (s.sheet.type === "inline" && sheet.type === "inline") {
return s.sheet.content === sheet.content;
}
return false;
});
if (!alreadyAdded) {
pageData.styles.push({
...orderingInfo,
sheet
});
}
sheetAddedToPage = true;
}
});
// Apply `moduleIdToPropagatedCss` information to `internals.propagatedStylesMap`.
// NOTE: It's pretty much a copy over to `internals.propagatedStylesMap` as it should be
// completely empty. The whole propagation handling could be better refactored in the future.
for (const moduleId in moduleIdToPropagatedCss) {
if (!moduleIdToPropagatedCss[moduleId].has(stylesheet.fileName)) continue;
let propagatedStyles = internals.propagatedStylesMap.get(moduleId);
if (!propagatedStyles) {
propagatedStyles = new Set();
internals.propagatedStylesMap.set(moduleId, propagatedStyles);
}
propagatedStyles.add(sheet);
sheetAddedToPage = true;
}
const wasInlined = toBeInlined && sheetAddedToPage;
// Stylesheets referenced by a chunk (via importedAssets for ?url imports,
// or importedCss for normal CSS imports) are delivered by Vite's preload
// helper at runtime and must not be treated as orphaned.
const wasAddedToChunk = Object.values(bundle).some((chunk) => chunk.type === "chunk" && (chunk.viteMetadata?.importedAssets?.has(id) || chunk.viteMetadata?.importedCss?.has(id)));
const isOrphaned = !sheetAddedToPage && !wasAddedToChunk;
if (wasInlined || isOrphaned) {
// wasInlined : CSS is already added to all used pages
// isOrphaned : CSS is already used in a merged chunk
// we can delete it from the bundle
// and make sure no chunks reference it via `importedCss` (for Vite preloading)
// to avoid duplicate CSS.
delete bundle[id];
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk") {
chunk.viteMetadata?.importedCss?.delete(id);
}
}
}
});
}
};
return [
cssBuildPlugin,
singleCssPlugin,
inlineStylesheetsPlugin
];
}
/***** UTILITY FUNCTIONS *****/
/**
* Renames an emitted asset in the bundle and updates every chunk's Vite metadata
* that references the old filename. Used to give an SSR CSS asset the filename of
* the equivalent prerender CSS asset so the two builds don't emit duplicate files.
*/
function renameBundleAsset(bundle, fromFileName, toFileName) {
const asset = bundle[fromFileName];
// Never clobber an asset that already exists under the target name in this bundle.
if (!asset || asset.type !== "asset" || bundle[toFileName]) return;
// Mutate `fileName` in place instead of re-keying the bundle object: the bundle
// is a Rolldown proxy that ignores direct key assignment, and the writer emits
// files based on `fileName`, not the bundle key.
asset.fileName = toFileName;
for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk") continue;
const meta = chunk.viteMetadata;
if (meta?.importedCss?.delete(fromFileName)) {
meta.importedCss.add(toFileName);
}
if (meta?.importedAssets?.delete(fromFileName)) {
meta.importedAssets.add(toFileName);
}
}
}
/**
* Check if a CSS chunk should be deleted. Only delete if it contains client-only or hydrated
* components that are NOT also used on other pages.
*/
function shouldDeleteCSSChunk(allModules, internals) {
// Find all components in this chunk that are client-only or hydrated
const componentPaths = new Set();
for (const componentPath of internals.discoveredClientOnlyComponents.keys()) {
if (allModules.some((m) => m.includes(componentPath))) {
componentPaths.add(componentPath);
}
}
for (const componentPath of internals.discoveredHydratedComponents.keys()) {
if (allModules.some((m) => m.includes(componentPath))) {
componentPaths.add(componentPath);
}
}
// If no special components found, don't delete
if (componentPaths.size === 0) return false;
// Check if any component is used on non-client-only pages
for (const componentPath of componentPaths) {
const pagesUsingClientOnly = internals.pagesByClientOnly.get(componentPath);
if (pagesUsingClientOnly) {
// If every page using this component is in the client-only set, it's safe to delete
// Otherwise, keep the CSS for pages that use it normally
for (const pageData of internals.pagesByKeys.values()) {
if (!pagesUsingClientOnly.has(pageData)) {
return false;
}
}
}
}
return true;
}
function* getParentClientOnlys(id, ctx, internals) {
for (const info of getParentModuleInfos(id, ctx)) {
yield* getPageDatasByClientOnlyID(internals, info.id);
}
}
/**
* Whether the page is rendered by the given server environment: SSR renders on-demand
* pages and prerender renders prerendered pages. Always false for the client environment.
*/
function isRenderedByServerEnvironment(pageData, environmentName) {
if (environmentName === ASTRO_VITE_ENVIRONMENT_NAMES.ssr) return !pageData.route.prerender;
if (environmentName === ASTRO_VITE_ENVIRONMENT_NAMES.prerender) return pageData.route.prerender;
return false;
}
function appendCSSToPage(pageData, meta, pagesToCss, depth, order, environmentName) {
// In SSR/prerender builds, only add CSS to pages that match the current environment.
// SSR build handles non-prerendered pages, prerender build handles prerendered pages.
// Client build adds CSS to all pages.
if (environmentName === ASTRO_VITE_ENVIRONMENT_NAMES.ssr && pageData.route.prerender) {
return;
}
if (environmentName === ASTRO_VITE_ENVIRONMENT_NAMES.prerender && !pageData.route.prerender) {
return;
}
for (const importedCssImport of meta.importedCss) {
// CSS is prioritized based on depth. Shared CSS has a higher depth due to being imported by multiple pages.
// Depth info is used when sorting the links on the page.
const cssInfo = pagesToCss[pageData.moduleSpecifier]?.[importedCssImport];
if (cssInfo !== undefined) {
if (depth < cssInfo.depth) {
cssInfo.depth = depth;
}
// Update the order, preferring the lowest order we have.
if (cssInfo.order === -1) {
cssInfo.order = order;
} else if (order < cssInfo.order && order > -1) {
cssInfo.order = order;
}
} else {
const cssToInfoRecord = pagesToCss[pageData.moduleSpecifier] ??= {};
cssToInfoRecord[importedCssImport] = {
depth,
order
};
}
}
}