Skip to content

Commit aed36bc

Browse files
authored
fix: Rewrite relative url() paths when inlining @Stylesheet imports in production bundle (#24165)
When a CSS file referenced by @Stylesheet contains @import statements that pull in other CSS files with relative url(...) references, the production build inlined the imports but left the urls untouched. After inlining, the browser resolves those urls relative to the entry file, breaking image paths. Add a new build-time entry point that rewrites url(...) references so each one is expressed relative to the entry CSS file's folder. Absolute urls, protocol urls (http, https, data, ftp, file) and urls whose resolved target falls outside the entry's base folder (e.g. npm package siblings) are left untouched. Fixes #24164
1 parent 56b652f commit aed36bc

10 files changed

Lines changed: 350 additions & 28 deletions

File tree

flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskProcessStylesheetCss.java

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,17 +184,14 @@ private File resolveCssFile(File resourcesDir, String cssPath) {
184184
private void processCssFile(File cssFile) throws IOException {
185185
getLogger().debug("Processing CSS file: {}", cssFile.getName());
186186

187-
// Get node_modules folder for resolving npm package imports
188187
File nodeModulesFolder = options.getNodeModulesFolder();
189188

190-
// Inline @import statements for local files and node_modules
191-
String content = CssBundler.inlineImports(cssFile.getParentFile(),
192-
cssFile, null, nodeModulesFolder);
189+
// Inline @import statements and rewrite relative url() references so
190+
// they remain correct after imports are inlined into the entry file.
191+
String content = CssBundler.inlineImportsForStaticResourcesRelative(
192+
cssFile.getParentFile(), cssFile, nodeModulesFolder);
193193

194-
// Minify the CSS
195194
content = CssBundler.minifyCss(content);
196-
197-
// Write back to the same file
198195
Files.writeString(cssFile.toPath(), content);
199196
}
200197

flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskProcessStylesheetCssTest.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,66 @@ void execute_inlinesNodeModulesImports()
304304
"Should contain main CSS");
305305
}
306306

307+
@Test
308+
void execute_rewritesRelativeUrlsInInlinedImports()
309+
throws ExecutionFailedException, IOException {
310+
Mockito.when(classFinder.getAnnotatedClasses(StyleSheet.class))
311+
.thenReturn(Set.of(TestClassWithStyleSheet.class));
312+
313+
// Entry file at META-INF/resources/styles.css imports
314+
// views/messages.css which references ../images/foo.svg
315+
String mainCss = "@import './views/messages.css';";
316+
String messagesCss = ".icon { mask-image: url(\"../images/foo.svg\"); }";
317+
318+
File mainFile = new File(metaInfResources, "styles.css");
319+
FileUtils.writeStringToFile(mainFile, mainCss, StandardCharsets.UTF_8);
320+
321+
File viewsDir = new File(metaInfResources, "views");
322+
viewsDir.mkdirs();
323+
File messagesFile = new File(viewsDir, "messages.css");
324+
FileUtils.writeStringToFile(messagesFile, messagesCss,
325+
StandardCharsets.UTF_8);
326+
327+
File imagesDir = new File(metaInfResources, "images");
328+
imagesDir.mkdirs();
329+
new File(imagesDir, "foo.svg").createNewFile();
330+
331+
TaskProcessStylesheetCss task = new TaskProcessStylesheetCss(options);
332+
task.execute();
333+
334+
String processedCss = Files.readString(mainFile.toPath());
335+
assertTrue(processedCss.contains("url('images/foo.svg')"),
336+
"Expected url rewritten to 'images/foo.svg' but got: "
337+
+ processedCss);
338+
assertTrue(!processedCss.contains("../images/"),
339+
"Expected '../images/' to be rewritten away but got: "
340+
+ processedCss);
341+
}
342+
343+
@Test
344+
void execute_leavesAbsoluteAndExternalUrlsAlone()
345+
throws ExecutionFailedException, IOException {
346+
Mockito.when(classFinder.getAnnotatedClasses(StyleSheet.class))
347+
.thenReturn(Set.of(TestClassWithStyleSheet.class));
348+
349+
String mainCss = """
350+
.a { background: url('/absolute/x.svg'); }
351+
.b { background: url('https://cdn.example.com/y.svg'); }
352+
""";
353+
File mainFile = new File(metaInfResources, "styles.css");
354+
FileUtils.writeStringToFile(mainFile, mainCss, StandardCharsets.UTF_8);
355+
356+
TaskProcessStylesheetCss task = new TaskProcessStylesheetCss(options);
357+
task.execute();
358+
359+
String processedCss = Files.readString(mainFile.toPath());
360+
assertTrue(processedCss.contains("url('/absolute/x.svg')"),
361+
"Absolute url should remain unchanged: " + processedCss);
362+
assertTrue(
363+
processedCss.contains("url('https://cdn.example.com/y.svg')"),
364+
"External url should remain unchanged: " + processedCss);
365+
}
366+
307367
@Test
308368
void execute_processesMultipleAnnotatedClasses()
309369
throws ExecutionFailedException, IOException {

flow-server/src/main/java/com/vaadin/flow/internal/CssBundler.java

Lines changed: 108 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,34 @@ public class CssBundler {
5959
private static final String MAYBE_LAYER_OR_MEDIA_QUERY = "(" + LAYER + "|"
6060
+ MEDIA_QUERY + ")";
6161

62+
// Selects how url(...) references are rewritten when inlining @import
63+
// statements. The right choice depends on how the bundled CSS is later
64+
// delivered to the browser:
65+
//
66+
// THEMES — used for application themes; url() targets are rewritten to
67+
// absolute "VAADIN/themes/<theme>/..." paths because themes are
68+
// served from a known fixed location.
69+
//
70+
// STATIC_RESOURCES — used in dev mode for @StyleSheet files served from
71+
// public roots (META-INF/resources, webapp, ...). The dev-tools
72+
// live-reload client (vaadin-dev-tools.ts onUpdate) injects the
73+
// bundled content into an inline <style> tag and removes the
74+
// original <link>. An inline <style> has no URL of its own, so the
75+
// browser resolves any relative url() against the *page URL*, which
76+
// would point to the wrong folder. To stay correct we rewrite to
77+
// absolute paths rooted at the servlet context (e.g.
78+
// "/myapp/relurl-test/images/dot.svg").
79+
//
80+
// STATIC_RESOURCES_RELATIVE — used in prod by TaskProcessStylesheetCss.
81+
// The bundled CSS is written back in-place under META-INF/resources
82+
// and served via the original <link href>, so the browser still
83+
// fetches it from the entry file's URL. Relative url() therefore
84+
// resolves against the entry's folder and we just need to express
85+
// each url() relative to that folder. We can't use STATIC_RESOURCES
86+
// here because the deployment context path is not known at build
87+
// time.
6288
private enum BundleFor {
63-
THEMES, STATIC_RESOURCES
89+
THEMES, STATIC_RESOURCES, STATIC_RESOURCES_RELATIVE
6490
}
6591

6692
/**
@@ -167,6 +193,36 @@ public static String inlineImportsForPublicResources(File baseFolder,
167193
BundleFor.STATIC_RESOURCES, contextPath, null);
168194
}
169195

196+
/**
197+
* Inlines imports for CSS files located under public static resources (e.g.
198+
* META-INF/resources) at build time, rewriting relative {@code url(...)}
199+
* references so they are expressed relative to the entry CSS file's folder.
200+
* <p>
201+
* Unlike {@link #inlineImportsForPublicResources(File, File, String)}, this
202+
* variant does not prepend a servlet context path. The resulting URLs are
203+
* purely relative, which is required when the CSS is processed at build
204+
* time and the deployment context path is not yet known.
205+
*
206+
* @param baseFolder
207+
* folder of the entry CSS file; inlined {@code url(...)}
208+
* references are rewritten to paths relative to this folder.
209+
* @param cssFile
210+
* the CSS file to process.
211+
* @param nodeModulesFolder
212+
* the node_modules folder for resolving npm package imports, or
213+
* {@code null} if node_modules resolution is not needed.
214+
* @return the processed stylesheet content, with inlined imports and
215+
* rewritten URLs.
216+
* @throws IOException
217+
* if filesystem resources cannot be read.
218+
*/
219+
public static String inlineImportsForStaticResourcesRelative(
220+
File baseFolder, File cssFile, File nodeModulesFolder)
221+
throws IOException {
222+
return inlineImports(baseFolder, cssFile, new HashSet<>(),
223+
BundleFor.STATIC_RESOURCES_RELATIVE, null, nodeModulesFolder);
224+
}
225+
170226
/**
171227
* Internal implementation that can optionally skip URL rewriting.
172228
*
@@ -247,6 +303,9 @@ private static String inlineImports(File baseFolder, File cssFile,
247303
} else if (bundleFor == BundleFor.STATIC_RESOURCES) {
248304
content = rewriteCssUrlsForStaticResources(baseFolder, cssFile,
249305
contextPath, content);
306+
} else if (bundleFor == BundleFor.STATIC_RESOURCES_RELATIVE) {
307+
content = rewriteCssUrlsForStaticResourcesRelative(baseFolder,
308+
cssFile, content);
250309
}
251310
content = StringUtil.removeComments(content, true);
252311
List<String> unhandledImports = new ArrayList<>();
@@ -307,48 +366,76 @@ private static String inlineImports(File baseFolder, File cssFile,
307366

308367
private static String rewriteCssUrlsForStaticResources(File baseFolder,
309368
File cssFile, String contextPath, String content) {
310-
// Public resources: rebase URLs from the current cssFile to the
311-
// entry stylesheet base folder
369+
// Bundled CSS is delivered as inline <style> in dev mode, so url()s
370+
// need to be absolute paths rooted at the servlet context.
371+
return rewriteCssUrls(baseFolder, cssFile, content,
372+
target -> rebaseToContextPath(baseFolder, contextPath, target));
373+
}
374+
375+
private static String rewriteCssUrlsForStaticResourcesRelative(
376+
File baseFolder, File cssFile, String content) {
377+
// Bundled CSS is written back to disk and served from the entry
378+
// file's URL in prod mode, so url()s can be expressed relative to
379+
// that entry folder.
380+
Path baseNormalized = baseFolder.toPath().normalize().toAbsolutePath();
381+
return rewriteCssUrls(baseFolder, cssFile, content,
382+
target -> baseNormalized.relativize(target).toString()
383+
.replace('\\', '/'));
384+
}
385+
386+
/**
387+
* Common url() rewriting pipeline used by both static-resource bundling
388+
* strategies. Walks every {@code url(...)} in {@code content}, skips ones
389+
* that should be left untouched (empty, absolute, protocol-prefixed,
390+
* unresolvable, outside {@code baseFolder}, or pointing at a missing file),
391+
* and lets the caller decide how to format the kept ones via
392+
* {@code targetToUrl}.
393+
*/
394+
private static String rewriteCssUrls(File baseFolder, File cssFile,
395+
String content,
396+
java.util.function.Function<Path, String> targetToUrl) {
397+
Path baseNormalized = baseFolder.toPath().normalize().toAbsolutePath();
312398
Matcher urlMatcher = URL_PATTERN.matcher(content);
313-
content = urlMatcher.replaceAll(result -> {
399+
return urlMatcher.replaceAll(result -> {
314400
String url = getNonNullGroup(result, 2, 3, 4);
315401
if (url == null || url.trim().endsWith(".css")) {
316-
// CSS imports handled separately below
402+
// @import-style url()s are handled by import inlining, not
403+
// here.
317404
return Matcher.quoteReplacement(urlMatcher.group());
318405
}
319406
String sanitized = sanitizeUrl(url);
320407
if (sanitized == null) {
321408
return Matcher.quoteReplacement(urlMatcher.group());
322409
}
323410
String trimmed = sanitized.trim();
324-
// Only handle relative URLs (no protocol, no leading slash, not
325-
// data URIs)
326-
// Treat only known protocols as absolute to avoid false
327-
// positives like "my:file.css"
328-
if (trimmed.startsWith("/")
411+
// Only handle relative URLs: not empty, no leading slash, no
412+
// known protocol prefix. Match only known protocols as absolute
413+
// to avoid false positives like "my:file.css".
414+
if (trimmed.isEmpty() || trimmed.startsWith("/")
329415
|| PROTOCOL_PATTER_FOR_URLS.matcher(trimmed).matches()) {
330416
return Matcher.quoteReplacement(urlMatcher.group());
331417
}
332418
try {
333419
Path target = cssFile.getParentFile().toPath().resolve(trimmed)
334-
.normalize();
335-
// Only rewrite when we can safely confirm the target (in
336-
// url()) file exists
337-
if (Files.exists(target)) {
338-
// For inline <style> tags, make URLs absolute to the
339-
// application context
340-
String rebased = rebaseToContextPath(baseFolder,
341-
contextPath, target);
342-
return Matcher.quoteReplacement("url('" + rebased + "')");
420+
.normalize().toAbsolutePath();
421+
if (!target.startsWith(baseNormalized)) {
422+
// Target is outside the entry's base folder (e.g. an
423+
// npm-package CSS referencing a sibling file). Don't
424+
// invent a path for it.
425+
return Matcher.quoteReplacement(urlMatcher.group());
343426
}
427+
if (!Files.exists(target)) {
428+
// Don't rewrite something we can't confirm.
429+
return Matcher.quoteReplacement(urlMatcher.group());
430+
}
431+
return Matcher.quoteReplacement(
432+
"url('" + targetToUrl.apply(target) + "')");
344433
} catch (Exception e) {
345-
// On any resolution issue, keep the original
346434
getLogger().debug("Unable to resolve url: {}",
347435
urlMatcher.group());
348436
}
349437
return Matcher.quoteReplacement(urlMatcher.group());
350438
});
351-
return content;
352439
}
353440

354441
private static String rewriteCssUrlsForThemes(File baseFolder, File cssFile,

0 commit comments

Comments
 (0)