Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tender-mice-spend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes CSS url() references to public assets returning 404 in dev mode when base path is configured
1 change: 1 addition & 0 deletions packages/astro/src/core/compile/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export async function compile({
preprocessStyle: createStylePreprocessor({
filename,
viteConfig,
astroConfig,
cssPartialCompileResults,
cssTransformErrors,
}),
Expand Down
77 changes: 76 additions & 1 deletion packages/astro/src/core/compile/style.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,91 @@
import fs from 'node:fs';
import type { TransformOptions } from '@astrojs/compiler';
import { preprocessCSS, type ResolvedConfig } from 'vite';
import type { AstroConfig } from '../../types/public/config.js';
import { AstroErrorData, CSSError, positionAt } from '../errors/index.js';
import { normalizePath } from '../viteUtils.js';
import type { CompileCssResult } from './types.js';

export type PartialCompileCssResult = Pick<CompileCssResult, 'isGlobal' | 'dependencies'>;

/**
* 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: string, base: string): string {
// 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
// eslint-disable-next-line regexp/no-unused-capturing-group
const cssUrlRE = /(?<!@import\s+)(?<=^|[^\w\-\u0080-\uffff])url\((\s*('[^']+'|"[^"]+")\s*|(?:\\.|[^'")\\])+)\)/g;

return css.replace(cssUrlRE, (match, rawUrl: string) => {
// 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;
});
}

export function createStylePreprocessor({
filename,
viteConfig,
astroConfig,
cssPartialCompileResults,
cssTransformErrors,
}: {
filename: string;
viteConfig: ResolvedConfig;
astroConfig: AstroConfig;
cssPartialCompileResults: Partial<CompileCssResult>[];
cssTransformErrors: Error[];
}): TransformOptions['preprocessStyle'] {
Expand All @@ -27,6 +98,10 @@ export function createStylePreprocessor({
try {
const result = await preprocessCSS(content, id, viteConfig);

// 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] = {
isGlobal: !!attrs['is:global'],
dependencies: result.deps ? [...result.deps].map((dep) => normalizePath(dep)) : [],
Expand All @@ -41,7 +116,7 @@ export function createStylePreprocessor({
}
}

return { code: result.code, map };
return { code: rewrittenCode, map };
} catch (err: any) {
try {
err = enhanceCSSError(err, filename, content);
Expand Down
Loading
Loading