UNPKG

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
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 }; } } }