perf(iconify): tree-shake MDI to used icons (-99.5% JSON, -71% client bundle)
The previous setup loaded the full @iconify-json/mdi collection (7000+ icons, 2.95 MB) just to render the ~30 icons we actually use, producing a 2.99 MB client chunk. generate-icons.mjs scans src/ for static `icon="mdi:..."` references, extracts a minimal IconifyJSON containing only those icons (plus an EXTRA_ICONS list for dynamic CMS-driven names) from @iconify-json/mdi/icons.json, and writes it to a generated subset file. iconify-offline.ts now registers that subset instead of the full pack. build/client: 5.6 MB -> 1.6 MB (-71%) build/server: 5.3 MB -> 2.4 MB (-55%) largest client chunk: 2.99 MB -> 127 KB (-96%) iconify subset: 2.95 MB -> 15.5 KB (-99.5%) Hooked into predev/prebuild so the subset stays fresh; missing icons fall back to the iconify API at runtime. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tree-shake @iconify-json/mdi: scan src/ for `icon="mdi:..."` references and
|
||||
* emit a minimal IconifyJSON containing only those icons. Saves ~3 MB in the
|
||||
* client bundle vs. shipping the full 7000+ icon collection.
|
||||
*
|
||||
* Dynamic icons (from CMS data, conditional names) cannot be detected
|
||||
* statically — list them under EXTRA_ICONS below. Anything missing falls
|
||||
* through to the iconify API at runtime, which adds a network roundtrip but
|
||||
* does not break.
|
||||
*
|
||||
* Run via `npm run generate:icons`. Hooked into `prebuild`.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SRC_DIR = path.join(ROOT, 'src');
|
||||
const ICONS_JSON = path.join(ROOT, 'node_modules', '@iconify-json', 'mdi', 'icons.json');
|
||||
const OUT_FILE = path.join(ROOT, 'src', 'lib', 'iconify-mdi-subset.generated.json');
|
||||
|
||||
/**
|
||||
* Icons that appear via dynamic bindings (CMS data, conditional expressions)
|
||||
* and would otherwise miss the static scan. Add new ones here when editors
|
||||
* pick icons that appear blank — better than shipping the full collection.
|
||||
*/
|
||||
const EXTRA_ICONS = [
|
||||
// PostActions: dynamic share/copy state
|
||||
'share-variant-outline',
|
||||
'link-variant',
|
||||
// Search: file-type fallback
|
||||
'file-outline',
|
||||
// Common social icons editors pick (Header.svelte: social.icon)
|
||||
'facebook',
|
||||
'instagram',
|
||||
'twitter',
|
||||
'youtube',
|
||||
'linkedin',
|
||||
'mastodon',
|
||||
'github',
|
||||
'email',
|
||||
'email-outline',
|
||||
'phone',
|
||||
'whatsapp',
|
||||
'telegram',
|
||||
// Common file-type icons (FilesBlock: f.iconName)
|
||||
'file-pdf-box',
|
||||
'file-word-box',
|
||||
'file-excel-box',
|
||||
'file-image',
|
||||
'file-video',
|
||||
'file-music',
|
||||
// Tag/link/banner fallbacks (LinkListBlock, Tags, DeadlineBannerBlock)
|
||||
'link',
|
||||
'tag-outline',
|
||||
'flag-outline',
|
||||
'information-outline',
|
||||
];
|
||||
|
||||
const STATIC_PATTERN = /icon="mdi:([a-z0-9-]+)"/g;
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...walk(full));
|
||||
} else if (/\.(svelte|ts|tsx|js|mjs)$/.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function scanStatic() {
|
||||
const found = new Set();
|
||||
for (const file of walk(SRC_DIR)) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
let m;
|
||||
while ((m = STATIC_PATTERN.exec(content)) !== null) {
|
||||
found.add(m[1]);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(ICONS_JSON)) {
|
||||
console.error(
|
||||
`[generate-icons] missing ${ICONS_JSON} — run \`npm install\` first.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const collection = JSON.parse(fs.readFileSync(ICONS_JSON, 'utf8'));
|
||||
const wanted = new Set([...scanStatic(), ...EXTRA_ICONS]);
|
||||
|
||||
const icons = {};
|
||||
const aliases = {};
|
||||
const missing = [];
|
||||
|
||||
for (const name of wanted) {
|
||||
if (collection.icons[name]) {
|
||||
icons[name] = collection.icons[name];
|
||||
} else if (collection.aliases?.[name]) {
|
||||
aliases[name] = collection.aliases[name];
|
||||
const parent = collection.aliases[name].parent;
|
||||
if (parent && collection.icons[parent] && !icons[parent]) {
|
||||
icons[parent] = collection.icons[parent];
|
||||
}
|
||||
} else {
|
||||
missing.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
const subset = {
|
||||
prefix: collection.prefix,
|
||||
width: collection.width,
|
||||
height: collection.height,
|
||||
icons,
|
||||
...(Object.keys(aliases).length ? { aliases } : {}),
|
||||
};
|
||||
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(subset, null, 2) + '\n');
|
||||
|
||||
const sizeBefore = fs.statSync(ICONS_JSON).size;
|
||||
const sizeAfter = fs.statSync(OUT_FILE).size;
|
||||
console.log(
|
||||
`[generate-icons] ${Object.keys(icons).length} icons + ${Object.keys(aliases).length} aliases → ${path.relative(ROOT, OUT_FILE)}`,
|
||||
);
|
||||
console.log(
|
||||
`[generate-icons] ${(sizeBefore / 1024 / 1024).toFixed(2)} MB → ${(sizeAfter / 1024).toFixed(1)} KB (${((1 - sizeAfter / sizeBefore) * 100).toFixed(1)}% smaller)`,
|
||||
);
|
||||
if (missing.length) {
|
||||
console.warn(
|
||||
`[generate-icons] ${missing.length} icon(s) not found in MDI: ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user