openvibers/openvibe.design Commits

Icons: find one by any name, and switch from Font Awesome, Lucide or Material (#77)

OP OpenVibers committed 12 days ago · 73e4f708bc · 10 files, +445 −12

Plan L50 step 4.
- server/glyphs/aliases.js: the words people search with (settings, delete, like…), Lucide's names (189) and
  Material's and MUI's (243) mapped to our glyphs; resolve() for any of them; scan() reads a pasted page or
  component (Font Awesome classes without the utility ones, data-lucide and lucide-react/vue/svelte imports, Material
  ligature spans and <mat-icon>, @mui/icons-material imports) and counts each icon with its twin (brands: the drop-in).
- /icons/migrate: paste a page, see every icon with its OpenVibe twin, the <ov-icon> line and how to switch; a
  missing one links to Draw it. Read only: nothing stored, fetched or run. POST /api/v1/glyphs/migrate as data (CORS).
- The /icons finder matches the words and the other sets' names; <ov-icon name="lucide:settings"> and
  "material:delete" work, as does /api/v1/glyphs?names=.
- Linked from /icons and the Font Awesome page; in the sitemap and llms.txt.

Changes

ddiff --git a/public/css/icons.css b/public/css/icons.css
iindex b5592e8..9d9eb70 100644
---- a/public/css/icons.css
++++ b/public/css/icons.css
@@@ -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}
ddiff --git a/public/js/icons.js b/public/js/icons.js
iindex 8a1cedb..c1625a3 100644
---- a/public/js/icons.js
++++ b/public/js/icons.js
@@@ -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;
ddiff --git a/public/ov-icon.js b/public/ov-icon.js
iindex 5330076..e949a2a 100644
---- a/public/ov-icon.js
++++ b/public/ov-icon.js
@@@ -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>
ddiff --git a/server/glyphs/aliases.js b/server/glyphs/aliases.js
nnew file mode 100644
iindex 0000000..70f9438
---- /dev/null
++++ b/server/glyphs/aliases.js
@@@ -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 };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex d8dc554..006051b 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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' },
ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex 4f1e0be..61b3a2e 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -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 });
ddiff --git a/server/views/icon-page.js b/server/views/icon-page.js
iindex e275f4d..42ef224 100644
---- a/server/views/icon-page.js
++++ b/server/views/icon-page.js
@@@ -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&rsquo;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&rsquo;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&rsquo;s stylesheet</b> once every name is covered: one less stylesheet and font on every first visit. Or use the font with Font Awesome&rsquo;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>&lt;ov-icon&gt;</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&rsquo;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>&lt;ov-icon name="${esc(f.glyph)}"&gt;&lt;/ov-icon&gt;</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&rsquo;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&rsquo;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="&lt;i class=&quot;fa-solid fa-gear&quot;&gt;&lt;/i&gt; &lt;i data-lucide=&quot;trash-2&quot;&gt;&lt;/i&gt; import { Settings } from 'lucide-react' &lt;span class=&quot;material-symbols-outlined&quot;&gt;home&lt;/span&gt;">${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&rsquo;s name works in <code>&lt;ov-icon&gt;</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 };
ddiff --git a/server/views/icons.js b/server/views/icons.js
iindex cbe9ea8..50158e2 100644
---- a/server/views/icons.js
++++ b/server/views/icons.js
@@@ -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 = [], r
const 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>&lt;ov-icon name="lucide:settings"&gt;</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>`;
ddiff --git a/test/icon-exports.test.js b/test/icon-exports.test.js
iindex 4cf6cd3..4a878bb 100644
---- a/test/icon-exports.test.js
++++ b/test/icon-exports.test.js
@@@ -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 {
ddiff --git a/test/icons-find.test.js b/test/icons-find.test.js
nnew file mode 100644
iindex 0000000..46439b4
---- /dev/null
++++ b/test/icons-find.test.js
@@@ -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>&lt;ov-icon name="gear"&gt;&lt;/ov-icon&gt;</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('&lt;i data-lucide=&quot;trash-2&quot;&gt;'), '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);
++ }
++})();