Skip to content

Commit 536bb08

Browse files
authored
feat: Optimize StyleSheet css for production build (#22852)
* feat: Optimize StyleSheet css for production build Minify and inline imports for css imported using StyleSheet annotation. Fixes #22472 * format * Use better comment removal * add removed javadoc * format
1 parent 44ca810 commit 536bb08

File tree

13 files changed

+914
-13
lines changed

13 files changed

+914
-13
lines changed

flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildDevBundleMojo.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
import com.vaadin.flow.theme.Theme;
7070
import com.vaadin.flow.utils.FlowFileUtils;
7171

72+
import static com.vaadin.flow.server.Constants.META_INF;
7273
import static com.vaadin.flow.server.Constants.VAADIN_SERVLET_RESOURCES;
7374
import static com.vaadin.flow.server.Constants.VAADIN_WEBAPP_RESOURCES;
7475
import static com.vaadin.flow.server.frontend.FrontendUtils.FRONTEND;
@@ -190,6 +191,14 @@ public class BuildDevBundleMojo extends AbstractMojo
190191
@Parameter(property = FrontendUtils.PARAM_IGNORE_VERSION_CHECKS, defaultValue = "false")
191192
private boolean frontendIgnoreVersionChecks;
192193

194+
/**
195+
* The folder where the META-INF/resources files are copied. Used for
196+
* finding the StyleSheet referenced css files.
197+
*/
198+
@Parameter(defaultValue = "${project.build.outputDirectory}/" + META_INF
199+
+ "resources/")
200+
private File resourcesOutputDirectory;
201+
193202
static final String CLASSFINDER_FIELD_NAME = "classFinder";
194203

195204
private ClassFinder classFinder;
@@ -527,6 +536,11 @@ public boolean isFrontendIgnoreVersionChecks() {
527536
return frontendIgnoreVersionChecks;
528537
}
529538

539+
@Override
540+
public File resourcesOutputDirectory() {
541+
return resourcesOutputDirectory;
542+
}
543+
530544
private static URLClassLoader createIsolatedClassLoader(
531545
MavenProject project, MojoExecution mojoExecution) {
532546
List<URL> urls = new ArrayList<>();

flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/GradlePluginAdapter.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ internal class GradlePluginAdapter private constructor(
237237
override fun frontendOutputDirectory(): File =
238238
config.frontendOutputDirectory.get()
239239

240+
override fun resourcesOutputDirectory(): File =
241+
config.resourcesOutputDirectory.get()
242+
240243
override fun frontendResourcesDirectory(): File =
241244
config.frontendResourcesDirectory.get()
242245

flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/PrepareFrontendInputProperties.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ internal class PrepareFrontendInputProperties(
4949
.filterExists()
5050
.absolutePath
5151

52+
@Input
53+
@Optional
54+
fun getResourcesOutputDirectory(): Provider<String> =
55+
config.resourcesOutputDirectory
56+
.filterExists()
57+
.absolutePath
58+
5259
@Input
5360
fun getNpmFolder(): Provider<String> = config.npmFolder.absolutePath
5461

flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/VaadinFlowPluginExtension.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ public abstract class VaadinFlowPluginExtension @Inject constructor(private val
6262
*/
6363
public abstract val frontendOutputDirectory: Property<File>
6464

65+
/**
66+
* The folder where the META-INF/resources files are copied. Used for
67+
* finding the StyleSheet referenced css files.
68+
* Defaults to `null` which will use the auto-detected value of
69+
* resoucesDir of the main SourceSet, usually `build/resources/main/META-INF/resources/`.
70+
*/
71+
public abstract val resourcesOutputDirectory: Property<File>
72+
6573
/**
6674
* The folder where `package.json` file is located. Default is project root
6775
* dir.
@@ -394,6 +402,17 @@ public class PluginEffectiveConfiguration(
394402
)
395403
)
396404

405+
406+
public val resourcesOutputDirectory: Provider<File> =
407+
extension.resourcesOutputDirectory.convention(
408+
sourceSetName.map {
409+
File(
410+
project.getBuildResourcesDir(it),
411+
Constants.META_INF + "resources/"
412+
)
413+
}
414+
)
415+
397416
public val npmFolder: Provider<File> = extension.npmFolder
398417
.convention(project.projectDir)
399418

@@ -642,6 +661,7 @@ public class PluginEffectiveConfiguration(
642661
"productionMode=${productionMode.get()}, " +
643662
"applicationIdentifier=${applicationIdentifier.get()}, " +
644663
"frontendOutputDirectory=${frontendOutputDirectory.get()}, " +
664+
"resourcesOutputDirectory=${resourcesOutputDirectory.get()}, " +
645665
"npmFolder=${npmFolder.get()}, " +
646666
"frontendDirectory=${frontendDirectory.get()}, " +
647667
"generateBundle=${generateBundle.get()}, " +

flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildFrontendMojo.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
import com.vaadin.pro.licensechecker.LicenseChecker;
4949
import com.vaadin.pro.licensechecker.MissingLicenseKeyException;
5050

51+
import static com.vaadin.flow.server.Constants.META_INF;
52+
5153
/**
5254
* Goal that builds the frontend bundle.
5355
*
@@ -136,6 +138,14 @@ public class BuildFrontendMojo extends FlowModeAbstractMojo
136138
@Parameter(property = InitParameters.CLEAN_BUILD_FRONTEND_FILES, defaultValue = "true")
137139
private boolean cleanFrontendFiles;
138140

141+
/**
142+
* The folder where the META-INF/resources files are copied. Used for
143+
* finding the StyleSheet referenced css files.
144+
*/
145+
@Parameter(defaultValue = "${project.build.outputDirectory}/" + META_INF
146+
+ "resources/")
147+
private File resourcesOutputDirectory;
148+
139149
@Override
140150
protected void executeInternal()
141151
throws MojoExecutionException, MojoFailureException {
@@ -276,6 +286,11 @@ public boolean compressBundle() {
276286
return true;
277287
}
278288

289+
@Override
290+
public File resourcesOutputDirectory() {
291+
return resourcesOutputDirectory;
292+
}
293+
279294
@Override
280295
public boolean checkRuntimeDependency(String groupId, String artifactId,
281296
Consumer<String> missingDependencyMessage) {

flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/plugin/base/BuildFrontendUtil.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,9 @@ public static void runNodeUpdater(PluginAdapterBuild adapter,
370370
.withFrontendIgnoreVersionChecks(
371371
adapter.isFrontendIgnoreVersionChecks())
372372
.withFrontendDependenciesScanner(frontendDependencies)
373-
.withCommercialBanner(adapter.isCommercialBannerEnabled());
373+
.withCommercialBanner(adapter.isCommercialBannerEnabled())
374+
.withMetaInfResourcesDirectory(
375+
adapter.resourcesOutputDirectory());
374376
new NodeTasks(options).execute();
375377
} catch (ExecutionFailedException exception) {
376378
throw exception;

flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/plugin/base/PluginAdapterBuild.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,12 @@ public interface PluginAdapterBuild extends PluginAdapterBase {
118118
boolean checkRuntimeDependency(String groupId, String artifactId,
119119
Consumer<String> missingDependencyMessageConsumer);
120120

121+
/**
122+
* The resources output directory for META-INF/resources in the classes
123+
* output directory.
124+
*
125+
* @return the META-INF/resources directory, usually
126+
* {output}/classes/META-INF/resources
127+
*/
128+
File resourcesOutputDirectory();
121129
}

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

Lines changed: 112 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import tools.jackson.databind.JsonNode;
3333

3434
import com.vaadin.flow.internal.JacksonUtils;
35+
import com.vaadin.flow.internal.StringUtil;
3536

3637
/**
3738
* Utility methods to handle application theme CSS content.
@@ -112,7 +113,41 @@ private enum BundleFor {
112113
public static String inlineImportsForThemes(File themeFolder, File cssFile,
113114
JsonNode themeJson) throws IOException {
114115
return inlineImports(themeFolder, cssFile,
115-
getThemeAssetsAliases(themeJson), BundleFor.THEMES, null);
116+
getThemeAssetsAliases(themeJson), BundleFor.THEMES, null, null);
117+
}
118+
119+
/**
120+
* Recurse over CSS import and inlines all of them into a single CSS block.
121+
* <p>
122+
*
123+
* Unresolvable imports are put on the top of the resulting code, because
124+
* {@code @import} statements must come before any other CSS instruction,
125+
* otherwise the import is ignored by the browser.
126+
* <p>
127+
*
128+
* This overload supports resolving imports from node_modules in addition to
129+
* relative paths.
130+
*
131+
* @param themeFolder
132+
* location of theme folder on the filesystem. May be null if not
133+
* processing theme files.
134+
* @param cssFile
135+
* the CSS file to process.
136+
* @param themeJson
137+
* the theme configuration, usually stored in
138+
* {@literal theme.json} file. May be null.
139+
* @param nodeModulesFolder
140+
* the node_modules folder for resolving npm package imports. May
141+
* be null if node_modules resolution is not needed.
142+
* @return the processed stylesheet content, with inlined imports and
143+
* rewritten URLs.
144+
* @throws IOException
145+
* if filesystem resources can not be read.
146+
*/
147+
public static String inlineImports(File themeFolder, File cssFile,
148+
JsonNode themeJson, File nodeModulesFolder) throws IOException {
149+
return inlineImports(themeFolder, cssFile,
150+
getThemeAssetsAliases(themeJson), null, "", nodeModulesFolder);
116151
}
117152

118153
/**
@@ -132,7 +167,7 @@ public static String inlineImportsForThemes(File themeFolder, File cssFile,
132167
public static String inlineImportsForPublicResources(File baseFolder,
133168
File cssFile, String contextPath) throws IOException {
134169
return inlineImports(baseFolder, cssFile, new HashSet<>(),
135-
BundleFor.STATIC_RESOURCES, contextPath);
170+
BundleFor.STATIC_RESOURCES, contextPath, null);
136171
}
137172

138173
/**
@@ -145,17 +180,21 @@ public static String inlineImportsForPublicResources(File baseFolder,
145180
* the CSS file to process
146181
* @param assetAliases
147182
* theme asset aliases (only used when rewriting URLs)
148-
* @param contextPath
149-
* that url() are rewritten to and rebased onto
150183
* @param bundleFor
151184
* defines a way how bundler resolves url(...), e.g. whether
152185
* {@link com.vaadin.flow.theme.Theme} location is used
153186
* ({@code src/main/frontend/themes/}) and whether to rewrite
154187
* url(...) references to VAADIN/themes paths
188+
* @param contextPath
189+
* that url() are rewritten to and rebased onto
190+
* @param nodeModulesFolder
191+
* the node_modules folder for resolving npm package imports. May
192+
* be null if node_modules resolution is not needed.
155193
*/
156194
private static String inlineImports(File baseFolder, File cssFile,
157-
Set<String> assetAliases, BundleFor bundleFor, String contextPath)
158-
throws IOException {
195+
Set<String> assetAliases, BundleFor bundleFor, String contextPath,
196+
File nodeModulesFolder) throws IOException {
197+
159198
String content = Files.readString(cssFile.toPath());
160199
if (bundleFor == BundleFor.THEMES) {
161200
Matcher urlMatcher = URL_PATTERN.matcher(content);
@@ -181,13 +220,13 @@ private static String inlineImports(File baseFolder, File cssFile,
181220
String url = getNonNullGroup(result, 3, 4, 5, 7, 8);
182221
String sanitizedUrl = sanitizeUrl(url);
183222
if (sanitizedUrl != null && sanitizedUrl.endsWith(".css")) {
184-
File potentialFile = new File(cssFile.getParentFile(),
185-
sanitizedUrl);
186-
if (potentialFile.exists()) {
223+
File potentialFile = resolveImportPath(sanitizedUrl,
224+
cssFile.getParentFile(), nodeModulesFolder);
225+
if (potentialFile != null && potentialFile.exists()) {
187226
try {
188-
return Matcher.quoteReplacement(
189-
inlineImports(baseFolder, potentialFile,
190-
assetAliases, bundleFor, contextPath));
227+
return Matcher.quoteReplacement(inlineImports(
228+
baseFolder, potentialFile, assetAliases,
229+
bundleFor, contextPath, nodeModulesFolder));
191230
} catch (IOException e) {
192231
getLogger().warn("Unable to inline import: {}",
193232
result.group());
@@ -387,8 +426,69 @@ private static String sanitizeUrl(String url) {
387426
return url.trim().split("\\?")[0];
388427
}
389428

429+
/**
430+
* Resolve import path to a file. First check relative to the CSS file's,
431+
* then check node_modules for non-relative path.
432+
*
433+
* @param importPath
434+
* the import path from the CSS file
435+
* @param cssFileDir
436+
* the directory containing the CSS file
437+
* @param nodeModulesFolder
438+
* the node_modules folder, may be null
439+
* @return the resolved file, or null if not found
440+
*/
441+
private static File resolveImportPath(String importPath, File cssFileDir,
442+
File nodeModulesFolder) {
443+
// First, try relative to the CSS file's directory
444+
File relativeFile = new File(cssFileDir, importPath);
445+
if (relativeFile.exists()) {
446+
return relativeFile;
447+
}
448+
449+
// If not a relative path (doesn't start with ./ or ../) and
450+
// node_modules is available, try resolving from node_modules
451+
if (nodeModulesFolder != null && !importPath.startsWith("./")
452+
&& !importPath.startsWith("../")) {
453+
File nodeModulesFile = new File(nodeModulesFolder, importPath);
454+
if (nodeModulesFile.exists()) {
455+
return nodeModulesFile;
456+
}
457+
}
458+
459+
return null;
460+
}
461+
390462
private static Logger getLogger() {
391463
return LoggerFactory.getLogger(CssBundler.class);
392464
}
393465

466+
/**
467+
* Minify CSS content by removing comments and unnecessary whitespace.
468+
* <p>
469+
* This method performs basic CSS minification:
470+
* <ul>
471+
* <li>Remove CSS comments</li>
472+
* <li>Collapse multiple whitespace characters</li>
473+
* <li>Remove whitespace around special characters like braces and
474+
* colons</li>
475+
* <li>Remove trailing semicolons before closing braces</li>
476+
* </ul>
477+
*
478+
* @param css
479+
* the CSS content to minify
480+
* @return the minified CSS content
481+
*/
482+
public static String minifyCss(String css) {
483+
// Remove CSS comments /* ... */
484+
css = StringUtil.removeComments(css, true);
485+
// Collapse whitespace
486+
css = css.replaceAll("\\s+", " ");
487+
// Remove spaces around special characters
488+
css = css.replaceAll("\\s*([{};:,>~+])\\s*", "$1");
489+
// Remove trailing semicolons before }
490+
css = css.replaceAll(";}", "}");
491+
return css.trim();
492+
}
493+
394494
}

flow-server/src/main/java/com/vaadin/flow/server/frontend/NodeTasks.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ public class NodeTasks implements FallibleCommand {
8989
TaskGenerateBootstrap.class,
9090
TaskRunDevBundleBuild.class,
9191
TaskPrepareProdBundle.class,
92+
TaskProcessStylesheetCss.class,
9293
TaskCleanFrontendFiles.class,
9394
TaskRemoveOldFrontendGeneratedFiles.class
9495
));
@@ -144,6 +145,8 @@ public NodeTasks(Options options) {
144145
commands.add(new TaskGenerateCommercialBanner(options));
145146
BundleUtils.copyPackageLockFromBundle(options);
146147
}
148+
// Process @StyleSheet CSS files (minify and inline @imports)
149+
commands.add(new TaskProcessStylesheetCss(options));
147150
} else if (options.isBundleBuild()) {
148151
// The dev bundle check needs the frontendDependencies to be
149152
// able to

flow-server/src/main/java/com/vaadin/flow/server/frontend/Options.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ public class Options implements Serializable {
133133
*/
134134
private File javaResourceFolder;
135135

136+
/**
137+
* META-INF/resources directory.
138+
*/
139+
private File resourcesDirectory;
140+
136141
/**
137142
* Additional npm packages to run postinstall for.
138143
*/
@@ -1091,4 +1096,25 @@ public boolean copyAssets() {
10911096
}
10921097
return copyAssets;
10931098
}
1099+
1100+
/**
1101+
* Set where the META-INF/resources files are copied by the build.
1102+
*
1103+
* @param resourcesDirectory
1104+
* META-INF resources directory
1105+
* @return this builder
1106+
*/
1107+
public Options withMetaInfResourcesDirectory(File resourcesDirectory) {
1108+
this.resourcesDirectory = resourcesDirectory;
1109+
return this;
1110+
}
1111+
1112+
/**
1113+
* Get the resources directory if defined.
1114+
*
1115+
* @return META-INF resources directory
1116+
*/
1117+
public File getMetaInfResourcesDirectory() {
1118+
return resourcesDirectory;
1119+
}
10941120
}

0 commit comments

Comments
 (0)