perf(dashboard): subset MDI icon font and self-host Google Fonts#5884
perf(dashboard): subset MDI icon font and self-host Google Fonts#5884camera-2018 wants to merge 3 commits intoAstrBotDevs:masterfrom
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly enhances the dashboard's frontend performance by optimizing font loading. It achieves this by subsetting the Material Design Icons font to include only the icons actually used, drastically reducing its size, and by self-hosting Google Fonts to remove external network dependencies. These changes lead to a faster First Contentful Paint and a more robust user experience, especially under slow or unavailable network conditions. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
|
@sourcery-ai review |
|
/gemini review |
There was a problem hiding this comment.
Hey,我发现了两个问题,并给出了一些整体反馈:
- 目前
mdiSubset插件会在每次buildStart时运行,包括vite dev;可以考虑在defineConfig里通过command === 'build'或环境变量进行判断,只在构建时启用,这样在开发服务器启动时就不会为子集化付出开销(以及产生噪声日志)。 - 在
subset-mdi-font.mjs中,你使用了import.meta.dirname,但它并不是标准 Node ESM API 的一部分;为了避免在不同 Node 版本之间产生运行时问题,你可能需要改为通过fileURLToPath(new URL('..', import.meta.url))来派生ROOT。
给 AI Agent 的提示
Please address the comments from this code review:
## Overall Comments
- 目前 `mdiSubset` 插件会在每次 `buildStart` 时运行,包括 `vite dev`;可以考虑在 `defineConfig` 里通过 `command === 'build'` 或环境变量进行判断,只在构建时启用,这样在开发服务器启动时就不会为子集化付出开销(以及产生噪声日志)。
- 在 `subset-mdi-font.mjs` 中,你使用了 `import.meta.dirname`,但它并不是标准 Node ESM API 的一部分;为了避免在不同 Node 版本之间产生运行时问题,你可能需要改为通过 `fileURLToPath(new URL('..', import.meta.url))` 来派生 `ROOT`。
## Individual Comments
### Comment 1
<location path="dashboard/scripts/subset-mdi-font.mjs" line_range="18" />
<code_context>
+import { readFileSync, writeFileSync, copyFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs";
+import { join, resolve, extname } from "path";
+
+const ROOT = resolve(import.meta.dirname, "..");
+const SRC = join(ROOT, "src");
+const MDI_CSS_PATH = join(ROOT, "node_modules/@mdi/font/css/materialdesignicons.css");
</code_context>
<issue_to_address>
**issue (bug_risk):** 使用 `import.meta.dirname` 在不同 Node 版本之间可能不具备可移植性;建议改为从 `import.meta.url` 派生 `__dirname`。
`import.meta.dirname` 不是 Node 稳定 ESM API 的一部分,在某些 Node 版本或其他运行时中可能为 `undefined`,从而导致运行时失败。为了使其具备可移植性,建议改为从 `import.meta.url` 派生目录:
```js
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const ROOT = resolve(__dirname, '..');
```
这样可以让脚本在不同 Node 版本和工具链之间都能正常工作。
</issue_to_address>
### Comment 2
<location path="dashboard/scripts/subset-mdi-font.mjs" line_range="55" />
<code_context>
+ console.warn(`⚠️ Fallback complete: using full font (${(size / 1024).toFixed(1)} KB woff2)`);
+}
+
+try {
+ // ── Pre-checks ────────────────────────────────────────────────────────────
+ if (!existsSync(MDI_CSS_PATH)) {
</code_context>
<issue_to_address>
**issue (complexity):** 可以考虑将主流程和关键的解析/转换步骤拆分成一些小的、具名的辅助函数(以及一个比较薄的 main/try 代码块),以在不改变行为的前提下,让这个脚本更易于理解和维护。
在不改变行为的前提下降低“感知复杂度”的一种方式,是把几类职责拆出来放到小的具名辅助函数中。你不需要完全重写;就算只多拆分几个函数,并让 `try` 代码块更薄一些,也能让脚本更易读、更易维护。
### 1. 抽取 `main()`,并让顶层 `try/catch` 保持精简
目前 `try` 代码块把所有逻辑都混在了一起。你可以在保持“任意错误都回退到完整字体”行为的前提下,将主流程移动到一个函数中:
```js
async function main() {
// Pre-checks
// dynamic import subset-font
// scan icons
// parse mdi CSS
// resolve codepoints
// subset fonts
// generate CSS
// report
}
try {
await main();
} catch (err) {
try {
fallbackToFullFont(err.message);
} catch (fallbackErr) {
console.error(`❌ Fallback also failed: ${fallbackErr.message}`);
console.error(`❌ Please ensure @mdi/font is installed: pnpm install`);
process.exit(1);
}
}
```
这样可以让全局作用域和错误处理保持简单,同时保持完全相同的行为。
### 2. 将 `collectFiles` 从 `main` 中分离出来并做成可复用函数
当前的递归目录遍历逻辑本身没问题,但被内联在主流程里。把它抽出来可以明确意图,也便于复用/测试。
```js
function collectFiles(dir, exts) {
let files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
files = files.concat(collectFiles(full, exts));
} else if (exts.includes(extname(entry.name))) {
files.push(full);
}
}
return files;
}
// in main():
const sourceFiles = collectFiles(SRC, [".vue", ".ts", ".js"]);
```
行为保持不变,但顶层逻辑更加清晰。
### 3. 将 CSS 解析逻辑独立出来
目前正则 + 标记的逻辑混在主脚本中。把这些拆成具名函数,可以让逻辑更易于理解,并且在 @mdi/font 升级时更易调整。
```js
function parseIconCodepoints(mdiCSS) {
const classPattern = /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"/g;
const iconMap = new Map();
for (const match of mdiCSS.matchAll(classPattern)) {
iconMap.set(match[1], match[2]);
}
return iconMap;
}
function extractUtilityCss(mdiCSS) {
const utilityStartMarker = ".mdi-18px.mdi-set"; // @mdi/font 中的第一个 utility 规则
const idx = mdiCSS.indexOf(utilityStartMarker);
if (idx === -1) return "";
return mdiCSS
.slice(idx)
.replace(/\/\*# sourceMappingURL=.*\*\//, "")
.trim();
}
```
然后在 `main()` 中:
```js
const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
const iconMap = parseIconCodepoints(mdiCSS);
let css = /* font-face + base .mdi rules */;
for (const icon of resolvedIcons.sort()) {
const cp = iconMap.get(icon);
css += `.${icon}::before {\n content: "\\${cp}";\n}\n\n`;
}
const utilityCss = extractUtilityCss(mdiCSS);
if (utilityCss) {
css += `/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`;
} else {
console.warn("⚠️ Could not find MDI utility classes in original CSS, skipping");
}
```
输出结果保持不变,但所有 CSS 解析逻辑都集中并被清晰地组织起来了。
### 4. 将“used → resolved/missing/subset”步骤变成一个数据转换
与其在主流程中内联地修改三个集合,不如从辅助函数中返回分组后的结果:
```js
function resolveUsedIcons(usedIcons, iconMap) {
const resolvedIcons = [];
const missingIcons = [];
const subsetChars = [];
for (const icon of usedIcons) {
const cp = iconMap.get(icon);
if (cp) {
resolvedIcons.push(icon);
subsetChars.push(String.fromCodePoint(parseInt(cp, 16)));
} else {
missingIcons.push(icon);
}
}
return { resolvedIcons, missingIcons, subsetChars };
}
// in main():
const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap);
```
这样可以在不改变语义的前提下,让数据流更加清晰明确。
### 5. 可选:让 `fallbackToFullFont` 的转换步骤更清楚
你可以保留相同的正则,只是让意图更清晰:
```js
function rewriteFontUrlsToSubset(mdiCSS) {
return mdiCSS
.replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(woff2?)\?[^"]*"\)/g,
(_, ext) => `url("./materialdesignicons-webfont-subset.${ext}")`);
}
function removeLegacySources(mdiCSS) {
return mdiCSS
.replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(eot|ttf)\?[^"]*"\)[^,]*/g, "");
}
function normalizeSrcSeparators(mdiCSS) {
return mdiCSS
.replace(/src:\s*,/g, "src:")
.replace(/,\s*;/g, ";");
}
function buildFallbackCss() {
const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
let css = rewriteFontUrlsToSubset(mdiCSS);
css = removeLegacySources(css);
css = normalizeSrcSeparators(css);
return css;
}
// in fallbackToFullFont():
const fallbackCSS = buildFallbackCss();
writeFileSync(join(OUT_DIR, "materialdesignicons-subset.css"), fallbackCSS);
```
行为完全相同,只是把嵌套的 `.replace()` 调用链,变成了一个个具名步骤,使其更易于之后审查和调整。
</issue_to_address>帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据这些反馈改进后续的评审。
Original comment in English
Hey - I've found 2 issues, and left some high level feedback:
- The
mdiSubsetplugin currently runs on everybuildStart, includingvite dev; consider gating it behindcommand === 'build'indefineConfigor an env check so you don't pay the subsetting cost (and noisy logs) during development server startup. - In
subset-mdi-font.mjs, you useimport.meta.dirnamewhich is not part of the standard Node ESM API; to avoid runtime issues across Node versions, you may want to deriveROOTviafileURLToPath(new URL('..', import.meta.url))instead.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The `mdiSubset` plugin currently runs on every `buildStart`, including `vite dev`; consider gating it behind `command === 'build'` in `defineConfig` or an env check so you don't pay the subsetting cost (and noisy logs) during development server startup.
- In `subset-mdi-font.mjs`, you use `import.meta.dirname` which is not part of the standard Node ESM API; to avoid runtime issues across Node versions, you may want to derive `ROOT` via `fileURLToPath(new URL('..', import.meta.url))` instead.
## Individual Comments
### Comment 1
<location path="dashboard/scripts/subset-mdi-font.mjs" line_range="18" />
<code_context>
+import { readFileSync, writeFileSync, copyFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs";
+import { join, resolve, extname } from "path";
+
+const ROOT = resolve(import.meta.dirname, "..");
+const SRC = join(ROOT, "src");
+const MDI_CSS_PATH = join(ROOT, "node_modules/@mdi/font/css/materialdesignicons.css");
</code_context>
<issue_to_address>
**issue (bug_risk):** Using `import.meta.dirname` may not be portable across Node versions; prefer deriving `__dirname` from `import.meta.url`.
`import.meta.dirname` isn’t part of Node’s stable ESM API and may be undefined in some Node versions or other runtimes, causing runtime failures. To make this portable, derive the directory from `import.meta.url` instead:
```js
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const ROOT = resolve(__dirname, '..');
```
This keeps the script working across Node versions and toolchains.
</issue_to_address>
### Comment 2
<location path="dashboard/scripts/subset-mdi-font.mjs" line_range="55" />
<code_context>
+ console.warn(`⚠️ Fallback complete: using full font (${(size / 1024).toFixed(1)} KB woff2)`);
+}
+
+try {
+ // ── Pre-checks ────────────────────────────────────────────────────────────
+ if (!existsSync(MDI_CSS_PATH)) {
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the main flow and key parsing/transform steps into small, named helper functions (and a thin main/try block) to make this script easier to follow and maintain without changing behavior.
One way to reduce the perceived complexity without changing behavior is to tease apart a few responsibilities into small, named helpers. You don’t need a full rewrite; even a couple of extra functions and a thinner `try` block will make this easier to follow and maintain.
### 1. Extract `main()` and keep the top-level `try/catch` thin
Right now the `try` block mixes everything. You can keep the “fallback on any error” behavior but move the main flow into a function:
```js
async function main() {
// Pre-checks
// dynamic import subset-font
// scan icons
// parse mdi CSS
// resolve codepoints
// subset fonts
// generate CSS
// report
}
try {
await main();
} catch (err) {
try {
fallbackToFullFont(err.message);
} catch (fallbackErr) {
console.error(`❌ Fallback also failed: ${fallbackErr.message}`);
console.error(`❌ Please ensure @mdi/font is installed: pnpm install`);
process.exit(1);
}
}
```
This keeps the global scope and error handling simple while keeping identical behavior.
### 2. Move `collectFiles` out of `main` and make it reusable
The recursive directory walk is fine but inlined in the main flow. Pulling it out clarifies intent and allows reuse/tests.
```js
function collectFiles(dir, exts) {
let files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
files = files.concat(collectFiles(full, exts));
} else if (exts.includes(extname(entry.name))) {
files.push(full);
}
}
return files;
}
// in main():
const sourceFiles = collectFiles(SRC, [".vue", ".ts", ".js"]);
```
Same behavior, simpler top-level.
### 3. Isolate CSS parsing logic
The regex + marker logic is currently mixed into the main script. Pulling these into named functions makes the logic easier to reason about and change if @mdi/font updates.
```js
function parseIconCodepoints(mdiCSS) {
const classPattern = /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"/g;
const iconMap = new Map();
for (const match of mdiCSS.matchAll(classPattern)) {
iconMap.set(match[1], match[2]);
}
return iconMap;
}
function extractUtilityCss(mdiCSS) {
const utilityStartMarker = ".mdi-18px.mdi-set"; // first utility rule in @mdi/font
const idx = mdiCSS.indexOf(utilityStartMarker);
if (idx === -1) return "";
return mdiCSS
.slice(idx)
.replace(/\/\*# sourceMappingURL=.*\*\//, "")
.trim();
}
```
Then in `main()`:
```js
const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
const iconMap = parseIconCodepoints(mdiCSS);
let css = /* font-face + base .mdi rules */;
for (const icon of resolvedIcons.sort()) {
const cp = iconMap.get(icon);
css += `.${icon}::before {\n content: "\\${cp}";\n}\n\n`;
}
const utilityCss = extractUtilityCss(mdiCSS);
if (utilityCss) {
css += `/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`;
} else {
console.warn("⚠️ Could not find MDI utility classes in original CSS, skipping");
}
```
Same output, but all CSS parsing is clearly grouped and documented.
### 4. Make the “used → resolved/missing/subset” step a data transform
Instead of mutating three collections inline, return the grouped result from a helper:
```js
function resolveUsedIcons(usedIcons, iconMap) {
const resolvedIcons = [];
const missingIcons = [];
const subsetChars = [];
for (const icon of usedIcons) {
const cp = iconMap.get(icon);
if (cp) {
resolvedIcons.push(icon);
subsetChars.push(String.fromCodePoint(parseInt(cp, 16)));
} else {
missingIcons.push(icon);
}
}
return { resolvedIcons, missingIcons, subsetChars };
}
// in main():
const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap);
```
This keeps the data flow explicit without changing any semantics.
### 5. Optional: clarify `fallbackToFullFont` transformations
You can keep the same regexes but make the intent clearer:
```js
function rewriteFontUrlsToSubset(mdiCSS) {
return mdiCSS
.replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(woff2?)\?[^"]*"\)/g,
(_, ext) => `url("./materialdesignicons-webfont-subset.${ext}")`);
}
function removeLegacySources(mdiCSS) {
return mdiCSS
.replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(eot|ttf)\?[^"]*"\)[^,]*/g, "");
}
function normalizeSrcSeparators(mdiCSS) {
return mdiCSS
.replace(/src:\s*,/g, "src:")
.replace(/,\s*;/g, ";");
}
function buildFallbackCss() {
const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
let css = rewriteFontUrlsToSubset(mdiCSS);
css = removeLegacySources(css);
css = normalizeSrcSeparators(css);
return css;
}
// in fallbackToFullFont():
const fallbackCSS = buildFallbackCss();
writeFileSync(join(OUT_DIR, "materialdesignicons-subset.css"), fallbackCSS);
```
Behavior is identical; the nested `.replace()` chain becomes a sequence of named steps, which makes it easier to audit and adjust later.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| import { readFileSync, writeFileSync, copyFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs"; | ||
| import { join, resolve, extname } from "path"; | ||
|
|
||
| const ROOT = resolve(import.meta.dirname, ".."); |
There was a problem hiding this comment.
issue (bug_risk): 使用 import.meta.dirname 在不同 Node 版本之间可能不具备可移植性;建议改为从 import.meta.url 派生 __dirname。
import.meta.dirname 不是 Node 稳定 ESM API 的一部分,在某些 Node 版本或其他运行时中可能为 undefined,从而导致运行时失败。为了使其具备可移植性,建议改为从 import.meta.url 派生目录:
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const ROOT = resolve(__dirname, '..');这样可以让脚本在不同 Node 版本和工具链之间都能正常工作。
Original comment in English
issue (bug_risk): Using import.meta.dirname may not be portable across Node versions; prefer deriving __dirname from import.meta.url.
import.meta.dirname isn’t part of Node’s stable ESM API and may be undefined in some Node versions or other runtimes, causing runtime failures. To make this portable, derive the directory from import.meta.url instead:
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const ROOT = resolve(__dirname, '..');This keeps the script working across Node versions and toolchains.
| console.warn(`⚠️ Fallback complete: using full font (${(size / 1024).toFixed(1)} KB woff2)`); | ||
| } | ||
|
|
||
| try { |
There was a problem hiding this comment.
issue (complexity): 可以考虑将主流程和关键的解析/转换步骤拆分成一些小的、具名的辅助函数(以及一个比较薄的 main/try 代码块),以在不改变行为的前提下,让这个脚本更易于理解和维护。
在不改变行为的前提下降低“感知复杂度”的一种方式,是把几类职责拆出来放到小的具名辅助函数中。你不需要完全重写;就算只多拆分几个函数,并让 try 代码块更薄一些,也能让脚本更易读、更易维护。
1. 抽取 main(),并让顶层 try/catch 保持精简
目前 try 代码块把所有逻辑都混在了一起。你可以在保持“任意错误都回退到完整字体”行为的前提下,将主流程移动到一个函数中:
async function main() {
// Pre-checks
// dynamic import subset-font
// scan icons
// parse mdi CSS
// resolve codepoints
// subset fonts
// generate CSS
// report
}
try {
await main();
} catch (err) {
try {
fallbackToFullFont(err.message);
} catch (fallbackErr) {
console.error(`❌ Fallback also failed: ${fallbackErr.message}`);
console.error(`❌ Please ensure @mdi/font is installed: pnpm install`);
process.exit(1);
}
}这样可以让全局作用域和错误处理保持简单,同时保持完全相同的行为。
2. 将 collectFiles 从 main 中分离出来并做成可复用函数
当前的递归目录遍历逻辑本身没问题,但被内联在主流程里。把它抽出来可以明确意图,也便于复用/测试。
function collectFiles(dir, exts) {
let files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
files = files.concat(collectFiles(full, exts));
} else if (exts.includes(extname(entry.name))) {
files.push(full);
}
}
return files;
}
// in main():
const sourceFiles = collectFiles(SRC, [".vue", ".ts", ".js"]);行为保持不变,但顶层逻辑更加清晰。
3. 将 CSS 解析逻辑独立出来
目前正则 + 标记的逻辑混在主脚本中。把这些拆成具名函数,可以让逻辑更易于理解,并且在 @mdi/font 升级时更易调整。
function parseIconCodepoints(mdiCSS) {
const classPattern = /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"/g;
const iconMap = new Map();
for (const match of mdiCSS.matchAll(classPattern)) {
iconMap.set(match[1], match[2]);
}
return iconMap;
}
function extractUtilityCss(mdiCSS) {
const utilityStartMarker = ".mdi-18px.mdi-set"; // @mdi/font 中的第一个 utility 规则
const idx = mdiCSS.indexOf(utilityStartMarker);
if (idx === -1) return "";
return mdiCSS
.slice(idx)
.replace(/\/\*# sourceMappingURL=.*\*\//, "")
.trim();
}然后在 main() 中:
const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
const iconMap = parseIconCodepoints(mdiCSS);
let css = /* font-face + base .mdi rules */;
for (const icon of resolvedIcons.sort()) {
const cp = iconMap.get(icon);
css += `.${icon}::before {\n content: "\\${cp}";\n}\n\n`;
}
const utilityCss = extractUtilityCss(mdiCSS);
if (utilityCss) {
css += `/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`;
} else {
console.warn("⚠️ Could not find MDI utility classes in original CSS, skipping");
}输出结果保持不变,但所有 CSS 解析逻辑都集中并被清晰地组织起来了。
4. 将“used → resolved/missing/subset”步骤变成一个数据转换
与其在主流程中内联地修改三个集合,不如从辅助函数中返回分组后的结果:
function resolveUsedIcons(usedIcons, iconMap) {
const resolvedIcons = [];
const missingIcons = [];
const subsetChars = [];
for (const icon of usedIcons) {
const cp = iconMap.get(icon);
if (cp) {
resolvedIcons.push(icon);
subsetChars.push(String.fromCodePoint(parseInt(cp, 16)));
} else {
missingIcons.push(icon);
}
}
return { resolvedIcons, missingIcons, subsetChars };
}
// in main():
const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap);这样可以在不改变语义的前提下,让数据流更加清晰明确。
5. 可选:让 fallbackToFullFont 的转换步骤更清楚
你可以保留相同的正则,只是让意图更清晰:
function rewriteFontUrlsToSubset(mdiCSS) {
return mdiCSS
.replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(woff2?)\?[^"]*"\)/g,
(_, ext) => `url("./materialdesignicons-webfont-subset.${ext}")`);
}
function removeLegacySources(mdiCSS) {
return mdiCSS
.replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(eot|ttf)\?[^"]*"\)[^,]*/g, "");
}
function normalizeSrcSeparators(mdiCSS) {
return mdiCSS
.replace(/src:\s*,/g, "src:")
.replace(/,\s*;/g, ";");
}
function buildFallbackCss() {
const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
let css = rewriteFontUrlsToSubset(mdiCSS);
css = removeLegacySources(css);
css = normalizeSrcSeparators(css);
return css;
}
// in fallbackToFullFont():
const fallbackCSS = buildFallbackCss();
writeFileSync(join(OUT_DIR, "materialdesignicons-subset.css"), fallbackCSS);行为完全相同,只是把嵌套的 .replace() 调用链,变成了一个个具名步骤,使其更易于之后审查和调整。
Original comment in English
issue (complexity): Consider extracting the main flow and key parsing/transform steps into small, named helper functions (and a thin main/try block) to make this script easier to follow and maintain without changing behavior.
One way to reduce the perceived complexity without changing behavior is to tease apart a few responsibilities into small, named helpers. You don’t need a full rewrite; even a couple of extra functions and a thinner try block will make this easier to follow and maintain.
1. Extract main() and keep the top-level try/catch thin
Right now the try block mixes everything. You can keep the “fallback on any error” behavior but move the main flow into a function:
async function main() {
// Pre-checks
// dynamic import subset-font
// scan icons
// parse mdi CSS
// resolve codepoints
// subset fonts
// generate CSS
// report
}
try {
await main();
} catch (err) {
try {
fallbackToFullFont(err.message);
} catch (fallbackErr) {
console.error(`❌ Fallback also failed: ${fallbackErr.message}`);
console.error(`❌ Please ensure @mdi/font is installed: pnpm install`);
process.exit(1);
}
}This keeps the global scope and error handling simple while keeping identical behavior.
2. Move collectFiles out of main and make it reusable
The recursive directory walk is fine but inlined in the main flow. Pulling it out clarifies intent and allows reuse/tests.
function collectFiles(dir, exts) {
let files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
files = files.concat(collectFiles(full, exts));
} else if (exts.includes(extname(entry.name))) {
files.push(full);
}
}
return files;
}
// in main():
const sourceFiles = collectFiles(SRC, [".vue", ".ts", ".js"]);Same behavior, simpler top-level.
3. Isolate CSS parsing logic
The regex + marker logic is currently mixed into the main script. Pulling these into named functions makes the logic easier to reason about and change if @mdi/font updates.
function parseIconCodepoints(mdiCSS) {
const classPattern = /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"/g;
const iconMap = new Map();
for (const match of mdiCSS.matchAll(classPattern)) {
iconMap.set(match[1], match[2]);
}
return iconMap;
}
function extractUtilityCss(mdiCSS) {
const utilityStartMarker = ".mdi-18px.mdi-set"; // first utility rule in @mdi/font
const idx = mdiCSS.indexOf(utilityStartMarker);
if (idx === -1) return "";
return mdiCSS
.slice(idx)
.replace(/\/\*# sourceMappingURL=.*\*\//, "")
.trim();
}Then in main():
const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
const iconMap = parseIconCodepoints(mdiCSS);
let css = /* font-face + base .mdi rules */;
for (const icon of resolvedIcons.sort()) {
const cp = iconMap.get(icon);
css += `.${icon}::before {\n content: "\\${cp}";\n}\n\n`;
}
const utilityCss = extractUtilityCss(mdiCSS);
if (utilityCss) {
css += `/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`;
} else {
console.warn("⚠️ Could not find MDI utility classes in original CSS, skipping");
}Same output, but all CSS parsing is clearly grouped and documented.
4. Make the “used → resolved/missing/subset” step a data transform
Instead of mutating three collections inline, return the grouped result from a helper:
function resolveUsedIcons(usedIcons, iconMap) {
const resolvedIcons = [];
const missingIcons = [];
const subsetChars = [];
for (const icon of usedIcons) {
const cp = iconMap.get(icon);
if (cp) {
resolvedIcons.push(icon);
subsetChars.push(String.fromCodePoint(parseInt(cp, 16)));
} else {
missingIcons.push(icon);
}
}
return { resolvedIcons, missingIcons, subsetChars };
}
// in main():
const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap);This keeps the data flow explicit without changing any semantics.
5. Optional: clarify fallbackToFullFont transformations
You can keep the same regexes but make the intent clearer:
function rewriteFontUrlsToSubset(mdiCSS) {
return mdiCSS
.replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(woff2?)\?[^"]*"\)/g,
(_, ext) => `url("./materialdesignicons-webfont-subset.${ext}")`);
}
function removeLegacySources(mdiCSS) {
return mdiCSS
.replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(eot|ttf)\?[^"]*"\)[^,]*/g, "");
}
function normalizeSrcSeparators(mdiCSS) {
return mdiCSS
.replace(/src:\s*,/g, "src:")
.replace(/,\s*;/g, ";");
}
function buildFallbackCss() {
const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
let css = rewriteFontUrlsToSubset(mdiCSS);
css = removeLegacySources(css);
css = normalizeSrcSeparators(css);
return css;
}
// in fallbackToFullFont():
const fallbackCSS = buildFallbackCss();
writeFileSync(join(OUT_DIR, "materialdesignicons-subset.css"), fallbackCSS);Behavior is identical; the nested .replace() chain becomes a sequence of named steps, which makes it easier to audit and adjust later.
| // Extract all utility classes from the original MDI CSS | ||
| // These are the non-icon classes: size modifiers, color themes, rotation, flip, spin | ||
| const utilityStartMarker = ".mdi-18px.mdi-set"; | ||
| const utilityStartIndex = mdiCSS.indexOf(utilityStartMarker); | ||
| if (utilityStartIndex !== -1) { | ||
| const utilityCss = mdiCSS.slice(utilityStartIndex).replace(/\/\*# sourceMappingURL=.*\*\//, "").trim(); | ||
| css += `/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`; | ||
| } else { | ||
| console.warn("⚠️ Could not find MDI utility classes in original CSS, skipping"); | ||
| } |
There was a problem hiding this comment.
当前通过查找 utilityStartMarker 并截取剩余 CSS 来提取实用工具类的方法比较脆弱。它依赖于 @mdi/font/css/materialdesignicons.css 的内部结构,未来版本中该结构可能会发生变化,从而破坏此逻辑。一种更健壮的方法是,以编程方式移除您正在重新生成的部分(如图标定义、@font-face 等),并保留其余部分。这使得脚本对源 CSS 文件的顺序依赖性降低。
// 通过从原始 CSS 中移除已重新生成规则的方式来提取实用工具类。
// 这种方法比依赖固定的起始标记更健壮。
let utilityCss = mdiCSS
.replace(classPattern, "") // 移除图标定义
.replace(/@font-face\s*\{[\s\S]*?}/g, "") // 移除 @font-face
.replace(/\.mdi:before,\s*\.mdi-set\s*\{[\s\S]*?}/g, "") // 移除基础规则
.replace(/\/\*# sourceMappingURL=.*\*\//, "") // 移除 source map
.trim();
// 清理替换后可能产生的多余换行符
utilityCss = utilityCss.replace(/(\r\n|\n){3,}/g, '\n\n');
if (utilityCss) {
css += `\n/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`;
} else {
console.warn("⚠️ Could not find MDI utility classes in original CSS, skipping");
}| function mdiSubset() { | ||
| return { | ||
| name: 'vite-plugin-mdi-subset', | ||
| buildStart() { | ||
| console.log('\n🔧 Running MDI icon font subsetting...'); | ||
| execFileSync('node', ['scripts/subset-mdi-font.mjs'], { | ||
| cwd: fileURLToPath(new URL('.', import.meta.url)), | ||
| stdio: 'inherit', | ||
| }); | ||
| }, | ||
| }; |
There was a problem hiding this comment.
使用 execFileSync 为子集化脚本生成一个新的 Node.js 进程是有效的,但会产生一些开销。为了获得更好的性能和更清晰的集成,您可以将该脚本重构为一个导出的函数,并直接从 buildStart 钩子中调用它。
这需要:
- 在
scripts/subset-mdi-font.mjs中,将主要逻辑包装在export async function runMdiSubset() { ... }中。 - 为了保持脚本可以从命令行运行,您可以在
subset-mdi-font.mjs的末尾添加一个检查:// 如果直接从命令行运行 if (import.meta.url.startsWith('file:') && process.argv[1] === new URL(import.meta.url).pathname) { runMdiSubset().catch(err => { console.error(err); process.exit(1); }); }
- 然后,在
vite.config.ts中,您可以导入并调用此函数:import { runMdiSubset } from './scripts/subset-mdi-font.mjs'; function mdiSubset() { return { name: 'vite-plugin-mdi-subset', async buildStart() { console.log('\n🔧 Running MDI icon font subsetting...'); await runMdiSubset(); }, }; }
这种方法避免了创建进程的开销,并使代码更模块化、更易于测试。
| function collectFiles(dir, exts) { | ||
| let files = []; | ||
| for (const entry of readdirSync(dir, { withFileTypes: true })) { | ||
| const full = join(dir, entry.name); | ||
| if (entry.isDirectory() && entry.name !== "node_modules") { | ||
| files = files.concat(collectFiles(full, exts)); | ||
| } else if (exts.includes(extname(entry.name))) { | ||
| files.push(full); | ||
| } | ||
| } | ||
| return files; | ||
| } | ||
|
|
||
| const sourceFiles = collectFiles(SRC, [".vue", ".ts", ".js"]); |
There was a problem hiding this comment.
你好,collectFiles 这个递归函数写得不错。不过,在每次递归调用中使用 files.concat 会创建新数组,当目录层级很深或文件数量巨大时,可能会影响性能并增加内存消耗。
建议使用 generator 函数进行优化,这样代码更简洁,也更符合现代 JavaScript 的风格,同时能有效避免不必要的内存分配。
function* collectFiles(dir, exts) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
yield* collectFiles(full, exts);
} else if (exts.includes(extname(entry.name))) {
yield full;
}
}
}
const sourceFiles = [...collectFiles(SRC, [".vue", ".ts", ".js"])];
Dashboard 每次加载时,所有 icon 和字体都从外部网络请求(Google Fonts CDN + 全量 MDI 字体 7297 图标),若网络不可用或慢则阻塞整个前端渲染。本 PR 通过 MDI 字体子集化 + Google Fonts 本地托管消除外部字体依赖,大幅提升首屏加载速度。
Modifications / 改动点
MDI Icon 字体子集化:
scripts/subset-mdi-font.mjs构建脚本,自动扫描源码中使用的mdi-*图标,通过subset-font(纯 JS/WASM,无 Python 依赖)生成子集字体buildStart钩子自动执行,无需手动调用@mdi/font,确保 build 永远不会中断mdi-spin、mdi-rotate-*、mdi-flip-*、尺寸修饰符等)vuetify.ts引用子集化 CSSGoogle Fonts 本地托管:
vite-plugin-webfont-dl,构建时自动从 Google Fonts 下载字体并内联到 distindex.html中对fonts.googleapis.com的渲染阻塞外部请求清理:
remixicon依赖核心文件:
dashboard/scripts/subset-mdi-font.mjs— [NEW] 子集化构建脚本(含兜底逻辑)dashboard/src/assets/mdi-subset/*— [NEW] 子集化字体 + CSS(自动生成)dashboard/vite.config.ts— 添加mdiSubset()+webfontDl()插件dashboard/src/plugins/vuetify.ts— 切换 MDI import 为子集版dashboard/package.json— 脚本 + 依赖变更This is NOT a breaking change. / 这不是一个破坏性变更。
Screenshots or Test Results / 运行截图或测试结果
基准测试对比 (localhost):
优化后所有图标和字体正常渲染,零外部字体请求。
Checklist / 检查清单
requirements.txt和pyproject.toml文件相应位置。(新增vite-plugin-webfont-dl和subset-font为前端 devDependency,不影响 Python 后端)Summary by Sourcery
通过在构建时对 MDI 图标字体进行子集化处理,并在本地自托管 Web 字体,优化 dashboard 前端资源管线,从而移除对外部字体的依赖并提升加载性能。
New Features:
Enhancements:
Build:
Original summary in English
Summary by Sourcery
Optimize the dashboard frontend asset pipeline by subsetting the MDI icon font at build time and self-hosting web fonts to remove external font dependencies and improve load performance.
New Features:
Enhancements:
Build: