feat(header): icon nav redesign + migrate icons mdi→lucide
- Header desktop nav: social icons grouped in a wald-tinted pill with labels, active-state (aria-current + bold), opacity hover (no per-item bg) - Mobile overlay: social links as icon+label rows with active-state - Search button: lucide:search, icon-only - Migrate all icons mdi→lucide app-wide (234 refs); brand/language logos (google, whatsapp, mastodon, telegram, language-*) stay on mdi - generate-icons: emit mdi + lucide subsets; add @iconify-json/lucide - iconify-offline: register lucide collection - scripts/icon-map|validate|apply: reusable migration tooling Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+82
-64
@@ -19,8 +19,6 @@ 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)
|
||||
@@ -28,59 +26,70 @@ const OUT_FILE = path.join(ROOT, 'src', 'lib', 'iconify-mdi-subset.generated.jso
|
||||
* 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)
|
||||
// Brand/Logo-Icons ohne Lucide-Pendant (Editors picken sie als social.icon)
|
||||
'google',
|
||||
'whatsapp',
|
||||
'mastodon',
|
||||
'telegram',
|
||||
// Sprach-Logos (CodeBlock: dynamische language->icon-Map, kein literal icon="")
|
||||
'language-javascript',
|
||||
'language-python',
|
||||
'language-rust',
|
||||
];
|
||||
|
||||
/**
|
||||
* Lucide-Icons aus CMS-Daten (Header social.icon, FilesBlock file types, …) +
|
||||
* dynamische Bindings, die der statische Scan nicht findet.
|
||||
*/
|
||||
const EXTRA_LUCIDE = [
|
||||
// Nav / Header social.icon (CMS)
|
||||
'calendar-1',
|
||||
'contact-round',
|
||||
'search',
|
||||
'x',
|
||||
'cloud',
|
||||
'mail',
|
||||
'facebook',
|
||||
'youtube',
|
||||
'map',
|
||||
'video',
|
||||
'instagram',
|
||||
'twitter',
|
||||
'youtube',
|
||||
'linkedin',
|
||||
'mastodon',
|
||||
'github',
|
||||
'email',
|
||||
'email-outline',
|
||||
'phone',
|
||||
'whatsapp',
|
||||
'telegram',
|
||||
'import-contacts',
|
||||
'arrow-top-right',
|
||||
'open-in-new',
|
||||
'card-account-details-outline',
|
||||
'qrcode',
|
||||
'sort-alphabetical-ascending',
|
||||
'account-group-outline',
|
||||
// Common file-type icons (FilesBlock: f.iconName)
|
||||
'file-pdf-box',
|
||||
'file-word-box',
|
||||
'file-excel-box',
|
||||
// PostActions / Links
|
||||
'share-2',
|
||||
'link',
|
||||
'external-link',
|
||||
'arrow-up-right',
|
||||
// Kontakt/Verzeichnis
|
||||
'id-card',
|
||||
'qr-code',
|
||||
'arrow-down-a-z',
|
||||
'users',
|
||||
// File-type icons (FilesBlock: dynamic f.iconName)
|
||||
'file',
|
||||
'file-text',
|
||||
'file-spreadsheet',
|
||||
'file-image',
|
||||
'file-video',
|
||||
'file-music',
|
||||
// Tag/link/banner fallbacks (LinkListBlock, Tags, DeadlineBannerBlock)
|
||||
'link',
|
||||
'tag-outline',
|
||||
'flag-outline',
|
||||
'information-outline',
|
||||
// Windkarte: dynamic wind-turbine used in panel
|
||||
'wind-turbine',
|
||||
// OrganisationsMapBlock: activate-overlay
|
||||
'cursor-default-click-outline',
|
||||
// CalendarBlock: social-image modal button
|
||||
'image-outline',
|
||||
// StellingnahmeGenerator: KI-Regeln-Accordion + Detail-Anpassen
|
||||
'tune-variant',
|
||||
'tune',
|
||||
'check-decagram',
|
||||
// CMS-Content (Social-Link o.ä.): tauchte als Runtime-Fetch in Lighthouse auf
|
||||
'cloud',
|
||||
'file-down',
|
||||
'presentation',
|
||||
// Tag/flag/info/format fallbacks
|
||||
'tag',
|
||||
'flag',
|
||||
'info',
|
||||
'type',
|
||||
'image',
|
||||
// Windkarte / Map / Generator
|
||||
'fan',
|
||||
'mouse-pointer-click',
|
||||
'sliders-horizontal',
|
||||
'badge-check',
|
||||
];
|
||||
|
||||
const STATIC_PATTERN = /icon="mdi:([a-z0-9-]+)"/g;
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
@@ -95,27 +104,28 @@ function walk(dir) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function scanStatic() {
|
||||
function scanStatic(prefix) {
|
||||
const pattern = new RegExp(`icon="${prefix}:([a-z0-9-]+)"`, 'g');
|
||||
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) {
|
||||
while ((m = 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.`,
|
||||
);
|
||||
/** Build a tree-shaken subset for one Iconify collection. */
|
||||
function buildSubset({ prefix, pkg, outFile, extra }) {
|
||||
const iconsJson = path.join(ROOT, 'node_modules', '@iconify-json', pkg, 'icons.json');
|
||||
if (!fs.existsSync(iconsJson)) {
|
||||
console.error(`[generate-icons] missing ${iconsJson} — run \`npm install\` first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const collection = JSON.parse(fs.readFileSync(ICONS_JSON, 'utf8'));
|
||||
const wanted = new Set([...scanStatic(), ...EXTRA_ICONS]);
|
||||
const collection = JSON.parse(fs.readFileSync(iconsJson, 'utf8'));
|
||||
const wanted = new Set([...scanStatic(prefix), ...extra]);
|
||||
|
||||
const icons = {};
|
||||
const aliases = {};
|
||||
@@ -142,22 +152,30 @@ function main() {
|
||||
icons,
|
||||
...(Object.keys(aliases).length ? { aliases } : {}),
|
||||
};
|
||||
fs.writeFileSync(outFile, JSON.stringify(subset, null, 2) + '\n');
|
||||
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(subset, null, 2) + '\n');
|
||||
|
||||
const sizeBefore = fs.statSync(ICONS_JSON).size;
|
||||
const sizeAfter = fs.statSync(OUT_FILE).size;
|
||||
const sizeAfter = fs.statSync(outFile).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)`,
|
||||
`[generate-icons] ${prefix}: ${Object.keys(icons).length} icons + ${Object.keys(aliases).length} aliases → ${path.relative(ROOT, outFile)} (${(sizeAfter / 1024).toFixed(1)} KB)`,
|
||||
);
|
||||
if (missing.length) {
|
||||
console.warn(
|
||||
`[generate-icons] ${missing.length} icon(s) not found in MDI: ${missing.join(', ')}`,
|
||||
);
|
||||
console.warn(`[generate-icons] ${prefix}: ${missing.length} not found: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
buildSubset({
|
||||
prefix: 'mdi',
|
||||
pkg: 'mdi',
|
||||
outFile: path.join(ROOT, 'src', 'lib', 'iconify-mdi-subset.generated.json'),
|
||||
extra: EXTRA_ICONS,
|
||||
});
|
||||
buildSubset({
|
||||
prefix: 'lucide',
|
||||
pkg: 'lucide',
|
||||
outFile: path.join(ROOT, 'src', 'lib', 'iconify-lucide-subset.generated.json'),
|
||||
extra: EXTRA_LUCIDE,
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Rewrite `mdi:NAME` -> `lucide:MAPPED` across frontend src + CMS content.
|
||||
// KEEP_MDI names are left untouched (brand/language logos with no lucide pendant).
|
||||
import { MAP, KEEP_MDI } from './icon-map.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WEB_ROOT = path.resolve(ROOT, '..');
|
||||
|
||||
const TARGETS = [
|
||||
{ dir: path.join(ROOT, 'src'), exts: /\.(svelte|ts|tsx|js|mjs)$/ },
|
||||
{ dir: path.join(WEB_ROOT, 'cms_content', 'windwiderstand'), exts: /\.json5$/ },
|
||||
];
|
||||
const SKIP_DIR = /(node_modules|\.history|\.search|_legacy|\.svelte-kit)/;
|
||||
|
||||
const stats = { files: 0, repl: 0, kept: {} };
|
||||
|
||||
function walk(dir, exts, acc) {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.name.startsWith('.') && e.name !== '.') continue;
|
||||
const full = path.join(dir, e.name);
|
||||
if (SKIP_DIR.test(full)) continue;
|
||||
if (e.isDirectory()) walk(full, exts, acc);
|
||||
else if (exts.test(e.name)) acc.push(full);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const RE = /mdi:([a-z0-9-]+)/g;
|
||||
for (const { dir, exts } of TARGETS) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const file of walk(dir, exts, [])) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
let n = 0;
|
||||
const out = src.replace(RE, (m, name) => {
|
||||
if (KEEP_MDI.has(name)) {
|
||||
stats.kept[name] = (stats.kept[name] || 0) + 1;
|
||||
return m;
|
||||
}
|
||||
const l = MAP[name];
|
||||
if (!l) return m; // unknown -> leave, validator should have caught
|
||||
n++;
|
||||
return `lucide:${l}`;
|
||||
});
|
||||
if (n > 0) {
|
||||
fs.writeFileSync(file, out);
|
||||
stats.files++;
|
||||
stats.repl += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`rewrote ${stats.repl} refs in ${stats.files} files`);
|
||||
console.log('kept on mdi:', JSON.stringify(stats.kept));
|
||||
@@ -0,0 +1,104 @@
|
||||
// mdi -> lucide migration map. Names without a sensible lucide pendant
|
||||
// (brand/language logos) intentionally stay on mdi — listed in KEEP_MDI.
|
||||
export const KEEP_MDI = new Set([
|
||||
'google', 'whatsapp', 'mastodon', // brands missing in lucide
|
||||
'language-javascript', 'language-python', 'language-rust', // language logos
|
||||
]);
|
||||
|
||||
export const MAP = {
|
||||
'account-group-outline': 'users',
|
||||
'alert-circle': 'circle-alert',
|
||||
'alert-circle-outline': 'circle-alert',
|
||||
'arrow-right': 'arrow-right',
|
||||
'arrow-top-right': 'arrow-up-right',
|
||||
'calendar': 'calendar',
|
||||
'calendar-blank': 'calendar',
|
||||
'calendar-clock': 'calendar-clock',
|
||||
'calendar-export': 'calendar-arrow-up',
|
||||
'calendar-month-outline': 'calendar',
|
||||
'calendar-plus': 'calendar-plus',
|
||||
'calendar-search': 'calendar-search',
|
||||
'card-account-details-outline': 'id-card',
|
||||
'check': 'check',
|
||||
'check-circle': 'circle-check',
|
||||
'check-decagram': 'badge-check',
|
||||
'chevron-down': 'chevron-down',
|
||||
'chevron-left': 'chevron-left',
|
||||
'chevron-right': 'chevron-right',
|
||||
'chevron-up': 'chevron-up',
|
||||
'clock-alert-outline': 'alarm-clock',
|
||||
'clock-outline': 'clock',
|
||||
'clock-remove-outline': 'clock-fading',
|
||||
'close': 'x',
|
||||
'cloud': 'cloud',
|
||||
'cloud-off-outline': 'cloud-off',
|
||||
'code-json': 'braces',
|
||||
'comment-outline': 'message-circle',
|
||||
'comment-text-outline': 'message-square-text',
|
||||
'console': 'terminal',
|
||||
'content-copy': 'copy',
|
||||
'content-save-outline': 'save',
|
||||
'cursor-default-click': 'mouse-pointer-click',
|
||||
'cursor-default-click-outline': 'mouse-pointer-click',
|
||||
'database-outline': 'database',
|
||||
'domain': 'building-2',
|
||||
'download': 'download',
|
||||
'download-outline': 'download',
|
||||
'drag-vertical': 'grip-vertical',
|
||||
'email': 'mail',
|
||||
'email-outline': 'mail',
|
||||
'facebook': 'facebook',
|
||||
'file-document-edit-outline': 'file-pen',
|
||||
'file-document-outline': 'file-text',
|
||||
'file-download': 'file-down',
|
||||
'file-excel-box': 'file-spreadsheet',
|
||||
'file-image': 'file-image',
|
||||
'file-outline': 'file',
|
||||
'file-pdf-box': 'file-text',
|
||||
'file-powerpoint-box': 'presentation',
|
||||
'file-word-box': 'file-text',
|
||||
'flag-outline': 'flag',
|
||||
'folder-zip': 'folder-archive',
|
||||
'format-text-variant-outline': 'type',
|
||||
'home': 'house',
|
||||
'home-group': 'house',
|
||||
'home-outline': 'house',
|
||||
'image-outline': 'image',
|
||||
'information-outline': 'info',
|
||||
'link': 'link',
|
||||
'link-variant': 'link',
|
||||
'loading': 'loader-circle',
|
||||
'magnify': 'search',
|
||||
'magnify-plus': 'zoom-in',
|
||||
'map': 'map',
|
||||
'map-marker': 'map-pin',
|
||||
'map-marker-outline': 'map-pin',
|
||||
'map-outline': 'map',
|
||||
'menu': 'menu',
|
||||
'open-in-new': 'external-link',
|
||||
'pencil-outline': 'pencil',
|
||||
'phone': 'phone',
|
||||
'phone-outline': 'phone',
|
||||
'play': 'play',
|
||||
'printer': 'printer',
|
||||
'printer-outline': 'printer',
|
||||
'qrcode': 'qr-code',
|
||||
'refresh': 'refresh-cw',
|
||||
'reply': 'reply',
|
||||
'robot-outline': 'bot',
|
||||
'rss': 'rss',
|
||||
'ruler-square': 'ruler',
|
||||
'share-variant': 'share-2',
|
||||
'share-variant-outline': 'share-2',
|
||||
'shield-check-outline': 'shield-check',
|
||||
'sort-alphabetical-ascending': 'arrow-down-a-z',
|
||||
'transmission-tower': 'radio-tower',
|
||||
'trash-can-outline': 'trash-2',
|
||||
'tune': 'sliders-horizontal',
|
||||
'tune-variant': 'sliders-horizontal',
|
||||
'vector-square': 'square-dashed',
|
||||
'video': 'video',
|
||||
'wind-turbine': 'fan',
|
||||
'xml': 'code',
|
||||
'youtube': 'youtube',
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MAP, KEEP_MDI } from './icon-map.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const lucide = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, 'node_modules/@iconify-json/lucide/icons.json'), 'utf8'),
|
||||
);
|
||||
const has = (n) => Boolean(lucide.icons[n] || (lucide.aliases && lucide.aliases[n]));
|
||||
|
||||
const all = fs
|
||||
.readFileSync(process.env.TMPDIR + '/mdi_all.txt', 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((s) => s.replace('mdi:', ''));
|
||||
const cms = ['cloud', 'email', 'facebook', 'map', 'video', 'whatsapp', 'youtube'];
|
||||
const set = [...new Set([...all, ...cms])].sort();
|
||||
|
||||
const bad = [];
|
||||
const unmapped = [];
|
||||
for (const m of set) {
|
||||
if (KEEP_MDI.has(m)) continue;
|
||||
const l = MAP[m];
|
||||
if (l === undefined) {
|
||||
unmapped.push(m);
|
||||
continue;
|
||||
}
|
||||
if (has(l) === false) bad.push(`${m} -> lucide:${l}`);
|
||||
}
|
||||
console.log('total mdi:', set.length, '| keep-mdi:', [...KEEP_MDI].filter((k) => set.includes(k)).length);
|
||||
console.log('UNMAPPED:', unmapped.length ? unmapped.join(', ') : 'none');
|
||||
console.log('INVALID lucide names:', bad.length ? '\n ' + bad.join('\n ') : 'none');
|
||||
Reference in New Issue
Block a user