astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
198 lines (197 loc) • 7.75 kB
JavaScript
import fs from "node:fs";
import { createRequire } from "node:module";
import { preprocessCSS } from "vite";
import { AstroErrorData, CSSError, positionAt } from "../errors/index.js";
import { normalizePath } from "../viteUtils.js";
/**
* Rewrites absolute URLs in CSS to include the base path.
*
* Vite's `preprocessCSS` function explicitly does NOT resolve URLs in `url()` or `image-set()`
* (https://vite.dev/guide/api-javascript.html#preprocesscss). During build, Vite's CSS plugin handles URL rewriting through its
* full transform pipeline, but during dev, Astro calls `preprocessCSS` directly through the
* compiler, bypassing that pipeline.
*
* Only absolute URLs starting with `/` (e.g., `/fonts/font.woff`, `/images/bg.png`) are rewritten
*
* Uses Vite's cssUrlRE regex pattern for reliable URL matching.
* See: https://github.com/vitejs/vite/blob/main/packages/vite/src/node/plugins/css.ts
*
* @param css - The CSS string to process
* @param base - The base path to prepend (e.g., `/my-base`)
* @returns The CSS with rewritten URLs
*/
function rewriteCssUrls(css, base) {
// Only rewrite if base is not the default '/'
if (!base || base === "/") {
return css;
}
// Normalize base path (remove trailing slash for consistent joining)
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
// Safety check: base should start with '/' (already normalized by Astro config)
if (!normalizedBase.startsWith("/")) {
return css;
}
// Vite's production-tested regex for matching url() in CSS
// Matches url(...) while handling quotes, unquoted URLs, and edge cases
// Excludes @import statements via negative lookbehind
// Matches Vite's cssUrlRE pattern exactly - capturing groups preserved for compatibility
const cssUrlRE = /(?<!@import\s+)(?<=^|[^\w\-\u0080-\uffff])url\((\s*('[^']+'|"[^"]+")\s*|(?:\\.|[^'")\\])+)\)/g;
return css.replace(cssUrlRE, (match, rawUrl) => {
// Extract URL value, removing quotes if present
let url = rawUrl.trim();
let quote = "";
// Check if URL is quoted (single or double)
if (url.startsWith("'") && url.endsWith("'") || url.startsWith("\"") && url.endsWith("\"")) {
quote = url[0];
url = url.slice(1, -1);
}
url = url.trim();
// Only rewrite root-relative URLs (start with / but not //)
const isRootRelative = url.startsWith("/") && !url.startsWith("//");
// Skip external URLs and data URIs
const isExternal = url.startsWith("data:") || url.startsWith("http:") || url.startsWith("https:");
// Skip if already has base path (makes function idempotent)
const alreadyHasBase = url.startsWith(normalizedBase + "/");
if (isRootRelative && !isExternal && !alreadyHasBase) {
return `url(${quote}${normalizedBase}${url}${quote})`;
}
return match;
});
}
/**
* Workaround for https://github.com/withastro/astro/issues/16524.
*
* When `vite.css.transformer === 'lightningcss'`, lightningcss flattens nested
* selectors (e.g. `.parent { :where(& > :not(:last-child)) { ... } }`) BEFORE
* `@astrojs/compiler` injects scope attributes. The injector then sees a
* top-level rule whose leading compound is `:where(...)` rather than
* `.parent`, and falls back to prepending `[data-astro-cid-X]` as a new
* leading compound, which constrains the wrong element.
*
* To preserve the structural shape the scope injector expects, we ask
* lightningcss to skip its `Nesting` lowering pass for the per-component
* preprocess call. Vite's downstream pipeline still lowers nesting for the
* final bundle, so the produced CSS remains compatible with the user's
* targets.
*
* Returns a NEW config object (no mutation of the shared `viteConfig`) so
* that parallel `.astro` compilations don't race on a shared mutable
* property. Returns `undefined` if `lightningcss` cannot be resolved from
* the user's project, in which case the caller falls back to the original
* config (and Vite's preprocessCSS will surface the misconfiguration).
*/
function withNestingExcluded(viteConfig) {
let Features;
try {
// `lightningcss` is loaded by Vite as an optional peer dep, so we
// resolve it from the user's project root (where Vite resolves it).
const requireFromRoot = createRequire(viteConfig.root + "/");
Features = requireFromRoot("lightningcss").Features;
} catch {
return undefined;
}
const lcss = viteConfig.css?.lightningcss ?? {};
const prevExclude = lcss.exclude ?? 0;
return {
...viteConfig,
css: {
...viteConfig.css,
lightningcss: {
...lcss,
exclude: prevExclude | Features.Nesting
}
}
};
}
export function createStylePreprocessor({ filename, viteConfig, astroConfig, cssPartialCompileResults, cssTransformErrors }) {
let processedStylesCount = 0;
return async (content, attrs) => {
const index = processedStylesCount++;
const lang = `.${attrs?.lang || "css"}`.toLowerCase();
const id = `${filename}?astro&type=style&index=${index}&lang${lang}`;
try {
// Workaround for #16524: when lightningcss is the Vite CSS transformer,
// exclude its Nesting lowering pass so the Astro compiler's scope
// injector still sees `.parent` (and not `:where(.parent ...)`) as the
// leading compound. Vite's downstream pipeline still lowers nesting
// for the final bundle.
const effectiveViteConfig = viteConfig.css?.transformer === "lightningcss" ? withNestingExcluded(viteConfig) ?? viteConfig : viteConfig;
const result = await preprocessCSS(content, id, effectiveViteConfig);
// Rewrite CSS URLs to include the base path
// This is necessary because preprocessCSS doesn't handle URL rewriting
const rewrittenCode = rewriteCssUrls(result.code, astroConfig.base);
cssPartialCompileResults[index] = {
// Use `in` operator to handle both Go compiler (boolean `true`) and
// Rust compiler (empty string `""`) representations of boolean attributes.
isGlobal: "is:global" in attrs,
dependencies: result.deps ? [...result.deps].map((dep) => normalizePath(dep)) : []
};
let map;
if (result.map) {
if (typeof result.map === "string") {
map = result.map;
} else if (result.map.mappings) {
map = result.map.toString();
}
}
return {
code: rewrittenCode,
map
};
} catch (err) {
try {
err = enhanceCSSError(err, filename, content);
} catch {}
cssTransformErrors.push(err);
return { error: err + "" };
}
};
}
function enhanceCSSError(err, filename, cssContent) {
const fileContent = fs.readFileSync(filename).toString();
const styleTagBeginning = fileContent.indexOf(cssContent);
// PostCSS Syntax Error
if (err.name === "CssSyntaxError") {
const errorLine = positionAt(styleTagBeginning, fileContent).line + (err.line ?? 0);
// Vite will handle creating the frame for us with proper line numbers, no need to create one
return new CSSError({
...AstroErrorData.CSSSyntaxError,
message: err.reason,
location: {
file: filename,
line: errorLine,
column: err.column
},
stack: err.stack
});
}
// Some CSS processor will return a line and a column, so let's try to show a pretty error
if (err.line && err.column) {
const errorLine = positionAt(styleTagBeginning, fileContent).line + (err.line ?? 0);
return new CSSError({
...AstroErrorData.UnknownCSSError,
message: err.message,
location: {
file: filename,
line: errorLine,
column: err.column
},
frame: err.frame,
stack: err.stack
});
}
// For other errors we'll just point to the beginning of the style tag
const errorPosition = positionAt(styleTagBeginning, fileContent);
errorPosition.line += 1;
return new CSSError({
name: "CSSError",
message: err.message,
location: {
file: filename,
line: errorPosition.line,
column: 0
},
frame: err.frame,
stack: err.stack
});
}