openvibers/openvibe.design Commits
Icons: find one by any name, and switch from Font Awesome, Lucide or Material (#77)
Changes
@@@ -212,3 +212,25 @@.kit-bar .ic-btn:disabled{opacity:.5;cursor:default}@media (max-width:760px){.kit-grid{grid-template-columns:repeat(auto-fill,minmax(88px,1fr))}}@media (prefers-reduced-motion:reduce){.kit-tile{transition:none}}++++/* /icons/migrate (L50 step 4): paste a page, see every icon with its twin. */++.mg-form{display:grid;gap:10px;margin:0 0 8px}++.mg-label{font-weight:750;font-size:14px}++.mg-form textarea{width:100%;box-sizing:border-box;min-height:180px;padding:14px 16px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;resize:vertical}++.mg-form textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}++.mg-go{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px}++.mg-go>span{color:var(--text-muted);font-size:13px}++.mg-wrap{overflow-x:auto;border-radius:14px;border:1px solid var(--border)}++.mg-table{width:100%;border-collapse:collapse;font-size:14px}++.mg-table th{position:sticky;top:0;padding:10px 14px;text-align:left;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);background:var(--bg-secondary);border-bottom:1px solid var(--border)}++.mg-table td{padding:10px 14px;border-bottom:1px solid color-mix(in srgb,var(--border) 70%,transparent);vertical-align:middle}++.mg-table tr:last-child td{border-bottom:0}++.mg-yours{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px}++.mg-set{color:var(--text-muted);font-size:12px}++.mg-n{font-variant-numeric:tabular-nums;color:var(--text-secondary)}++.mg-ours{display:inline-flex;align-items:center;gap:8px;color:var(--text-primary);text-decoration:none;font-weight:650}++.mg-ours:hover span{text-decoration:underline}++.mg-ours.is-brand small{color:var(--text-muted);font-weight:400}++.mg-none{color:var(--text-muted)}++.mg-table tr.is-missing td{background:color-mix(in srgb,var(--bg-secondary) 50%,transparent)}++.mg-table code{white-space:nowrap}@@@ -11,7 +11,8 @@const tools = $('[data-ic-tools]');if (tools) tools.hidden = false;-- // Find as you type: a glyph's name or any Font Awesome name it stands for.++ // Find as you type: a glyph's name, any Font Awesome name it stands for, and the words and other sets' names for it++ // (server/glyphs/aliases.js: "settings", "delete", Lucide's "trash-2", Material's "delete outline").const find = $('[data-ic-find]');const none = $('[data-ic-none]');const drawLink = $('[data-ic-draw-link]');@@@ -19,7 +20,8 @@const q = find.value.trim().toLowerCase().replace(/^fa-/, '');let shown = 0;grid.querySelectorAll('.ic-tile').forEach((t) => {-- const hit = !q || t.dataset.name.includes(q) || (t.dataset.fa || '').split(' ').some((f) => f.includes(q));++ const hit = !q || t.dataset.name.includes(q) || (t.dataset.fa || '').split(' ').some((f) => f.includes(q))++ || (q.length > 1 && (t.dataset.words || '').includes(q));t.hidden = !hit; if (hit) shown++;});if (none) none.hidden = shown > 0;@@@ -3,7 +3,8 @@* <script src="https://openvibe.design/ov-icon.js" defer></script>* <ov-icon name="heart"></ov-icon> follows the text: 1em, currentColor* <ov-icon name="mic" size="16" label="Microphone"></ov-icon> a size in pixels; a label makes it an image to screen readers-- * <ov-icon name="fa-gear"></ov-icon> a Font Awesome name works too++ * <ov-icon name="fa-gear"></ov-icon> a Font Awesome name works too, and Lucide's or Material's:++ * <ov-icon name="lucide:settings"></ov-icon> <ov-icon name="material:delete"></ov-icon> (openvibe.design/icons/migrate)* <ov-icon name="bell" variant="solid"></ov-icon> line | solid | auto (the default)** Every size by itself: on auto, an icon 18 px or smaller shows its solid drawing when it has one (a container query,@@@ -19,7 +20,7 @@var script = document.currentScript;var BASE = 'https://openvibe.design';try { if (script && script.src) BASE = new URL(script.src).origin; } catch (e) { /* the default */ }-- var NAME = /^(?:fa-)?[a-z][a-z0-9-]{0,47}$/;++ var NAME = /^(?:(?:lucide|material|mui|fa):)?[a-z][a-z0-9_-]{0,47}$/;// ── The drawings: asked for together, kept for the page's life ──var known = Object.create(null); // name → Promise<{ line, solid } | null>nnew file mode 100644@@@ -0,0 +1,236 @@++'use strict';++/**++ * Finding a glyph by any name (plan L50 step 4): the words people search with, and the names other icon sets use++ * (Lucide, Material Symbols and MUI; Font Awesome's are openvibe-shared's own glyphs.FA). One table, used by the /icons++ * finder, <ov-icon>, /api/v1/glyphs and the migration page (/icons/migrate), so a name works the same everywhere.++ *++ * words(glyph) → the search words for a glyph (its synonyms, then every other set's names for it)++ * resolve(name) → our glyph for "gear", "fa-gear", "lucide:settings", "material:settings" or "settings", or null++ * scan(text) → every icon a page or a component asks for, by set: [{ set, name, count, glyph, brand }]++ * LUCIDE, MATERIAL → { theirName: glyph } (only names whose glyph exists)++ */++const glyphs = require('openvibe-shared/glyphs');++// Brand logos (Shared 4.72.0, Simple Icons, CC0): the drop-in draws fa-brands names too.++let brands = null;++try { brands = require('openvibe-shared/brands'); } catch { brands = null; }++++const has = (g) => !!glyphs.path(g);++const keep = (map) => Object.freeze(Object.fromEntries(Object.entries(map).filter(([, g]) => has(g))));++++// ── Synonyms: the words a person types for each glyph (not its own name, nor Font Awesome's) ──++const SYN = {++ gear: 'settings cog preferences options config', sliders: 'adjust controls settings tune mixer', trash: 'delete remove bin garbage',++ close: 'x cancel dismiss exit', pencil: 'edit write compose rename', plus: 'add new create', minus: 'remove subtract less',++ search: 'find magnifier lookup', home: 'house start main', user: 'person profile account me', members: 'people users team',++ group: 'team community crowd', chat: 'message comment talk conversation', mail: 'email envelope letter', inbox: 'mail messages tray',++ bell: 'notification alert ring', heart: 'like love favorite', star: 'favorite rate rating', eye: 'view show visible watch see',++ 'eye-off': 'hide hidden invisible private', lock: 'secure private password closed', key: 'password access unlock',++ shield: 'security safe protect privacy', check: 'done ok tick yes confirm', copy: 'duplicate clone', clipboard: 'paste notes',++ scissors: 'cut clip trim', save: 'floppy disk store keep', download: 'save get import', upload: 'send put export', share: 'send forward',++ open: 'external new tab link out', link: 'url chain hyperlink', retry: 'refresh reload again redo repeat', undo: 'revert back',++ history: 'past recent previous time', clock: 'time hour watch', calendar: 'date schedule event day', pin: 'location place marker',++ map: 'directions place', globe: 'world earth internet web', camera: 'photo picture snapshot', video: 'movie record clip camera',++ image: 'picture photo', film: 'movie video cinema reel', music: 'song audio note', mic: 'microphone voice record audio',++ volume: 'sound speaker audio loud', mute: 'silent sound off quiet', play: 'start run go', pause: 'hold wait', stop: 'halt end',++ live: 'broadcast stream on air', tv: 'television screen', computer: 'desktop monitor laptop pc screen', phone: 'call telephone',++ robot: 'bot ai machine automation', brain: 'mind think ai smart', spark: 'magic ai sparkles new shine', fire: 'hot trending flame',++ zap: 'lightning bolt power energy fast', trophy: 'award win prize cup', crown: 'king premium vip', gem: 'diamond premium jewel',++ gift: 'present reward', coin: 'money credits currency token', dollar: 'money price usd cost', wallet: 'money payment purse',++ card: 'credit payment', cart: 'shop basket buy', store: 'shop market', tag: 'label price', ticket: 'pass admission',++ chart: 'graph stats analytics data', gauge: 'speed meter performance dashboard', 'trend-up': 'growth increase rising',++ 'trend-down': 'decline decrease falling', rocket: 'launch fast startup', plane: 'flight travel send', airplane: 'flight travel',++ car: 'drive vehicle', train: 'rail travel', walk: 'pedestrian step', route: 'path directions way', code: 'develop programming html',++ terminal: 'console shell command cli', bug: 'error issue defect', wrench: 'tool fix repair', hammer: 'build tool',++ toolbox: 'tools kit', box: 'package parcel', archive: 'storage box', folder: 'directory', file: 'document page',++ book: 'read guide manual docs', 'book-open': 'read learn guide', news: 'article newspaper press', megaphone: 'announce shout marketing',++ rss: 'feed subscribe', idea: 'lightbulb tip insight', help: 'question support faq', info: 'information about details',++ alert: 'warning caution danger', 'alert-circle': 'error warning', ban: 'block forbid prohibited', filter: 'funnel refine',++ sort: 'order arrange', list: 'items bullets', grid: 'tiles layout apps', menu: 'hamburger navigation bars', more: 'ellipsis options dots',++ sun: 'light day bright', moon: 'dark night', cloud: 'weather sky', snow: 'winter cold', drop: 'water rain liquid',++ tree: 'nature forest', leaf: 'nature eco plant', paw: 'pet animal dog cat', fish: 'sea animal', game: 'controller gamepad gaming',++ dice: 'random chance luck', sword: 'fight battle', target: 'goal aim bullseye', flag: 'report mark', enter: 'login sign in',++ leave: 'logout sign out exit', expand: 'fullscreen maximize enlarge', compress: 'minimize shrink', busy: 'loading wait spinner',++ nodes: 'network connections graph', server: 'host backend', database: 'data storage sql', chip: 'cpu processor hardware',++ drive: 'disk storage', wifi: 'wireless internet signal', palette: 'colors theme paint', text: 'type font letters',++ signature: 'sign autograph', scale: 'balance law justice', gavel: 'law judge auction', medical: 'health doctor hospital',++ food: 'restaurant eat meal', dish: 'meal plate food', mug: 'coffee cup tea drink', couch: 'sofa lounge', door: 'exit entrance room',++ city: 'buildings town urban', tent: 'camping outdoors', mountain: 'hiking peak nature', umbrella: 'rain weather protect',++ lifebuoy: 'support help rescue', smile: 'happy emoji face', laugh: 'funny lol emoji', skull: 'dead danger', ghost: 'boo spooky',++ child: 'kid baby', hand: 'wave stop palm', handshake: 'deal agreement partner', shirt: 'clothes merch', 'thumbs-up': 'like approve good',++ swap: 'exchange switch trade', move: 'drag arrange', grip: 'drag handle reorder', sidebar: 'panel layout', columns: 'layout split',++ table: 'spreadsheet rows data', steps: 'checklist todo progress', task: 'todo checklist done', reply: 'respond answer back',++ forward: 'send next', infinity: 'forever endless loop', keyboard: 'type input keys', print: 'printer paper', contrast: 'theme dark light',++ ear: 'listen hearing audio', record: 'rec dot capture', cc: 'captions subtitles', scroll: 'document paper history',++ focus: 'target center aim', compass: 'explore navigate direction', hourglass: 'timer wait loading', weight: 'gym fitness heavy',++ parking: 'car park', restroom: 'toilet wc bathroom', storm: 'thunder lightning weather', wind: 'air breeze weather',++ wave: 'audio signal ocean', water: 'sea ocean waves', seedling: 'grow plant sprout', recycle: 'reuse green eco',++ piggy: 'savings bank money', money: 'cash bills payment', donate: 'give charity', ranking: 'leaderboard medal top',++ 'check-double': 'read seen done all', 'user-circle': 'avatar profile account', invite: 'add person join',++ 'user-off': 'remove person block', 'user-lock': 'private account', 'link-off': 'unlink broken', 'chat-off': 'no messages muted',++ 'camera-off': 'no camera', 'camera-flip': 'switch camera', 'mic-off': 'muted no mic', 'phone-off': 'hang up end call',++ 'cloud-off': 'offline', 'heart-broken': 'sad breakup', 'heart-pulse': 'health heartbeat', clapper: 'movie film action',++ headphones: 'audio listen music', radio: 'broadcast fm', branch: 'git fork', commit: 'git version', layers: 'stack levels',++ shapes: 'geometry objects', cubes: 'blocks 3d boxes', id: 'badge identity card', at: 'email mention', hash: 'tag hashtag number',++ quote: 'citation saying', heading: 'title header', 'align-left': 'text paragraph', 'list-ordered': 'numbers ranked',++ timeline: 'history events', window: 'app browser', 'window-restore': 'windows',++};++++// ── Lucide's names (lucide.dev) whose glyph has another name here; identical names need no entry ──++const LUCIDE = keep({++ x: 'close', settings: 'gear', 'settings-2': 'sliders', 'sliders-horizontal': 'sliders', 'sliders-vertical': 'sliders', 'trash-2': 'trash',++ 'external-link': 'open', 'square-arrow-out-up-right': 'open', loader: 'busy', 'loader-circle': 'busy', 'loader-2': 'busy',++ 'volume-1': 'volume', 'volume-2': 'volume', 'volume-x': 'mute', 'volume-off': 'mute', users: 'members', 'users-round': 'members',++ 'message-circle': 'chat', 'message-square': 'chat', 'messages-square': 'chat', 'message-circle-off': 'chat-off', house: 'home',++ 'map-pin': 'pin', 'map-pinned': 'pin', pin: 'tack', 'chevron-down': 'chevron', 'rotate-ccw': 'retry', 'rotate-cw': 'retry',++ 'refresh-cw': 'retry', 'refresh-ccw': 'retry', repeat: 'retry', 'undo-2': 'undo', 'log-in': 'enter', 'log-out': 'leave',++ 'circle-help': 'help', 'help-circle': 'help', 'circle-alert': 'alert-circle', 'triangle-alert': 'alert', 'alert-triangle': 'alert',++ 'circle-check': 'check-circle', 'circle-check-big': 'check-circle', 'check-circle-2': 'check-circle', 'circle-x': 'x-circle',++ 'circle-plus': 'plus-circle', 'circle-minus': 'minus-circle', 'check-check': 'check-double', ellipsis: 'more',++ 'more-horizontal': 'more', 'ellipsis-vertical': 'more', 'more-vertical': 'more', 'grip-vertical': 'grip', 'layout-grid': 'grid',++ 'grid-3x3': 'grid', 'panel-left': 'sidebar', monitor: 'computer', laptop: 'computer', cpu: 'chip', 'hard-drive': 'drive',++ 'share-2': 'nodes', network: 'nodes', workflow: 'nodes', 'link-2': 'link', unlink: 'link-off', 'link-2-off': 'link-off',++ 'shield-check': 'shield', 'user-round': 'user', 'circle-user': 'user-circle', 'circle-user-round': 'user-circle', 'user-x': 'user-off',++ 'user-plus': 'invite', 'calendar-days': 'calendar', 'alarm-clock': 'clock', 'switch-camera': 'camera-flip', images: 'image',++ clapperboard: 'clapper', 'circle-stop': 'stop', 'skip-forward': 'skip', 'heart-crack': 'heart-broken', 'file-text': 'file',++ pen: 'pencil', 'pen-line': 'pencil', 'square-pen': 'pencil', edit: 'pencil', 'cloud-lightning': 'storm', snowflake: 'snow',++ droplet: 'drop', droplets: 'water', waves: 'wave', flame: 'fire', sparkles: 'spark', sparkle: 'spark', 'wand-sparkles': 'spark',++ award: 'trophy', medal: 'ranking', crosshair: 'focus', earth: 'globe', languages: 'language', 'code-xml': 'code',++ 'square-terminal': 'terminal', 'git-branch': 'branch', 'git-commit-horizontal': 'commit', 'git-commit': 'commit', package: 'box',++ boxes: 'cubes', type: 'text', 'at-sign': 'at', funnel: 'filter', 'arrow-up-down': 'sort', 'arrow-down-wide-narrow': 'sort-asc',++ printer: 'print', tags: 'tag', 'shopping-cart': 'cart', 'shopping-bag': 'cart', 'credit-card': 'card', coins: 'coin',++ 'dollar-sign': 'dollar', 'piggy-bank': 'piggy', banknote: 'money', 'trending-up': 'trend-up', 'trending-down': 'trend-down',++ 'chart-line': 'chart', 'line-chart': 'chart', 'bar-chart': 'chart', 'bar-chart-2': 'chart', 'chart-column': 'chart',++ 'chart-no-axes-column': 'chart', activity: 'heart-pulse', 'train-front': 'train', footprints: 'walk', trees: 'tree', 'tree-pine': 'tree',++ 'tree-deciduous': 'tree', sprout: 'seedling', 'paw-print': 'paw', bot: 'robot', 'bot-message-square': 'robot', baby: 'child',++ coffee: 'mug', 'cup-soda': 'mug', utensils: 'food', 'utensils-crossed': 'food', 'flask-conical': 'flask', stethoscope: 'medical',++ sofa: 'couch', 'door-open': 'door', 'door-closed': 'door', 'building-2': 'city', building: 'city', 'life-buoy': 'lifebuoy',++ swords: 'sword', dices: 'dice', 'dice-5': 'dice', 'gamepad-2': 'game', gamepad: 'game', newspaper: 'news', 'id-card': 'id',++ 'key-round': 'key', shrink: 'compress', maximize: 'expand', minimize: 'compress', 'maximize-2': 'expand', 'minimize-2': 'compress',++ 'arrow-left-right': 'arrows-h', 'arrow-right-left': 'swap', 'columns-2': 'columns', 'table-2': 'table', 'list-checks': 'steps',++ 'list-todo': 'task', 'corner-up-left': 'reply', 'circle-parking': 'parking', 'square-parking': 'parking', dumbbell: 'weight',++ 'tv-2': 'tv', 'tv-minimal': 'tv', 'circle-dot': 'record', 'mail-open': 'mail', 'bell-ring': 'bell', 'phone-off': 'phone-off',++ 'arrow-left': 'back', 'chevron-left': 'back', 'move-right': 'arrow-right', hourglass: 'hourglass', timer: 'hourglass',++ 'ticket-check': 'ticket', 'radio-tower': 'live', podcast: 'live', 'ear-off': 'ear', 'scale-3d': 'cubes', 'shapes': 'shapes',++});++++// ── Material Symbols / Material Icons names (Google Fonts) and MUI's, snake_case; identical names need no entry ──++const MATERIAL = keep({++ settings: 'gear', tune: 'sliders', more_vert: 'more', more_horiz: 'more', delete: 'trash', delete_outline: 'trash', edit: 'pencil',++ add: 'plus', remove: 'minus', done: 'check', done_all: 'check-double', check_circle: 'check-circle', cancel: 'x-circle', error: 'alert-circle',++ warning: 'alert', help_outline: 'help', favorite: 'heart', favorite_border: 'heart', star_border: 'star', thumb_up: 'thumbs-up',++ visibility: 'eye', visibility_off: 'eye-off', person: 'user', people: 'members', groups: 'group', person_add: 'invite',++ account_circle: 'user-circle', email: 'mail', chat_bubble: 'chat', forum: 'chat', notifications: 'bell', content_copy: 'copy',++ content_paste: 'clipboard', content_cut: 'scissors', file_download: 'download', file_upload: 'upload', description: 'file',++ insert_drive_file: 'file', photo_camera: 'camera', videocam: 'video', volume_up: 'volume', volume_off: 'mute', play_arrow: 'play',++ skip_next: 'skip', fast_rewind: 'rewind', music_note: 'music', laptop: 'computer', desktop_windows: 'computer', call: 'phone',++ call_end: 'phone-off', schedule: 'clock', access_time: 'clock', calendar_today: 'calendar', event: 'calendar', today: 'calendar',++ hourglass_empty: 'hourglass', place: 'pin', location_on: 'pin', explore: 'compass', public: 'globe', translate: 'language',++ bug_report: 'bug', build: 'wrench', construction: 'hammer', handyman: 'toolbox', inventory_2: 'box', text_fields: 'text',++ title: 'heading', format_quote: 'quote', format_align_left: 'align-left', tag: 'hash', alternate_email: 'at', filter_list: 'filter',++ filter_alt: 'filter', local_offer: 'tag', sell: 'tag', confirmation_number: 'ticket', shopping_cart: 'cart', storefront: 'store',++ account_balance_wallet: 'wallet', credit_card: 'card', payments: 'money', attach_money: 'dollar', savings: 'piggy',++ trending_up: 'trend-up', trending_down: 'trend-down', show_chart: 'chart', bar_chart: 'chart', insights: 'chart', speed: 'gauge',++ rocket_launch: 'rocket', flight: 'plane', airplanemode_active: 'airplane', directions_car: 'car', directions_walk: 'walk',++ terrain: 'mountain', park: 'tree', eco: 'leaf', pets: 'paw', smart_toy: 'robot', psychology: 'brain', sentiment_satisfied: 'smile',++ mood: 'smile', child_care: 'child', pan_tool: 'hand', checkroom: 'shirt', local_cafe: 'mug', coffee: 'mug', restaurant: 'food',++ science: 'flask', medical_services: 'medical', local_hospital: 'medical', weekend: 'couch', door_front: 'door', meeting_room: 'door',++ apartment: 'city', location_city: 'city', beach_access: 'umbrella', support: 'lifebuoy', balance: 'scale', casino: 'dice',++ sports_esports: 'game', videogame_asset: 'game', all_inclusive: 'infinity', rss_feed: 'rss', newspaper: 'news', article: 'news',++ feed: 'news', campaign: 'megaphone', hearing: 'ear', badge: 'id', vpn_key: 'key', hub: 'nodes', account_tree: 'nodes',++ open_in_new: 'open', launch: 'open', refresh: 'retry', autorenew: 'retry', replay: 'retry', login: 'enter', logout: 'leave',++ fullscreen: 'expand', fullscreen_exit: 'compress', open_with: 'move', swap_horiz: 'swap', view_column: 'columns',++ table_chart: 'table', table_rows: 'table', checklist: 'steps', task_alt: 'task', arrow_back: 'back', arrow_forward: 'arrow-right',++ arrow_upward: 'arrow-up', arrow_downward: 'arrow-down', expand_more: 'chevron', keyboard_arrow_down: 'chevron',++ local_fire_department: 'fire', whatshot: 'fire', bolt: 'zap', flash_on: 'zap', auto_awesome: 'spark', card_giftcard: 'gift',++ redeem: 'gift', diamond: 'gem', workspace_premium: 'crown', emoji_events: 'trophy', military_tech: 'ranking', leaderboard: 'ranking',++ gps_fixed: 'target', my_location: 'target', center_focus_strong: 'focus', wb_sunny: 'sun', light_mode: 'sun', dark_mode: 'moon',++ bedtime: 'moon', ac_unit: 'snow', air: 'wind', water_drop: 'drop', waves: 'wave', thunderstorm: 'storm', dashboard: 'grid',++ apps: 'grid', grid_view: 'grid', view_sidebar: 'sidebar', drag_indicator: 'grip', dns: 'server', storage: 'database',++ memory: 'chip', sd_storage: 'drive', fiber_manual_record: 'record', radio_button_checked: 'record', live_tv: 'live',++ closed_caption: 'cc', subtitles: 'cc', monitor_weight: 'weight', fitness_center: 'weight', local_parking: 'parking',++ wc: 'restroom', security: 'shield', verified_user: 'shield', privacy_tip: 'shield', draw: 'signature', history_edu: 'scroll',++ receipt_long: 'scroll', toc: 'list', format_list_numbered: 'list-ordered', format_list_bulleted: 'list', notes: 'text',++ data_object: 'code', integration_instructions: 'code', call_split: 'branch', category: 'shapes', interests: 'shapes',++ menu_book: 'book-open', auto_stories: 'book-open', import_contacts: 'book-open', library_books: 'book', lightbulb: 'idea',++ tips_and_updates: 'idea', mic_none: 'mic', mic_off: 'mic-off', videocam_off: 'camera-off', cloud_off: 'cloud-off', link_off: 'link-off',++ lock_outline: 'lock', send: 'send', share: 'share', person_off: 'user-off', how_to_reg: 'user-check', manage_accounts: 'user-pen',++ playlist_add_check: 'steps', sort: 'sort', print: 'print', inbox: 'inbox', terminal: 'terminal', code: 'code', layers: 'layers',++ palette: 'palette', keyboard: 'keyboard', contrast: 'contrast', timeline: 'timeline', handshake: 'handshake', mail: 'mail',++});++++// Their names for each of our glyphs, and the search words, built once.++const THEIRS = new Map();++for (const [from, map] of [['lucide', LUCIDE], ['material', MATERIAL]]) {++ for (const [name, g] of Object.entries(map)) {++ if (!THEIRS.has(g)) THEIRS.set(g, []);++ THEIRS.get(g).push(from === 'material' ? name.replace(/_/g, ' ') : name);++ }++}++function words(g) {++ const out = new Set(String(SYN[g] || '').split(/\s+/).filter(Boolean));++ for (const n of THEIRS.get(g) || []) out.add(n);++ return [...out];++}++++const kebab = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Za-z])(\d)/g, '$1-$2').toLowerCase();++const snake = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().replace(/_(outlined|outline|rounded|sharp|two_?tone|filled)$/, '');++++/** Our glyph for a name in any set, or null. "lucide:x", "material:settings", "fa-gear", "gear", or a bare other name. */++function resolve(raw) {++ const name = String(raw || '').trim();++ const m = /^(lucide|material|mui|fa):(.+)$/i.exec(name);++ if (m) {++ const set = m[1].toLowerCase(), n = m[2];++ if (set === 'lucide') { const k = kebab(n); return LUCIDE[k] || (has(k) ? k : null); }++ if (set === 'fa') return glyphs.fromFa(n);++ const s = snake(n); return MATERIAL[s] || (has(s.replace(/_/g, '-')) ? s.replace(/_/g, '-') : null);++ }++ const low = name.toLowerCase();++ if (has(low)) return low;++ return glyphs.fromFa(low) || LUCIDE[low] || MATERIAL[low.replace(/-/g, '_')] || null;++}++++// Font Awesome's own utility classes: not icon names.++const FA_UTIL = /^(solid|regular|light|thin|duotone|brands|sharp|sharp-solid|fw|lg|sm|xs|xl|2xs|2xl|[1-9]x|10x|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|fade|bounce|flip|shake|border|pull-left|pull-right|pull-start|pull-end|li|ul|stack|stack-1x|stack-2x|inverse|rotate-\d+|rotate-by|flip-horizontal|flip-vertical|flip-both|layers|layers-text|layers-counter|layers-top-right|layers-bottom-left|swap-opacity|width-auto)$/;++++/**++ * Every icon a page or a component asks for: Font Awesome classes, Lucide (data-lucide, the lucide-react/-vue/-svelte++ * imports, <ov-icon>-style names), Material (the ligature spans, <mat-icon>, @mui/icons-material imports). Counted,++ * each with our glyph or null. Text only: nothing is fetched or run.++ */++function scan(text) {++ const src = String(text || '').slice(0, 400000);++ const found = new Map();++ const add = (set, name, glyph) => {++ const key = `${set}:${name}`;++ const was = found.get(key);++ if (was) was.count++;++ else found.set(key, { set, name, count: 1, glyph: glyph || null, brand: set === 'fa-brands' && brands && brands.fromFa ? brands.fromFa(name) || null : null });++ };++ for (const m of src.matchAll(/class\s*=\s*["']([^"']*\bfa[srlbdt]?\b[^"']*|[^"']*\bfa-[a-z0-9-]+[^"']*)["']/gi)) {++ const classes = m[1].split(/\s+/);++ const brand = classes.includes('fa-brands') || classes.includes('fab');++ for (const c of classes) {++ const n = /^fa-([a-z0-9-]+)$/.exec(c);++ if (!n || FA_UTIL.test(n[1])) continue;++ add(brand ? 'fa-brands' : 'fa', n[1], brand ? null : glyphs.fromFa(n[1]));++ }++ }++ for (const m of src.matchAll(/data-lucide\s*=\s*["']([a-z0-9-]+)["']/gi)) add('lucide', m[1], resolve(`lucide:${m[1]}`));++ for (const m of src.matchAll(/import\s*\{([^}]{1,2000})\}\s*from\s*["'](?:lucide-react|lucide-vue-next|lucide-svelte|lucide-solid|lucide-preact|@lucide\/[a-z-]+)["']/g)) {++ for (const part of m[1].split(',')) {++ const n = part.trim().split(/\s+as\s+/)[0].replace(/Icon$/, '');++ if (/^[A-Z][A-Za-z0-9]*$/.test(n)) add('lucide', kebab(n), resolve(`lucide:${n}`));++ }++ }++ for (const m of src.matchAll(/<(?:span|i)[^>]*class\s*=\s*["'][^"']*material-(?:icons|symbols)[a-z-]*[^"']*["'][^>]*>\s*([a-z0-9_]{2,40})\s*</gi)) add('material', m[1], resolve(`material:${m[1]}`));++ for (const m of src.matchAll(/<mat-icon[^>]*>\s*([a-z0-9_]{2,40})\s*<\/mat-icon>/gi)) add('material', m[1], resolve(`material:${m[1]}`));++ for (const m of src.matchAll(/(?:import\s+([A-Z][A-Za-z0-9]*)\s+from\s*["']@mui\/icons-material\/([A-Za-z0-9]+)["'])|(?:import\s*\{([^}]{1,2000})\}\s*from\s*["']@mui\/icons-material["'])/g)) {++ const names = m[2] ? [m[2]] : m[3].split(',').map((p) => p.trim().split(/\s+as\s+/)[0]);++ for (const n of names) if (/^[A-Z][A-Za-z0-9]*$/.test(n)) add('material', snake(n), resolve(`material:${n}`));++ }++ const order = { fa: 0, lucide: 1, material: 2, 'fa-brands': 3 };++ return [...found.values()].sort((a, b) => (a.glyph || a.brand ? 0 : 1) - (b.glyph || b.brand ? 0 : 1) || order[a.set] - order[b.set] || b.count - a.count || (a.name < b.name ? -1 : 1));++}++++module.exports = { words, resolve, scan, LUCIDE, MATERIAL };@@@ -37,6 +37,7 @@ function publicPages(themes = []) {{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },++ { path: '/icons/migrate', changefreq: 'monthly', priority: 0.5, title: 'Switch from Font Awesome, Lucide or Material', text: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin.' },{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },];const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({@@@ -83,7 +84,7 @@ function createDiscoveryRoutes(ctx) {{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },-- { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b for just those). All open, no sign-in, CORS *' },++ { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *' },{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs' },{ title: 'The OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },@@@ -13,6 +13,8 @@* GET /icons/pack/<file> the fonts (WOFF2, TTF, OTF), the sprite, the CSS, the code points, and* ov-glyphs-svg.zip with every glyph* GET /icons/font-awesome-alternative how to switch, and what keeps working++ * GET /icons/migrate, POST /icons/migrate paste a page or code: every Font Awesome, Lucide, Material or MUI icon in++ * it with its OpenVibe twin (L50 step 4); POST /api/v1/glyphs/migrate as data* GET /icons/kit, POST /icons/kit pick glyphs, get a font with only those (WOFF2, TTF, CSS, code points) as a ZIP* GET /icons/kit.zip?names=&name=&fa= the same kit from a link* POST /icons/draft signed in: OpenVibe.AI drafts candidates (same-origin form)@@@ -27,7 +29,8 @@ const { asyncRouter } = require('./router');const { send, baseUrl } = require('../render/layout');const { writeAllowed } = require('./same-origin');const { iconsPage, glyphsJson } = require('../views/icons');--const { glyphPage, faAlternativePage, kitPage } = require('../views/icon-page');++const { glyphPage, faAlternativePage, kitPage, migratePage } = require('../views/icon-page');++const aliases = require('../glyphs/aliases');const kits = require('../glyphs/kits');const ex = require('../glyphs/export');const { createGlyphDrafts } = require('../glyphs/drafts');@@@ -64,6 +67,24 @@ function createIconRoutes(ctx) {r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));++ // Switching from another set (L50 step 4). The text is only read, never stored, fetched from or run.++ const bigForm = express.urlencoded({ extended: false, limit: '450kb' });++ r.get('/icons/migrate', (req, res) => send(res, 200, migratePage({ base: baseUrl() }), { viewer: req.viewer }));++ r.post('/icons/migrate', bigForm, (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ const text = String((req.body || {}).text || '').slice(0, 400000);++ send(res, 200, migratePage({ base: baseUrl(), text, found: aliases.scan(text) }), { viewer: req.viewer, cacheControl: 'private, no-store' });++ });++ r.options('/api/v1/glyphs/migrate', (_req, res) => res.set(OPEN).set('Access-Control-Allow-Methods', 'POST, OPTIONS')++ .set('Access-Control-Allow-Headers', 'Content-Type').set('Access-Control-Max-Age', '86400').status(204).end());++ r.post('/api/v1/glyphs/migrate', (req, res) => {++ res.set(OPEN).set('Cache-Control', 'no-store');++ const text = req.body && typeof req.body.text === 'string' ? req.body.text : null;++ if (text == null) return problem(res, req, 422, 'design.invalid', 'Send { "text": "…" }: a page, a template or code, up to 64 KB.');++ const found = aliases.scan(text);++ res.json({ count: found.length, matched: found.filter((f) => f.glyph || f.brand).length, icons: found });++ });++// Kits: a font with only the glyphs a site uses (L32 I5). The form posts the picks; the link form is for a build step.const sendKit = async (req, res, choice) => {let parsed;@@@ -146,9 +167,10 @@ function createIconRoutes(ctx) {if (req.query.names == null) return res.json(glyphsJson());// Just the names asked for (<ov-icon> asks for a page's in one go): a glyph or a Font Awesome name, each answered// under the name it was asked by; an unknown one is left out.-- const asked = [...new Set(String(req.query.names).split(',').map((n) => n.trim().toLowerCase()).filter((n) => /^[a-z][a-z0-9-]{0,47}$/.test(n)))].slice(0, 120);++ // Any set's name works (L50 step 4): "gear", "fa-gear", "lucide:settings", "material:delete", or a bare "settings".++ const asked = [...new Set(String(req.query.names).split(',').map((n) => n.trim().toLowerCase()).filter((n) => /^(?:(?:lucide|material|mui|fa):)?[a-z][a-z0-9_-]{0,47}$/.test(n)))].slice(0, 120);const out = asked.map((a) => {-- const name = glyphs.path(a) ? a : glyphs.fromFa(a);++ const name = aliases.resolve(a);return name ? { asked: a, name, svg: glyphs.path(name), solid: glyphs.solid(name) || '' } : null;}).filter(Boolean);res.json({ count: out.length, glyphs: out });@@@ -202,7 +202,7 @@ function faAlternativePage({ base = 'https://openvibe.design' } = {}) {<div class="ic-sec-head"><h2 id="fa-switch">Switch in a minute</h2><p>${Object.keys(glyphs.FA).length} Font Awesome names map to ${glyphs.NAMES.length} glyphs today. A name we have not drawn stays Font Awesome’s, so nothing goes blank.</p></div><ol class="fa-steps"><li><b>Add the drop-in.</b>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>`, id: 'fa-step1', label: 'Copy' })}</li>-- <li><b>Check what your pages use</b> against <a href="/icons#ic-left">what is still Font Awesome</a>. Missing one? <a href="/icons#draw">Draw it</a>.</li>++ <li><b>Check what your pages use:</b> <a href="/icons/migrate">paste a page</a> and see each icon’s twin, or look at <a href="/icons#ic-left">what is still Font Awesome</a>. Missing one? <a href="/icons#draw">Draw it</a>.</li><li><b>Drop Font Awesome’s stylesheet</b> once every name is covered: one less stylesheet and font on every first visit. Or use the font with Font Awesome’s names on it:${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-fa.css">`, id: 'fa-step3', label: 'Copy' })}</li></ol></section>@@@ -252,4 +252,58 @@ ${kit.snippet({ code: 'https://openvibe.design/icons/kit.zip?names=eye,live,hear});}--module.exports = { glyphPage, faAlternativePage, kitPage, related, faOf, title };++/**++ * /icons/migrate (plan L50 step 4): paste a page, a template or a component, and see every icon it asks for with its++ * OpenVibe twin: Font Awesome, Lucide (data-lucide, lucide-react/vue/svelte imports), Material Symbols and MUI. A plain++ * form; nothing is fetched or run, the text is only read (server/glyphs/aliases.js scan) and never kept.++ */++function migratePage({ base = 'https://openvibe.design', text = '', found = null, error = '' } = {}) {++ const SETS = { fa: 'Font Awesome', 'fa-brands': 'Font Awesome brand', lucide: 'Lucide', material: 'Material' };++ const theirs = (f) => (f.set === 'fa' || f.set === 'fa-brands' ? `fa-${f.name}` : f.set === 'material' ? f.name : f.name);++ let results = '';++ if (found) {++ const matched = found.filter((f) => f.glyph || f.brand);++ const uses = found.reduce((a, f) => a + f.count, 0), usesOk = matched.reduce((a, f) => a + f.count, 0);++ const sets = [...new Set(found.map((f) => f.set))];++ const how = [++ sets.includes('fa') || sets.includes('fa-brands') ? `<li><b>Font Awesome: keep your markup.</b> Add the drop-in, and every name with a twin shows ours, brands included.${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>`, id: 'mg-fa', label: 'Copy' })}</li>` : '',++ sets.includes('lucide') || sets.includes('material') ? `<li><b>Lucide and Material: one tag each.</b> Add the web component, then swap each icon for the <code><ov-icon></code> in the table (or keep the old name with <code>lucide:</code> or <code>material:</code> in front).${kit.snippet({ code: `<script src="${base}/ov-icon.js" defer></script>`, id: 'mg-wc', label: 'Copy' })}</li>` : '',++ ].filter(Boolean).join('');++ const row = (f) => {++ const ours = f.glyph ? `<a class="mg-ours" href="/icons/${esc(f.glyph)}">${glyph(f.glyph, 22)}<span>${esc(f.glyph)}</span></a>`++ : f.brand ? `<span class="mg-ours is-brand">${esc(f.brand)} <small>the brand’s own logo</small></span>`++ : `<span class="mg-none">None yet. <a href="/icons?draw=${esc(String(f.name).replace(/_/g, '-').slice(0, 31))}#draw">Draw it</a></span>`;++ const use = f.glyph ? `<code><ov-icon name="${esc(f.glyph)}"></ov-icon></code>` : f.brand ? '<code>the drop-in draws it</code>' : '';++ return `<tr${f.glyph || f.brand ? '' : ' class="is-missing"'}><td><span class="mg-yours"><span class="mg-set">${esc(SETS[f.set] || f.set)}</span><code>${esc(theirs(f))}</code></span></td><td class="mg-n">${f.count}</td><td>${ours}</td><td>${use}</td></tr>`;++ };++ results = found.length ? `<section class="ic-sec" id="results" aria-labelledby="mg-res">++ <div class="ic-sec-head"><h2 id="mg-res">${matched.length} of ${found.length} have a twin</h2><p>${usesOk} of the ${uses} times your text asks for an icon would show an OpenVibe one. The rest stay as they are until someone draws them.</p></div>++ ${how ? `<ol class="fa-steps">${how}</ol>` : ''}++ <div class="mg-wrap"><table class="mg-table"><thead><tr><th scope="col">Yours</th><th scope="col">Uses</th><th scope="col">OpenVibe</th><th scope="col">Use</th></tr></thead><tbody>${found.map(row).join('')}</tbody></table></div>++</section>` : `<section class="ic-sec" id="results" aria-labelledby="mg-res"><div class="ic-sec-head"><h2 id="mg-res">No icons found</h2><p>Nothing in that text looks like a Font Awesome class, a Lucide icon or a Material one. Paste the HTML of a page, a template, or a component’s imports.</p></div></section>`;++ }++ const body = `<article class="ic mg">++<nav class="gp-crumbs" aria-label="Breadcrumb"><a href="/icons">Icons</a><span aria-hidden="true">/</span><span>Switch from another set</span></nav>++<header class="ic-hero">++ <p class="ic-eye">Switch icon sets</p>++ <h1 class="ic-h1">Paste your page. See your icons, ours.</h1>++ <p class="ic-lede">Font Awesome, Lucide, Material Symbols or MUI: paste a page’s HTML, a template or a component, and every icon it uses comes back with its OpenVibe twin and the line to use it. Nothing is stored or fetched; the text is only read.</p>++</header>++${error ? kit.notice({ kind: 'error', text: error }) : ''}++<form class="mg-form" method="post" action="/icons/migrate#results">++ <label class="mg-label" for="mg-text">Your HTML or code</label>++ <textarea id="mg-text" name="text" rows="10" maxlength="400000" spellcheck="false" placeholder="<i class="fa-solid fa-gear"></i> <i data-lucide="trash-2"></i> import { Settings } from 'lucide-react' <span class="material-symbols-outlined">home</span>">${esc(text)}</textarea>++ <div class="mg-go"><button type="submit" class="ic-btn is-go">${kit.icon('search')} Find the twins</button><span>Up to 400 KB. Font Awesome, Lucide, Material, MUI.</span></div>++</form>++${results}++<section class="ic-sec" aria-labelledby="mg-api"><div class="ic-sec-head"><h2 id="mg-api">For a build step or an agent</h2><p>The same answer as data, and any set’s name works in <code><ov-icon></code> and the drawings API.</p></div>++${kit.snippet({ code: `POST ${base}/api/v1/glyphs/migrate\nContent-Type: application/json\n\n{ "text": "<i data-lucide=\"settings\"></i>" }\n\n<ov-icon name="lucide:settings"></ov-icon> <ov-icon name="material:delete"></ov-icon>`, id: 'mg-api-code', label: 'Copy' })}</section>++</article>`;++ return layout({++ title: 'Switch from Font Awesome, Lucide or Material',++ description: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin, and the line to use it.',++ path: '/icons/migrate', active: '/icons', styles: ['css/icons.css'], scripts: [], body,++ });++}++++module.exports = { glyphPage, faAlternativePage, kitPage, migratePage, related, faOf, title };@@@ -11,6 +11,7 @@* iconsPage({ base, drafts, mine, reviewer, notice, error, draw })*/const kit = require('openvibe-shared/kit');++const { words: findWords } = require('../glyphs/aliases');const glyphs = require('openvibe-shared/glyphs');const { layout, esc } = require('../render/layout');const usage = require('../glyphs/fa-usage.json');@@@ -41,7 +42,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], rconst tile = (name) => {const aliases = fa.get(name) || [];const drawn = glyphs.META[name] && /Opus/.test(glyphs.META[name].by) ? 'Drawn for the set' : 'From the kit';-- return `<a class="ic-tile" href="/icons/${esc(name)}" data-name="${esc(name)}" data-fa="${esc(aliases.join(' '))}" data-by="${esc(drawn)}">${svg(inner(name))}<span>${esc(name)}</span></a>`;++ return `<a class="ic-tile" href="/icons/${esc(name)}" data-name="${esc(name)}" data-fa="${esc(aliases.join(' '))}" data-words="${esc(findWords(name).join(' '))}" data-by="${esc(drawn)}">${svg(inner(name))}<span>${esc(name)}</span></a>`;};const hero = `<header class="ic-hero">@@@ -104,6 +105,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r<div><h3>In place of Font Awesome</h3>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>\n<!-- your <i class="fa-solid fa-eye"> now shows the glyph -->`, id: 'ic-use-web', label: 'Copy the HTML' })}<p class="ic-more">Sizes, animations and transforms keep working: <a href="/icons/font-awesome-alternative">what keeps working</a>.</p></div><div><h3>As a font</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-eye"></i>`, id: 'ic-use-font', label: 'Copy the HTML' })}</div><div><h3>On a server</h3>${kit.snippet({ code: "const glyphs = require('openvibe-shared/glyphs');\n\nglyphs.svg('live', { size: 20 }); // a standalone SVG\nglyphs.fromFa('fa-eye'); // 'eye'", id: 'ic-use-node', label: 'Copy the code' })}</div>++ <div><h3>Switching from another set?</h3><p class="ic-more">Font Awesome, Lucide, Material or MUI: <a href="/icons/migrate">paste your page</a> and see every icon with its OpenVibe twin. Their names work in <code><ov-icon name="lucide:settings"></code> too.</p></div><div><h3>As data</h3>${kit.snippet({ code: `GET ${base}/api/v1/glyphs\nGET ${base}/api/v1/glyphs?names=heart,fa-gear\nGET ${base}/icons/<name>.svg?style=auto|line|solid\nGET ${base}/icons/<name>.mjs | .xml | /favicon.zip`, id: 'ic-use-api', label: 'Copy' })}</div></div></section>`;@@@ -228,7 +228,7 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"const map = (await t.get('/sitemap.xml')).text;assert.ok(map.includes('<loc>https://openvibe.design/icons/eye</loc>'));assert.ok(map.includes('<image:image><image:loc>https://openvibe.design/icons/eye.svg</image:loc></image:image>'));-- assert.equal((map.match(/<loc>https:\/\/openvibe\.design\/icons\/[a-z0-9-]+<\/loc>/g) || []).length, glyphs.NAMES.length + 1, 'each glyph, and the alternative');++ assert.equal((map.match(/<loc>https:\/\/openvibe\.design\/icons\/[a-z0-9-]+<\/loc>/g) || []).length, glyphs.NAMES.length + 2, 'each glyph, the alternative and the migration page');assert.ok((await t.get('/llms.txt')).text.includes('/icons/font-awesome-alternative'));});} finally {nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++// Finding a glyph by any name (plan L50 step 4): the words people search with, Lucide's, Material's and MUI's names;++// the migration page and API that read a pasted page or component and give each icon its OpenVibe twin; and any set's++// name in <ov-icon> and the drawings API.++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const glyphs = require('openvibe-shared/glyphs');++const aliases = require('../server/glyphs/aliases');++++const ORIGIN = 'https://openvibe.design';++const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });++const PASTE = `<i class="fa-solid fa-gear fa-fw"></i> <i class="fa-brands fa-github"></i> <i class="fa-solid fa-gear"></i>++<i data-lucide="trash-2"></i> <span class="material-symbols-outlined">home</span> <mat-icon>settings</mat-icon>++import { Settings, Trash2 as Bin, Wand2 } from "lucide-react";++import DeleteIcon from "@mui/icons-material/DeleteOutlined";`;++++(async () => {++ const t = await boot();++ try {++ await check('every name in the tables leads to a glyph that exists; their names resolve, unknown ones do not', async () => {++ for (const [name, g] of Object.entries(aliases.LUCIDE)) assert.ok(glyphs.path(g), `lucide ${name} → ${g}`);++ for (const [name, g] of Object.entries(aliases.MATERIAL)) assert.ok(glyphs.path(g), `material ${name} → ${g}`);++ assert.ok(Object.keys(aliases.LUCIDE).length > 150 && Object.keys(aliases.MATERIAL).length > 200, 'the common names of both sets');++ const cases = { 'lucide:Settings': 'gear', 'lucide:Trash2': 'trash', 'lucide:x': 'close', 'material:delete_outline': 'trash',++ 'mui:DeleteOutlined': 'trash', 'fa-gear': 'gear', 'fa:gear': 'gear', settings: 'gear', heart: 'heart', 'material:home': 'home' };++ for (const [n, g] of Object.entries(cases)) assert.equal(aliases.resolve(n), g, n);++ assert.equal(aliases.resolve('no-such-icon'), null);++ assert.ok(aliases.words('gear').includes('settings') && aliases.words('trash').includes('delete'), 'the words people type');++ });++++ await check('the scan reads Font Awesome, Lucide, Material and MUI, counts uses, and keeps utility classes out', async () => {++ const found = aliases.scan(PASTE);++ const by = Object.fromEntries(found.map((f) => [`${f.set}:${f.name}`, f]));++ assert.equal(by['fa:gear'].count, 2);++ assert.equal(by['fa:gear'].glyph, 'gear');++ assert.ok(!by['fa:fw'] && !by['fa:solid'], 'fa-fw and fa-solid are not icons');++ assert.equal(by['fa-brands:github'].brand, 'github', 'a brand the drop-in draws');++ assert.equal(by['lucide:trash-2'].count, 2, 'data-lucide and the import');++ assert.equal(by['lucide:settings'].glyph, 'gear');++ assert.equal(by['lucide:wand-2'].glyph, null, 'one with no twin yet');++ assert.equal(by['material:home'].glyph, 'home');++ assert.equal(by['material:settings'].glyph, 'gear');++ assert.equal(by['material:delete'].glyph, 'trash', 'MUI\'s DeleteOutlined');++ assert.equal(found[found.length - 1].glyph || found[found.length - 1].brand, null, 'the ones without a twin come last');++ assert.deepEqual(aliases.scan(''), []);++ });++++ await check('the migration page: a form, the twins, how to switch; never a cross-site post', async () => {++ const page = await t.get('/icons/migrate');++ assert.equal(page.status, 200);++ assert.ok(page.text.includes('<form class="mg-form" method="post" action="/icons/migrate#results">'));++ const res = await t.get('/icons/migrate', { method: 'POST', ...form({ text: PASTE }), origin: ORIGIN });++ assert.equal(res.status, 200);++ assert.match(res.text, /<h2 id="mg-res">\d+ of \d+ have a twin<\/h2>/);++ assert.ok(res.text.includes('<code><ov-icon name="gear"></ov-icon></code>'), 'the line to use');++ assert.ok(res.text.includes('/shared/ov-glyphs.js') && res.text.includes('/ov-icon.js'), 'how to switch, for both kinds');++ assert.ok(res.text.includes('href="/icons?draw=wand-2#draw"'), 'draw a missing one');++ assert.ok(res.text.includes('<i data-lucide="trash-2">'), 'the pasted text comes back escaped');++ assert.match(res.headers.get('cache-control'), /no-store/);++ // A signed-in person's post must come from openvibe.design (an anonymous one changes nothing and is only read).++ const ana = t.network.addUser('ana');++ assert.equal((await t.get('/icons/migrate', { method: 'POST', as: ana, ...form({ text: PASTE }), origin: 'https://evil.test' })).status, 403);++ const none = await t.get('/icons/migrate', { method: 'POST', ...form({ text: 'hello' }), origin: ORIGIN });++ assert.ok(none.text.includes('No icons found'));++ });++++ await check('the API: the same answer as data, open to any origin; any set\'s name in the drawings API', async () => {++ const pre = await t.get('/api/v1/glyphs/migrate', { method: 'OPTIONS', headers: { origin: 'https://example.com', 'access-control-request-method': 'POST' } });++ assert.equal(pre.status, 204);++ assert.equal(pre.headers.get('access-control-allow-origin'), '*');++ const r = await t.get('/api/v1/glyphs/migrate', { json: { text: PASTE }, headers: { origin: 'https://example.com' } });++ assert.equal(r.status, 200);++ const j = r.json();++ assert.equal(r.headers.get('access-control-allow-origin'), '*');++ assert.ok(j.count === 8 && j.matched === 7 && j.icons.some((f) => f.set === 'lucide' && f.name === 'trash-2' && f.glyph === 'trash'));++ assert.equal((await t.get('/api/v1/glyphs/migrate', { json: { nope: 1 } })).status, 422);++ const names = (await t.get('/api/v1/glyphs?names=lucide:settings,material:delete,settings,fa-gear')).json();++ assert.deepEqual(names.glyphs.map((g) => [g.asked, g.name]), [['lucide:settings', 'gear'], ['material:delete', 'trash'], ['settings', 'gear'], ['fa-gear', 'gear']]);++ });++++ await check('the finder searches the words; /icons, the Font Awesome page, the sitemap and llms.txt lead to the page', async () => {++ const icons = (await t.get('/icons')).text;++ assert.match(icons, /data-name="gear" data-fa="[^"]*" data-words="[^"]*settings[^"]*"/);++ assert.ok(icons.includes('href="/icons/migrate"'));++ assert.ok((await t.get('/icons/font-awesome-alternative')).text.includes('href="/icons/migrate"'));++ assert.ok((await t.get('/sitemap.xml')).text.includes('/icons/migrate'));++ assert.ok((await t.get('/llms.txt')).text.includes('/api/v1/glyphs/migrate'));++ assert.ok((await t.get('/ov-icon.js')).text.includes('lucide|material|mui|fa'), '<ov-icon> takes any set\'s name');++ });++ } finally {++ await done(t);++ }++})();
@@ -212,3 +212,25 @@
@@ -212,3 +212,25 @@
.kit-bar .ic-btn:disabled{opacity:.5;cursor:default}
.kit-bar .ic-btn:disabled{opacity:.5;cursor:default}
@media (max-width:760px){.kit-grid{grid-template-columns:repeat(auto-fill,minmax(88px,1fr))}}
@media (max-width:760px){.kit-grid{grid-template-columns:repeat(auto-fill,minmax(88px,1fr))}}
@media (prefers-reduced-motion:reduce){.kit-tile{transition:none}}
@media (prefers-reduced-motion:reduce){.kit-tile{transition:none}}
+
+/* /icons/migrate (L50 step 4): paste a page, see every icon with its twin. */
+.mg-form{display:grid;gap:10px;margin:0 0 8px}
+.mg-label{font-weight:750;font-size:14px}
+.mg-form textarea{width:100%;box-sizing:border-box;min-height:180px;padding:14px 16px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;resize:vertical}
+.mg-form textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}
+.mg-go{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px}
+.mg-go>span{color:var(--text-muted);font-size:13px}
+.mg-wrap{overflow-x:auto;border-radius:14px;border:1px solid var(--border)}
+.mg-table{width:100%;border-collapse:collapse;font-size:14px}
+.mg-table th{position:sticky;top:0;padding:10px 14px;text-align:left;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);background:var(--bg-secondary);border-bottom:1px solid var(--border)}
+.mg-table td{padding:10px 14px;border-bottom:1px solid color-mix(in srgb,var(--border) 70%,transparent);vertical-align:middle}
+.mg-table tr:last-child td{border-bottom:0}
+.mg-yours{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px}
+.mg-set{color:var(--text-muted);font-size:12px}
+.mg-n{font-variant-numeric:tabular-nums;color:var(--text-secondary)}
+.mg-ours{display:inline-flex;align-items:center;gap:8px;color:var(--text-primary);text-decoration:none;font-weight:650}
+.mg-ours:hover span{text-decoration:underline}
+.mg-ours.is-brand small{color:var(--text-muted);font-weight:400}
+.mg-none{color:var(--text-muted)}
+.mg-table tr.is-missing td{background:color-mix(in srgb,var(--bg-secondary) 50%,transparent)}
+.mg-table code{white-space:nowrap}
@@ -11,7 +11,8 @@
@@ -11,7 +11,8 @@
const tools = $('[data-ic-tools]');
const tools = $('[data-ic-tools]');
if (tools) tools.hidden = false;
if (tools) tools.hidden = false;
- // Find as you type: a glyph's name or any Font Awesome name it stands for.
+ // Find as you type: a glyph's name, any Font Awesome name it stands for, and the words and other sets' names for it
+ // (server/glyphs/aliases.js: "settings", "delete", Lucide's "trash-2", Material's "delete outline").
const find = $('[data-ic-find]');
const find = $('[data-ic-find]');
const none = $('[data-ic-none]');
const none = $('[data-ic-none]');
const drawLink = $('[data-ic-draw-link]');
const drawLink = $('[data-ic-draw-link]');
@@ -19,7 +20,8 @@
@@ -19,7 +20,8 @@
const q = find.value.trim().toLowerCase().replace(/^fa-/, '');
const q = find.value.trim().toLowerCase().replace(/^fa-/, '');
let shown = 0;
let shown = 0;
grid.querySelectorAll('.ic-tile').forEach((t) => {
grid.querySelectorAll('.ic-tile').forEach((t) => {
- const hit = !q || t.dataset.name.includes(q) || (t.dataset.fa || '').split(' ').some((f) => f.includes(q));
+ const hit = !q || t.dataset.name.includes(q) || (t.dataset.fa || '').split(' ').some((f) => f.includes(q))
+ || (q.length > 1 && (t.dataset.words || '').includes(q));
t.hidden = !hit; if (hit) shown++;
t.hidden = !hit; if (hit) shown++;
});
});
if (none) none.hidden = shown > 0;
if (none) none.hidden = shown > 0;
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
* <script src="https://openvibe.design/ov-icon.js" defer></script>
* <script src="https://openvibe.design/ov-icon.js" defer></script>
* <ov-icon name="heart"></ov-icon> follows the text: 1em, currentColor
* <ov-icon name="heart"></ov-icon> follows the text: 1em, currentColor
* <ov-icon name="mic" size="16" label="Microphone"></ov-icon> a size in pixels; a label makes it an image to screen readers
* <ov-icon name="mic" size="16" label="Microphone"></ov-icon> a size in pixels; a label makes it an image to screen readers
- * <ov-icon name="fa-gear"></ov-icon> a Font Awesome name works too
+ * <ov-icon name="fa-gear"></ov-icon> a Font Awesome name works too, and Lucide's or Material's:
+ * <ov-icon name="lucide:settings"></ov-icon> <ov-icon name="material:delete"></ov-icon> (openvibe.design/icons/migrate)
* <ov-icon name="bell" variant="solid"></ov-icon> line | solid | auto (the default)
* <ov-icon name="bell" variant="solid"></ov-icon> line | solid | auto (the default)
*
*
* Every size by itself: on auto, an icon 18 px or smaller shows its solid drawing when it has one (a container query,
* Every size by itself: on auto, an icon 18 px or smaller shows its solid drawing when it has one (a container query,
@@ -19,7 +20,7 @@
@@ -19,7 +20,7 @@
var script = document.currentScript;
var script = document.currentScript;
var BASE = 'https://openvibe.design';
var BASE = 'https://openvibe.design';
try { if (script && script.src) BASE = new URL(script.src).origin; } catch (e) { /* the default */ }
try { if (script && script.src) BASE = new URL(script.src).origin; } catch (e) { /* the default */ }
- var NAME = /^(?:fa-)?[a-z][a-z0-9-]{0,47}$/;
+ var NAME = /^(?:(?:lucide|material|mui|fa):)?[a-z][a-z0-9_-]{0,47}$/;
// ── The drawings: asked for together, kept for the page's life ──
// ── The drawings: asked for together, kept for the page's life ──
var known = Object.create(null); // name → Promise<{ line, solid } | null>
var known = Object.create(null); // name → Promise<{ line, solid } | null>
new file mode 100644
new file mode 100644
@@ -0,0 +1,236 @@
@@ -0,0 +1,236 @@
+'use strict';
+/**
+ * Finding a glyph by any name (plan L50 step 4): the words people search with, and the names other icon sets use
+ * (Lucide, Material Symbols and MUI; Font Awesome's are openvibe-shared's own glyphs.FA). One table, used by the /icons
+ * finder, <ov-icon>, /api/v1/glyphs and the migration page (/icons/migrate), so a name works the same everywhere.
+ *
+ * words(glyph) → the search words for a glyph (its synonyms, then every other set's names for it)
+ * resolve(name) → our glyph for "gear", "fa-gear", "lucide:settings", "material:settings" or "settings", or null
+ * scan(text) → every icon a page or a component asks for, by set: [{ set, name, count, glyph, brand }]
+ * LUCIDE, MATERIAL → { theirName: glyph } (only names whose glyph exists)
+ */
+const glyphs = require('openvibe-shared/glyphs');
+// Brand logos (Shared 4.72.0, Simple Icons, CC0): the drop-in draws fa-brands names too.
+let brands = null;
+try { brands = require('openvibe-shared/brands'); } catch { brands = null; }
+
+const has = (g) => !!glyphs.path(g);
+const keep = (map) => Object.freeze(Object.fromEntries(Object.entries(map).filter(([, g]) => has(g))));
+
+// ── Synonyms: the words a person types for each glyph (not its own name, nor Font Awesome's) ──
+const SYN = {
+ gear: 'settings cog preferences options config', sliders: 'adjust controls settings tune mixer', trash: 'delete remove bin garbage',
+ close: 'x cancel dismiss exit', pencil: 'edit write compose rename', plus: 'add new create', minus: 'remove subtract less',
+ search: 'find magnifier lookup', home: 'house start main', user: 'person profile account me', members: 'people users team',
+ group: 'team community crowd', chat: 'message comment talk conversation', mail: 'email envelope letter', inbox: 'mail messages tray',
+ bell: 'notification alert ring', heart: 'like love favorite', star: 'favorite rate rating', eye: 'view show visible watch see',
+ 'eye-off': 'hide hidden invisible private', lock: 'secure private password closed', key: 'password access unlock',
+ shield: 'security safe protect privacy', check: 'done ok tick yes confirm', copy: 'duplicate clone', clipboard: 'paste notes',
+ scissors: 'cut clip trim', save: 'floppy disk store keep', download: 'save get import', upload: 'send put export', share: 'send forward',
+ open: 'external new tab link out', link: 'url chain hyperlink', retry: 'refresh reload again redo repeat', undo: 'revert back',
+ history: 'past recent previous time', clock: 'time hour watch', calendar: 'date schedule event day', pin: 'location place marker',
+ map: 'directions place', globe: 'world earth internet web', camera: 'photo picture snapshot', video: 'movie record clip camera',
+ image: 'picture photo', film: 'movie video cinema reel', music: 'song audio note', mic: 'microphone voice record audio',
+ volume: 'sound speaker audio loud', mute: 'silent sound off quiet', play: 'start run go', pause: 'hold wait', stop: 'halt end',
+ live: 'broadcast stream on air', tv: 'television screen', computer: 'desktop monitor laptop pc screen', phone: 'call telephone',
+ robot: 'bot ai machine automation', brain: 'mind think ai smart', spark: 'magic ai sparkles new shine', fire: 'hot trending flame',
+ zap: 'lightning bolt power energy fast', trophy: 'award win prize cup', crown: 'king premium vip', gem: 'diamond premium jewel',
+ gift: 'present reward', coin: 'money credits currency token', dollar: 'money price usd cost', wallet: 'money payment purse',
+ card: 'credit payment', cart: 'shop basket buy', store: 'shop market', tag: 'label price', ticket: 'pass admission',
+ chart: 'graph stats analytics data', gauge: 'speed meter performance dashboard', 'trend-up': 'growth increase rising',
+ 'trend-down': 'decline decrease falling', rocket: 'launch fast startup', plane: 'flight travel send', airplane: 'flight travel',
+ car: 'drive vehicle', train: 'rail travel', walk: 'pedestrian step', route: 'path directions way', code: 'develop programming html',
+ terminal: 'console shell command cli', bug: 'error issue defect', wrench: 'tool fix repair', hammer: 'build tool',
+ toolbox: 'tools kit', box: 'package parcel', archive: 'storage box', folder: 'directory', file: 'document page',
+ book: 'read guide manual docs', 'book-open': 'read learn guide', news: 'article newspaper press', megaphone: 'announce shout marketing',
+ rss: 'feed subscribe', idea: 'lightbulb tip insight', help: 'question support faq', info: 'information about details',
+ alert: 'warning caution danger', 'alert-circle': 'error warning', ban: 'block forbid prohibited', filter: 'funnel refine',
+ sort: 'order arrange', list: 'items bullets', grid: 'tiles layout apps', menu: 'hamburger navigation bars', more: 'ellipsis options dots',
+ sun: 'light day bright', moon: 'dark night', cloud: 'weather sky', snow: 'winter cold', drop: 'water rain liquid',
+ tree: 'nature forest', leaf: 'nature eco plant', paw: 'pet animal dog cat', fish: 'sea animal', game: 'controller gamepad gaming',
+ dice: 'random chance luck', sword: 'fight battle', target: 'goal aim bullseye', flag: 'report mark', enter: 'login sign in',
+ leave: 'logout sign out exit', expand: 'fullscreen maximize enlarge', compress: 'minimize shrink', busy: 'loading wait spinner',
+ nodes: 'network connections graph', server: 'host backend', database: 'data storage sql', chip: 'cpu processor hardware',
+ drive: 'disk storage', wifi: 'wireless internet signal', palette: 'colors theme paint', text: 'type font letters',
+ signature: 'sign autograph', scale: 'balance law justice', gavel: 'law judge auction', medical: 'health doctor hospital',
+ food: 'restaurant eat meal', dish: 'meal plate food', mug: 'coffee cup tea drink', couch: 'sofa lounge', door: 'exit entrance room',
+ city: 'buildings town urban', tent: 'camping outdoors', mountain: 'hiking peak nature', umbrella: 'rain weather protect',
+ lifebuoy: 'support help rescue', smile: 'happy emoji face', laugh: 'funny lol emoji', skull: 'dead danger', ghost: 'boo spooky',
+ child: 'kid baby', hand: 'wave stop palm', handshake: 'deal agreement partner', shirt: 'clothes merch', 'thumbs-up': 'like approve good',
+ swap: 'exchange switch trade', move: 'drag arrange', grip: 'drag handle reorder', sidebar: 'panel layout', columns: 'layout split',
+ table: 'spreadsheet rows data', steps: 'checklist todo progress', task: 'todo checklist done', reply: 'respond answer back',
+ forward: 'send next', infinity: 'forever endless loop', keyboard: 'type input keys', print: 'printer paper', contrast: 'theme dark light',
+ ear: 'listen hearing audio', record: 'rec dot capture', cc: 'captions subtitles', scroll: 'document paper history',
+ focus: 'target center aim', compass: 'explore navigate direction', hourglass: 'timer wait loading', weight: 'gym fitness heavy',
+ parking: 'car park', restroom: 'toilet wc bathroom', storm: 'thunder lightning weather', wind: 'air breeze weather',
+ wave: 'audio signal ocean', water: 'sea ocean waves', seedling: 'grow plant sprout', recycle: 'reuse green eco',
+ piggy: 'savings bank money', money: 'cash bills payment', donate: 'give charity', ranking: 'leaderboard medal top',
+ 'check-double': 'read seen done all', 'user-circle': 'avatar profile account', invite: 'add person join',
+ 'user-off': 'remove person block', 'user-lock': 'private account', 'link-off': 'unlink broken', 'chat-off': 'no messages muted',
+ 'camera-off': 'no camera', 'camera-flip': 'switch camera', 'mic-off': 'muted no mic', 'phone-off': 'hang up end call',
+ 'cloud-off': 'offline', 'heart-broken': 'sad breakup', 'heart-pulse': 'health heartbeat', clapper: 'movie film action',
+ headphones: 'audio listen music', radio: 'broadcast fm', branch: 'git fork', commit: 'git version', layers: 'stack levels',
+ shapes: 'geometry objects', cubes: 'blocks 3d boxes', id: 'badge identity card', at: 'email mention', hash: 'tag hashtag number',
+ quote: 'citation saying', heading: 'title header', 'align-left': 'text paragraph', 'list-ordered': 'numbers ranked',
+ timeline: 'history events', window: 'app browser', 'window-restore': 'windows',
+};
+
+// ── Lucide's names (lucide.dev) whose glyph has another name here; identical names need no entry ──
+const LUCIDE = keep({
+ x: 'close', settings: 'gear', 'settings-2': 'sliders', 'sliders-horizontal': 'sliders', 'sliders-vertical': 'sliders', 'trash-2': 'trash',
+ 'external-link': 'open', 'square-arrow-out-up-right': 'open', loader: 'busy', 'loader-circle': 'busy', 'loader-2': 'busy',
+ 'volume-1': 'volume', 'volume-2': 'volume', 'volume-x': 'mute', 'volume-off': 'mute', users: 'members', 'users-round': 'members',
+ 'message-circle': 'chat', 'message-square': 'chat', 'messages-square': 'chat', 'message-circle-off': 'chat-off', house: 'home',
+ 'map-pin': 'pin', 'map-pinned': 'pin', pin: 'tack', 'chevron-down': 'chevron', 'rotate-ccw': 'retry', 'rotate-cw': 'retry',
+ 'refresh-cw': 'retry', 'refresh-ccw': 'retry', repeat: 'retry', 'undo-2': 'undo', 'log-in': 'enter', 'log-out': 'leave',
+ 'circle-help': 'help', 'help-circle': 'help', 'circle-alert': 'alert-circle', 'triangle-alert': 'alert', 'alert-triangle': 'alert',
+ 'circle-check': 'check-circle', 'circle-check-big': 'check-circle', 'check-circle-2': 'check-circle', 'circle-x': 'x-circle',
+ 'circle-plus': 'plus-circle', 'circle-minus': 'minus-circle', 'check-check': 'check-double', ellipsis: 'more',
+ 'more-horizontal': 'more', 'ellipsis-vertical': 'more', 'more-vertical': 'more', 'grip-vertical': 'grip', 'layout-grid': 'grid',
+ 'grid-3x3': 'grid', 'panel-left': 'sidebar', monitor: 'computer', laptop: 'computer', cpu: 'chip', 'hard-drive': 'drive',
+ 'share-2': 'nodes', network: 'nodes', workflow: 'nodes', 'link-2': 'link', unlink: 'link-off', 'link-2-off': 'link-off',
+ 'shield-check': 'shield', 'user-round': 'user', 'circle-user': 'user-circle', 'circle-user-round': 'user-circle', 'user-x': 'user-off',
+ 'user-plus': 'invite', 'calendar-days': 'calendar', 'alarm-clock': 'clock', 'switch-camera': 'camera-flip', images: 'image',
+ clapperboard: 'clapper', 'circle-stop': 'stop', 'skip-forward': 'skip', 'heart-crack': 'heart-broken', 'file-text': 'file',
+ pen: 'pencil', 'pen-line': 'pencil', 'square-pen': 'pencil', edit: 'pencil', 'cloud-lightning': 'storm', snowflake: 'snow',
+ droplet: 'drop', droplets: 'water', waves: 'wave', flame: 'fire', sparkles: 'spark', sparkle: 'spark', 'wand-sparkles': 'spark',
+ award: 'trophy', medal: 'ranking', crosshair: 'focus', earth: 'globe', languages: 'language', 'code-xml': 'code',
+ 'square-terminal': 'terminal', 'git-branch': 'branch', 'git-commit-horizontal': 'commit', 'git-commit': 'commit', package: 'box',
+ boxes: 'cubes', type: 'text', 'at-sign': 'at', funnel: 'filter', 'arrow-up-down': 'sort', 'arrow-down-wide-narrow': 'sort-asc',
+ printer: 'print', tags: 'tag', 'shopping-cart': 'cart', 'shopping-bag': 'cart', 'credit-card': 'card', coins: 'coin',
+ 'dollar-sign': 'dollar', 'piggy-bank': 'piggy', banknote: 'money', 'trending-up': 'trend-up', 'trending-down': 'trend-down',
+ 'chart-line': 'chart', 'line-chart': 'chart', 'bar-chart': 'chart', 'bar-chart-2': 'chart', 'chart-column': 'chart',
+ 'chart-no-axes-column': 'chart', activity: 'heart-pulse', 'train-front': 'train', footprints: 'walk', trees: 'tree', 'tree-pine': 'tree',
+ 'tree-deciduous': 'tree', sprout: 'seedling', 'paw-print': 'paw', bot: 'robot', 'bot-message-square': 'robot', baby: 'child',
+ coffee: 'mug', 'cup-soda': 'mug', utensils: 'food', 'utensils-crossed': 'food', 'flask-conical': 'flask', stethoscope: 'medical',
+ sofa: 'couch', 'door-open': 'door', 'door-closed': 'door', 'building-2': 'city', building: 'city', 'life-buoy': 'lifebuoy',
+ swords: 'sword', dices: 'dice', 'dice-5': 'dice', 'gamepad-2': 'game', gamepad: 'game', newspaper: 'news', 'id-card': 'id',
+ 'key-round': 'key', shrink: 'compress', maximize: 'expand', minimize: 'compress', 'maximize-2': 'expand', 'minimize-2': 'compress',
+ 'arrow-left-right': 'arrows-h', 'arrow-right-left': 'swap', 'columns-2': 'columns', 'table-2': 'table', 'list-checks': 'steps',
+ 'list-todo': 'task', 'corner-up-left': 'reply', 'circle-parking': 'parking', 'square-parking': 'parking', dumbbell: 'weight',
+ 'tv-2': 'tv', 'tv-minimal': 'tv', 'circle-dot': 'record', 'mail-open': 'mail', 'bell-ring': 'bell', 'phone-off': 'phone-off',
+ 'arrow-left': 'back', 'chevron-left': 'back', 'move-right': 'arrow-right', hourglass: 'hourglass', timer: 'hourglass',
+ 'ticket-check': 'ticket', 'radio-tower': 'live', podcast: 'live', 'ear-off': 'ear', 'scale-3d': 'cubes', 'shapes': 'shapes',
+});
+
+// ── Material Symbols / Material Icons names (Google Fonts) and MUI's, snake_case; identical names need no entry ──
+const MATERIAL = keep({
+ settings: 'gear', tune: 'sliders', more_vert: 'more', more_horiz: 'more', delete: 'trash', delete_outline: 'trash', edit: 'pencil',
+ add: 'plus', remove: 'minus', done: 'check', done_all: 'check-double', check_circle: 'check-circle', cancel: 'x-circle', error: 'alert-circle',
+ warning: 'alert', help_outline: 'help', favorite: 'heart', favorite_border: 'heart', star_border: 'star', thumb_up: 'thumbs-up',
+ visibility: 'eye', visibility_off: 'eye-off', person: 'user', people: 'members', groups: 'group', person_add: 'invite',
+ account_circle: 'user-circle', email: 'mail', chat_bubble: 'chat', forum: 'chat', notifications: 'bell', content_copy: 'copy',
+ content_paste: 'clipboard', content_cut: 'scissors', file_download: 'download', file_upload: 'upload', description: 'file',
+ insert_drive_file: 'file', photo_camera: 'camera', videocam: 'video', volume_up: 'volume', volume_off: 'mute', play_arrow: 'play',
+ skip_next: 'skip', fast_rewind: 'rewind', music_note: 'music', laptop: 'computer', desktop_windows: 'computer', call: 'phone',
+ call_end: 'phone-off', schedule: 'clock', access_time: 'clock', calendar_today: 'calendar', event: 'calendar', today: 'calendar',
+ hourglass_empty: 'hourglass', place: 'pin', location_on: 'pin', explore: 'compass', public: 'globe', translate: 'language',
+ bug_report: 'bug', build: 'wrench', construction: 'hammer', handyman: 'toolbox', inventory_2: 'box', text_fields: 'text',
+ title: 'heading', format_quote: 'quote', format_align_left: 'align-left', tag: 'hash', alternate_email: 'at', filter_list: 'filter',
+ filter_alt: 'filter', local_offer: 'tag', sell: 'tag', confirmation_number: 'ticket', shopping_cart: 'cart', storefront: 'store',
+ account_balance_wallet: 'wallet', credit_card: 'card', payments: 'money', attach_money: 'dollar', savings: 'piggy',
+ trending_up: 'trend-up', trending_down: 'trend-down', show_chart: 'chart', bar_chart: 'chart', insights: 'chart', speed: 'gauge',
+ rocket_launch: 'rocket', flight: 'plane', airplanemode_active: 'airplane', directions_car: 'car', directions_walk: 'walk',
+ terrain: 'mountain', park: 'tree', eco: 'leaf', pets: 'paw', smart_toy: 'robot', psychology: 'brain', sentiment_satisfied: 'smile',
+ mood: 'smile', child_care: 'child', pan_tool: 'hand', checkroom: 'shirt', local_cafe: 'mug', coffee: 'mug', restaurant: 'food',
+ science: 'flask', medical_services: 'medical', local_hospital: 'medical', weekend: 'couch', door_front: 'door', meeting_room: 'door',
+ apartment: 'city', location_city: 'city', beach_access: 'umbrella', support: 'lifebuoy', balance: 'scale', casino: 'dice',
+ sports_esports: 'game', videogame_asset: 'game', all_inclusive: 'infinity', rss_feed: 'rss', newspaper: 'news', article: 'news',
+ feed: 'news', campaign: 'megaphone', hearing: 'ear', badge: 'id', vpn_key: 'key', hub: 'nodes', account_tree: 'nodes',
+ open_in_new: 'open', launch: 'open', refresh: 'retry', autorenew: 'retry', replay: 'retry', login: 'enter', logout: 'leave',
+ fullscreen: 'expand', fullscreen_exit: 'compress', open_with: 'move', swap_horiz: 'swap', view_column: 'columns',
+ table_chart: 'table', table_rows: 'table', checklist: 'steps', task_alt: 'task', arrow_back: 'back', arrow_forward: 'arrow-right',
+ arrow_upward: 'arrow-up', arrow_downward: 'arrow-down', expand_more: 'chevron', keyboard_arrow_down: 'chevron',
+ local_fire_department: 'fire', whatshot: 'fire', bolt: 'zap', flash_on: 'zap', auto_awesome: 'spark', card_giftcard: 'gift',
+ redeem: 'gift', diamond: 'gem', workspace_premium: 'crown', emoji_events: 'trophy', military_tech: 'ranking', leaderboard: 'ranking',
+ gps_fixed: 'target', my_location: 'target', center_focus_strong: 'focus', wb_sunny: 'sun', light_mode: 'sun', dark_mode: 'moon',
+ bedtime: 'moon', ac_unit: 'snow', air: 'wind', water_drop: 'drop', waves: 'wave', thunderstorm: 'storm', dashboard: 'grid',
+ apps: 'grid', grid_view: 'grid', view_sidebar: 'sidebar', drag_indicator: 'grip', dns: 'server', storage: 'database',
+ memory: 'chip', sd_storage: 'drive', fiber_manual_record: 'record', radio_button_checked: 'record', live_tv: 'live',
+ closed_caption: 'cc', subtitles: 'cc', monitor_weight: 'weight', fitness_center: 'weight', local_parking: 'parking',
+ wc: 'restroom', security: 'shield', verified_user: 'shield', privacy_tip: 'shield', draw: 'signature', history_edu: 'scroll',
+ receipt_long: 'scroll', toc: 'list', format_list_numbered: 'list-ordered', format_list_bulleted: 'list', notes: 'text',
+ data_object: 'code', integration_instructions: 'code', call_split: 'branch', category: 'shapes', interests: 'shapes',
+ menu_book: 'book-open', auto_stories: 'book-open', import_contacts: 'book-open', library_books: 'book', lightbulb: 'idea',
+ tips_and_updates: 'idea', mic_none: 'mic', mic_off: 'mic-off', videocam_off: 'camera-off', cloud_off: 'cloud-off', link_off: 'link-off',
+ lock_outline: 'lock', send: 'send', share: 'share', person_off: 'user-off', how_to_reg: 'user-check', manage_accounts: 'user-pen',
+ playlist_add_check: 'steps', sort: 'sort', print: 'print', inbox: 'inbox', terminal: 'terminal', code: 'code', layers: 'layers',
+ palette: 'palette', keyboard: 'keyboard', contrast: 'contrast', timeline: 'timeline', handshake: 'handshake', mail: 'mail',
+});
+
+// Their names for each of our glyphs, and the search words, built once.
+const THEIRS = new Map();
+for (const [from, map] of [['lucide', LUCIDE], ['material', MATERIAL]]) {
+ for (const [name, g] of Object.entries(map)) {
+ if (!THEIRS.has(g)) THEIRS.set(g, []);
+ THEIRS.get(g).push(from === 'material' ? name.replace(/_/g, ' ') : name);
+ }
+}
+function words(g) {
+ const out = new Set(String(SYN[g] || '').split(/\s+/).filter(Boolean));
+ for (const n of THEIRS.get(g) || []) out.add(n);
+ return [...out];
+}
+
+const kebab = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Za-z])(\d)/g, '$1-$2').toLowerCase();
+const snake = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().replace(/_(outlined|outline|rounded|sharp|two_?tone|filled)$/, '');
+
+/** Our glyph for a name in any set, or null. "lucide:x", "material:settings", "fa-gear", "gear", or a bare other name. */
+function resolve(raw) {
+ const name = String(raw || '').trim();
+ const m = /^(lucide|material|mui|fa):(.+)$/i.exec(name);
+ if (m) {
+ const set = m[1].toLowerCase(), n = m[2];
+ if (set === 'lucide') { const k = kebab(n); return LUCIDE[k] || (has(k) ? k : null); }
+ if (set === 'fa') return glyphs.fromFa(n);
+ const s = snake(n); return MATERIAL[s] || (has(s.replace(/_/g, '-')) ? s.replace(/_/g, '-') : null);
+ }
+ const low = name.toLowerCase();
+ if (has(low)) return low;
+ return glyphs.fromFa(low) || LUCIDE[low] || MATERIAL[low.replace(/-/g, '_')] || null;
+}
+
+// Font Awesome's own utility classes: not icon names.
+const FA_UTIL = /^(solid|regular|light|thin|duotone|brands|sharp|sharp-solid|fw|lg|sm|xs|xl|2xs|2xl|[1-9]x|10x|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|fade|bounce|flip|shake|border|pull-left|pull-right|pull-start|pull-end|li|ul|stack|stack-1x|stack-2x|inverse|rotate-\d+|rotate-by|flip-horizontal|flip-vertical|flip-both|layers|layers-text|layers-counter|layers-top-right|layers-bottom-left|swap-opacity|width-auto)$/;
+
+/**
+ * Every icon a page or a component asks for: Font Awesome classes, Lucide (data-lucide, the lucide-react/-vue/-svelte
+ * imports, <ov-icon>-style names), Material (the ligature spans, <mat-icon>, @mui/icons-material imports). Counted,
+ * each with our glyph or null. Text only: nothing is fetched or run.
+ */
+function scan(text) {
+ const src = String(text || '').slice(0, 400000);
+ const found = new Map();
+ const add = (set, name, glyph) => {
+ const key = `${set}:${name}`;
+ const was = found.get(key);
+ if (was) was.count++;
+ else found.set(key, { set, name, count: 1, glyph: glyph || null, brand: set === 'fa-brands' && brands && brands.fromFa ? brands.fromFa(name) || null : null });
+ };
+ for (const m of src.matchAll(/class\s*=\s*["']([^"']*\bfa[srlbdt]?\b[^"']*|[^"']*\bfa-[a-z0-9-]+[^"']*)["']/gi)) {
+ const classes = m[1].split(/\s+/);
+ const brand = classes.includes('fa-brands') || classes.includes('fab');
+ for (const c of classes) {
+ const n = /^fa-([a-z0-9-]+)$/.exec(c);
+ if (!n || FA_UTIL.test(n[1])) continue;
+ add(brand ? 'fa-brands' : 'fa', n[1], brand ? null : glyphs.fromFa(n[1]));
+ }
+ }
+ for (const m of src.matchAll(/data-lucide\s*=\s*["']([a-z0-9-]+)["']/gi)) add('lucide', m[1], resolve(`lucide:${m[1]}`));
+ for (const m of src.matchAll(/import\s*\{([^}]{1,2000})\}\s*from\s*["'](?:lucide-react|lucide-vue-next|lucide-svelte|lucide-solid|lucide-preact|@lucide\/[a-z-]+)["']/g)) {
+ for (const part of m[1].split(',')) {
+ const n = part.trim().split(/\s+as\s+/)[0].replace(/Icon$/, '');
+ if (/^[A-Z][A-Za-z0-9]*$/.test(n)) add('lucide', kebab(n), resolve(`lucide:${n}`));
+ }
+ }
+ for (const m of src.matchAll(/<(?:span|i)[^>]*class\s*=\s*["'][^"']*material-(?:icons|symbols)[a-z-]*[^"']*["'][^>]*>\s*([a-z0-9_]{2,40})\s*</gi)) add('material', m[1], resolve(`material:${m[1]}`));
+ for (const m of src.matchAll(/<mat-icon[^>]*>\s*([a-z0-9_]{2,40})\s*<\/mat-icon>/gi)) add('material', m[1], resolve(`material:${m[1]}`));
+ for (const m of src.matchAll(/(?:import\s+([A-Z][A-Za-z0-9]*)\s+from\s*["']@mui\/icons-material\/([A-Za-z0-9]+)["'])|(?:import\s*\{([^}]{1,2000})\}\s*from\s*["']@mui\/icons-material["'])/g)) {
+ const names = m[2] ? [m[2]] : m[3].split(',').map((p) => p.trim().split(/\s+as\s+/)[0]);
+ for (const n of names) if (/^[A-Z][A-Za-z0-9]*$/.test(n)) add('material', snake(n), resolve(`material:${n}`));
+ }
+ const order = { fa: 0, lucide: 1, material: 2, 'fa-brands': 3 };
+ return [...found.values()].sort((a, b) => (a.glyph || a.brand ? 0 : 1) - (b.glyph || b.brand ? 0 : 1) || order[a.set] - order[b.set] || b.count - a.count || (a.name < b.name ? -1 : 1));
+}
+
+module.exports = { words, resolve, scan, LUCIDE, MATERIAL };
@@ -37,6 +37,7 @@ function publicPages(themes = []) {
@@ -37,6 +37,7 @@ function publicPages(themes = []) {
{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
+ { path: '/icons/migrate', changefreq: 'monthly', priority: 0.5, title: 'Switch from Font Awesome, Lucide or Material', text: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
];
];
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
@@ -83,7 +84,7 @@ function createDiscoveryRoutes(ctx) {
@@ -83,7 +84,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
- { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b for just those). All open, no sign-in, CORS *' },
+ { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *' },
{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs' },
{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs' },
{ title: 'The OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },
{ title: 'The OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
* GET /icons/pack/<file> the fonts (WOFF2, TTF, OTF), the sprite, the CSS, the code points, and
* GET /icons/pack/<file> the fonts (WOFF2, TTF, OTF), the sprite, the CSS, the code points, and
* ov-glyphs-svg.zip with every glyph
* ov-glyphs-svg.zip with every glyph
* GET /icons/font-awesome-alternative how to switch, and what keeps working
* GET /icons/font-awesome-alternative how to switch, and what keeps working
+ * GET /icons/migrate, POST /icons/migrate paste a page or code: every Font Awesome, Lucide, Material or MUI icon in
+ * it with its OpenVibe twin (L50 step 4); POST /api/v1/glyphs/migrate as data
* GET /icons/kit, POST /icons/kit pick glyphs, get a font with only those (WOFF2, TTF, CSS, code points) as a ZIP
* GET /icons/kit, POST /icons/kit pick glyphs, get a font with only those (WOFF2, TTF, CSS, code points) as a ZIP
* GET /icons/kit.zip?names=&name=&fa= the same kit from a link
* GET /icons/kit.zip?names=&name=&fa= the same kit from a link
* POST /icons/draft signed in: OpenVibe.AI drafts candidates (same-origin form)
* POST /icons/draft signed in: OpenVibe.AI drafts candidates (same-origin form)
@@ -27,7 +29,8 @@ const { asyncRouter } = require('./router');
@@ -27,7 +29,8 @@ const { asyncRouter } = require('./router');
const { send, baseUrl } = require('../render/layout');
const { send, baseUrl } = require('../render/layout');
const { writeAllowed } = require('./same-origin');
const { writeAllowed } = require('./same-origin');
const { iconsPage, glyphsJson } = require('../views/icons');
const { iconsPage, glyphsJson } = require('../views/icons');
-const { glyphPage, faAlternativePage, kitPage } = require('../views/icon-page');
+const { glyphPage, faAlternativePage, kitPage, migratePage } = require('../views/icon-page');
+const aliases = require('../glyphs/aliases');
const kits = require('../glyphs/kits');
const kits = require('../glyphs/kits');
const ex = require('../glyphs/export');
const ex = require('../glyphs/export');
const { createGlyphDrafts } = require('../glyphs/drafts');
const { createGlyphDrafts } = require('../glyphs/drafts');
@@ -64,6 +67,24 @@ function createIconRoutes(ctx) {
@@ -64,6 +67,24 @@ function createIconRoutes(ctx) {
r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));
r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));
+ // Switching from another set (L50 step 4). The text is only read, never stored, fetched from or run.
+ const bigForm = express.urlencoded({ extended: false, limit: '450kb' });
+ r.get('/icons/migrate', (req, res) => send(res, 200, migratePage({ base: baseUrl() }), { viewer: req.viewer }));
+ r.post('/icons/migrate', bigForm, (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ const text = String((req.body || {}).text || '').slice(0, 400000);
+ send(res, 200, migratePage({ base: baseUrl(), text, found: aliases.scan(text) }), { viewer: req.viewer, cacheControl: 'private, no-store' });
+ });
+ r.options('/api/v1/glyphs/migrate', (_req, res) => res.set(OPEN).set('Access-Control-Allow-Methods', 'POST, OPTIONS')
+ .set('Access-Control-Allow-Headers', 'Content-Type').set('Access-Control-Max-Age', '86400').status(204).end());
+ r.post('/api/v1/glyphs/migrate', (req, res) => {
+ res.set(OPEN).set('Cache-Control', 'no-store');
+ const text = req.body && typeof req.body.text === 'string' ? req.body.text : null;
+ if (text == null) return problem(res, req, 422, 'design.invalid', 'Send { "text": "…" }: a page, a template or code, up to 64 KB.');
+ const found = aliases.scan(text);
+ res.json({ count: found.length, matched: found.filter((f) => f.glyph || f.brand).length, icons: found });
+ });
+
// Kits: a font with only the glyphs a site uses (L32 I5). The form posts the picks; the link form is for a build step.
// Kits: a font with only the glyphs a site uses (L32 I5). The form posts the picks; the link form is for a build step.
const sendKit = async (req, res, choice) => {
const sendKit = async (req, res, choice) => {
let parsed;
let parsed;
@@ -146,9 +167,10 @@ function createIconRoutes(ctx) {
@@ -146,9 +167,10 @@ function createIconRoutes(ctx) {
if (req.query.names == null) return res.json(glyphsJson());
if (req.query.names == null) return res.json(glyphsJson());
// Just the names asked for (<ov-icon> asks for a page's in one go): a glyph or a Font Awesome name, each answered
// Just the names asked for (<ov-icon> asks for a page's in one go): a glyph or a Font Awesome name, each answered
// under the name it was asked by; an unknown one is left out.
// under the name it was asked by; an unknown one is left out.
- const asked = [...new Set(String(req.query.names).split(',').map((n) => n.trim().toLowerCase()).filter((n) => /^[a-z][a-z0-9-]{0,47}$/.test(n)))].slice(0, 120);
+ // Any set's name works (L50 step 4): "gear", "fa-gear", "lucide:settings", "material:delete", or a bare "settings".
+ const asked = [...new Set(String(req.query.names).split(',').map((n) => n.trim().toLowerCase()).filter((n) => /^(?:(?:lucide|material|mui|fa):)?[a-z][a-z0-9_-]{0,47}$/.test(n)))].slice(0, 120);
const out = asked.map((a) => {
const out = asked.map((a) => {
- const name = glyphs.path(a) ? a : glyphs.fromFa(a);
+ const name = aliases.resolve(a);
return name ? { asked: a, name, svg: glyphs.path(name), solid: glyphs.solid(name) || '' } : null;
return name ? { asked: a, name, svg: glyphs.path(name), solid: glyphs.solid(name) || '' } : null;
}).filter(Boolean);
}).filter(Boolean);
res.json({ count: out.length, glyphs: out });
res.json({ count: out.length, glyphs: out });
@@ -202,7 +202,7 @@ function faAlternativePage({ base = 'https://openvibe.design' } = {}) {
@@ -202,7 +202,7 @@ function faAlternativePage({ base = 'https://openvibe.design' } = {}) {
<div class="ic-sec-head"><h2 id="fa-switch">Switch in a minute</h2><p>${Object.keys(glyphs.FA).length} Font Awesome names map to ${glyphs.NAMES.length} glyphs today. A name we have not drawn stays Font Awesome’s, so nothing goes blank.</p></div>
<div class="ic-sec-head"><h2 id="fa-switch">Switch in a minute</h2><p>${Object.keys(glyphs.FA).length} Font Awesome names map to ${glyphs.NAMES.length} glyphs today. A name we have not drawn stays Font Awesome’s, so nothing goes blank.</p></div>
<ol class="fa-steps">
<ol class="fa-steps">
<li><b>Add the drop-in.</b>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>`, id: 'fa-step1', label: 'Copy' })}</li>
<li><b>Add the drop-in.</b>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>`, id: 'fa-step1', label: 'Copy' })}</li>
- <li><b>Check what your pages use</b> against <a href="/icons#ic-left">what is still Font Awesome</a>. Missing one? <a href="/icons#draw">Draw it</a>.</li>
+ <li><b>Check what your pages use:</b> <a href="/icons/migrate">paste a page</a> and see each icon’s twin, or look at <a href="/icons#ic-left">what is still Font Awesome</a>. Missing one? <a href="/icons#draw">Draw it</a>.</li>
<li><b>Drop Font Awesome’s stylesheet</b> once every name is covered: one less stylesheet and font on every first visit. Or use the font with Font Awesome’s names on it:${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-fa.css">`, id: 'fa-step3', label: 'Copy' })}</li>
<li><b>Drop Font Awesome’s stylesheet</b> once every name is covered: one less stylesheet and font on every first visit. Or use the font with Font Awesome’s names on it:${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-fa.css">`, id: 'fa-step3', label: 'Copy' })}</li>
</ol>
</ol>
</section>
</section>
@@ -252,4 +252,58 @@ ${kit.snippet({ code: 'https://openvibe.design/icons/kit.zip?names=eye,live,hear
@@ -252,4 +252,58 @@ ${kit.snippet({ code: 'https://openvibe.design/icons/kit.zip?names=eye,live,hear
});
});
}
}
-module.exports = { glyphPage, faAlternativePage, kitPage, related, faOf, title };
+/**
+ * /icons/migrate (plan L50 step 4): paste a page, a template or a component, and see every icon it asks for with its
+ * OpenVibe twin: Font Awesome, Lucide (data-lucide, lucide-react/vue/svelte imports), Material Symbols and MUI. A plain
+ * form; nothing is fetched or run, the text is only read (server/glyphs/aliases.js scan) and never kept.
+ */
+function migratePage({ base = 'https://openvibe.design', text = '', found = null, error = '' } = {}) {
+ const SETS = { fa: 'Font Awesome', 'fa-brands': 'Font Awesome brand', lucide: 'Lucide', material: 'Material' };
+ const theirs = (f) => (f.set === 'fa' || f.set === 'fa-brands' ? `fa-${f.name}` : f.set === 'material' ? f.name : f.name);
+ let results = '';
+ if (found) {
+ const matched = found.filter((f) => f.glyph || f.brand);
+ const uses = found.reduce((a, f) => a + f.count, 0), usesOk = matched.reduce((a, f) => a + f.count, 0);
+ const sets = [...new Set(found.map((f) => f.set))];
+ const how = [
+ sets.includes('fa') || sets.includes('fa-brands') ? `<li><b>Font Awesome: keep your markup.</b> Add the drop-in, and every name with a twin shows ours, brands included.${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>`, id: 'mg-fa', label: 'Copy' })}</li>` : '',
+ sets.includes('lucide') || sets.includes('material') ? `<li><b>Lucide and Material: one tag each.</b> Add the web component, then swap each icon for the <code><ov-icon></code> in the table (or keep the old name with <code>lucide:</code> or <code>material:</code> in front).${kit.snippet({ code: `<script src="${base}/ov-icon.js" defer></script>`, id: 'mg-wc', label: 'Copy' })}</li>` : '',
+ ].filter(Boolean).join('');
+ const row = (f) => {
+ const ours = f.glyph ? `<a class="mg-ours" href="/icons/${esc(f.glyph)}">${glyph(f.glyph, 22)}<span>${esc(f.glyph)}</span></a>`
+ : f.brand ? `<span class="mg-ours is-brand">${esc(f.brand)} <small>the brand’s own logo</small></span>`
+ : `<span class="mg-none">None yet. <a href="/icons?draw=${esc(String(f.name).replace(/_/g, '-').slice(0, 31))}#draw">Draw it</a></span>`;
+ const use = f.glyph ? `<code><ov-icon name="${esc(f.glyph)}"></ov-icon></code>` : f.brand ? '<code>the drop-in draws it</code>' : '';
+ return `<tr${f.glyph || f.brand ? '' : ' class="is-missing"'}><td><span class="mg-yours"><span class="mg-set">${esc(SETS[f.set] || f.set)}</span><code>${esc(theirs(f))}</code></span></td><td class="mg-n">${f.count}</td><td>${ours}</td><td>${use}</td></tr>`;
+ };
+ results = found.length ? `<section class="ic-sec" id="results" aria-labelledby="mg-res">
+ <div class="ic-sec-head"><h2 id="mg-res">${matched.length} of ${found.length} have a twin</h2><p>${usesOk} of the ${uses} times your text asks for an icon would show an OpenVibe one. The rest stay as they are until someone draws them.</p></div>
+ ${how ? `<ol class="fa-steps">${how}</ol>` : ''}
+ <div class="mg-wrap"><table class="mg-table"><thead><tr><th scope="col">Yours</th><th scope="col">Uses</th><th scope="col">OpenVibe</th><th scope="col">Use</th></tr></thead><tbody>${found.map(row).join('')}</tbody></table></div>
+</section>` : `<section class="ic-sec" id="results" aria-labelledby="mg-res"><div class="ic-sec-head"><h2 id="mg-res">No icons found</h2><p>Nothing in that text looks like a Font Awesome class, a Lucide icon or a Material one. Paste the HTML of a page, a template, or a component’s imports.</p></div></section>`;
+ }
+ const body = `<article class="ic mg">
+<nav class="gp-crumbs" aria-label="Breadcrumb"><a href="/icons">Icons</a><span aria-hidden="true">/</span><span>Switch from another set</span></nav>
+<header class="ic-hero">
+ <p class="ic-eye">Switch icon sets</p>
+ <h1 class="ic-h1">Paste your page. See your icons, ours.</h1>
+ <p class="ic-lede">Font Awesome, Lucide, Material Symbols or MUI: paste a page’s HTML, a template or a component, and every icon it uses comes back with its OpenVibe twin and the line to use it. Nothing is stored or fetched; the text is only read.</p>
+</header>
+${error ? kit.notice({ kind: 'error', text: error }) : ''}
+<form class="mg-form" method="post" action="/icons/migrate#results">
+ <label class="mg-label" for="mg-text">Your HTML or code</label>
+ <textarea id="mg-text" name="text" rows="10" maxlength="400000" spellcheck="false" placeholder="<i class="fa-solid fa-gear"></i> <i data-lucide="trash-2"></i> import { Settings } from 'lucide-react' <span class="material-symbols-outlined">home</span>">${esc(text)}</textarea>
+ <div class="mg-go"><button type="submit" class="ic-btn is-go">${kit.icon('search')} Find the twins</button><span>Up to 400 KB. Font Awesome, Lucide, Material, MUI.</span></div>
+</form>
+${results}
+<section class="ic-sec" aria-labelledby="mg-api"><div class="ic-sec-head"><h2 id="mg-api">For a build step or an agent</h2><p>The same answer as data, and any set’s name works in <code><ov-icon></code> and the drawings API.</p></div>
+${kit.snippet({ code: `POST ${base}/api/v1/glyphs/migrate\nContent-Type: application/json\n\n{ "text": "<i data-lucide=\"settings\"></i>" }\n\n<ov-icon name="lucide:settings"></ov-icon> <ov-icon name="material:delete"></ov-icon>`, id: 'mg-api-code', label: 'Copy' })}</section>
+</article>`;
+ return layout({
+ title: 'Switch from Font Awesome, Lucide or Material',
+ description: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin, and the line to use it.',
+ path: '/icons/migrate', active: '/icons', styles: ['css/icons.css'], scripts: [], body,
+ });
+}
+
+module.exports = { glyphPage, faAlternativePage, kitPage, migratePage, related, faOf, title };
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
* iconsPage({ base, drafts, mine, reviewer, notice, error, draw })
* iconsPage({ base, drafts, mine, reviewer, notice, error, draw })
*/
*/
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const { words: findWords } = require('../glyphs/aliases');
const glyphs = require('openvibe-shared/glyphs');
const glyphs = require('openvibe-shared/glyphs');
const { layout, esc } = require('../render/layout');
const { layout, esc } = require('../render/layout');
const usage = require('../glyphs/fa-usage.json');
const usage = require('../glyphs/fa-usage.json');
@@ -41,7 +42,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
@@ -41,7 +42,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
const tile = (name) => {
const tile = (name) => {
const aliases = fa.get(name) || [];
const aliases = fa.get(name) || [];
const drawn = glyphs.META[name] && /Opus/.test(glyphs.META[name].by) ? 'Drawn for the set' : 'From the kit';
const drawn = glyphs.META[name] && /Opus/.test(glyphs.META[name].by) ? 'Drawn for the set' : 'From the kit';
- return `<a class="ic-tile" href="/icons/${esc(name)}" data-name="${esc(name)}" data-fa="${esc(aliases.join(' '))}" data-by="${esc(drawn)}">${svg(inner(name))}<span>${esc(name)}</span></a>`;
+ return `<a class="ic-tile" href="/icons/${esc(name)}" data-name="${esc(name)}" data-fa="${esc(aliases.join(' '))}" data-words="${esc(findWords(name).join(' '))}" data-by="${esc(drawn)}">${svg(inner(name))}<span>${esc(name)}</span></a>`;
};
};
const hero = `<header class="ic-hero">
const hero = `<header class="ic-hero">
@@ -104,6 +105,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
@@ -104,6 +105,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
<div><h3>In place of Font Awesome</h3>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>\n<!-- your <i class="fa-solid fa-eye"> now shows the glyph -->`, id: 'ic-use-web', label: 'Copy the HTML' })}<p class="ic-more">Sizes, animations and transforms keep working: <a href="/icons/font-awesome-alternative">what keeps working</a>.</p></div>
<div><h3>In place of Font Awesome</h3>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>\n<!-- your <i class="fa-solid fa-eye"> now shows the glyph -->`, id: 'ic-use-web', label: 'Copy the HTML' })}<p class="ic-more">Sizes, animations and transforms keep working: <a href="/icons/font-awesome-alternative">what keeps working</a>.</p></div>
<div><h3>As a font</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-eye"></i>`, id: 'ic-use-font', label: 'Copy the HTML' })}</div>
<div><h3>As a font</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-eye"></i>`, id: 'ic-use-font', label: 'Copy the HTML' })}</div>
<div><h3>On a server</h3>${kit.snippet({ code: "const glyphs = require('openvibe-shared/glyphs');\n\nglyphs.svg('live', { size: 20 }); // a standalone SVG\nglyphs.fromFa('fa-eye'); // 'eye'", id: 'ic-use-node', label: 'Copy the code' })}</div>
<div><h3>On a server</h3>${kit.snippet({ code: "const glyphs = require('openvibe-shared/glyphs');\n\nglyphs.svg('live', { size: 20 }); // a standalone SVG\nglyphs.fromFa('fa-eye'); // 'eye'", id: 'ic-use-node', label: 'Copy the code' })}</div>
+ <div><h3>Switching from another set?</h3><p class="ic-more">Font Awesome, Lucide, Material or MUI: <a href="/icons/migrate">paste your page</a> and see every icon with its OpenVibe twin. Their names work in <code><ov-icon name="lucide:settings"></code> too.</p></div>
<div><h3>As data</h3>${kit.snippet({ code: `GET ${base}/api/v1/glyphs\nGET ${base}/api/v1/glyphs?names=heart,fa-gear\nGET ${base}/icons/<name>.svg?style=auto|line|solid\nGET ${base}/icons/<name>.mjs | .xml | /favicon.zip`, id: 'ic-use-api', label: 'Copy' })}</div>
<div><h3>As data</h3>${kit.snippet({ code: `GET ${base}/api/v1/glyphs\nGET ${base}/api/v1/glyphs?names=heart,fa-gear\nGET ${base}/icons/<name>.svg?style=auto|line|solid\nGET ${base}/icons/<name>.mjs | .xml | /favicon.zip`, id: 'ic-use-api', label: 'Copy' })}</div>
</div>
</div>
</section>`;
</section>`;
@@ -228,7 +228,7 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
@@ -228,7 +228,7 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
const map = (await t.get('/sitemap.xml')).text;
const map = (await t.get('/sitemap.xml')).text;
assert.ok(map.includes('<loc>https://openvibe.design/icons/eye</loc>'));
assert.ok(map.includes('<loc>https://openvibe.design/icons/eye</loc>'));
assert.ok(map.includes('<image:image><image:loc>https://openvibe.design/icons/eye.svg</image:loc></image:image>'));
assert.ok(map.includes('<image:image><image:loc>https://openvibe.design/icons/eye.svg</image:loc></image:image>'));
- assert.equal((map.match(/<loc>https:\/\/openvibe\.design\/icons\/[a-z0-9-]+<\/loc>/g) || []).length, glyphs.NAMES.length + 1, 'each glyph, and the alternative');
+ assert.equal((map.match(/<loc>https:\/\/openvibe\.design\/icons\/[a-z0-9-]+<\/loc>/g) || []).length, glyphs.NAMES.length + 2, 'each glyph, the alternative and the migration page');
assert.ok((await t.get('/llms.txt')).text.includes('/icons/font-awesome-alternative'));
assert.ok((await t.get('/llms.txt')).text.includes('/icons/font-awesome-alternative'));
});
});
} finally {
} finally {
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+// Finding a glyph by any name (plan L50 step 4): the words people search with, Lucide's, Material's and MUI's names;
+// the migration page and API that read a pasted page or component and give each icon its OpenVibe twin; and any set's
+// name in <ov-icon> and the drawings API.
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const glyphs = require('openvibe-shared/glyphs');
+const aliases = require('../server/glyphs/aliases');
+
+const ORIGIN = 'https://openvibe.design';
+const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
+const PASTE = `<i class="fa-solid fa-gear fa-fw"></i> <i class="fa-brands fa-github"></i> <i class="fa-solid fa-gear"></i>
+<i data-lucide="trash-2"></i> <span class="material-symbols-outlined">home</span> <mat-icon>settings</mat-icon>
+import { Settings, Trash2 as Bin, Wand2 } from "lucide-react";
+import DeleteIcon from "@mui/icons-material/DeleteOutlined";`;
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('every name in the tables leads to a glyph that exists; their names resolve, unknown ones do not', async () => {
+ for (const [name, g] of Object.entries(aliases.LUCIDE)) assert.ok(glyphs.path(g), `lucide ${name} → ${g}`);
+ for (const [name, g] of Object.entries(aliases.MATERIAL)) assert.ok(glyphs.path(g), `material ${name} → ${g}`);
+ assert.ok(Object.keys(aliases.LUCIDE).length > 150 && Object.keys(aliases.MATERIAL).length > 200, 'the common names of both sets');
+ const cases = { 'lucide:Settings': 'gear', 'lucide:Trash2': 'trash', 'lucide:x': 'close', 'material:delete_outline': 'trash',
+ 'mui:DeleteOutlined': 'trash', 'fa-gear': 'gear', 'fa:gear': 'gear', settings: 'gear', heart: 'heart', 'material:home': 'home' };
+ for (const [n, g] of Object.entries(cases)) assert.equal(aliases.resolve(n), g, n);
+ assert.equal(aliases.resolve('no-such-icon'), null);
+ assert.ok(aliases.words('gear').includes('settings') && aliases.words('trash').includes('delete'), 'the words people type');
+ });
+
+ await check('the scan reads Font Awesome, Lucide, Material and MUI, counts uses, and keeps utility classes out', async () => {
+ const found = aliases.scan(PASTE);
+ const by = Object.fromEntries(found.map((f) => [`${f.set}:${f.name}`, f]));
+ assert.equal(by['fa:gear'].count, 2);
+ assert.equal(by['fa:gear'].glyph, 'gear');
+ assert.ok(!by['fa:fw'] && !by['fa:solid'], 'fa-fw and fa-solid are not icons');
+ assert.equal(by['fa-brands:github'].brand, 'github', 'a brand the drop-in draws');
+ assert.equal(by['lucide:trash-2'].count, 2, 'data-lucide and the import');
+ assert.equal(by['lucide:settings'].glyph, 'gear');
+ assert.equal(by['lucide:wand-2'].glyph, null, 'one with no twin yet');
+ assert.equal(by['material:home'].glyph, 'home');
+ assert.equal(by['material:settings'].glyph, 'gear');
+ assert.equal(by['material:delete'].glyph, 'trash', 'MUI\'s DeleteOutlined');
+ assert.equal(found[found.length - 1].glyph || found[found.length - 1].brand, null, 'the ones without a twin come last');
+ assert.deepEqual(aliases.scan(''), []);
+ });
+
+ await check('the migration page: a form, the twins, how to switch; never a cross-site post', async () => {
+ const page = await t.get('/icons/migrate');
+ assert.equal(page.status, 200);
+ assert.ok(page.text.includes('<form class="mg-form" method="post" action="/icons/migrate#results">'));
+ const res = await t.get('/icons/migrate', { method: 'POST', ...form({ text: PASTE }), origin: ORIGIN });
+ assert.equal(res.status, 200);
+ assert.match(res.text, /<h2 id="mg-res">\d+ of \d+ have a twin<\/h2>/);
+ assert.ok(res.text.includes('<code><ov-icon name="gear"></ov-icon></code>'), 'the line to use');
+ assert.ok(res.text.includes('/shared/ov-glyphs.js') && res.text.includes('/ov-icon.js'), 'how to switch, for both kinds');
+ assert.ok(res.text.includes('href="/icons?draw=wand-2#draw"'), 'draw a missing one');
+ assert.ok(res.text.includes('<i data-lucide="trash-2">'), 'the pasted text comes back escaped');
+ assert.match(res.headers.get('cache-control'), /no-store/);
+ // A signed-in person's post must come from openvibe.design (an anonymous one changes nothing and is only read).
+ const ana = t.network.addUser('ana');
+ assert.equal((await t.get('/icons/migrate', { method: 'POST', as: ana, ...form({ text: PASTE }), origin: 'https://evil.test' })).status, 403);
+ const none = await t.get('/icons/migrate', { method: 'POST', ...form({ text: 'hello' }), origin: ORIGIN });
+ assert.ok(none.text.includes('No icons found'));
+ });
+
+ await check('the API: the same answer as data, open to any origin; any set\'s name in the drawings API', async () => {
+ const pre = await t.get('/api/v1/glyphs/migrate', { method: 'OPTIONS', headers: { origin: 'https://example.com', 'access-control-request-method': 'POST' } });
+ assert.equal(pre.status, 204);
+ assert.equal(pre.headers.get('access-control-allow-origin'), '*');
+ const r = await t.get('/api/v1/glyphs/migrate', { json: { text: PASTE }, headers: { origin: 'https://example.com' } });
+ assert.equal(r.status, 200);
+ const j = r.json();
+ assert.equal(r.headers.get('access-control-allow-origin'), '*');
+ assert.ok(j.count === 8 && j.matched === 7 && j.icons.some((f) => f.set === 'lucide' && f.name === 'trash-2' && f.glyph === 'trash'));
+ assert.equal((await t.get('/api/v1/glyphs/migrate', { json: { nope: 1 } })).status, 422);
+ const names = (await t.get('/api/v1/glyphs?names=lucide:settings,material:delete,settings,fa-gear')).json();
+ assert.deepEqual(names.glyphs.map((g) => [g.asked, g.name]), [['lucide:settings', 'gear'], ['material:delete', 'trash'], ['settings', 'gear'], ['fa-gear', 'gear']]);
+ });
+
+ await check('the finder searches the words; /icons, the Font Awesome page, the sitemap and llms.txt lead to the page', async () => {
+ const icons = (await t.get('/icons')).text;
+ assert.match(icons, /data-name="gear" data-fa="[^"]*" data-words="[^"]*settings[^"]*"/);
+ assert.ok(icons.includes('href="/icons/migrate"'));
+ assert.ok((await t.get('/icons/font-awesome-alternative')).text.includes('href="/icons/migrate"'));
+ assert.ok((await t.get('/sitemap.xml')).text.includes('/icons/migrate'));
+ assert.ok((await t.get('/llms.txt')).text.includes('/api/v1/glyphs/migrate'));
+ assert.ok((await t.get('/ov-icon.js')).text.includes('lucide|material|mui|fa'), '<ov-icon> takes any set\'s name');
+ });
+ } finally {
+ await done(t);
+ }
+})();