321 lines
12 KiB
Python
321 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
migrate_fa_to_lucide.py — Replace FontAwesome <span class="fas fa-X"> with Lucide components.
|
||
|
||
Usage:
|
||
python3 scripts/migrate_fa_to_lucide.py src/routes/events/[event_id]/\(pres_mgmt\)/
|
||
|
||
Skips content inside HTML comments. Adds/merges lucide-svelte imports.
|
||
"""
|
||
import re
|
||
import sys
|
||
import os
|
||
from pathlib import Path
|
||
|
||
# ── FA icon → Lucide component name ─────────────────────────────────────────
|
||
FA_TO_LUCIDE = {
|
||
'fa-spinner': 'LoaderCircle',
|
||
'fa-cog': 'LoaderCircle', # only when fa-spin
|
||
'fa-sync-alt': 'RefreshCw',
|
||
'fa-times': 'X',
|
||
'fa-exclamation-triangle': 'TriangleAlert',
|
||
'fa-check': 'Check',
|
||
'fa-check-circle': 'CheckCircle',
|
||
'fa-plus': 'Plus',
|
||
'fa-minus': 'Minus',
|
||
'fa-save': 'Save',
|
||
'fa-edit': 'Pencil',
|
||
'fa-eye': 'Eye',
|
||
'fa-eye-slash': 'EyeOff',
|
||
'fa-toggle-on': 'ToggleRight',
|
||
'fa-toggle-off': 'ToggleLeft',
|
||
'fa-star-of-life': 'Asterisk',
|
||
'fa-id-card': 'IdCard',
|
||
'fa-paper-plane': 'Send',
|
||
'fa-map-marker-alt': 'MapPin',
|
||
'fa-file-alt': 'FileText',
|
||
'fa-envelope': 'Mail',
|
||
'fa-book': 'BookOpen',
|
||
'fa-angle-right': 'ChevronRight',
|
||
'fa-user': 'User',
|
||
'fa-tasks': 'ListChecks',
|
||
'fa-plane': 'Plane',
|
||
'fa-list': 'List',
|
||
'fa-link': 'Link',
|
||
'fa-file-archive': 'Archive',
|
||
'fa-comment-dots': 'MessageCircle',
|
||
'fa-chevron-up': 'ChevronUp',
|
||
'fa-chevron-down': 'ChevronDown',
|
||
'fa-camera': 'Camera',
|
||
'fa-barcode': 'Barcode',
|
||
'fa-upload': 'Upload',
|
||
'fa-search': 'Search',
|
||
'fa-mail-bulk': 'Mails',
|
||
'fa-laptop-code': 'Laptop',
|
||
'fa-copy': 'Copy',
|
||
'fa-user-tag': 'Tag',
|
||
'fa-user-secret': 'UserRound',
|
||
'fa-users': 'Users',
|
||
'fa-user-circle': 'CircleUser',
|
||
'fa-sort': 'ArrowUpDown',
|
||
'fa-question': 'HelpCircle',
|
||
'fa-map-marked': 'MapPinned',
|
||
'fa-list-ol': 'ListOrdered',
|
||
'fa-laptop': 'Laptop',
|
||
'fa-info': 'Info',
|
||
'fa-building': 'Building2',
|
||
'fa-user-slash': 'UserX',
|
||
'fa-user-check': 'UserCheck',
|
||
'fa-unlink': 'Unlink',
|
||
'fa-star': 'Star',
|
||
'fa-search-location': 'MapPin',
|
||
'fa-remove-format': 'RemoveFormatting',
|
||
'fa-qrcode': 'QrCode',
|
||
'fa-key': 'Key',
|
||
'fa-heartbeat': 'HeartPulse',
|
||
'fa-hat-wizard': 'Wand2',
|
||
'fa-fingerprint': 'Fingerprint',
|
||
'fa-file-csv': 'FileSpreadsheet',
|
||
'fa-file': 'File',
|
||
'fa-clock': 'Clock',
|
||
'fa-clipboard-list': 'ClipboardList',
|
||
'fa-chart-line': 'TrendingUp',
|
||
'fa-chalkboard-teacher': 'Presentation',
|
||
'fa-calendar-day': 'CalendarDays',
|
||
'fa-bell-slash': 'BellOff',
|
||
'fa-bell': 'Bell',
|
||
# ── Additional mappings ──────────────────────────────────────────────────
|
||
'fa-arrow-left': 'ArrowLeft',
|
||
'fa-arrow-right': 'ArrowRight',
|
||
'fa-arrow-up': 'ArrowUp',
|
||
'fa-arrow-down': 'ArrowDown',
|
||
'fa-ban': 'Ban',
|
||
'fa-broom': 'Trash2', # closest semantic match
|
||
'fa-calendar-alt': 'CalendarDays',
|
||
'fa-database': 'Database',
|
||
'fa-door-open': 'DoorOpen',
|
||
'fa-download': 'Download',
|
||
'fa-exchange-alt': 'ArrowLeftRight',
|
||
'fa-file-image': 'FileImage',
|
||
'fa-lock': 'Lock',
|
||
'fa-magic': 'Sparkles',
|
||
'fa-print': 'Printer',
|
||
'fa-sticky-note': 'StickyNote',
|
||
'fa-sync': 'RefreshCw',
|
||
'fa-tag': 'Tag',
|
||
'fa-trash': 'Trash2',
|
||
'fa-user-ninja': 'UserRound',
|
||
'fa-user-tie': 'UserRound',
|
||
'fa-video': 'Video',
|
||
'fa-archive': 'Archive',
|
||
'fa-link-slash': 'Unlink',
|
||
'fa-question-circle': 'HelpCircle',
|
||
# ── Additional unmapped icons ──────────────────────────────────────────────
|
||
'fa-compress-arrows-alt':'Minimize2',
|
||
'fa-expand-arrows-alt': 'Maximize2',
|
||
'fa-secret': 'ShieldCheck',
|
||
'fa-user-shield': 'ShieldUser',
|
||
'fa-user-nurse': 'UserRound',
|
||
'fa-user-friends': 'Users',
|
||
'fa-user-plus': 'UserPlus',
|
||
'fa-user-edit': 'UserRoundPen',
|
||
'fa-palette': 'Palette',
|
||
'fa-eraser': 'Eraser',
|
||
'fa-code': 'Code',
|
||
'fa-lock-open': 'LockOpen',
|
||
'fa-unlock': 'LockOpen',
|
||
'fa-trash-alt': 'Trash2',
|
||
'fa-folder-open': 'FolderOpen',
|
||
'fa-minus-circle': 'MinusCircle',
|
||
'fa-plus-circle': 'PlusCircle',
|
||
'fa-window-close': 'X',
|
||
'fa-cut': 'Scissors',
|
||
'fa-caret-down': 'ChevronDown',
|
||
'fa-caret-right': 'ChevronRight',
|
||
'fa-cogs': 'Settings2',
|
||
'fa-phone': 'Phone',
|
||
'fa-phone-slash': 'PhoneOff',
|
||
'fa-flag': 'Flag',
|
||
'fa-calendar-week': 'CalendarDays',
|
||
'fa-address-book': 'BookUser',
|
||
'fa-info-circle': 'Info',
|
||
'fa-comment-slash': 'MessageX',
|
||
'fa-paperclip': 'Paperclip',
|
||
'fa-keyboard': 'Keyboard',
|
||
'fa-crosshairs': 'Crosshair',
|
||
'fa-redo': 'RotateCcw',
|
||
'fa-tools': 'Wrench',
|
||
'fa-video-slash': 'VideoOff',
|
||
'fa-home': 'House',
|
||
'fa-calendar': 'Calendar',
|
||
'fa-check-square': 'SquareCheck',
|
||
'fa-square': 'Square',
|
||
'fa-times-circle': 'CircleX',
|
||
'fa-undo': 'RotateCcw',
|
||
'fa-trash-restore': 'ArchiveRestore',
|
||
'fa-lock-open': 'LockOpen',
|
||
'fa-compress': 'Minimize2',
|
||
'fa-expand': 'Maximize2',
|
||
'fa-grip-lines': 'GripHorizontal',
|
||
'fa-bars': 'Menu',
|
||
'fa-refresh': 'RefreshCw',
|
||
}
|
||
|
||
# Skip modifiers — not real icon names
|
||
FA_MODIFIERS = {'fas', 'far', 'fab', 'fa-spin', 'fa-fw', 'fa-lg', 'fa-2x', 'fa-sm'}
|
||
|
||
# ── Pattern: <span class="fas fa-X [extras]" [other-attrs]></span> ───────────
|
||
# [^>]* matches newlines too (character class, not dot)
|
||
SPAN_RE = re.compile(
|
||
r'<span\s+class="((?:fas|far|fab)\s+fa-[^"]*)"[^>]*>\s*</span>'
|
||
)
|
||
|
||
# ── Comment splitter ─────────────────────────────────────────────────────────
|
||
COMMENT_RE = re.compile(r'(<!--[\s\S]*?-->)')
|
||
|
||
# ── Lucide import line ────────────────────────────────────────────────────────
|
||
IMPORT_RE = re.compile(r"import\s*\{([^}]+)\}\s*from\s*'@lucide/svelte'\s*;?")
|
||
|
||
|
||
def parse_fa_class(class_str):
|
||
"""Return (icon_name, extra_classes, has_spin) from a FA class string."""
|
||
parts = class_str.split()
|
||
icon_name = None
|
||
has_spin = 'fa-spin' in parts
|
||
extra = []
|
||
for p in parts:
|
||
if p in FA_MODIFIERS:
|
||
continue
|
||
elif p.startswith('fa-'):
|
||
if icon_name is None:
|
||
icon_name = p # first real icon name wins
|
||
extra.append(p)
|
||
return icon_name, extra, has_spin
|
||
|
||
|
||
def replace_span(m):
|
||
"""Regex sub callback: replace a single FA span with a Lucide component."""
|
||
"""
|
||
if icon_name is None:
|
||
return m.group(0)
|
||
|
||
lucide = FA_TO_LUCIDE.get(icon_name)
|
||
if lucide is None:
|
||
print(f' ⚠ no mapping for {icon_name!r} — left as-is', file=sys.stderr)
|
||
return m.group(0)
|
||
|
||
classes = extra[:]
|
||
if has_spin:
|
||
classes.append('animate-spin')
|
||
|
||
class_attr = f' class="{" ".join(classes)}"' if classes else ''
|
||
return f'<{lucide} size="1em"{class_attr} />'
|
||
|
||
|
||
def process_content(content):
|
||
"""Replace FA spans, skip HTML comments. Return (new_content, used_icons)."""
|
||
used_icons = set()
|
||
|
||
def track_and_replace(m):
|
||
result = replace_span(m)
|
||
if result != m.group(0):
|
||
# Extract lucide name from result
|
||
lucide_name = result.split()[0].lstrip('<')
|
||
used_icons.add(lucide_name)
|
||
return result
|
||
|
||
# Split by comments; only process non-comment segments
|
||
parts = COMMENT_RE.split(content)
|
||
new_parts = []
|
||
for part in parts:
|
||
if part.startswith('<!--'):
|
||
new_parts.append(part)
|
||
else:
|
||
new_parts.append(SPAN_RE.sub(track_and_replace, part))
|
||
|
||
return ''.join(new_parts), used_icons
|
||
|
||
|
||
def add_import(content, icons):
|
||
"""Add/merge lucide-svelte import line inside <script>."""
|
||
if not icons:
|
||
return content
|
||
|
||
sorted_icons = sorted(icons)
|
||
|
||
existing = IMPORT_RE.search(content)
|
||
if existing:
|
||
current = [s.strip() for s in existing.group(1).split(',') if s.strip()]
|
||
merged = sorted(set(current) | set(sorted_icons))
|
||
new_import = f"import {{ {', '.join(merged)} }} from '@lucide/svelte';"
|
||
return content[:existing.start()] + new_import + content[existing.end():]
|
||
else:
|
||
# Insert after the last COMPLETE import statement (handles multiline imports).
|
||
# A complete import statement ends with: } from '...'; or import '...';
|
||
complete_import_re = re.compile(
|
||
r'^[ \t]*import\b[\s\S]*?(?:from\s*[\'"][^\'"]+[\'"]|[\'"][^\'"]+[\'"])\s*;?',
|
||
re.MULTILINE
|
||
)
|
||
all_imports = list(complete_import_re.finditer(content))
|
||
if all_imports:
|
||
pos = all_imports[-1].end()
|
||
new_line = f"\n import {{ {', '.join(sorted_icons)} }} from '@lucide/svelte';"
|
||
return content[:pos] + new_line + content[pos:]
|
||
# Fallback: add after <script> tag
|
||
script_tag = content.find('<script')
|
||
if script_tag != -1:
|
||
end = content.index('>', script_tag) + 1
|
||
return content[:end] + f"\n import {{ {', '.join(sorted_icons)} }} from 'lucide-svelte';" + content[end:]
|
||
return content
|
||
|
||
|
||
def migrate_file(filepath):
|
||
path = Path(filepath)
|
||
original = path.read_text()
|
||
new_content, used_icons = process_content(original)
|
||
|
||
if used_icons:
|
||
new_content = add_import(new_content, used_icons)
|
||
|
||
if new_content != original:
|
||
path.write_text(new_content)
|
||
print(f' ✓ {path.name} ({len(used_icons)} icon types: {", ".join(sorted(used_icons))})')
|
||
return True
|
||
else:
|
||
print(f' – {path.name} (no changes)')
|
||
return False
|
||
|
||
|
||
def main():
|
||
if len(sys.argv) < 2:
|
||
print('Usage: migrate_fa_to_lucide.py <directory_or_file ...>')
|
||
sys.exit(1)
|
||
|
||
|
||
# IDAA Guardrail: Abort if any target is src/routes/idaa or a subdirectory
|
||
for arg in sys.argv[1:]:
|
||
if Path(arg).resolve().as_posix().endswith('src/routes/idaa') or '/src/routes/idaa/' in Path(arg).resolve().as_posix():
|
||
print('ABORT: This script must not be run against src/routes/idaa or its subdirectories. IDAA content is private and protected.')
|
||
sys.exit(1)
|
||
|
||
targets = []
|
||
for arg in sys.argv[1:]:
|
||
p = Path(arg)
|
||
if p.is_dir():
|
||
targets.extend(sorted(p.rglob('*.svelte')))
|
||
elif p.is_file():
|
||
targets.append(p)
|
||
else:
|
||
print(f'Not found: {arg}', file=sys.stderr)
|
||
|
||
changed = 0
|
||
for t in targets:
|
||
if migrate_file(t):
|
||
changed += 1
|
||
|
||
print(f'\n{changed}/{len(targets)} files modified.')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|