perf(frontend): serve only the mdi icons the app uses (#5118)

* perf(frontend): serve only the mdi icons the app uses

@mdi/font ships a CSS rule and four font formats for every icon. Generate
a trimmed stylesheet from the icons named in the source and alias the
package import to it.

- scripts/build-icons.ts writes src/plugins/materialdesignicons-trimmed.css
  (woff2 only, LF line endings, no source map pointer). predev and prebuild
  run it through build:icons, and CI checks the committed file is current.
- scripts/trim-mdi-fonts.ts builds the allow list from src/, Vuetify's own
  icon set and the icons the backend names. Only a plain
  .mdi-name::before rule can be dropped; anything unsure is kept.
- Custom notification icons the app does not draw fall back to the level
  icon instead of rendering blank.
- An "All icons (mdi)" story under Primitives/RIcon shows every shipped
  icon with search, plus the icons that are available but not shipped.
- Vitest keeps ?raw CSS imports instead of emptying them.
- build:tokens leaves tokens.css alone when only formatting differs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* perf(frontend): trim the mdi icon css in a Vite plugin

Replace the generated, committed icon CSS with a transform plugin that
trims @mdi/font in memory, in dev, Storybook and build. A watcher
reloads dev when the source names a new icon. Drops build-icons.ts, the
alias, the CI staleness check and the checked-in CSS.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* PR feedback

* style(frontend): sort imports in notifications test

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* perf(frontend): reduce the mdi icon trim to a build-only plugin

Drop the icon browser story, the notification glyph probe, the
build:tokens compare and the woff2-only font rewrite. The plugin now
only removes unused icon rules in production builds, so dev serves the
full set and needs no file watcher.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Georges-Antoine Assi <[email protected]>
This commit is contained in:
Stu Anderson
2026-10-08 07:25:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Georges-Antoine Assi
parent 38dfb208f0
commit 4e8fec6845
3 changed files with 70 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
// Drops the @mdi/font icon rules nothing in src/ (or Vuetify itself) names.
import { readdirSync, readFileSync } from "node:fs";
import { extname, join } from "node:path";
import type { Plugin } from "vite";
const ICON_NAME = /mdi-[a-z0-9-]+/g;
const ICON_RULE = /\.(mdi-[a-z0-9-]+)::before\s*\{[^}]*\}/g;
export function usedIconNames(root: string): Set<string> {
const files = readdirSync(join(root, "src"), {
recursive: true,
encoding: "utf8",
})
.filter((path) => [".vue", ".ts"].includes(extname(path)))
.map((path) => join(root, "src", path));
files.push(join(root, "node_modules/vuetify/lib/iconsets/mdi.js"));
return new Set(
files.flatMap((file) => readFileSync(file, "utf8").match(ICON_NAME) ?? []),
);
}
export function trimIconCss(css: string, used: Set<string>): string {
return css.replace(ICON_RULE, (rule, name) => (used.has(name) ? rule : ""));
}
export function trimMdiIcons(): Plugin {
let used = new Set<string>();
return {
name: "romm:trim-mdi-icons",
apply: "build",
enforce: "pre",
configResolved(config) {
used = usedIconNames(config.root);
},
transform(code, id) {
if (!id.endsWith("@mdi/font/css/materialdesignicons.css")) return null;
return { code: trimIconCss(code, used), map: null };
},
};
}
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { trimIconCss, usedIconNames } from "../scripts/trimMdiIcons";
const MDI_CSS = readFileSync(
"node_modules/@mdi/font/css/materialdesignicons.css",
"utf8",
);
describe("trimIconCss", () => {
it("keeps used icon rules and the shared .mdi rules", () => {
const css = trimIconCss(MDI_CSS, new Set(["mdi-account"]));
expect(css).toContain(".mdi-account::before");
expect(css).not.toContain(".mdi-account-circle::before");
expect(css).toContain("@font-face");
expect(css).toContain(".mdi-spin");
});
});
describe("usedIconNames", () => {
it("finds icons from src and the ones Vuetify draws", () => {
const used = usedIconNames(".");
expect(used).toContain("mdi-magnify");
expect(used).toContain("mdi-checkbox-marked");
});
});
+2
View File
@@ -9,6 +9,7 @@ import { platformIconManifest } from "./scripts/platformIconManifest";
import { playerIsolationHeaders } from "./scripts/playerIsolationHeaders";
import { precompress } from "./scripts/precompress";
import { previewAssets } from "./scripts/previewAssets";
import { trimMdiIcons } from "./scripts/trimMdiIcons";
// Vuetify components to preoptimize for faster dev startup
const VUETIFY_COMPONENTS = [
@@ -135,6 +136,7 @@ export default defineConfig(({ mode }) => {
playerIsolationHeaders(),
previewAssets(),
platformIconManifest(),
trimMdiIcons(),
httpsMode &&
mkcert({
savePath: "/app/.vite-plugin-mkcert",