astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
244 lines (243 loc) • 11.2 kB
JavaScript
import { prependForwardSlash } from "@astrojs/internal-helpers/path";
import { ASTRO_VITE_ENVIRONMENT_NAMES } from "../core/constants.js";
import { wrapId } from "../core/util.js";
import { inlineRE, isBuildableCSSRequest, rawRE } from "../vite-plugin-astro-server/util.js";
import { getVirtualModulePageNameForComponent } from "../vite-plugin-pages/util.js";
import { getDevCSSModuleName } from "./util.js";
import { CSS_LANGS_RE } from "../core/viteUtils.js";
import { PROPAGATED_ASSET_QUERY_PARAM } from "../content/consts.js";
import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from "../vite-plugin-pages/const.js";
import { RESOLVED_SERVER_ISLAND_MANIFEST } from "../core/server-islands/vite-plugin-server-islands.js";
import { ASTRO_CSS_EXTENSION_POST_PATTERN, MODULE_DEV_CSS, MODULE_DEV_CSS_ALL, MODULE_DEV_CSS_PREFIX, RESOLVED_MODULE_DEV_CSS, RESOLVED_MODULE_DEV_CSS_ALL, RESOLVED_MODULE_DEV_CSS_PREFIX } from "./const.js";
/**
* Extract the original component path from a masked virtual module name.
* Inverse function of getVirtualModulePageName().
*/
function getComponentFromVirtualModuleCssName(virtualModulePrefix, id) {
return id.slice(virtualModulePrefix.length).replace(new RegExp(ASTRO_CSS_EXTENSION_POST_PATTERN, "g"), ".");
}
/**
* Ensure all modules reachable from the given module have been fetched and transformed.
* This is needed for dynamically imported components whose modules may be registered
* in the graph (via Vite's import analysis) but not yet transformed, meaning their
* own imports (including CSS) would not be visible during the CSS graph walk.
*/
async function ensureModulesLoaded(env, mod, seen = new Set()) {
const id = mod.id ?? mod.url;
if (seen.has(id)) return;
seen.add(id);
for (const imp of mod.importedModules) {
if (!imp.id) continue;
if (seen.has(imp.id)) continue;
// Mirror the stopping point used by collectCSSWithOrder — don't descend into propagated
// asset modules, as the CSS walk intentionally stops there too.
if (imp.id.includes(PROPAGATED_ASSET_QUERY_PARAM)) continue;
// Stop at page virtual module boundaries. When a page imports something that
// transitively pulls in virtual:astro:pages (e.g. via astro:config/server →
// virtual:astro:manifest), the walk would otherwise descend into every other
// page's module graph, fetching their CSS dependencies. This is the dev-mode
// counterpart of isBuildCssBoundary in plugin-css.ts. See #18060, #16116.
if (imp.id.startsWith(VIRTUAL_PAGE_RESOLVED_MODULE_ID)) continue;
// Skip the server island manifest. The server islands plugin refreshes it by clearing its
// transformResult so the module runner's next import re-transforms it and drops its stale
// copy. Fetching it here would repopulate transformResult first, so Vite would answer the
// runner with `cache: true` and it would keep an island map missing newly discovered
// islands. The manifest contains no CSS. See #18387.
if (imp.id === RESOLVED_SERVER_ISLAND_MANIFEST) continue;
// Skip virtual dev-css modules to prevent circular deadlocks with the Cloudflare adapter.
// The dev-css-all module statically imports all per-route dev-css:* modules, and those
// modules' load handlers may already be running (waiting on ensureModulesLoaded), causing
// a permanent deadlock via Vite's _pendingRequests map. These are CSS collector virtuals
// that don't contain CSS themselves, so skipping them has no effect on CSS injection.
if (imp.id === RESOLVED_MODULE_DEV_CSS || imp.id === RESOLVED_MODULE_DEV_CSS_ALL || imp.id.startsWith(RESOLVED_MODULE_DEV_CSS_PREFIX)) continue;
// If this module hasn't been transformed yet, fetch it to populate its importedModules
if (!imp.transformResult) {
try {
await env.fetchModule(imp.id);
} catch {}
}
// Recursively ensure child modules are loaded
await ensureModulesLoaded(env, imp, seen);
}
}
/**
* Walk down the dependency tree to collect CSS with depth/order.
* Performs depth-first traversal to ensure correct CSS ordering based on import order.
*/
function* collectCSSWithOrder(id, mod, seen = new Set()) {
seen.add(id);
// Stop traversing if we reach an asset propagation stopping point to ensure we only collect CSS
// relevant to a content collection entry, if any. Not doing so could cause CSS from other
// entries to potentially be collected and bleed into the CSS included on the page, causing
// unexpected styles, for example when a module shared between 2 pages would import
// `astro:content` and thus potentially adding multiple content collection entry assets to the
// module graph.
if (id.includes(PROPAGATED_ASSET_QUERY_PARAM)) {
return;
}
// Keep all of the imported modules into an array so we can go through them one at a time
const imported = Array.from(mod.importedModules);
// Check if this module is CSS and should be collected
if (isBuildableCSSRequest(id)) {
yield {
id: wrapId(mod.id ?? mod.url),
idKey: id,
content: "",
url: prependForwardSlash(wrapId(mod.url))
};
return;
} else if (id.endsWith("?raw")) {
return;
}
// Recursively walk imported modules (depth-first)
for (const imp of imported) {
if (imp.id && !seen.has(imp?.id)) {
// Stop at page virtual module boundaries to prevent CSS from other pages
// leaking in when the graph passes through virtual:astro:pages. See #18060.
if (imp.id.startsWith(VIRTUAL_PAGE_RESOLVED_MODULE_ID)) continue;
yield* collectCSSWithOrder(imp.id, imp, seen);
}
}
}
/**
* This plugin tracks the CSS that should be applied by route.
*
* The virtual module should be used only during development.
* Per-route virtual modules are created to avoid invalidation loops.
*
* The second plugin imports all of the individual CSS modules in a map.
*
* @param routesList
*/
export function astroDevCssPlugin({ routesList, command, cssContentCache }) {
let server;
function getCurrentEnvironment(pluginEnv) {
return pluginEnv ?? server?.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
}
return [{
name: MODULE_DEV_CSS,
async configureServer(viteServer) {
server = viteServer;
},
applyToEnvironment(env) {
return command === "dev" && env.name === ASTRO_VITE_ENVIRONMENT_NAMES.astro || env.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr || env.name === ASTRO_VITE_ENVIRONMENT_NAMES.client || env.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender;
},
resolveId: {
filter: { id: new RegExp(`^(${MODULE_DEV_CSS}|${MODULE_DEV_CSS_PREFIX}.*)$`) },
handler(id) {
if (id === MODULE_DEV_CSS) {
return RESOLVED_MODULE_DEV_CSS;
}
return RESOLVED_MODULE_DEV_CSS_PREFIX + id.slice(MODULE_DEV_CSS_PREFIX.length);
}
},
load: {
filter: { id: new RegExp(`^(${RESOLVED_MODULE_DEV_CSS}|${RESOLVED_MODULE_DEV_CSS_PREFIX}.*)$`) },
async handler(id) {
if (id === RESOLVED_MODULE_DEV_CSS) {
return { code: `export const css = new Set()` };
}
if (id.startsWith(RESOLVED_MODULE_DEV_CSS_PREFIX)) {
const componentPath = getComponentFromVirtualModuleCssName(RESOLVED_MODULE_DEV_CSS_PREFIX, id);
// Collect CSS by walking the dependency tree from the component
const cssWithOrder = new Map();
// The virtual module name for this page, like virtual:astro:dev-css:index@_@astro
const componentPageId = getVirtualModulePageNameForComponent(componentPath);
const env = getCurrentEnvironment(this.environment);
// Ensure the page module is loaded. This will populate the graph and allow us to walk through.
await env?.fetchModule(componentPageId);
const resolved = await env?.pluginContainer.resolveId(componentPageId);
if (!resolved?.id) {
return { code: "export const css = new Set()" };
}
// the vite.EnvironmentModuleNode has all of the info we need
const mod = env?.moduleGraph.getModuleById(resolved.id);
if (!mod) {
return { code: "export const css = new Set()" };
}
// Ensure all reachable modules have been transformed.
// Dynamically imported components may be in the graph (detected by Vite's
// import analysis) but not yet transformed, so their own CSS imports would
// be invisible during the graph walk. This eagerly fetches them.
if (env) {
await ensureModulesLoaded(env, mod);
}
// Walk through the graph depth-first
for (const collected of collectCSSWithOrder(componentPageId, mod)) {
// Use the CSS file ID as the key to deduplicate while keeping best ordering
if (!cssWithOrder.has(collected.idKey)) {
// Look up actual content from cache if available.
// Use `idKey` (the raw module ID) for cache lookup because that matches the key
// used by the transform hook. `collected.id` is wrapped via `wrapId()` which
// transforms the `\0` prefix of virtual modules, causing a cache miss.
const content = cssContentCache.get(collected.idKey) || collected.content;
cssWithOrder.set(collected.idKey, {
...collected,
content
});
}
}
const cssArray = Array.from(cssWithOrder.values());
// Remove the temporary fields added during collection
const cleanedCss = cssArray.map(({ content, id: cssId, url }) => ({
content,
id: cssId,
url
}));
return { code: `export const css = new Set(${JSON.stringify(cleanedCss)})` };
}
}
}
}, {
name: MODULE_DEV_CSS_ALL,
applyToEnvironment(env) {
return command === "dev" && env.name === ASTRO_VITE_ENVIRONMENT_NAMES.astro || env.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr || env.name === ASTRO_VITE_ENVIRONMENT_NAMES.client || env.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender;
},
resolveId: {
filter: { id: new RegExp(`^${MODULE_DEV_CSS_ALL}$`) },
handler() {
return RESOLVED_MODULE_DEV_CSS_ALL;
}
},
load: {
filter: { id: new RegExp(`^${RESOLVED_MODULE_DEV_CSS_ALL}$`) },
handler() {
// Creates a map of the component name to a function to import it
let code = `export const devCSSMap = new Map([`;
for (const route of routesList.routes) {
code += `\n\t[${JSON.stringify(route.component)}, () => import(${JSON.stringify(getDevCSSModuleName(route.component))})],`;
}
code += "]);";
return { code };
}
}
}];
}
/**
* Caches the final CSS of each module in `cssContentCache` during dev, so it can be inlined
* into server-rendered HTML.
*
* Must be registered after all integration and user plugins: its normal-order `transform` then
* runs after framework transforms (e.g. `@vitejs/plugin-vue` scoping SFC styles) and before
* `vite:css-post` turns the CSS into JS. See #18333.
*/
export function astroDevCssContentCachePlugin({ cssContentCache }) {
return {
name: "astro:dev-css-content-cache",
applyToEnvironment(env) {
return env.name === ASTRO_VITE_ENVIRONMENT_NAMES.astro || env.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr || env.name === ASTRO_VITE_ENVIRONMENT_NAMES.client || env.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender;
},
transform: {
filter: { id: {
include: [CSS_LANGS_RE],
exclude: [rawRE, inlineRE]
} },
handler(code, id) {
const mod = this.environment.moduleGraph.getModuleById(id);
if (mod) {
cssContentCache.set(id, code);
}
}
}
};
}