PageSourceSearch

https://nostr.net/script.js

js nostr.net collected 2026-10-03 21:44:31 UTC 43,085 bytes, 1,236 lines download raw bytes

1// Move these declarations to the very top of the file
2const darkModeToggle = document.getElementById('darkModeToggle');
3const menuToggle = document.getElementById('menuToggle');
4const sidebar = document.querySelector('.sidebar');
5const body = document.body;
6let touchStartX = 0;
7let touchEndX = 0;
8let touchStartY = 0;
9let touchEndY = 0;
10let isSwiping = false;
11
12// Color theme definitions
13const colorThemes = {
14    default: {
15        light: {
16            primary: '#e05297',
17            background: '#fdf8f3',
18            text: '#2d1b3d',
19            cardBackground: '#ffffff',
20            sidebarBackground: '#f6f0f8',
21            hoverColor: '#fef4fb',
22            linkColor: '#7c5cbf'
23        },
24        dark: {
25            primary: '#ff7eb3',
26            background: '#191020',
27            text: '#ede8f5',
28            cardBackground: '#241b2e',
29            sidebarBackground: '#1e1528',
30            hoverColor: 'rgba(255, 126, 179, 0.06)',
31            linkColor: '#c4b5fd'
32        }
33    },
34    purple: {
35        light: {
36            primary: '#9c528b',
37            background: '#ffffff',
38            text: '#2f0147',
39            cardBackground: '#f7f3f7',
40            sidebarBackground: '#b9929f',
41            hoverColor: '#c470a4'
42        },
43        dark: {
44            background: '#1a0d1a',
45            text: '#e6d6e6',
46            cardBackground: '#301a30',
47            sidebarBackground: '#4a2d4a',
48            linkColor: '#cc88cc'
49        }
50    },
51    nature: {
52        light: {
53            primary: '#2c5530',
54            background: '#ffffff',
55            text: '#1a2f1c',
56            cardBackground: '#f8faf8',
57            sidebarBackground: '#718355',
58            hoverColor: '#90a955'
59        },
60        dark: {
61            background: '#0f1a0f',
62            text: '#d4e6d4',
63            cardBackground: '#1a331a',
64            sidebarBackground: '#264d26',
65            linkColor: '#99dd99'
66        }
67    },
68    sunset: {
69        light: {
70            primary: '#cf5c36',
71            background: '#ffffff',
72            text: '#1f1f1f',
73            cardBackground: '#fdf8f3',
74            sidebarBackground: '#c8963e',
75            hoverColor: '#f3a953'
76        },
77        dark: {
78            background: '#1a0f0a',
79            text: '#ffd4a3',
80            cardBackground: '#2d1a0d',
81            sidebarBackground: '#4d2d1a',
82            linkColor: '#ffaa55'
83        }
84    },
85    grape: {
86        light: {
87            primary: '#642ca9',
88            background: '#ffffff',
89            text: '#2d1a47',
90            cardBackground: '#faf7fd',
91            sidebarBackground: '#9775cc',
92            hoverColor: '#8855cc'
93        },
94        dark: {
95            background: '#0d0a1a',
96            text: '#e6d4ff',
97            cardBackground: '#1a0d33',
98            sidebarBackground: '#33204d',
99            linkColor: '#bb88ff'
100        }
101    },
102    autumn: {
103        light: {
104            primary: '#d95d39',
105            background: '#ffffff',
106            text: '#2d1a0f',
107            cardBackground: '#fdf7f0',
108            sidebarBackground: '#cc8866',
109            hoverColor: '#f18805'
110        },
111        dark: {
112            background: '#1a0f0a',
113            text: '#ffcc99',
114            cardBackground: '#331a0d',
115            sidebarBackground: '#4d2d1a',
116            linkColor: '#ffaa77'
117        }
118    },
119    midnight: {
120        light: {
121            primary: '#b91372',
122            background: '#ffffff',
123            text: '#31081f',
124            cardBackground: '#fdf3f7',
125            sidebarBackground: '#d988aa',
126            hoverColor: '#e6558c'
127        },
128        dark: {
129            background: '#0a0005',
130            text: '#ff99cc',
131            cardBackground: '#260d1a',
132            sidebarBackground: '#4d1a33',
133            linkColor: '#ff77bb'
134        }
135    },
136    slate: {
137        light: {
138            primary: '#475569',
139            background: '#ffffff',
140            text: '#1e293b',
141            cardBackground: '#f8fafc',
142            sidebarBackground: '#94a3b8',
143            hoverColor: '#64748b'
144        },
145        dark: {
146            background: '#0f172a',
147            text: '#cbd5e1',
148            cardBackground: '#1e293b',
149            sidebarBackground: '#334155',
150            linkColor: '#94a3b8'
151        }
152    },
153    forest: {
154        light: {
155            primary: '#16a085',
156            background: '#ffffff',
157            text: '#2c3e50',
158            cardBackground: '#f0fdf4',
159            sidebarBackground: '#52d4a6',
160            hoverColor: '#1abc9c'
161        },
162        dark: {
163            background: '#0a1a12',
164            text: '#a7f3d0',
165            cardBackground: '#064e3b',
166            sidebarBackground: '#047857',
167            linkColor: '#6ee7b7'
168        }
169    },
170    ocean: {
171        light: {
172            primary: '#2563eb',
173            background: '#ffffff',
174            text: '#1e40af',
175            cardBackground: '#eff6ff',
176            sidebarBackground: '#60a5fa',
177            hoverColor: '#3b82f6'
178        },
179        dark: {
180            background: '#0c1429',
181            text: '#bfdbfe',
182            cardBackground: '#1e3a8a',
183            sidebarBackground: '#1d4ed8',
184            linkColor: '#93c5fd'
185        }
186    },
187    cherry: {
188        light: {
189            primary: '#dc2626',
190            background: '#ffffff',
191            text: '#991b1b',
192            cardBackground: '#fef2f2',
193            sidebarBackground: '#f87171',
194            hoverColor: '#ef4444'
195        },
196        dark: {
197            background: '#1a0606',
198            text: '#fecaca',
199            cardBackground: '#7f1d1d',
200            sidebarBackground: '#991b1b',
201            linkColor: '#fb7185'
202        }
203    },
204    cyberpunk: {
205        light: {
206            primary: '#8b5cf6',
207            background: '#fafafa',
208            text: '#171717',
209            cardBackground: '#f5f5f5',
210            sidebarBackground: '#a78bfa',
211            hoverColor: '#7c3aed'
212        },
213        dark: {
214            background: '#0a0a0a',
215            text: '#00ffff',
216            cardBackground: '#1a0a1a',
217            sidebarBackground: '#2a1a2a',
218            linkColor: '#ff0099'
219        }
220    },
221    nord: {
222        light: {
223            primary: '#5e81ac',
224            background: '#eceff4',
225            text: '#2e3440',
226            cardBackground: '#e5e9f0',
227            sidebarBackground: '#81a1c1',
228            hoverColor: '#88c0d0'
229        },
230        dark: {
231            background: '#2e3440',
232            text: '#d8dee9',
233            cardBackground: '#3b4252',
234            sidebarBackground: '#434c5e',
235            linkColor: '#88c0d0'
236        }
237    },
238    warm: {
239        light: {
240            primary: '#f59e0b',
241            background: '#ffffff',
242            text: '#92400e',
243            cardBackground: '#fffbeb',
244            sidebarBackground: '#fbbf24',
245            hoverColor: '#f97316'
246        },
247        dark: {
248            background: '#1c1917',
249            text: '#fed7aa',
250            cardBackground: '#451a03',
251            sidebarBackground: '#78350f',
252            linkColor: '#fb923c'
253        }
254    },
255    dracula: {
256        light: {
257            primary: '#bd93f9',
258            background: '#ffffff',
259            text: '#282a36',
260            cardBackground: '#f8f8f2',
261            sidebarBackground: '#cfc9e3',
262            hoverColor: '#ff79c6'
263        },
264        dark: {
265            background: '#282a36',
266            text: '#f8f8f2',
267            cardBackground: '#44475a',
268            sidebarBackground: '#6272a4',
269            linkColor: '#bd93f9'
270        }
271    }
272
273};
274
275// Initialize all UI controls
276document.addEventListener('DOMContentLoaded', () => {
277    // Dark mode initialization
278    const savedTheme = localStorage.getItem('theme');
279    if (savedTheme) {
280        body.dataset.theme = savedTheme;
281    } else {
282        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
283        body.dataset.theme = prefersDark ? 'dark' : 'light';
284        localStorage.setItem('theme', body.dataset.theme);
285    }
286    
287    // Update dark mode toggle button icon
288    updateDarkModeIcon();
289
290    // Dark mode toggle event listener
291    darkModeToggle.addEventListener('click', () => {
292        body.dataset.theme = body.dataset.theme === 'dark' ? 'light' : 'dark';
293        localStorage.setItem('theme', body.dataset.theme);
294        updateDarkModeIcon();
295        
296        // Reapply color theme when switching dark/light mode
297        const currentColorTheme = localStorage.getItem('colorTheme') || 'default';
298        applyColorTheme(currentColorTheme);
299    });
300
301    // Color theme initialization - color theme select is commented out in HTML
302    const savedColorTheme = localStorage.getItem('colorTheme');
303    const defaultTheme = 'default';
304
305    // Validate saved theme exists and apply it
306    const initialTheme = colorThemes[savedColorTheme] ? savedColorTheme : defaultTheme;
307    applyColorTheme(initialTheme);
308
309    // Test if marked is loaded
310    if (typeof marked === 'undefined') {
311        console.error('marked.js is not loaded!');
312        document.getElementById('resources-container').innerHTML = `
313            <div class="error-message">
314                Error: marked.js library is not loaded properly.
315            </div>`;
316        return;
317    }
318
319    // If everything is working, proceed with main functionality
320    parseAndDisplayContent()
321        .then(() => {
322            console.log("Content successfully parsed and displayed");
323
324            // Show section if page fragment exists
325            if (location.hash) {
326                document.querySelector(`a[href='${location.hash}']`).click();
327            }
328        })
329        .catch(error => {
330            console.error('Error in main content processing:', error);
331            document.getElementById('resources-container').innerHTML = `
332                <div class="error-message">
333                    Error loading content: ${error.message}
334                </div>`;
335        });
336
337    // grantless: banner jumps to the Zappable projects section
338    const fundBanner = document.querySelector('.fund-banner');
339    if (fundBanner) {
340        fundBanner.addEventListener('click', (e) => {
341            e.preventDefault();
342            const link = document.querySelector(`.nav-links a[data-section="${ZAPPABLE_SECTION}"]`);
343            if (link) link.click();
344        });
345    }
346
347    // Ensure we're working with valid elements
348    if (!sidebar || !menuToggle) {
349        console.error('Required elements not found');
350        return;
351    }
352
353    // Single function to handle sidebar state
354    const toggleSidebar = (show) => {
355        if (show === undefined) {
356            sidebar.classList.toggle('active');
357        } else {
358            sidebar.classList[show ? 'add' : 'remove']('active');
359        }
360        // Update aria-expanded state
361        menuToggle.setAttribute('aria-expanded', sidebar.classList.contains('active'));
362    };
363
364    // Menu toggle click handler
365    menuToggle.addEventListener('click', (e) => {
366        e.stopPropagation();
367        toggleSidebar();
368    });
369
370    // Delegate sidebar link clicks using event delegation
371    document.querySelector('.nav-links').addEventListener('click', (e) => {
372        const link = e.target.closest('a');
373        if (!link) return;
374
375        e.preventDefault();
376
377        // Remove active class from all links
378        document.querySelectorAll('.nav-links a').forEach(l => 
379            l.classList.remove('active')
380        );
381        
382        // Add active class to clicked link
383        link.classList.add('active');
384        
385        // Get section name and display it
386        const section = link.getAttribute('data-section');
387        displaySection(section, window.parsedResources);
388        
389        // Close sidebar on mobile
390        if (window.innerWidth <= 768) {
391            toggleSidebar(false);
392        }
393    });
394
395    // Touch handling
396    let touchStartX = 0;
397    let touchStartY = 0;
398    let touchEndX = 0;
399    let touchEndY = 0;
400    let isSwiping = false;
401    const EDGE_ZONE = 30; // px from left edge to trigger swipe
402
403    const handleTouchStart = (e) => {
404        touchStartX = e.touches[0].clientX;
405        touchStartY = e.touches[0].clientY;
406        // Only allow swipe gesture from the left edge or when sidebar is open
407        isSwiping = touchStartX < EDGE_ZONE || sidebar.classList.contains('active');
408    };
409
410    const handleTouchMove = (e) => {
411        if (!isSwiping) return;
412        
413        touchEndX = e.touches[0].clientX;
414        touchEndY = e.touches[0].clientY;
415
416        const deltaX = touchStartX - touchEndX;
417        const deltaY = Math.abs(touchStartY - touchEndY);
418
419        // If vertical movement dominates, this is a scroll, not a swipe
420        if (deltaY > Math.abs(deltaX)) {
421            isSwiping = false;
422            return;
423        }
424
425        // Only prevent default once we're confident this is a horizontal swipe
426        if (Math.abs(deltaX) > 30 && Math.abs(deltaX) > deltaY * 2) {
427            e.preventDefault();
428        }
429    };
430
431    const handleTouchEnd = () => {
432        if (!isSwiping) return;
433        
434        const deltaX = touchStartX - touchEndX;
435        const deltaY = Math.abs(touchStartY - touchEndY);
436        const swipeThreshold = 50;
437
438        if (Math.abs(deltaX) > swipeThreshold && deltaY < 100) {
439            toggleSidebar(deltaX < 0);
440        }
441        
442        isSwiping = false;
443    };
444
445    // Add touch event listeners
446    document.addEventListener('touchstart', handleTouchStart, { passive: true });
447    document.addEventListener('touchmove', handleTouchMove, { passive: false });
448    document.addEventListener('touchend', handleTouchEnd, { passive: true });
449
450    // Close sidebar when clicking outside
451    document.addEventListener('click', (e) => {
452        if (window.innerWidth <= 768 && 
453            !sidebar.contains(e.target) && 
454            !menuToggle.contains(e.target) && 
455            sidebar.classList.contains('active')) {
456            toggleSidebar(false);
457        }
458    });
459
460    // Handle window resize
461    let resizeTimer;
462    window.addEventListener('resize', () => {
463        clearTimeout(resizeTimer);
464        resizeTimer = setTimeout(() => {
465            if (window.innerWidth > 768) {
466                toggleSidebar(true);
467            } else {
468                toggleSidebar(false);
469            }
470        }, 250);
471    });
472});
473
474// Helper function to update dark mode icon
475function updateDarkModeIcon() {
476    darkModeToggle.innerHTML = body.dataset.theme === 'dark' 
477        ? '<i class="fas fa-sun"></i>' 
478        : '<i class="fas fa-moon"></i>';
479}
480
481function handleSwipe() {
482    const swipeThreshold = 100;
483    const swipeDistance = touchStartX - touchEndX;
484    
485    if (swipeDistance > swipeThreshold && sidebar.classList.contains('active')) {
486        sidebar.classList.remove('active');
487    }
488}
489
490// Search functionality
491const searchInput = document.getElementById('search');
492
493searchInput.addEventListener('input', (e) => {
494    const searchTerm = e.target.value.toLowerCase();
495    const container = document.getElementById('resources-container');
496    
497    if (!searchTerm) {
498        // If search is empty, restore current category view
499        // (the nav link's textContent carries the icon markup's whitespace,
500        // so it must be trimmed to match the keys in window.parsedResources)
501        const currentCategory = document.querySelector('.nav-links a.active')?.textContent.trim();
502        if (currentCategory) {
503            displaySection(currentCategory, window.parsedResources);
504        }
505        return;
506    }
507
508    // Clear current container
509    container.innerHTML = '';
510    
511    // Search through all sections
512    Object.entries(window.parsedResources).forEach(([sectionName, sectionContent]) => {
513        sectionContent.forEach(item => {
514            if (item.type === 'resources') {
515                // Process both top-level and nested items for search
516                searchResourceList(item.element, container, searchTerm, sectionName);
517            } else if (item.type === 'content') {
518                // Search through regular content
519                const contentText = item.element.textContent.toLowerCase();
520                if (contentText.includes(searchTerm)) {
521                    const contentDiv = document.createElement('div');
522                    contentDiv.className = 'markdown-content';
523                    contentDiv.innerHTML = item.element.outerHTML;
524                    
525                    // Add section label
526                    const sectionLabel = document.createElement('div');
527                    sectionLabel.className = 'category-label';
528                    sectionLabel.textContent = sectionName;
529                    
530                    container.appendChild(sectionLabel);
531                    container.appendChild(contentDiv);
532                }
533            }
534        });
535    });
536    
537    // Show "no results" message if nothing found
538    if (!container.children.length) {
539        container.innerHTML = `
540            <div class="no-results">
541                No resources found matching "${searchTerm}"
542            </div>
543        `;
544    }
545});
546
547// New helper function to search through resource lists recursively
548function searchResourceList(ulElement, container, searchTerm, sectionName) {
549    Array.from(ulElement.children).forEach(li => {
550        // Search in the main item if it has a link
551        if (li.querySelector(':scope > a')) {
552            const resource = parseListItem(li);
553            const searchableText = [resource.name, resource.description, resource.link]
554                .join(' ')
555                .toLowerCase();
556
557            if (searchableText.includes(searchTerm)) {
558                const card = createResourceCard(resource);
559
560                // Add section label to card
561                const sectionLabel = document.createElement('div');
562                sectionLabel.className = 'category-label';
563                sectionLabel.textContent = sectionName;
564                card.insertBefore(sectionLabel, card.firstChild);
565
566                container.appendChild(card);
567            }
568        }
569
570        // Search in nested items if they exist
571        const nestedUl = li.querySelector(':scope > ul');
572        if (nestedUl) {
573            searchResourceList(nestedUl, container, searchTerm, sectionName);
574        }
575    });
576}
577
578// Add active class handling for navigation
579document.querySelectorAll('.nav-links a').forEach(link => {
580    link.addEventListener('click', () => {
581        document.querySelectorAll('.nav-links a').forEach(l => l.classList.remove('active'));
582        link.classList.add('active');
583        searchInput.value = ''; // Clear search when changing categories
584    });
585});
586
587// Add this function to fetch contributors from GitHub API
588async function fetchContributors(owner, repo) {
589    try {
590        let page = 1;
591        let allContributors = [];
592        
593        while (true) {
594            const response = await fetch(
595                `https://api.github.com/repos/${owner}/${repo}/contributors?per_page=100&page=${page}`
596            );
597            
598            if (!response.ok) throw new Error('Failed to fetch contributors');
599            
600            const contributors = await response.json();
601            if (contributors.length === 0) break; // No more contributors
602            
603            allContributors = [...allContributors, ...contributors];
604            page++;
605            
606            // Check if we've reached the last page
607            const linkHeader = response.headers.get('Link');
608            if (!linkHeader || !linkHeader.includes('rel="next"')) {
609                break;
610            }
611        }
612        
613        console.log(`Total contributors fetched: ${allContributors.length}`);
614        return allContributors;
615        
616    } catch (error) {
617        console.error('Error fetching contributors:', error);
618        return [];
619    }
620}
621
622// Modify the parseResources function to handle contributors differently
623function parseResources(content) {
624    const resources = {
625        'most-popular': [],
626        'protocol': [],
627        'relays': [],
628        'clients': [],
629        'libraries': [],
630        'bridges-and-gateways': [],
631        'cache-services': [],
632        'tools': [],
633        'nip-05-identity-services': [],
634        'offline-signers': [],
635        'vanity-pubkey-mining': [],
636        'peer-to-peer-markets': [],
637        'nip-07-browser-extensions': [],
638        'nip-47-nostr-wallet-connect-nwc-implementations': [],
639        'nip-57-zaps-compatible-wallets-and-solutions': [],
640        'nip-90-data-vending-machines': [],
641        'nip-96-file-storage-servers': [],
642        'nostr-web-services-nws': [],
643        'adjacent-protocols': [],
644        'games-on-nostr': [],
645        'communities': [],
646        'tutorials': [],
647        'recommended-reading-watching': [],
648        'podcasts': [],
649        'other-links': [],
650        'deprecated': [],
651        'related-resources': [],
652        'contributing': [],
653        'contributors': []
654    };
655
656    const lines = content.split('\n');
657    let currentMainSection = '';
658    let currentSubSection = '';
659    let contributingContent = '';
660
661    // Extract repo info from contributors section
662    let repoInfo = null;
663    const repoRegex = /github\.com\/([\w-]+)\/([\w-]+)\/graphs\/contributors/;
664
665    lines.forEach(line => {
666        if (line.startsWith('## ')) {
667            currentMainSection = line.slice(3).trim();
668            currentSubSection = '';
669            console.log('Processing section:', currentMainSection); // Debug log
670        } else if (currentMainSection.toLowerCase() === 'contributors') {
671            const match = line.match(repoRegex);
672            if (match) {
673                repoInfo = {
674                    owner: match[1],
675                    repo: match[2]
676                };
677            }
678        } else if (currentMainSection.toLowerCase() === 'contributing') {
679            if (line.trim() && !line.startsWith('##')) {
680                contributingContent += line + '\n';
681            }
682        }
683        // Detect subsection headers (###)
684        else if (line.startsWith('### ')) {
685            currentSubSection = line.slice(4).trim();
686        }
687        // Parse regular resource lines (starting with '- [')
688        else if (line.trim().startsWith('- [')) {
689            const resource = parseResourceLine(line);
690            if (resource) {
691                // Convert section header to category ID format
692                const categoryId = currentMainSection
693                    .toLowerCase()
694                    .replace(/[^a-z0-9]+/g, '-')
695                    .replace(/(^-|-$)/g, '');
696
697                console.log('Parsed resource:', categoryId, resource); // Debug log
698
699                // Add resource to appropriate category if it exists
700                if (resources[categoryId]) {
701                    resources[categoryId].push(resource);
702                } else {
703                    console.warn('Category not found:', categoryId); // Debug log
704                }
705            }
706        }
707    });
708
709    // Add special handling for contributors and contributing sections
710    resources['contributors'] = [{
711        type: 'github-contributors',
712        repoInfo: repoInfo
713    }];
714    
715    resources['contributing'] = [{
716        type: 'markdown',
717        content: contributingContent.trim()
718    }];
719
720    // Log the parsed data for debugging
721    console.log('Parsed resources:', resources);
722    return resources;
723}
724
725// Function to parse a single resource line
726function parseResourceLine(line) {
727    // Modified regex patterns to be more lenient
728    const nameRegex = /\[(.*?)\]/;
729    const linkRegex = /\((.*?)\)/;
730    const starsRegex = /!\[stars\].*?stars\/(.*?)\/.*?style=social/;
731    
732    // Modified description regex to be optional
733    const descriptionRegex = /(?:\) - )(.*?)(?=(?:\[|\n|$))|(?:style=social\) - )(.*?)(?=(?:\[|\n|$))/;
734
735    try {
736        const nameMatch = nameRegex.exec(line);
737        const linkMatch = linkRegex.exec(line);
738        
739        // Only require name and link to be present
740        if (nameMatch?.[1] && linkMatch?.[1]) {
741            const name = nameMatch[1].trim();
742            const link = linkMatch[1].trim();
743            const stars = starsRegex.exec(line)?.[1];
744            
745            // Make description optional
746            const descMatch = descriptionRegex.exec(line);
747            const description = (descMatch?.[1] || descMatch?.[2] || '').trim();
748
749            return {
750                name,
751                link,
752                stars: stars ? parseInt(stars) : 0,
753                description, // This might be an empty string now
754                raw: line.trim()
755            };
756        }
757    } catch (error) {
758        console.error('Error parsing resource line:', error, line);
759    }
760    return null;
761}
762
763// ===== grantless: zap/donate chip support =====
764// Reads a rendered markdown <li> and extracts the resource link plus the
765// optional chips from the README convention:
766//   - [Name](https://...) - description [⚡ zap](https://nostr.net/grant/?zap=addr@host) [donate](https://...)
767// GitHub's sanitizer strips lightning: links entirely (verified via the
768// GitHub markdown API), so chips carry an https link to our own grant page,
769// which opens the zap dialog (QR / open-wallet / in-page zap). Plain
770// lightning: chips are still recognized for back-compat.
771const LIGHTNING_SCHEME_RE = /^lightning:/i;
772const ZAP_LABEL_RE = /^\s*⚡\s*zap\s*$/i;
773// chip host: our own grant page carries the address in the query string
774const GRANT_CHIP_RE = /^https:\/\/nostr\.net\/grant\/\?zap=([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+)(&|$)/i;
775
776// chip href + anchor label → lightning address, or null
777function lnAddressFromChip(href, label) {
778    href = href || '';
779    if (LIGHTNING_SCHEME_RE.test(href)) return href.replace(LIGHTNING_SCHEME_RE, '');
780    if (ZAP_LABEL_RE.test(label || '')) {
781        const m = href.match(GRANT_CHIP_RE);
782        if (m) return decodeURIComponent(m[1]);
783    }
784    return null;
785}
786
787function parseListItem(li) {
788    let name = '';
789    let link = '';
790    let lnAddress = null;
791    let donateUrl = null;
792
793    Array.from(li.querySelectorAll('a')).forEach(a => {
794        const href = a.getAttribute('href') || '';
795        const label = a.textContent.trim();
796        const chipAddr = lnAddressFromChip(href, label);
797        if (chipAddr) {
798            if (lnAddress === null) lnAddress = chipAddr;
799        } else if (label.toLowerCase() === 'donate' && /^https?:/i.test(href)) {
800            if (donateUrl === null) donateUrl = href;
801        } else if (!name) {
802            name = label;
803            link = href;
804        }
805    });
806
807    // Description = li text minus the name, chips, stars badge and nested lists
808    const clone = li.cloneNode(true);
809    clone.querySelectorAll('ul').forEach(u => u.remove());
810    clone.querySelectorAll('img').forEach(i => i.remove());
811    clone.querySelectorAll('a').forEach(a => {
812        const href = a.getAttribute('href') || '';
813        const label = a.textContent.trim();
814        if (lnAddressFromChip(href, label) || label.toLowerCase() === 'donate') a.remove();
815    });
816    let description = clone.textContent.replace(/\s+/g, ' ').trim();
817    if (name && description.toLowerCase().startsWith(name.toLowerCase())) {
818        description = description.slice(name.length).trim();
819    }
820    description = description.replace(/^[-–—:]\s*/, '').trim();
821
822    const starsImg = li.querySelector('img[alt="stars"]');
823    return {
824        name,
825        link,
826        description,
827        stars: starsImg ? parseInt(starsImg.src.match(/stars\/(\d+)/)?.[1]) || 0 : 0,
828        lnAddress,
829        donateUrl
830    };
831}
832
833// ===== grantless: aggregated "Zappable projects" section =====
834const CURATOR_LN_ADDRESS = '[email protected]';
835const ZAPPABLE_SECTION = 'Zappable projects';
836
837// Builds a synthetic resource list containing nostr.net (curator, pinned)
838// plus every entry from any section that carries a lightning: chip.
839function buildZappableSection(sections) {
840    const ul = document.createElement('ul');
841
842    // nostr.net itself — curator & host, always first
843    const curator = document.createElement('li');
844    curator.innerHTML = `<a href="https://nostr.net">nostr.net</a> - Curator &amp; host — this list and relay.nostr.net <a href="lightning:${CURATOR_LN_ADDRESS}">⚡ zap</a>`;
845    ul.appendChild(curator);
846
847    // collect zappable entries from all sections, nested lists included
848    const collect = (list) => {
849        Array.from(list.children).forEach(li => {
850            if (parseListItem(li).lnAddress) ul.appendChild(li.cloneNode(true));
851            const nested = li.querySelector(':scope > ul');
852            if (nested) collect(nested);
853        });
854    };
855    Object.values(sections).forEach(content =>
856        content.forEach(item => { if (item.type === 'resources') collect(item.element); })
857    );
858
859    return [{ type: 'resources', element: ul }];
860}
861
862// Modified createResourceCard function to display markdown links in a cleaner format
863function createResourceCard(resource) {
864    const card = document.createElement('div');
865    card.className = 'resource-card';
866    
867    // Add schema.org structured data
868    card.setAttribute('itemscope', '');
869    card.setAttribute('itemtype', 'https://schema.org/SoftwareApplication');
870    
871    // Extract domain and build multiple fallback favicon URLs
872    let faviconUrl = '';
873    try {
874        const url = new URL(resource.link);
875        const domain = url.hostname;
876        
877        // Try multiple favicon sources
878        const faviconSources = [
879            `https://www.google.com/s2/favicons?domain=https://${domain}&sz=64`,
880            `https://${domain}/favicon.ico`,
881            `https://${domain}/favicon.png`,
882            `https://${domain}/apple-touch-icon.png`,
883            `https://${domain}/apple-touch-icon-precomposed.png`
884        ];
885
886        // Create image element with fallback chain
887        const img = document.createElement('img');
888        img.className = 'resource-favicon';
889        img.alt = '';
890        
891        // Set first source as initial
892        img.src = faviconSources[0];
893        
894        // Add error handling to try next source
895        let sourceIndex = 0;
896        img.onerror = () => {
897            sourceIndex++;
898            if (sourceIndex < faviconSources.length) {
899                img.src = faviconSources[sourceIndex];
900            } else {
901                // If all sources fail, use a default icon
902                img.src = 'data:image/svg+xml,' + encodeURIComponent(`
903                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="32" height="32">
904                        <circle cx="50" cy="50" r="40" fill="#ccc"/>
905                        <text x="50" y="65" font-size="40" text-anchor="middle" fill="#fff">
906                            ${resource.name.charAt(0).toUpperCase()}
907                        </text>
908                    </svg>
909                `);
910                img.onerror = null; // Remove error handler once default is shown
911            }
912        };
913
914        faviconUrl = img.outerHTML;
915    } catch (e) {
916        console.warn('Invalid URL:', resource.link);
917        // Use default icon for invalid URLs
918        faviconUrl = `<div class="resource-favicon-fallback">${resource.name.charAt(0).toUpperCase()}</div>`;
919    }
920    
921    card.innerHTML = `
922        <div class="resource-header">
923            ${faviconUrl}
924            <div class="resource-content">
925                <h3 class="resource-title">
926                    <a href="${resource.link}" 
927                       target="_blank" 
928                       rel="noopener"
929                       itemprop="name">
930                        ${resource.name}
931                    </a>
932                    ${resource.stars ? `
933                        <div class="resource-stars" itemprop="aggregateRating" itemscope itemtype="https://schema.org/AggregateRating">
934                            <i class="fas fa-star" aria-hidden="true"></i>
935                            <span itemprop="ratingValue">${resource.stars}</span>
936                            <meta itemprop="ratingCount" content="${resource.stars}">
937                        </div>
938                    ` : ''}
939                </h3>
940                ${resource.description ? `
941                    <div class="resource-description" itemprop="description">
942                        ${resource.description}
943                    </div>
944                ` : ''}
945                ${(resource.lnAddress || resource.donateUrl) ? `
946                    <div class="resource-chips">
947                        ${resource.lnAddress ? `
948                            <a class="zap-chip" href="lightning:${resource.lnAddress}"
949                               title="Zap ${resource.name} · ${resource.lnAddress}"
950                               data-ln-address="${resource.lnAddress}">
951                                <i class="fas fa-bolt" aria-hidden="true"></i> Zap
952                            </a>` : ''}
953                        ${resource.donateUrl ? `
954                            <a class="donate-chip" href="${resource.donateUrl}" target="_blank" rel="noopener">
955                                <i class="fas fa-heart" aria-hidden="true"></i> Donate
956                            </a>` : ''}
957                    </div>` : ''}
958            </div>
959        </div>
960    `;
961
962    return card;
963}
964
965// Update createSpecialSectionCard to handle GitHub contributors
966async function createSpecialSectionCard(resource) {
967    const card = document.createElement('div');
968    card.className = 'resource-card';
969
970    if (resource.type === 'github-contributors') {
971        card.className += ' contributors-card';
972        card.innerHTML = `
973            <div class="resource-title">Contributors</div>
974            <div class="contributors-grid">
975                <div class="loading">Loading contributors...</div>
976            </div>
977        `;
978
979        if (resource.repoInfo) {
980            console.log('Fetching contributors for:', resource.repoInfo); // Debug log
981            const contributors = await fetchContributors(resource.repoInfo.owner, resource.repoInfo.repo);
982            console.log('Number of contributors:', contributors.length); // Debug log
983            const contributorsHtml = contributors.map(contributor => `
984                <a href="${contributor.html_url}" target="_blank" title="${contributor.login}">
985                    <img src="${contributor.avatar_url}" alt="${contributor.login}" />
986                    <span>${contributor.login}</span>
987                </a>
988            `).join('');
989
990            const grid = card.querySelector('.contributors-grid');
991            grid.innerHTML = contributorsHtml || 'No contributors found';
992        }
993    } else if (resource.type === 'markdown') {
994        // For contributing section
995        const formattedContent = resource.content
996            .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
997        
998        card.innerHTML = `
999            <div class="resource-title">How to Contribute</div>
1000            <div class="resource-description">
1001                ${formattedContent}
1002            </div>
1003        `;
1004    }
1005
1006    return card;
1007}
1008
1009// Update populateResources to handle both special and regular resources
1010async function populateResources(categoryId, resources) {
1011    const container = document.getElementById('resources-container');
1012    container.innerHTML = '';
1013
1014    const categoryResources = resources[categoryId];
1015    if (!categoryResources) return;
1016
1017    for (const resource of categoryResources) {
1018        let card;
1019        if (resource.type) {
1020            // Handle special sections (contributors and contributing)
1021            card = await createSpecialSectionCard(resource);
1022        } else {
1023            // Handle regular resource cards
1024            card = createResourceCard(resource);
1025        }
1026        container.appendChild(card);
1027    }
1028}
1029
1030// Function to get icon for category
1031function getCategoryIcon(category) {
1032    const iconMap = {
1033        'most-popular': 'fa-star',
1034        'protocol': 'fa-book',
1035        'relays': 'fa-server',
1036        'clients': 'fa-mobile-alt',
1037        'libraries': 'fa-code',
1038        'bridges-and-gateways': 'fa-bridge',
1039        'cache-services': 'fa-database',
1040        'tools': 'fa-wrench',
1041        'nip-05-identity-services': 'fa-id-card',
1042        'offline-signers': 'fa-signature',
1043        'vanity-pubkey-mining': 'fa-hammer',
1044        'peer-to-peer-markets': 'fa-store',
1045        'nip-07-browser-extensions': 'fa-puzzle-piece',
1046        'nip-47-nostr-wallet-connect-nwc-implementations': 'fa-wallet',
1047        'nip-57-zaps-compatible-wallets-and-solutions': 'fa-bolt',
1048        'nip-90-data-vending-machines': 'fa-store-alt',
1049        'nip-96-file-storage-servers': 'fa-folder',
1050        'nostr-web-services-nws': 'fa-globe',
1051        'adjacent-protocols': 'fa-link',
1052        'games-on-nostr': 'fa-gamepad',
1053        'communities': 'fa-users',
1054        'tutorials': 'fa-graduation-cap',
1055        'recommended-reading-watching': 'fa-book-reader',
1056        'podcasts': 'fa-podcast',
1057        'other-links': 'fa-external-link-alt',
1058        'deprecated': 'fa-archive',
1059        'related-resources': 'fa-project-diagram',
1060        'contributing': 'fa-hands-helping',
1061        'contributors': 'fa-users-cog',
1062        // Default icon if category not found
1063        'default': 'fa-circle'
1064    };
1065
1066    const key = category.toLowerCase().replace(/[^a-z0-9]+/g, '-');
1067    return iconMap[key] || iconMap.default;
1068}
1069
1070// Function to generate navigation from content
1071function generateNavigation(sectionNames) {
1072    const navList = document.querySelector('.nav-links');
1073    navList.innerHTML = ''; // Clear existing navigation
1074
1075    sectionNames.forEach(section => {
1076        // Create URL-friendly ID
1077        const sectionId = section
1078            .toLowerCase()
1079            .replace(/[^a-z0-9]+/g, '-')
1080            .replace(/(^-|-$)/g, '');
1081
1082        const li = document.createElement('li');
1083        li.innerHTML = `
1084            <a href="#${sectionId}" data-section="${section}">
1085                <i class="fas fa-chevron-right"></i>
1086                ${section}
1087            </a>
1088        `;
1089
1090        // Add click handler
1091        li.querySelector('a').addEventListener('click', (e) => {
1092            e.preventDefault();
1093            // Remove active class from all links
1094            document.querySelectorAll('.nav-links a').forEach(a => 
1095                a.classList.remove('active')
1096            );
1097            // Add active class to clicked link
1098            e.target.classList.add('active');
1099            // Display the section
1100            displaySection(section, window.parsedResources);
1101        });
1102
1103        navList.appendChild(li);
1104    });
1105
1106    // Set first item as active
1107    const firstLink = navList.querySelector('a');
1108    if (firstLink) {
1109        firstLink.classList.add('active');
1110    }
1111}
1112
1113async function parseAndDisplayContent() {
1114    try {
1115        const response = await fetch('./README.md');
1116        if (!response.ok) {
1117            throw new Error(`HTTP error! status: ${response.status}`);
1118        }
1119        const content = await response.text();
1120
1121        // Parse the markdown into HTML
1122        const htmlContent = marked.parse(content);
1123
1124        // Create a temporary element to parse the HTML
1125        const tempDiv = document.createElement('div');
1126        tempDiv.innerHTML = htmlContent;
1127
1128        // Get all sections (h2 headers and their content)
1129        const sections = {};
1130        let currentSection = null;
1131        let currentSectionContent = [];
1132
1133        Array.from(tempDiv.children).forEach(element => {
1134            if (element.tagName === 'H2') {
1135                // If we have a previous section, save it
1136                if (currentSection) {
1137                    sections[currentSection] = currentSectionContent;
1138                }
1139                // Start new section
1140                currentSection = element.textContent.trim();
1141                currentSectionContent = [];
1142            } else if (currentSection) {
1143                currentSectionContent.push({
1144                    type: element.tagName === 'UL' ? 'resources' : 'content',
1145                    element: element
1146                });
1147            }
1148        });
1149
1150        // Save the last section
1151        if (currentSection) {
1152            sections[currentSection] = currentSectionContent;
1153        }
1154
1155        // grantless: insert the aggregated zappable section after the first section
1156        const withZappable = {};
1157        const originalEntries = Object.entries(sections);
1158        if (originalEntries.length) {
1159            withZappable[originalEntries[0][0]] = originalEntries[0][1];
1160        }
1161        withZappable[ZAPPABLE_SECTION] = buildZappableSection(sections);
1162        originalEntries.slice(1).forEach(([name, content]) => { withZappable[name] = content; });
1163
1164        // Store sections globally
1165        window.parsedResources = withZappable;
1166
1167        // Generate navigation
1168        const sectionNames = Object.keys(withZappable);
1169        generateNavigation(sectionNames);
1170
1171        // Display initial section
1172        if (sectionNames.length > 0) {
1173            displaySection(sectionNames[0], withZappable);
1174        }
1175
1176    } catch (error) {
1177        console.error('Error processing markdown:', error);
1178        throw error; // Re-throw to be caught by the main error handler
1179    }
1180}
1181
1182function displaySection(sectionName, sections) {
1183    const container = document.getElementById('resources-container');
1184    container.innerHTML = '';
1185
1186    // Add section header
1187    const header = document.createElement('h2');
1188    header.textContent = sectionName;
1189    container.appendChild(header);
1190
1191    // Display section content
1192    sections[sectionName].forEach(item => {
1193        if (item.type === 'content') {
1194            // Regular markdown content
1195            const contentDiv = document.createElement('div');
1196            contentDiv.className = 'markdown-content';
1197            contentDiv.innerHTML = item.element.outerHTML;
1198            container.appendChild(contentDiv);
1199        } else if (item.type === 'resources') {
1200            // Resource list
1201            Array.from(item.element.children).forEach(li => {
1202                const card = createResourceCard(parseListItem(li));
1203                container.appendChild(card);
1204            });
1205        }
1206    });
1207
1208    window.scrollTo(0, 0);
1209}
1210
1211function applyColorTheme(themeName) {
1212    // Check if theme exists, otherwise fallback to default
1213    const themeExists = colorThemes[themeName];
1214    if (!themeExists) {
1215        console.warn(`Theme "${themeName}" not found, falling back to default theme`);
1216        themeName = 'default';
1217    }
1218
1219    const isDark = document.body.dataset.theme === 'dark';
1220    const theme = colorThemes[themeName][isDark ? 'dark' : 'light'];
1221    
1222    if (!theme) {
1223        console.error(`Could not find ${isDark ? 'dark' : 'light'} mode for theme "${themeName}"`);
1224        return;
1225    }
1226    
1227    // Apply theme colors to CSS variables
1228    const root = document.documentElement;
1229    Object.entries(theme).forEach(([key, value]) => {
1230        const cssVar = `--${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
1231        root.style.setProperty(cssVar, value);
1232    });
1233
1234    // Store the successfully applied theme
1235    localStorage.setItem('colorTheme', themeName);
1236}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.