experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(lex): eliminate background.js, make extension fully declarative

Merge all command registration logic from background.js into home.js.
Remove duplicated FRIENDLY_NAMES table (use lexicon-ui.js as single source).
Replace BroadcastChannel with direct api.publish() in chain-form.js.
Set manifest to background: false with declarative window commands.

+193 -542
-50
extensions/lex/background.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - <head> 4 - <meta charset="utf-8"> 5 - <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; connect-src https: http:;"> 6 - <title>Lexicon Studio Extension</title> 7 - </head> 8 - <body> 9 - <script type="module"> 10 - import extension from './background.js'; 11 - 12 - const api = window.app; 13 - const extId = extension.id; 14 - 15 - console.log(`[ext:${extId}] background.html loaded`); 16 - 17 - // Signal ready to main process 18 - api.publish('ext:ready', { 19 - id: extId, 20 - manifest: { 21 - id: extension.id, 22 - labels: extension.labels, 23 - version: '1.0.0' 24 - } 25 - }, api.scopes.SYSTEM); 26 - 27 - // Initialize extension 28 - if (extension.init) { 29 - console.log(`[ext:${extId}] calling init()`); 30 - extension.init(); 31 - } 32 - 33 - // Handle shutdown request from main process 34 - api.subscribe('app:shutdown', () => { 35 - console.log(`[ext:${extId}] received shutdown`); 36 - if (extension.uninit) { 37 - extension.uninit(); 38 - } 39 - }, api.scopes.SYSTEM); 40 - 41 - // Handle extension-specific shutdown 42 - api.subscribe(`ext:${extId}:shutdown`, () => { 43 - console.log(`[ext:${extId}] received extension shutdown`); 44 - if (extension.uninit) { 45 - extension.uninit(); 46 - } 47 - }, api.scopes.SYSTEM); 48 - </script> 49 - </body> 50 - </html>
-344
extensions/lex/background.js
··· 1 - /** 2 - * Lexicon Studio Extension — Background (lightweight) 3 - * 4 - * This background page only handles: 5 - * - Command registration (lexicon studio, new, new {FriendlyName} shortcuts) 6 - * - Listening for session/collection changes to update command state 7 - * 8 - * All XRPC/OAuth/session management is done directly in home.js and chain-form.js 9 - * via the atproto.js module. No pubsub request/response channels needed. 10 - */ 11 - 12 - // Feature detection 13 - const hasPeekAPI = typeof window.app !== 'undefined'; 14 - const api = hasPeekAPI ? window.app : null; 15 - 16 - // ============================================================================ 17 - // BroadcastChannel for intra-extension messaging (background.js <-> home.js) 18 - // ============================================================================ 19 - 20 - let lexChannel = null; 21 - const lexChannelHandlers = {}; 22 - 23 - try { 24 - lexChannel = new BroadcastChannel('lex'); 25 - lexChannel.onmessage = (e) => { 26 - const { topic, data } = e.data; 27 - if (lexChannelHandlers[topic]) { 28 - for (const handler of lexChannelHandlers[topic]) { 29 - handler(data); 30 - } 31 - } 32 - }; 33 - } catch (err) { 34 - console.warn('[lex] BroadcastChannel unavailable, using IPC fallback:', err.message); 35 - } 36 - 37 - function onChannel(topic, handler) { 38 - if (!lexChannelHandlers[topic]) lexChannelHandlers[topic] = []; 39 - lexChannelHandlers[topic].push(handler); 40 - if (!lexChannel) api.subscribe(topic, handler, api.scopes.GLOBAL); 41 - } 42 - 43 - function emitChannel(topic, data) { 44 - if (lexChannel) { 45 - lexChannel.postMessage({ topic, data }); 46 - } else { 47 - api.publish(topic, data, api.scopes.GLOBAL); 48 - } 49 - } 50 - 51 - // ============================================================================ 52 - // Session state (read-only, loaded from storage for command auth checks) 53 - // ============================================================================ 54 - 55 - let currentSession = null; 56 - 57 - const STORAGE_KEY = 'session'; 58 - 59 - async function loadSession() { 60 - if (!api) return; 61 - try { 62 - const result = await api.settings.getKey(STORAGE_KEY); 63 - if (result.success && result.data) { 64 - currentSession = result.data; 65 - console.log('[lex:bg] Restored session for', currentSession.handle); 66 - } 67 - } catch (err) { 68 - console.warn('[lex:bg] Failed to load session:', err); 69 - } 70 - } 71 - 72 - // ============================================================================ 73 - // Commands 74 - // ============================================================================ 75 - 76 - function openMe() { 77 - if (hasPeekAPI) { 78 - api.window.open('peek://lex/home.html', { 79 - role: 'workspace', 80 - key: 'lex-home', 81 - width: 860, 82 - height: 620, 83 - title: 'Lexicon Studio', 84 - }); 85 - } 86 - } 87 - 88 - function openMeWithLexicon(nsid) { 89 - if (!hasPeekAPI) return; 90 - api.window.open('peek://lex/home.html', { 91 - role: 'workspace', 92 - key: 'lex-home', 93 - width: 860, 94 - height: 620, 95 - title: 'Lexicon Studio', 96 - }); 97 - // Publish after a short delay to let the window load 98 - setTimeout(() => { 99 - emitChannel('lex:open-create', { nsid }); 100 - }, 300); 101 - } 102 - 103 - // ============================================================================ 104 - // "new" command system 105 - // ============================================================================ 106 - 107 - const RECENT_LEXICONS_KEY = 'recentLexicons'; 108 - let registeredNewCommands = []; 109 - let knownCollectionNsids = []; 110 - let knownRecentNsids = []; 111 - 112 - const FRIENDLY_NAMES = { 113 - 'app.bsky.feed.post': 'Post', 114 - 'app.bsky.feed.like': 'Like', 115 - 'app.bsky.feed.repost': 'Repost', 116 - 'app.bsky.feed.generator': 'Feed Generator', 117 - 'app.bsky.feed.threadgate': 'Thread Gate', 118 - 'app.bsky.feed.postgate': 'Post Gate', 119 - 'app.bsky.graph.follow': 'Follow', 120 - 'app.bsky.graph.block': 'Block', 121 - 'app.bsky.graph.list': 'List', 122 - 'app.bsky.graph.listitem': 'List Item', 123 - 'app.bsky.graph.listblock': 'List Block', 124 - 'app.bsky.graph.starterpack': 'Starter Pack', 125 - 'app.bsky.actor.profile': 'Profile', 126 - 'app.bsky.labeler.service': 'Labeler', 127 - 'chat.bsky.actor.declaration': 'Chat Settings', 128 - 'com.whtwnd.blog.entry': 'Blog Entry', 129 - 'blue.linkat.board': 'Board', 130 - 'fyi.frontpage.post': 'Frontpage Post', 131 - 'site.standard.publication': 'Publication', 132 - 'site.standard.document': 'Document', 133 - 'site.standard.graph.subscription': 'Subscription', 134 - }; 135 - 136 - function nsidToFriendlyName(nsid) { 137 - if (FRIENDLY_NAMES[nsid]) return FRIENDLY_NAMES[nsid]; 138 - const parts = nsid.split('.'); 139 - const last = parts[parts.length - 1]; 140 - return last 141 - .replace(/([a-z])([A-Z])/g, '$1 $2') 142 - .replace(/^./, c => c.toUpperCase()); 143 - } 144 - 145 - function nsidDomainLabel(nsid) { 146 - const parts = nsid.split('.'); 147 - return parts.length >= 2 ? parts[1] : parts[0]; 148 - } 149 - 150 - async function loadAndRegisterNewCommands() { 151 - if (!api) return; 152 - try { 153 - const result = await api.settings.getKey(RECENT_LEXICONS_KEY); 154 - if (result.success && Array.isArray(result.data)) { 155 - knownRecentNsids = result.data; 156 - } 157 - } catch {} 158 - rebuildNewCommands(); 159 - } 160 - 161 - function rebuildNewCommands() { 162 - if (!api) return; 163 - 164 - // Unregister old commands 165 - for (const name of registeredNewCommands) { 166 - try { api.commands.unregister(name); } catch {} 167 - } 168 - registeredNewCommands = []; 169 - 170 - // Deduplicate NSIDs from both sources 171 - const allNsids = [...new Set([...knownRecentNsids, ...knownCollectionNsids])]; 172 - 173 - if (allNsids.length === 0) return; 174 - 175 - // Build friendly name -> [nsid, ...] map to detect collisions 176 - const nameToNsids = new Map(); 177 - for (const nsid of allNsids) { 178 - const friendly = nsidToFriendlyName(nsid); 179 - if (!nameToNsids.has(friendly)) { 180 - nameToNsids.set(friendly, []); 181 - } 182 - nameToNsids.get(friendly).push(nsid); 183 - } 184 - 185 - // Register each NSID as a "new {FriendlyName}" command 186 - for (const nsid of allNsids) { 187 - const friendly = nsidToFriendlyName(nsid); 188 - const siblings = nameToNsids.get(friendly); 189 - 190 - let displayName; 191 - if (siblings.length > 1) { 192 - displayName = `${friendly} (${nsidDomainLabel(nsid)})`; 193 - } else { 194 - displayName = friendly; 195 - } 196 - 197 - const cmdName = `new ${displayName} — ${nsid}`; 198 - api.commands.register({ 199 - name: cmdName, 200 - description: `Create ${nsid} record`, 201 - execute: () => { 202 - if (!currentSession) { 203 - openMe(); 204 - return { success: false, message: 'Not logged in. Opening login...' }; 205 - } 206 - return { 207 - success: true, 208 - output: { 209 - data: { nsid }, 210 - mimeType: 'application/x-lexicon-form', 211 - title: displayName, 212 - interactive: 'peek://lex/chain-form.html', 213 - }, 214 - }; 215 - }, 216 - }); 217 - registeredNewCommands.push(cmdName); 218 - } 219 - 220 - if (registeredNewCommands.length > 0) { 221 - console.log('[lex:bg] Registered "new" commands:', registeredNewCommands.length); 222 - } 223 - } 224 - 225 - // ============================================================================ 226 - // Extension lifecycle 227 - // ============================================================================ 228 - 229 - const extension = { 230 - id: 'lex', 231 - labels: { 232 - name: 'Lexicon Studio', 233 - }, 234 - 235 - registerCommands() { 236 - api.commands.register({ 237 - name: 'lexicon studio', 238 - description: 'Open AT Protocol identity panel', 239 - execute: () => { 240 - openMe(); 241 - return { success: true }; 242 - }, 243 - }); 244 - 245 - // Base "new" command 246 - api.commands.register({ 247 - name: 'new', 248 - description: 'Create a new AT Protocol record', 249 - execute: async (ctx) => { 250 - if (!currentSession) { 251 - openMe(); 252 - return { success: false, message: 'Not logged in. Opening login...' }; 253 - } 254 - 255 - // If search text contains a dotted NSID, open chain form popup 256 - if (ctx.search && ctx.search.includes('.')) { 257 - const nsid = ctx.search.trim(); 258 - const friendlyName = nsidToFriendlyName(nsid); 259 - return { 260 - success: true, 261 - output: { 262 - data: { nsid }, 263 - mimeType: 'application/x-lexicon-form', 264 - title: friendlyName, 265 - interactive: 'peek://lex/chain-form.html', 266 - }, 267 - }; 268 - } 269 - 270 - // No specific NSID — open the Create panel with its built-in picker 271 - openMeWithLexicon(''); 272 - return { success: true }; 273 - }, 274 - }); 275 - 276 - console.log('[lex:bg] Commands registered'); 277 - }, 278 - 279 - async init() { 280 - console.log('[lex:bg] Initializing...'); 281 - 282 - if (!hasPeekAPI) { 283 - console.log('[lex:bg] Running without Peek API — limited functionality'); 284 - return; 285 - } 286 - 287 - // Load saved session (read-only, for command auth checks) 288 - await loadSession(); 289 - 290 - // Register commands 291 - this.registerCommands(); 292 - 293 - // Load recent lexicons and register "new" commands 294 - await loadAndRegisterNewCommands(); 295 - 296 - // Listen for session changes from home.js 297 - onChannel('lex:session-changed', (msg) => { 298 - if (msg.authenticated) { 299 - // Reload session from storage to get full session data 300 - loadSession(); 301 - } else { 302 - currentSession = null; 303 - } 304 - }); 305 - 306 - // Listen for recent lexicons changes from home.js / chain-form.js 307 - onChannel('lex:recent-lexicons-changed', (msg) => { 308 - if (Array.isArray(msg.nsids)) { 309 - // Merge new nsids into known recent 310 - for (const nsid of msg.nsids) { 311 - if (!knownRecentNsids.includes(nsid)) { 312 - knownRecentNsids.push(nsid); 313 - } 314 - } 315 - knownRecentNsids.sort(); 316 - rebuildNewCommands(); 317 - } 318 - }); 319 - 320 - // Listen for collections loaded from home.js 321 - onChannel('lex:collections-loaded', (msg) => { 322 - if (Array.isArray(msg.collections)) { 323 - knownCollectionNsids = msg.collections; 324 - rebuildNewCommands(); 325 - } 326 - }); 327 - 328 - console.log('[lex:bg] Extension loaded', currentSession ? `(session: @${currentSession.handle})` : '(no session)'); 329 - }, 330 - 331 - uninit() { 332 - console.log('[lex:bg] Cleaning up...'); 333 - if (hasPeekAPI) { 334 - api.commands.unregister('lexicon studio'); 335 - api.commands.unregister('new'); 336 - for (const name of registeredNewCommands) { 337 - try { api.commands.unregister(name); } catch {} 338 - } 339 - registeredNewCommands = []; 340 - } 341 - }, 342 - }; 343 - 344 - export default extension;
+1 -14
extensions/lex/chain-form.js
··· 30 30 31 31 const api = window.app; 32 32 33 - // BroadcastChannel for intra-extension messaging (falls back to IPC if unavailable) 34 - let lexChannel = null; 35 - 36 - try { 37 - lexChannel = new BroadcastChannel('lex'); 38 - } catch (err) { 39 - console.warn('[chain-form] BroadcastChannel unavailable, using IPC fallback:', err.message); 40 - } 41 - 42 33 function emitChannel(topic, data) { 43 - if (lexChannel) { 44 - lexChannel.postMessage({ topic, data }); 45 - } else { 46 - api.publish(topic, data, api.scopes.GLOBAL); 47 - } 34 + api.publish(topic, data, api.scopes.GLOBAL); 48 35 } 49 36 50 37 // Read URL params
+150 -46
extensions/lex/home.js
··· 33 33 const api = window.app; 34 34 35 35 // ============================================================================ 36 - // BroadcastChannel for intra-extension messaging (home.js <-> background.js) 36 + // "new" command system 37 37 // ============================================================================ 38 38 39 - let lexChannel = null; 40 - const lexChannelHandlers = {}; 39 + let registeredNewCommands = []; 40 + let knownCollectionNsids = []; 41 + let knownRecentNsids = []; 42 + 43 + function nsidDomainLabel(nsid) { 44 + const parts = nsid.split('.'); 45 + return parts.length >= 2 ? parts[1] : parts[0]; 46 + } 41 47 42 - try { 43 - lexChannel = new BroadcastChannel('lex'); 44 - lexChannel.onmessage = (e) => { 45 - const { topic, data } = e.data; 46 - if (lexChannelHandlers[topic]) { 47 - for (const handler of lexChannelHandlers[topic]) { 48 - handler(data); 49 - } 50 - } 51 - }; 52 - } catch (err) { 53 - console.warn('[lex] BroadcastChannel unavailable, using IPC fallback:', err.message); 48 + function rebuildNewCommands() { 49 + // Unregister old commands 50 + for (const name of registeredNewCommands) { 51 + try { api.commands.unregister(name); } catch {} 52 + } 53 + registeredNewCommands = []; 54 + 55 + const allNsids = [...new Set([...knownRecentNsids, ...knownCollectionNsids])]; 56 + if (allNsids.length === 0) return; 57 + 58 + const nameToNsids = new Map(); 59 + for (const nsid of allNsids) { 60 + const friendly = nsidToFriendlyName(nsid); 61 + if (!nameToNsids.has(friendly)) nameToNsids.set(friendly, []); 62 + nameToNsids.get(friendly).push(nsid); 63 + } 64 + 65 + for (const nsid of allNsids) { 66 + const friendly = nsidToFriendlyName(nsid); 67 + const siblings = nameToNsids.get(friendly); 68 + const displayName = siblings.length > 1 ? `${friendly} (${nsidDomainLabel(nsid)})` : friendly; 69 + const cmdName = `new ${displayName} — ${nsid}`; 70 + 71 + api.commands.register({ 72 + name: cmdName, 73 + description: `Create ${nsid} record`, 74 + execute: () => { 75 + if (!currentSession) { 76 + openLexWindow(); 77 + return { success: false, message: 'Not logged in. Opening login...' }; 78 + } 79 + return { 80 + success: true, 81 + output: { 82 + data: { nsid }, 83 + mimeType: 'application/x-lexicon-form', 84 + title: displayName, 85 + interactive: 'peek://lex/chain-form.html', 86 + }, 87 + }; 88 + }, 89 + }); 90 + registeredNewCommands.push(cmdName); 91 + } 54 92 } 55 93 56 - function onChannel(topic, handler) { 57 - if (!lexChannelHandlers[topic]) lexChannelHandlers[topic] = []; 58 - lexChannelHandlers[topic].push(handler); 59 - if (!lexChannel) api.subscribe(topic, handler, api.scopes.GLOBAL); 94 + function openLexWindow() { 95 + api.window.open('peek://lex/home.html', { 96 + role: 'workspace', 97 + key: 'lex-home', 98 + width: 860, 99 + height: 620, 100 + title: 'Lexicon Studio', 101 + webPreferences: { webSecurity: false }, 102 + }); 60 103 } 61 104 62 - function emitChannel(topic, data) { 63 - if (lexChannel) { 64 - lexChannel.postMessage({ topic, data }); 65 - } else { 66 - api.publish(topic, data, api.scopes.GLOBAL); 105 + function openLexWindowWithLexicon(nsid) { 106 + openLexWindow(); 107 + // If already loaded, handle directly; otherwise delay for window load 108 + if (state.authenticated && nsid) { 109 + switchPanel('create'); 110 + selectPickerLexicon(nsid); 67 111 } 68 112 } 69 113 114 + function registerCommands() { 115 + api.commands.register({ 116 + name: 'lexicon studio', 117 + description: 'Open AT Protocol identity panel', 118 + execute: () => { 119 + openLexWindow(); 120 + return { success: true }; 121 + }, 122 + }); 123 + 124 + api.commands.register({ 125 + name: 'new', 126 + description: 'Create a new AT Protocol record', 127 + execute: async (ctx) => { 128 + if (!currentSession) { 129 + openLexWindow(); 130 + return { success: false, message: 'Not logged in. Opening login...' }; 131 + } 132 + if (ctx.search && ctx.search.includes('.')) { 133 + const nsid = ctx.search.trim(); 134 + const friendlyName = nsidToFriendlyName(nsid); 135 + return { 136 + success: true, 137 + output: { 138 + data: { nsid }, 139 + mimeType: 'application/x-lexicon-form', 140 + title: friendlyName, 141 + interactive: 'peek://lex/chain-form.html', 142 + }, 143 + }; 144 + } 145 + openLexWindowWithLexicon(''); 146 + return { success: true }; 147 + }, 148 + }); 149 + 150 + // Load recent nsids and build "new {Name}" commands 151 + knownRecentNsids = [...(state.recentLexicons || [])]; 152 + rebuildNewCommands(); 153 + } 154 + 155 + function onCollectionsChanged(nsids) { 156 + knownCollectionNsids = nsids; 157 + rebuildNewCommands(); 158 + } 159 + 160 + function onRecentLexiconsChanged() { 161 + knownRecentNsids = [...(state.recentLexicons || [])]; 162 + rebuildNewCommands(); 163 + // Also publish for chain-form.js (runs in separate modal window) 164 + api.publish('lex:recent-lexicons-changed', { nsids: state.recentLexicons }, api.scopes.GLOBAL); 165 + } 166 + 167 + // Listen for recent lexicon changes from chain-form.js 168 + api.subscribe('lex:recent-lexicons-changed', (msg) => { 169 + if (Array.isArray(msg.nsids)) { 170 + for (const nsid of msg.nsids) { 171 + if (!state.recentLexicons.includes(nsid)) { 172 + state.recentLexicons.push(nsid); 173 + } 174 + } 175 + state.recentLexicons.sort(); 176 + saveRecentLexicons(); 177 + renderRecentLexicons(); 178 + knownRecentNsids = [...state.recentLexicons]; 179 + rebuildNewCommands(); 180 + } 181 + }, api.scopes.GLOBAL); 182 + 70 183 // ============================================================================ 71 - // Session state (previously in background.js) 184 + // Session state 72 185 // ============================================================================ 73 186 74 187 let currentSession = null; ··· 617 730 // Load recent lexicons from storage 618 731 loadRecentLexicons(); 619 732 620 - // Listen for open-create from command palette (via background commands) 621 - onChannel('lex:open-create', (msg) => { 733 + // Listen for open-create from command palette 734 + api.subscribe('lex:open-create', (msg) => { 622 735 if (!state.authenticated) return; 623 736 if (msg.nsid) { 624 737 switchPanel('create'); ··· 626 739 } else { 627 740 switchPanel('create'); 628 741 } 629 - }); 742 + }, api.scopes.GLOBAL); 630 743 631 744 // Load session from storage directly 632 745 await loadSession(); 746 + 747 + // Register commands (after session loaded so auth checks work) 748 + registerCommands(); 633 749 634 750 if (currentSession) { 635 751 // Session exists — show account view ··· 803 919 } 804 920 keysToRemove.forEach(k => localStorage.removeItem(k)); 805 921 } catch {} 806 - // Notify background (for command state) and other windows 807 - emitChannel('lex:session-changed', { authenticated: false }); 922 + // Session cleared — no commands to update (they check currentSession at execute time) 808 923 showLoginView(); 809 924 }); 810 925 } ··· 1003 1118 state.pdsUrl = currentSession.pdsUrl; 1004 1119 state.profile = currentProfile; 1005 1120 1006 - // Notify background (for command state) and other windows 1007 - emitChannel('lex:session-changed', { 1008 - authenticated: true, 1009 - handle: currentSession.handle, 1010 - did: currentSession.did, 1011 - pdsUrl: currentSession.pdsUrl, 1012 - profile: currentProfile, 1013 - }); 1121 + // Session restored — commands check currentSession at execute time 1014 1122 1015 1123 showAccountView(); 1016 1124 console.log('[lex] Logged in as', currentSession.handle); ··· 1067 1175 state.collections = counts; 1068 1176 renderCollections(); 1069 1177 1070 - // Notify background to update "new" commands with repo collections 1071 - emitChannel('lex:collections-loaded', { 1072 - collections: counts.map(c => c.nsid), 1073 - }); 1178 + // Update "new" commands with repo collections 1179 + onCollectionsChanged(counts.map(c => c.nsid)); 1074 1180 } catch (err) { 1075 1181 collectionsListEl.innerHTML = `<div class="error">${escapeHtml(err.message)}</div>`; 1076 1182 } ··· 2333 2439 state.recentLexicons.sort(); 2334 2440 saveRecentLexicons(); 2335 2441 renderRecentLexicons(); 2336 - // Notify background to re-register shortcut commands 2337 - emitChannel('lex:recent-lexicons-changed', { nsids: state.recentLexicons }); 2442 + onRecentLexiconsChanged(); 2338 2443 } 2339 2444 2340 2445 function removeRecentLexicon(nsid) { 2341 2446 state.recentLexicons = state.recentLexicons.filter(n => n !== nsid); 2342 2447 saveRecentLexicons(); 2343 2448 renderRecentLexicons(); 2344 - // Notify background to re-register shortcut commands 2345 - emitChannel('lex:recent-lexicons-changed', { nsids: state.recentLexicons }); 2449 + onRecentLexiconsChanged(); 2346 2450 } 2347 2451 2348 2452 function renderRecentLexicons() {
+35 -1
extensions/lex/manifest.json
··· 4 4 "name": "Lexicon Studio", 5 5 "description": "Your AT Protocol identity and data", 6 6 "version": "1.0.0", 7 - "background": "background.html" 7 + "background": false, 8 + "commands": [ 9 + { 10 + "name": "lexicon studio", 11 + "description": "Open AT Protocol identity panel", 12 + "action": { 13 + "type": "window", 14 + "url": "peek://lex/home.html", 15 + "options": { 16 + "role": "workspace", 17 + "key": "lex-home", 18 + "width": 860, 19 + "height": 620, 20 + "title": "Lexicon Studio", 21 + "webPreferences": { "webSecurity": false } 22 + } 23 + } 24 + }, 25 + { 26 + "name": "new", 27 + "description": "Create a new AT Protocol record", 28 + "action": { 29 + "type": "window", 30 + "url": "peek://lex/home.html", 31 + "options": { 32 + "role": "workspace", 33 + "key": "lex-home", 34 + "width": 860, 35 + "height": 620, 36 + "title": "Lexicon Studio", 37 + "webPreferences": { "webSecurity": false } 38 + } 39 + } 40 + } 41 + ] 8 42 }
+7 -87
extensions/lex/tests/background-logic.test.js
··· 1 1 /** 2 - * Background.js Logic — Unit Tests 2 + * Lex Command Logic — Unit Tests 3 3 * 4 - * Tests for pure logic functions used in background.js. 5 - * Since nsidToFriendlyName and nsidDomainLabel are not exported from background.js, 6 - * we re-implement them here to verify the logic patterns, and test the 7 - * rebuildNewCommands collision-handling algorithm. 4 + * Tests for the command-building logic in home.js. 5 + * nsidToFriendlyName comes from lexicon-ui.js (single source of truth). 6 + * nsidDomainLabel and rebuildNewCommands logic live in home.js. 8 7 * 9 8 * Run with: node --test extensions/lex/tests/background-logic.test.js 10 9 */ 11 10 12 11 import { describe, test } from 'node:test'; 13 12 import assert from 'node:assert/strict'; 13 + import { nsidToFriendlyName } from '../lexicon-ui.js'; 14 14 15 15 // ============================================================================ 16 - // Re-implement the unexported functions from background.js for testing. 17 - // These MUST stay in sync with background.js — if they diverge, tests 18 - // become meaningless. The canonical source is background.js lines 77-113. 16 + // Re-implement nsidDomainLabel from home.js for testing. 19 17 // ============================================================================ 20 - 21 - const FRIENDLY_NAMES = { 22 - 'app.bsky.feed.post': 'Post', 23 - 'app.bsky.feed.like': 'Like', 24 - 'app.bsky.feed.repost': 'Repost', 25 - 'app.bsky.feed.generator': 'Feed Generator', 26 - 'app.bsky.feed.threadgate': 'Thread Gate', 27 - 'app.bsky.feed.postgate': 'Post Gate', 28 - 'app.bsky.graph.follow': 'Follow', 29 - 'app.bsky.graph.block': 'Block', 30 - 'app.bsky.graph.list': 'List', 31 - 'app.bsky.graph.listitem': 'List Item', 32 - 'app.bsky.graph.listblock': 'List Block', 33 - 'app.bsky.graph.starterpack': 'Starter Pack', 34 - 'app.bsky.actor.profile': 'Profile', 35 - 'app.bsky.labeler.service': 'Labeler', 36 - 'chat.bsky.actor.declaration': 'Chat Settings', 37 - 'com.whtwnd.blog.entry': 'Blog Entry', 38 - 'blue.linkat.board': 'Board', 39 - 'fyi.frontpage.post': 'Frontpage Post', 40 - 'site.standard.publication': 'Publication', 41 - 'site.standard.document': 'Document', 42 - 'site.standard.graph.subscription': 'Subscription', 43 - }; 44 - 45 - function nsidToFriendlyName(nsid) { 46 - if (FRIENDLY_NAMES[nsid]) return FRIENDLY_NAMES[nsid]; 47 - const parts = nsid.split('.'); 48 - const last = parts[parts.length - 1]; 49 - return last 50 - .replace(/([a-z])([A-Z])/g, '$1 $2') 51 - .replace(/^./, c => c.toUpperCase()); 52 - } 53 18 54 19 function nsidDomainLabel(nsid) { 55 20 const parts = nsid.split('.'); ··· 57 22 } 58 23 59 24 /** 60 - * Simulates the rebuildNewCommands logic from background.js. 25 + * Simulates the rebuildNewCommands logic from home.js. 61 26 * Takes a list of NSIDs and returns the command names that would be registered. 62 - * 63 - * This mirrors background.js lines 126-188. 64 27 */ 65 28 function computeCommandNames(allNsids) { 66 29 const nameToNsids = new Map(); ··· 140 103 }); 141 104 142 105 test('colliding friendly names get domain label disambiguation', () => { 143 - // Both "app.bsky.feed.post" and "fyi.frontpage.post" map to "Post" 144 - // (frontpage.post has a hardcoded name "Frontpage Post", so let's use 145 - // two unknown NSIDs that collide on the last segment) 146 106 const commands = computeCommandNames([ 147 107 'com.alpha.feed.widget', 148 108 'org.beta.data.widget', ··· 160 120 }); 161 121 162 122 test('no collision when one NSID has known name and other does not', () => { 163 - // "app.bsky.feed.post" -> "Post" (known) 164 - // "com.example.post" -> "Post" (fallback) 165 - // Both map to "Post" so they collide 166 123 const commands = computeCommandNames([ 167 124 'app.bsky.feed.post', 168 125 'com.example.post', ··· 171 128 const bskyPost = commands.find(c => c.nsid === 'app.bsky.feed.post'); 172 129 const exPost = commands.find(c => c.nsid === 'com.example.post'); 173 130 174 - // Both should be disambiguated since they share the friendly name "Post" 175 131 assert.equal(bskyPost.displayName, 'Post (bsky)'); 176 132 assert.equal(exPost.displayName, 'Post (example)'); 177 133 }); ··· 213 169 assert.equal(commands[2].displayName, 'Item (gamma)'); 214 170 }); 215 171 }); 216 - 217 - // ============================================================================ 218 - // Consistency check: background.js and lexicon-ui.js FRIENDLY_NAMES match 219 - // ============================================================================ 220 - 221 - describe('FRIENDLY_NAMES consistency between background.js and lexicon-ui.js', () => { 222 - test('background.js nsidToFriendlyName matches lexicon-ui.js for all known NSIDs', async () => { 223 - // Import the lexicon-ui version 224 - const { nsidToFriendlyName: uiFriendlyName } = await import('../lexicon-ui.js'); 225 - 226 - for (const [nsid, expected] of Object.entries(FRIENDLY_NAMES)) { 227 - const bgResult = nsidToFriendlyName(nsid); 228 - const uiResult = uiFriendlyName(nsid); 229 - assert.equal(bgResult, uiResult, 230 - `Mismatch for ${nsid}: background.js="${bgResult}", lexicon-ui.js="${uiResult}"`); 231 - } 232 - }); 233 - 234 - test('fallback camelCase parsing is identical between both implementations', async () => { 235 - const { nsidToFriendlyName: uiFriendlyName } = await import('../lexicon-ui.js'); 236 - 237 - const unknownNsids = [ 238 - 'com.example.myCustomRecord', 239 - 'org.test.superLongCamelCase', 240 - 'net.foo.simple', 241 - 'xyz.abc.someThing', 242 - ]; 243 - 244 - for (const nsid of unknownNsids) { 245 - const bgResult = nsidToFriendlyName(nsid); 246 - const uiResult = uiFriendlyName(nsid); 247 - assert.equal(bgResult, uiResult, 248 - `Fallback mismatch for ${nsid}: background.js="${bgResult}", lexicon-ui.js="${uiResult}"`); 249 - } 250 - }); 251 - });