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 & 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.