managarten/scripts/audit-icon-usage.mjs
Till JS 4d5a96e21b perf(invoices): lazy-load pdf-lib + swissqrbill, -516 KB on route
/(app)/invoices/[id] route bundle drops from **534 KB → 18.6 KB** by
moving PDF rendering behind dynamic imports.

Changes:
  - views/DetailView.svelte: `await import('../pdf/renderer')` inside
    renderPdf() + downloadPdf(), cached in a module-local ref.
  - components/SendModal.svelte: same for openAndDownload().
  - pdf/scor.ts (new): generateSCORReference extracted so the
    invoices store can derive a reference string without pulling
    swissqrbill/svg + pdf-lib into the list-view bundle.
  - pdf/qr-bill.ts: re-exports generateSCORReference from scor.ts
    for backward compatibility.
  - stores/invoices.svelte.ts: imports from ../pdf/scor (light) instead
    of ../pdf/qr-bill (heavy).
  - index.ts: drop re-export of the PDF renderer from the module
    barrel so `import ... from '$lib/modules/invoices'` never drags
    pdf-lib in.

The heavy chunk (pdf-lib + swissqrbill, ~576 KB) now only loads when
a user actually opens an invoice detail — list views, create flow, and
all other routes stay lean.

20/20 qr-bill tests pass; svelte-check clean.

Bonus: scripts/audit-icon-usage.mjs (+ pnpm run audit:icon-usage)
audits @mana/shared-icons imports. Reveals 204 distinct icons across
the codebase, 199 of them at default weight but paying for all 6
Phosphor weights. Biggest offender: app-registry/apps.ts with 69
static icon imports accounting for ~290 KB of the shared 466 KB icon
chunk. Migration path for that is documented in
docs/optimizable/bundle-analysis.md §2 — next session's work.

docs/optimizable/bundle-analysis.md also updated with the root (app)
layout (260 KB) investigation notes (start/stop lifecycle hooks to
defer via idleCallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:03:53 +02:00

137 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Audit @mana/shared-icons usage to inform the tree-shaking migration.
*
* The shared-icons package re-exports phosphor-svelte via a barrel
* (`export * from 'phosphor-svelte'`). Each phosphor-svelte component
* inlines ALL SIX weight variants (thin / light / regular / bold /
* fill / duotone) because the weight is a runtime prop. So importing
* `<House weight="bold" />` still ships the other five paths.
*
* Result: the prod bundle has ~466 KB of icon path data across two
* chunks (`chunks/*.js` with `<path d="M..." />` bodies).
*
* This audit reports:
* - Which icons are actually used, sorted by frequency (top N).
* - Which weights each icon uses (to gauge how much of the 6x
* weight-per-icon cost is actually exercised at runtime).
* - Files with the most icon imports (worth splitting first if they
* end up in the shared chunk).
*
* Usage:
* node scripts/audit-icon-usage.mjs
* node scripts/audit-icon-usage.mjs --top 30
*/
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..');
const WEB_SRC = join(REPO_ROOT, 'apps/mana/apps/web/src');
const args = process.argv.slice(2);
const TOP_IDX = args.indexOf('--top');
const TOP_N = TOP_IDX >= 0 ? Number(args[TOP_IDX + 1] || 30) : 30;
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.svelte-kit']);
function walk(dir, collect) {
for (const ent of readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(ent.name)) continue;
const p = join(dir, ent.name);
if (ent.isDirectory()) walk(p, collect);
else if (ent.isFile() && /\.(svelte|ts|tsx|js)$/.test(ent.name)) collect(p);
}
}
function audit() {
const files = [];
walk(WEB_SRC, (p) => files.push(p));
const iconFrequency = new Map(); // name → count
const perFile = new Map(); // file → Set<name>
const weightsUsed = new Map(); // name → Set<weight>
const importRe = /import\s*\{([^}]+)\}\s*from\s*['"]@mana\/shared-icons['"]/g;
const weightRe = /<\s*([A-Z]\w+)[^>]*\bweight\s*=\s*["']?(\w+)["']?/g;
for (const file of files) {
const src = readFileSync(file, 'utf8');
let m;
importRe.lastIndex = 0;
while ((m = importRe.exec(src)) !== null) {
const names = m[1]
.split(',')
.map((n) => n.trim().split(/\s+as\s+/)[0])
.filter(Boolean);
for (const n of names) {
iconFrequency.set(n, (iconFrequency.get(n) ?? 0) + 1);
const rel = file.slice(REPO_ROOT.length + 1);
if (!perFile.has(rel)) perFile.set(rel, new Set());
perFile.get(rel).add(n);
}
}
weightRe.lastIndex = 0;
while ((m = weightRe.exec(src)) !== null) {
const [, name, weight] = m;
if (!weightsUsed.has(name)) weightsUsed.set(name, new Set());
weightsUsed.get(name).add(weight);
}
}
const ranked = [...iconFrequency.entries()].sort((a, b) => b[1] - a[1]);
console.log(`\n── Icon usage audit ───────────────────────────────────\n`);
console.log(`Distinct icons imported: ${iconFrequency.size}`);
console.log(`Files importing icons: ${perFile.size}`);
console.log(
`Bundle cost (estimated): ${(iconFrequency.size * 6 * 0.7).toFixed(0)} KB ` +
`(${iconFrequency.size} icons × 6 weights × ~0.7 KB each path)`
);
console.log('');
console.log(`Top ${Math.min(TOP_N, ranked.length)} icons by import count:\n`);
for (const [name, count] of ranked.slice(0, TOP_N)) {
const weights = weightsUsed.get(name);
const wStr = weights && weights.size > 0 ? `weights: ${[...weights].sort().join(', ')}` : '—';
console.log(` ${String(count).padStart(4)}× ${name.padEnd(28)} ${wStr}`);
}
console.log('');
// Weight distribution (how many icons use each weight at all)
const weightCounts = new Map();
for (const [, weights] of weightsUsed) {
for (const w of weights) weightCounts.set(w, (weightCounts.get(w) ?? 0) + 1);
}
console.log(`Weights actually used across the codebase:\n`);
for (const [weight, count] of [...weightCounts.entries()].sort((a, b) => b[1] - a[1])) {
console.log(` ${weight.padEnd(10)} ${count} icon(s)`);
}
const defaultWeight = iconFrequency.size - (weightCounts.get('regular') ?? 0);
console.log(` (unset/default) ~${defaultWeight} icon(s) use the default "regular" weight`);
console.log('');
const topFiles = [...perFile.entries()].sort((a, b) => b[1].size - a[1].size).slice(0, 10);
console.log(`Top 10 files by distinct-icon count:\n`);
for (const [file, icons] of topFiles) {
console.log(` ${String(icons.size).padStart(3)} icons ${file}`);
}
console.log('');
console.log(
`Migration path to reduce the 466 KB icon chunks:\n\n` +
` 1. Change @mana/shared-icons/src/index.ts to drop \`export * from\n` +
` 'phosphor-svelte'\` — require per-icon re-exports of only the\n` +
` ${iconFrequency.size} icons actually used.\n\n` +
` 2. OR migrate callers to import directly from phosphor-svelte's\n` +
` per-icon paths: \`import House from 'phosphor-svelte/House'\`.\n\n` +
` 3. Longest-term: build a custom icon set that only ships the\n` +
` weights actually used (most icons only use "regular" or "bold").\n`
);
}
audit();