diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..a48692b --- /dev/null +++ b/scripts/generate-icons.mjs @@ -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(); diff --git a/src/lib/iconify-mdi-subset.generated.json b/src/lib/iconify-mdi-subset.generated.json new file mode 100644 index 0000000..c958ed8 --- /dev/null +++ b/src/lib/iconify-mdi-subset.generated.json @@ -0,0 +1,170 @@ +{ + "prefix": "mdi", + "width": 24, + "height": 24, + "icons": { + "chevron-right": { + "body": "" + }, + "magnify": { + "body": "" + }, + "close": { + "body": "" + }, + "calendar": { + "body": "" + }, + "chevron-down": { + "body": "" + }, + "calendar-clock": { + "body": "" + }, + "rss": { + "body": "" + }, + "pencil-outline": { + "body": "" + }, + "trash-can-outline": { + "body": "" + }, + "clock-outline": { + "body": "" + }, + "alert-circle-outline": { + "body": "" + }, + "comment-text-outline": { + "body": "" + }, + "reply": { + "body": "" + }, + "shield-check-outline": { + "body": "" + }, + "map-marker": { + "body": "" + }, + "cursor-default-click": { + "body": "" + }, + "open-in-new": { + "body": "" + }, + "menu": { + "body": "" + }, + "chevron-left": { + "body": "" + }, + "comment-outline": { + "body": "" + }, + "printer-outline": { + "body": "" + }, + "download-outline": { + "body": "" + }, + "help-circle-outline": { + "body": "" + }, + "check": { + "body": "" + }, + "content-copy": { + "body": "" + }, + "play": { + "body": "" + }, + "home": { + "body": "" + }, + "home-outline": { + "body": "" + }, + "file-document-outline": { + "body": "" + }, + "share-variant-outline": { + "body": "" + }, + "link-variant": { + "body": "" + }, + "file-outline": { + "body": "" + }, + "facebook": { + "body": "" + }, + "instagram": { + "body": "" + }, + "twitter": { + "body": "" + }, + "youtube": { + "body": "" + }, + "linkedin": { + "body": "" + }, + "mastodon": { + "body": "" + }, + "github": { + "body": "" + }, + "email": { + "body": "" + }, + "email-outline": { + "body": "" + }, + "phone": { + "body": "" + }, + "whatsapp": { + "body": "" + }, + "telegram": { + "body": "", + "hidden": true + }, + "file-pdf-box": { + "body": "" + }, + "file-word-box": { + "body": "" + }, + "file-excel-box": { + "body": "" + }, + "file-image": { + "body": "" + }, + "file-video": { + "body": "" + }, + "file-music": { + "body": "" + }, + "link": { + "body": "" + }, + "tag-outline": { + "body": "" + }, + "flag-outline": { + "body": "" + }, + "information-outline": { + "body": "" + } + } +} diff --git a/src/lib/iconify-offline.ts b/src/lib/iconify-offline.ts index c79a430..6cc13d6 100644 --- a/src/lib/iconify-offline.ts +++ b/src/lib/iconify-offline.ts @@ -1,12 +1,20 @@ /** - * Iconify Offline: MDI-Collection für Server und Client registrieren. - * Muss an zwei Stellen importiert werden (Side-Effect-Import), damit - * überall gleich rendert (kein Hydration-Mismatch): - * 1. Layout.astro (--- Block) → läuft beim SSR - * 2. Header.svelte (oder andere frühe client:load-Komponente) → läuft im Browser - * Danach können alle Svelte-Komponenten Icon ohne API nutzen. + * Iconify-Subset für MDI-Icons. Statt der kompletten Collection (~3 MB) wird + * nur eine generierte Untermenge eingebunden — siehe `scripts/generate-icons.mjs` + * und `iconify-mdi-subset.generated.json`. Statisch verwendete Icons werden + * automatisch erkannt; Icons aus CMS-Daten / dynamischen Expressions stehen + * unter `EXTRA_ICONS` im Generator. + * + * Side-Effect-Import: an zwei Stellen einbinden, damit SSR und Client-Hydration + * dieselbe Collection sehen (kein Hydration-Mismatch): + * 1. `+layout.svelte` / `+error.svelte` (top-level import) → Server + Client + * 2. überall sonst implizit über das Layout + * + * Fehlt ein Icon, fällt @iconify/svelte zur Laufzeit auf api.iconify.design + * zurück. Lieber `EXTRA_ICONS` ergänzen + neu generieren. */ -import { addCollection } from "@iconify/svelte"; -import { icons as mdiIcons } from "@iconify-json/mdi"; +import { addCollection } from '@iconify/svelte'; +import type { IconifyJSON } from '@iconify/types'; +import mdiSubset from './iconify-mdi-subset.generated.json'; -addCollection(mdiIcons); +addCollection(mdiSubset as IconifyJSON);