From 8ea91e39d8a8a8e147b779953e3c3fe00ac19fd3 Mon Sep 17 00:00:00 2001 From: mleku Date: Sat, 6 Dec 2025 06:56:57 +0000 Subject: [PATCH] Add Claude Code skills for web frontend frameworks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Svelte 3/4 skill covering components, reactivity, stores, lifecycle - Add Rollup skill covering configuration, plugins, code splitting - Add nostr-tools skill covering event creation, signing, relay communication - Add applesauce-core skill covering event stores, reactive queries - Add applesauce-signers skill covering NIP-07/NIP-46 signing abstractions - Update .gitignore to include .claude/** directory 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 5 +- .claude/skills/applesauce-core/SKILL.md | 634 ++++++++++++ .claude/skills/applesauce-signers/SKILL.md | 757 +++++++++++++++ .claude/skills/nostr-tools/SKILL.md | 767 +++++++++++++++ .claude/skills/rollup/SKILL.md | 899 ++++++++++++++++++ .claude/skills/svelte/SKILL.md | 1004 ++++++++++++++++++++ .gitignore | 1 + 7 files changed, 4066 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/applesauce-core/SKILL.md create mode 100644 .claude/skills/applesauce-signers/SKILL.md create mode 100644 .claude/skills/nostr-tools/SKILL.md create mode 100644 .claude/skills/rollup/SKILL.md create mode 100644 .claude/skills/svelte/SKILL.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ccbaec8..e24b021 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,10 @@ "Bash(./scripts/test.sh:*)", "Bash(./scripts/update-embedded-web.sh:*)", "Bash(bun run build:*)", - "Bash(bun update:*)" + "Bash(bun update:*)", + "Bash(cat:*)", + "Bash(git add:*)", + "Bash(git commit:*)" ], "deny": [], "ask": [] diff --git a/.claude/skills/applesauce-core/SKILL.md b/.claude/skills/applesauce-core/SKILL.md new file mode 100644 index 0000000..7830711 --- /dev/null +++ b/.claude/skills/applesauce-core/SKILL.md @@ -0,0 +1,634 @@ +--- +name: applesauce-core +description: This skill should be used when working with applesauce-core library for Nostr client development, including event stores, queries, observables, and client utilities. Provides comprehensive knowledge of applesauce patterns for building reactive Nostr applications. +--- + +# applesauce-core Skill + +This skill provides comprehensive knowledge and patterns for working with applesauce-core, a library that provides reactive utilities and patterns for building Nostr clients. + +## When to Use This Skill + +Use this skill when: +- Building reactive Nostr applications +- Managing event stores and caches +- Working with observable patterns for Nostr +- Implementing real-time updates +- Building timeline and feed views +- Managing replaceable events +- Working with profiles and metadata +- Creating efficient Nostr queries + +## Core Concepts + +### applesauce-core Overview + +applesauce-core provides: +- **Event stores** - Reactive event caching and management +- **Queries** - Declarative event querying patterns +- **Observables** - RxJS-based reactive patterns +- **Profile helpers** - Profile metadata management +- **Timeline utilities** - Feed and timeline building +- **NIP helpers** - NIP-specific utilities + +### Installation + +```bash +npm install applesauce-core +``` + +### Basic Architecture + +applesauce-core is built on reactive principles: +- Events are stored in reactive stores +- Queries return observables that update when new events arrive +- Components subscribe to observables for real-time updates + +## Event Store + +### Creating an Event Store + +```javascript +import { EventStore } from 'applesauce-core'; + +// Create event store +const eventStore = new EventStore(); + +// Add events +eventStore.add(event1); +eventStore.add(event2); + +// Add multiple events +eventStore.addMany([event1, event2, event3]); + +// Check if event exists +const exists = eventStore.has(eventId); + +// Get event by ID +const event = eventStore.get(eventId); + +// Remove event +eventStore.remove(eventId); + +// Clear all events +eventStore.clear(); +``` + +### Event Store Queries + +```javascript +// Get all events +const allEvents = eventStore.getAll(); + +// Get events by filter +const filtered = eventStore.filter({ + kinds: [1], + authors: [pubkey] +}); + +// Get events by author +const authorEvents = eventStore.getByAuthor(pubkey); + +// Get events by kind +const textNotes = eventStore.getByKind(1); +``` + +### Replaceable Events + +applesauce-core handles replaceable events automatically: + +```javascript +// For kind 0 (profile), only latest is kept +eventStore.add(profileEvent1); // stored +eventStore.add(profileEvent2); // replaces if newer + +// For parameterized replaceable (30000-39999) +eventStore.add(articleEvent); // keyed by author + kind + d-tag + +// Get replaceable event +const profile = eventStore.getReplaceable(0, pubkey); +const article = eventStore.getReplaceable(30023, pubkey, 'article-slug'); +``` + +## Queries + +### Query Patterns + +```javascript +import { createQuery } from 'applesauce-core'; + +// Create a query +const query = createQuery(eventStore, { + kinds: [1], + limit: 50 +}); + +// Subscribe to query results +query.subscribe(events => { + console.log('Current events:', events); +}); + +// Query updates automatically when new events added +eventStore.add(newEvent); // Subscribers notified +``` + +### Timeline Query + +```javascript +import { TimelineQuery } from 'applesauce-core'; + +// Create timeline for user's notes +const timeline = new TimelineQuery(eventStore, { + kinds: [1], + authors: [userPubkey] +}); + +// Get observable of timeline +const timeline$ = timeline.events$; + +// Subscribe +timeline$.subscribe(events => { + // Events sorted by created_at, newest first + renderTimeline(events); +}); +``` + +### Profile Query + +```javascript +import { ProfileQuery } from 'applesauce-core'; + +// Query profile metadata +const profileQuery = new ProfileQuery(eventStore, pubkey); + +// Get observable +const profile$ = profileQuery.profile$; + +profile$.subscribe(profile => { + if (profile) { + console.log('Name:', profile.name); + console.log('Picture:', profile.picture); + } +}); +``` + +## Observables + +### Working with RxJS + +applesauce-core uses RxJS observables: + +```javascript +import { map, filter, distinctUntilChanged } from 'rxjs/operators'; + +// Transform query results +const names$ = profileQuery.profile$.pipe( + filter(profile => profile !== null), + map(profile => profile.name), + distinctUntilChanged() +); + +// Combine multiple observables +import { combineLatest } from 'rxjs'; + +const combined$ = combineLatest([ + timeline$, + profile$ +]).pipe( + map(([events, profile]) => ({ + events, + authorName: profile?.name + })) +); +``` + +### Creating Custom Observables + +```javascript +import { Observable } from 'rxjs'; + +function createEventObservable(store, filter) { + return new Observable(subscriber => { + // Initial emit + subscriber.next(store.filter(filter)); + + // Subscribe to store changes + const unsubscribe = store.onChange(() => { + subscriber.next(store.filter(filter)); + }); + + // Cleanup + return () => unsubscribe(); + }); +} +``` + +## Profile Helpers + +### Profile Metadata + +```javascript +import { parseProfile, ProfileContent } from 'applesauce-core'; + +// Parse kind 0 content +const profileEvent = await getProfileEvent(pubkey); +const profile = parseProfile(profileEvent); + +// Profile fields +console.log(profile.name); // Display name +console.log(profile.about); // Bio +console.log(profile.picture); // Avatar URL +console.log(profile.banner); // Banner image URL +console.log(profile.nip05); // NIP-05 identifier +console.log(profile.lud16); // Lightning address +console.log(profile.website); // Website URL +``` + +### Profile Store + +```javascript +import { ProfileStore } from 'applesauce-core'; + +const profileStore = new ProfileStore(eventStore); + +// Get profile observable +const profile$ = profileStore.getProfile(pubkey); + +// Get multiple profiles +const profiles$ = profileStore.getProfiles([pubkey1, pubkey2]); + +// Request profile load (triggers fetch if not cached) +profileStore.requestProfile(pubkey); +``` + +## Timeline Utilities + +### Building Feeds + +```javascript +import { Timeline } from 'applesauce-core'; + +// Create timeline +const timeline = new Timeline(eventStore); + +// Add filter +timeline.setFilter({ + kinds: [1, 6], + authors: followedPubkeys +}); + +// Get events observable +const events$ = timeline.events$; + +// Load more (pagination) +timeline.loadMore(50); + +// Refresh (get latest) +timeline.refresh(); +``` + +### Thread Building + +```javascript +import { ThreadBuilder } from 'applesauce-core'; + +// Build thread from root event +const thread = new ThreadBuilder(eventStore, rootEventId); + +// Get thread observable +const thread$ = thread.thread$; + +thread$.subscribe(threadData => { + console.log('Root:', threadData.root); + console.log('Replies:', threadData.replies); + console.log('Reply count:', threadData.replyCount); +}); +``` + +### Reactions and Zaps + +```javascript +import { ReactionStore, ZapStore } from 'applesauce-core'; + +// Reactions +const reactionStore = new ReactionStore(eventStore); +const reactions$ = reactionStore.getReactions(eventId); + +reactions$.subscribe(reactions => { + console.log('Likes:', reactions.likes); + console.log('Custom:', reactions.custom); +}); + +// Zaps +const zapStore = new ZapStore(eventStore); +const zaps$ = zapStore.getZaps(eventId); + +zaps$.subscribe(zaps => { + console.log('Total sats:', zaps.totalAmount); + console.log('Zap count:', zaps.count); +}); +``` + +## NIP Helpers + +### NIP-05 Verification + +```javascript +import { verifyNip05 } from 'applesauce-core'; + +// Verify NIP-05 +const result = await verifyNip05('alice@example.com', expectedPubkey); + +if (result.valid) { + console.log('NIP-05 verified'); +} else { + console.log('Verification failed:', result.error); +} +``` + +### NIP-10 Reply Parsing + +```javascript +import { parseReplyTags } from 'applesauce-core'; + +// Parse reply structure +const parsed = parseReplyTags(event); + +console.log('Root event:', parsed.root); +console.log('Reply to:', parsed.reply); +console.log('Mentions:', parsed.mentions); +``` + +### NIP-65 Relay Lists + +```javascript +import { parseRelayList } from 'applesauce-core'; + +// Parse relay list event (kind 10002) +const relays = parseRelayList(relayListEvent); + +console.log('Read relays:', relays.read); +console.log('Write relays:', relays.write); +``` + +## Integration with nostr-tools + +### Using with SimplePool + +```javascript +import { SimplePool } from 'nostr-tools'; +import { EventStore } from 'applesauce-core'; + +const pool = new SimplePool(); +const eventStore = new EventStore(); + +// Load events into store +pool.subscribeMany(relays, [filter], { + onevent(event) { + eventStore.add(event); + } +}); + +// Query store reactively +const timeline$ = createTimelineQuery(eventStore, filter); +``` + +### Publishing Events + +```javascript +import { finalizeEvent } from 'nostr-tools'; + +// Create event +const event = finalizeEvent({ + kind: 1, + content: 'Hello!', + created_at: Math.floor(Date.now() / 1000), + tags: [] +}, secretKey); + +// Add to local store immediately (optimistic update) +eventStore.add(event); + +// Publish to relays +await pool.publish(relays, event); +``` + +## Svelte Integration + +### Using in Svelte Components + +```svelte + + +{#each events as event} +
+ {event.content} +
+{/each} +``` + +### Svelte Store Adapter + +```javascript +import { readable } from 'svelte/store'; + +// Convert RxJS observable to Svelte store +function fromObservable(observable, initialValue) { + return readable(initialValue, set => { + const subscription = observable.subscribe(set); + return () => subscription.unsubscribe(); + }); +} + +// Usage +const events$ = timeline.events$; +const eventsStore = fromObservable(events$, []); +``` + +```svelte + + +{#each $eventsStore as event} +
{event.content}
+{/each} +``` + +## Best Practices + +### Store Management + +1. **Single store instance** - Use one EventStore per app +2. **Clear stale data** - Implement cache limits +3. **Handle replaceable events** - Let store manage deduplication +4. **Unsubscribe** - Clean up subscriptions on component destroy + +### Query Optimization + +1. **Use specific filters** - Narrow queries perform better +2. **Limit results** - Use limit for initial loads +3. **Cache queries** - Reuse query instances +4. **Debounce updates** - Throttle rapid changes + +### Memory Management + +1. **Limit store size** - Implement LRU or time-based eviction +2. **Clean up observables** - Unsubscribe when done +3. **Use weak references** - For profile caches +4. **Paginate large feeds** - Don't load everything at once + +### Reactive Patterns + +1. **Prefer observables** - Over imperative queries +2. **Use operators** - Transform data with RxJS +3. **Combine streams** - For complex views +4. **Handle loading states** - Show placeholders + +## Common Patterns + +### Event Deduplication + +```javascript +// EventStore handles deduplication automatically +eventStore.add(event1); +eventStore.add(event1); // No duplicate + +// For manual deduplication +const seen = new Set(); +events.filter(e => { + if (seen.has(e.id)) return false; + seen.add(e.id); + return true; +}); +``` + +### Optimistic Updates + +```javascript +async function publishNote(content) { + // Create event + const event = await createEvent(content); + + // Add to store immediately (optimistic) + eventStore.add(event); + + try { + // Publish to relays + await pool.publish(relays, event); + } catch (error) { + // Remove on failure + eventStore.remove(event.id); + throw error; + } +} +``` + +### Loading States + +```javascript +import { BehaviorSubject, combineLatest } from 'rxjs'; + +const loading$ = new BehaviorSubject(true); +const events$ = timeline.events$; + +const state$ = combineLatest([loading$, events$]).pipe( + map(([loading, events]) => ({ + loading, + events, + empty: !loading && events.length === 0 + })) +); + +// Start loading +loading$.next(true); +await loadEvents(); +loading$.next(false); +``` + +### Infinite Scroll + +```javascript +function createInfiniteScroll(timeline, pageSize = 50) { + let loading = false; + + async function loadMore() { + if (loading) return; + + loading = true; + await timeline.loadMore(pageSize); + loading = false; + } + + function onScroll(event) { + const { scrollTop, scrollHeight, clientHeight } = event.target; + if (scrollHeight - scrollTop <= clientHeight * 1.5) { + loadMore(); + } + } + + return { loadMore, onScroll }; +} +``` + +## Troubleshooting + +### Common Issues + +**Events not updating:** +- Check subscription is active +- Verify events are being added to store +- Ensure filter matches events + +**Memory growing:** +- Implement store size limits +- Clean up subscriptions +- Use weak references where appropriate + +**Slow queries:** +- Add indexes for common queries +- Use more specific filters +- Implement pagination + +**Stale data:** +- Implement refresh mechanisms +- Set up real-time subscriptions +- Handle replaceable event updates + +## References + +- **applesauce GitHub**: https://github.com/hzrd149/applesauce +- **RxJS Documentation**: https://rxjs.dev +- **nostr-tools**: https://github.com/nbd-wtf/nostr-tools +- **Nostr Protocol**: https://github.com/nostr-protocol/nostr + +## Related Skills + +- **nostr-tools** - Lower-level Nostr operations +- **applesauce-signers** - Event signing abstractions +- **svelte** - Building reactive UIs +- **nostr** - Nostr protocol fundamentals diff --git a/.claude/skills/applesauce-signers/SKILL.md b/.claude/skills/applesauce-signers/SKILL.md new file mode 100644 index 0000000..40d07e6 --- /dev/null +++ b/.claude/skills/applesauce-signers/SKILL.md @@ -0,0 +1,757 @@ +--- +name: applesauce-signers +description: This skill should be used when working with applesauce-signers library for Nostr event signing, including NIP-07 browser extensions, NIP-46 remote signing, and custom signer implementations. Provides comprehensive knowledge of signing patterns and signer abstractions. +--- + +# applesauce-signers Skill + +This skill provides comprehensive knowledge and patterns for working with applesauce-signers, a library that provides signing abstractions for Nostr applications. + +## When to Use This Skill + +Use this skill when: +- Implementing event signing in Nostr applications +- Integrating with NIP-07 browser extensions +- Working with NIP-46 remote signers +- Building custom signer implementations +- Managing signing sessions +- Handling signing requests and permissions +- Implementing multi-signer support + +## Core Concepts + +### applesauce-signers Overview + +applesauce-signers provides: +- **Signer abstraction** - Unified interface for different signers +- **NIP-07 integration** - Browser extension support +- **NIP-46 support** - Remote signing (Nostr Connect) +- **Simple signers** - Direct key signing +- **Permission handling** - Manage signing requests +- **Observable patterns** - Reactive signing states + +### Installation + +```bash +npm install applesauce-signers +``` + +### Signer Interface + +All signers implement a common interface: + +```typescript +interface Signer { + // Get public key + getPublicKey(): Promise; + + // Sign event + signEvent(event: UnsignedEvent): Promise; + + // Encrypt (NIP-04) + nip04Encrypt?(pubkey: string, plaintext: string): Promise; + nip04Decrypt?(pubkey: string, ciphertext: string): Promise; + + // Encrypt (NIP-44) + nip44Encrypt?(pubkey: string, plaintext: string): Promise; + nip44Decrypt?(pubkey: string, ciphertext: string): Promise; +} +``` + +## Simple Signer + +### Using Secret Key + +```javascript +import { SimpleSigner } from 'applesauce-signers'; +import { generateSecretKey } from 'nostr-tools'; + +// Create signer with existing key +const signer = new SimpleSigner(secretKey); + +// Or generate new key +const newSecretKey = generateSecretKey(); +const newSigner = new SimpleSigner(newSecretKey); + +// Get public key +const pubkey = await signer.getPublicKey(); + +// Sign event +const unsignedEvent = { + kind: 1, + content: 'Hello Nostr!', + created_at: Math.floor(Date.now() / 1000), + tags: [] +}; + +const signedEvent = await signer.signEvent(unsignedEvent); +``` + +### NIP-04 Encryption + +```javascript +// Encrypt message +const ciphertext = await signer.nip04Encrypt( + recipientPubkey, + 'Secret message' +); + +// Decrypt message +const plaintext = await signer.nip04Decrypt( + senderPubkey, + ciphertext +); +``` + +### NIP-44 Encryption + +```javascript +// Encrypt with NIP-44 (preferred) +const ciphertext = await signer.nip44Encrypt( + recipientPubkey, + 'Secret message' +); + +// Decrypt +const plaintext = await signer.nip44Decrypt( + senderPubkey, + ciphertext +); +``` + +## NIP-07 Signer + +### Browser Extension Integration + +```javascript +import { Nip07Signer } from 'applesauce-signers'; + +// Check if extension is available +if (window.nostr) { + const signer = new Nip07Signer(); + + // Get public key (may prompt user) + const pubkey = await signer.getPublicKey(); + + // Sign event (prompts user) + const signedEvent = await signer.signEvent(unsignedEvent); +} +``` + +### Handling Extension Availability + +```javascript +function getAvailableSigner() { + if (typeof window !== 'undefined' && window.nostr) { + return new Nip07Signer(); + } + return null; +} + +// Wait for extension to load +async function waitForExtension(timeout = 3000) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (window.nostr) { + return new Nip07Signer(); + } + await new Promise(r => setTimeout(r, 100)); + } + + return null; +} +``` + +### Extension Permissions + +```javascript +// Some extensions support granular permissions +const signer = new Nip07Signer(); + +// Request specific permissions +try { + // This varies by extension + await window.nostr.enable(); +} catch (error) { + console.log('User denied permission'); +} +``` + +## NIP-46 Remote Signer + +### Nostr Connect + +```javascript +import { Nip46Signer } from 'applesauce-signers'; + +// Create remote signer +const signer = new Nip46Signer({ + // Remote signer's pubkey + remotePubkey: signerPubkey, + + // Relays for communication + relays: ['wss://relay.example.com'], + + // Local secret key for encryption + localSecretKey: localSecretKey, + + // Optional: custom client name + clientName: 'My Nostr App' +}); + +// Connect to remote signer +await signer.connect(); + +// Get public key +const pubkey = await signer.getPublicKey(); + +// Sign event +const signedEvent = await signer.signEvent(unsignedEvent); + +// Disconnect when done +signer.disconnect(); +``` + +### Connection URL + +```javascript +// Parse nostrconnect:// URL +function parseNostrConnectUrl(url) { + const parsed = new URL(url); + + return { + pubkey: parsed.pathname.replace('//', ''), + relay: parsed.searchParams.get('relay'), + secret: parsed.searchParams.get('secret') + }; +} + +// Create signer from URL +const { pubkey, relay, secret } = parseNostrConnectUrl(connectUrl); + +const signer = new Nip46Signer({ + remotePubkey: pubkey, + relays: [relay], + localSecretKey: generateSecretKey(), + secret: secret +}); +``` + +### Bunker URL + +```javascript +// Parse bunker:// URL (NIP-46) +function parseBunkerUrl(url) { + const parsed = new URL(url); + + return { + pubkey: parsed.pathname.replace('//', ''), + relays: parsed.searchParams.getAll('relay'), + secret: parsed.searchParams.get('secret') + }; +} + +const { pubkey, relays, secret } = parseBunkerUrl(bunkerUrl); +``` + +## Signer Management + +### Signer Store + +```javascript +import { SignerStore } from 'applesauce-signers'; + +const signerStore = new SignerStore(); + +// Set active signer +signerStore.setSigner(signer); + +// Get active signer +const activeSigner = signerStore.getSigner(); + +// Clear signer (logout) +signerStore.clearSigner(); + +// Observable for signer changes +signerStore.signer$.subscribe(signer => { + if (signer) { + console.log('Logged in'); + } else { + console.log('Logged out'); + } +}); +``` + +### Multi-Account Support + +```javascript +class AccountManager { + constructor() { + this.accounts = new Map(); + this.activeAccount = null; + } + + addAccount(pubkey, signer) { + this.accounts.set(pubkey, signer); + } + + removeAccount(pubkey) { + this.accounts.delete(pubkey); + if (this.activeAccount === pubkey) { + this.activeAccount = null; + } + } + + switchAccount(pubkey) { + if (this.accounts.has(pubkey)) { + this.activeAccount = pubkey; + return this.accounts.get(pubkey); + } + return null; + } + + getActiveSigner() { + return this.activeAccount + ? this.accounts.get(this.activeAccount) + : null; + } +} +``` + +## Custom Signers + +### Implementing a Custom Signer + +```javascript +class CustomSigner { + constructor(options) { + this.options = options; + } + + async getPublicKey() { + // Return public key + return this.options.pubkey; + } + + async signEvent(event) { + // Implement signing logic + // Could call external API, hardware wallet, etc. + + const signedEvent = await this.externalSign(event); + return signedEvent; + } + + async nip04Encrypt(pubkey, plaintext) { + // Implement NIP-04 encryption + throw new Error('NIP-04 not supported'); + } + + async nip04Decrypt(pubkey, ciphertext) { + throw new Error('NIP-04 not supported'); + } + + async nip44Encrypt(pubkey, plaintext) { + // Implement NIP-44 encryption + throw new Error('NIP-44 not supported'); + } + + async nip44Decrypt(pubkey, ciphertext) { + throw new Error('NIP-44 not supported'); + } +} +``` + +### Hardware Wallet Signer + +```javascript +class HardwareWalletSigner { + constructor(devicePath) { + this.devicePath = devicePath; + } + + async connect() { + // Connect to hardware device + this.device = await connectToDevice(this.devicePath); + } + + async getPublicKey() { + // Get public key from device + return await this.device.getNostrPubkey(); + } + + async signEvent(event) { + // Sign on device (user confirms on device) + const signature = await this.device.signNostrEvent(event); + + return { + ...event, + pubkey: await this.getPublicKey(), + id: getEventHash(event), + sig: signature + }; + } +} +``` + +### Read-Only Signer + +```javascript +class ReadOnlySigner { + constructor(pubkey) { + this.pubkey = pubkey; + } + + async getPublicKey() { + return this.pubkey; + } + + async signEvent(event) { + throw new Error('Read-only mode: cannot sign events'); + } + + async nip04Encrypt(pubkey, plaintext) { + throw new Error('Read-only mode: cannot encrypt'); + } + + async nip04Decrypt(pubkey, ciphertext) { + throw new Error('Read-only mode: cannot decrypt'); + } +} +``` + +## Signing Utilities + +### Event Creation Helper + +```javascript +async function createAndSignEvent(signer, template) { + const pubkey = await signer.getPublicKey(); + + const event = { + ...template, + pubkey, + created_at: template.created_at || Math.floor(Date.now() / 1000) + }; + + return await signer.signEvent(event); +} + +// Usage +const signedNote = await createAndSignEvent(signer, { + kind: 1, + content: 'Hello!', + tags: [] +}); +``` + +### Batch Signing + +```javascript +async function signEvents(signer, events) { + const signed = []; + + for (const event of events) { + const signedEvent = await signer.signEvent(event); + signed.push(signedEvent); + } + + return signed; +} + +// With parallelization (if signer supports) +async function signEventsParallel(signer, events) { + return Promise.all( + events.map(event => signer.signEvent(event)) + ); +} +``` + +## Svelte Integration + +### Signer Context + +```svelte + + + + +``` + +```svelte + + +``` + +### Login Component + +```svelte + + +{#if $signer} + +{:else} + + +
+ + +
+{/if} +``` + +## Best Practices + +### Security + +1. **Never store secret keys in plain text** - Use secure storage +2. **Prefer NIP-07** - Let extensions manage keys +3. **Clear keys on logout** - Don't leave in memory +4. **Validate before signing** - Check event content + +### User Experience + +1. **Show signing status** - Loading states +2. **Handle rejections gracefully** - User may cancel +3. **Provide fallbacks** - Multiple login options +4. **Remember preferences** - Store signer type + +### Error Handling + +```javascript +async function safeSign(signer, event) { + try { + return await signer.signEvent(event); + } catch (error) { + if (error.message.includes('rejected')) { + console.log('User rejected signing'); + return null; + } + if (error.message.includes('timeout')) { + console.log('Signing timed out'); + return null; + } + throw error; + } +} +``` + +### Permission Checking + +```javascript +function hasEncryptionSupport(signer) { + return typeof signer.nip04Encrypt === 'function' || + typeof signer.nip44Encrypt === 'function'; +} + +function getEncryptionMethod(signer) { + // Prefer NIP-44 + if (typeof signer.nip44Encrypt === 'function') { + return 'nip44'; + } + if (typeof signer.nip04Encrypt === 'function') { + return 'nip04'; + } + return null; +} +``` + +## Common Patterns + +### Signer Detection + +```javascript +async function detectSigners() { + const available = []; + + // Check NIP-07 + if (typeof window !== 'undefined' && window.nostr) { + available.push({ + type: 'nip07', + name: 'Browser Extension', + create: () => new Nip07Signer() + }); + } + + // Check stored credentials + const storedKey = localStorage.getItem('nsec'); + if (storedKey) { + available.push({ + type: 'stored', + name: 'Saved Key', + create: () => new SimpleSigner(storedKey) + }); + } + + return available; +} +``` + +### Auto-Reconnect for NIP-46 + +```javascript +class ReconnectingNip46Signer { + constructor(options) { + this.options = options; + this.signer = null; + } + + async connect() { + this.signer = new Nip46Signer(this.options); + await this.signer.connect(); + } + + async signEvent(event) { + try { + return await this.signer.signEvent(event); + } catch (error) { + if (error.message.includes('disconnected')) { + await this.connect(); + return await this.signer.signEvent(event); + } + throw error; + } + } +} +``` + +### Signer Type Persistence + +```javascript +const SIGNER_KEY = 'nostr_signer_type'; + +function saveSigner(type, data) { + localStorage.setItem(SIGNER_KEY, JSON.stringify({ type, data })); +} + +async function restoreSigner() { + const saved = localStorage.getItem(SIGNER_KEY); + if (!saved) return null; + + const { type, data } = JSON.parse(saved); + + switch (type) { + case 'nip07': + if (window.nostr) { + return new Nip07Signer(); + } + break; + case 'simple': + // Don't store secret keys! + break; + case 'nip46': + const signer = new Nip46Signer(data); + await signer.connect(); + return signer; + } + + return null; +} +``` + +## Troubleshooting + +### Common Issues + +**Extension not detected:** +- Wait for page load +- Check window.nostr exists +- Verify extension is enabled + +**Signing rejected:** +- User cancelled in extension +- Handle gracefully with error message + +**NIP-46 connection fails:** +- Check relay is accessible +- Verify remote signer is online +- Check secret matches + +**Encryption not supported:** +- Check signer has encrypt methods +- Fall back to alternative method +- Show user appropriate error + +## References + +- **applesauce GitHub**: https://github.com/hzrd149/applesauce +- **NIP-07 Specification**: https://github.com/nostr-protocol/nips/blob/master/07.md +- **NIP-46 Specification**: https://github.com/nostr-protocol/nips/blob/master/46.md +- **nostr-tools**: https://github.com/nbd-wtf/nostr-tools + +## Related Skills + +- **nostr-tools** - Event creation and signing utilities +- **applesauce-core** - Event stores and queries +- **nostr** - Nostr protocol fundamentals +- **svelte** - Building Nostr UIs diff --git a/.claude/skills/nostr-tools/SKILL.md b/.claude/skills/nostr-tools/SKILL.md new file mode 100644 index 0000000..905d41c --- /dev/null +++ b/.claude/skills/nostr-tools/SKILL.md @@ -0,0 +1,767 @@ +--- +name: nostr-tools +description: This skill should be used when working with nostr-tools library for Nostr protocol operations, including event creation, signing, filtering, relay communication, and NIP implementations. Provides comprehensive knowledge of nostr-tools APIs and patterns. +--- + +# nostr-tools Skill + +This skill provides comprehensive knowledge and patterns for working with nostr-tools, the most popular JavaScript/TypeScript library for Nostr protocol development. + +## When to Use This Skill + +Use this skill when: +- Building Nostr clients or applications +- Creating and signing Nostr events +- Connecting to Nostr relays +- Implementing NIP features +- Working with Nostr keys and cryptography +- Filtering and querying events +- Building relay pools or connections +- Implementing NIP-44/NIP-04 encryption + +## Core Concepts + +### nostr-tools Overview + +nostr-tools provides: +- **Event handling** - Create, sign, verify events +- **Key management** - Generate, convert, encode keys +- **Relay communication** - Connect, subscribe, publish +- **NIP implementations** - NIP-04, NIP-05, NIP-19, NIP-44, etc. +- **Cryptographic operations** - Schnorr signatures, encryption +- **Filter building** - Query events by various criteria + +### Installation + +```bash +npm install nostr-tools +``` + +### Basic Imports + +```javascript +// Core functionality +import { + SimplePool, + generateSecretKey, + getPublicKey, + finalizeEvent, + verifyEvent +} from 'nostr-tools'; + +// NIP-specific imports +import { nip04, nip05, nip19, nip44 } from 'nostr-tools'; + +// Relay operations +import { Relay } from 'nostr-tools/relay'; +``` + +## Key Management + +### Generating Keys + +```javascript +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; + +// Generate new secret key (Uint8Array) +const secretKey = generateSecretKey(); + +// Derive public key +const publicKey = getPublicKey(secretKey); + +console.log('Secret key:', bytesToHex(secretKey)); +console.log('Public key:', publicKey); // hex string +``` + +### Key Encoding (NIP-19) + +```javascript +import { nip19 } from 'nostr-tools'; + +// Encode to bech32 +const nsec = nip19.nsecEncode(secretKey); +const npub = nip19.npubEncode(publicKey); +const note = nip19.noteEncode(eventId); + +console.log(nsec); // nsec1... +console.log(npub); // npub1... +console.log(note); // note1... + +// Decode from bech32 +const { type, data } = nip19.decode(npub); +// type: 'npub', data: publicKey (hex) + +// Encode profile reference (nprofile) +const nprofile = nip19.nprofileEncode({ + pubkey: publicKey, + relays: ['wss://relay.example.com'] +}); + +// Encode event reference (nevent) +const nevent = nip19.neventEncode({ + id: eventId, + relays: ['wss://relay.example.com'], + author: publicKey, + kind: 1 +}); + +// Encode address (naddr) for replaceable events +const naddr = nip19.naddrEncode({ + identifier: 'my-article', + pubkey: publicKey, + kind: 30023, + relays: ['wss://relay.example.com'] +}); +``` + +## Event Operations + +### Event Structure + +```javascript +// Unsigned event template +const eventTemplate = { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: 'Hello Nostr!' +}; + +// Signed event (after finalizeEvent) +const signedEvent = { + id: '...', // 32-byte sha256 hash as hex + pubkey: '...', // 32-byte public key as hex + created_at: 1234567890, + kind: 1, + tags: [], + content: 'Hello Nostr!', + sig: '...' // 64-byte Schnorr signature as hex +}; +``` + +### Creating and Signing Events + +```javascript +import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'; + +// Create event template +const eventTemplate = { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['p', publicKey], // Mention + ['e', eventId, '', 'reply'], // Reply + ['t', 'nostr'] // Hashtag + ], + content: 'Hello Nostr!' +}; + +// Sign event +const signedEvent = finalizeEvent(eventTemplate, secretKey); + +// Verify event +const isValid = verifyEvent(signedEvent); +console.log('Event valid:', isValid); +``` + +### Event Kinds + +```javascript +// Common event kinds +const KINDS = { + Metadata: 0, // Profile metadata (NIP-01) + Text: 1, // Short text note (NIP-01) + RecommendRelay: 2, // Relay recommendation + Contacts: 3, // Contact list (NIP-02) + EncryptedDM: 4, // Encrypted DM (NIP-04) + EventDeletion: 5, // Delete events (NIP-09) + Repost: 6, // Repost (NIP-18) + Reaction: 7, // Reaction (NIP-25) + ChannelCreation: 40, // Channel (NIP-28) + ChannelMessage: 42, // Channel message + Zap: 9735, // Zap receipt (NIP-57) + Report: 1984, // Report (NIP-56) + RelayList: 10002, // Relay list (NIP-65) + Article: 30023, // Long-form content (NIP-23) +}; +``` + +### Creating Specific Events + +```javascript +// Profile metadata (kind 0) +const profileEvent = finalizeEvent({ + kind: 0, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: JSON.stringify({ + name: 'Alice', + about: 'Nostr enthusiast', + picture: 'https://example.com/avatar.jpg', + nip05: 'alice@example.com', + lud16: 'alice@getalby.com' + }) +}, secretKey); + +// Contact list (kind 3) +const contactsEvent = finalizeEvent({ + kind: 3, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['p', pubkey1, 'wss://relay1.com', 'alice'], + ['p', pubkey2, 'wss://relay2.com', 'bob'], + ['p', pubkey3, '', 'carol'] + ], + content: '' // Or JSON relay preferences +}, secretKey); + +// Reply to an event +const replyEvent = finalizeEvent({ + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['e', rootEventId, '', 'root'], + ['e', parentEventId, '', 'reply'], + ['p', parentEventPubkey] + ], + content: 'This is a reply' +}, secretKey); + +// Reaction (kind 7) +const reactionEvent = finalizeEvent({ + kind: 7, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['e', eventId], + ['p', eventPubkey] + ], + content: '+' // or '-' or emoji +}, secretKey); + +// Delete event (kind 5) +const deleteEvent = finalizeEvent({ + kind: 5, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['e', eventIdToDelete], + ['e', anotherEventIdToDelete] + ], + content: 'Deletion reason' +}, secretKey); +``` + +## Relay Communication + +### Using SimplePool + +SimplePool is the recommended way to interact with multiple relays: + +```javascript +import { SimplePool } from 'nostr-tools/pool'; + +const pool = new SimplePool(); +const relays = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.nostr.band' +]; + +// Subscribe to events +const subscription = pool.subscribeMany( + relays, + [ + { + kinds: [1], + authors: [publicKey], + limit: 10 + } + ], + { + onevent(event) { + console.log('Received event:', event); + }, + oneose() { + console.log('End of stored events'); + } + } +); + +// Close subscription when done +subscription.close(); + +// Publish event to all relays +const results = await Promise.allSettled( + pool.publish(relays, signedEvent) +); + +// Query events (returns Promise) +const events = await pool.querySync(relays, { + kinds: [0], + authors: [publicKey] +}); + +// Get single event +const event = await pool.get(relays, { + ids: [eventId] +}); + +// Close pool when done +pool.close(relays); +``` + +### Direct Relay Connection + +```javascript +import { Relay } from 'nostr-tools/relay'; + +const relay = await Relay.connect('wss://relay.damus.io'); + +console.log(`Connected to ${relay.url}`); + +// Subscribe +const sub = relay.subscribe([ + { + kinds: [1], + limit: 100 + } +], { + onevent(event) { + console.log('Event:', event); + }, + oneose() { + console.log('EOSE'); + sub.close(); + } +}); + +// Publish +await relay.publish(signedEvent); + +// Close +relay.close(); +``` + +### Handling Connection States + +```javascript +import { Relay } from 'nostr-tools/relay'; + +const relay = await Relay.connect('wss://relay.example.com'); + +// Listen for disconnect +relay.onclose = () => { + console.log('Relay disconnected'); +}; + +// Check connection status +console.log('Connected:', relay.connected); +``` + +## Filters + +### Filter Structure + +```javascript +const filter = { + // Event IDs + ids: ['abc123...'], + + // Authors (pubkeys) + authors: ['pubkey1', 'pubkey2'], + + // Event kinds + kinds: [1, 6, 7], + + // Tags (single-letter keys) + '#e': ['eventId1', 'eventId2'], + '#p': ['pubkey1'], + '#t': ['nostr', 'bitcoin'], + '#d': ['article-identifier'], + + // Time range + since: 1704067200, // Unix timestamp + until: 1704153600, + + // Limit results + limit: 100, + + // Search (NIP-50, if relay supports) + search: 'nostr protocol' +}; +``` + +### Common Filter Patterns + +```javascript +// User's recent posts +const userPosts = { + kinds: [1], + authors: [userPubkey], + limit: 50 +}; + +// User's profile +const userProfile = { + kinds: [0], + authors: [userPubkey] +}; + +// User's contacts +const userContacts = { + kinds: [3], + authors: [userPubkey] +}; + +// Replies to an event +const replies = { + kinds: [1], + '#e': [eventId] +}; + +// Reactions to an event +const reactions = { + kinds: [7], + '#e': [eventId] +}; + +// Feed from followed users +const feed = { + kinds: [1, 6], + authors: followedPubkeys, + limit: 100 +}; + +// Events mentioning user +const mentions = { + kinds: [1], + '#p': [userPubkey], + limit: 50 +}; + +// Hashtag search +const hashtagEvents = { + kinds: [1], + '#t': ['bitcoin'], + limit: 100 +}; + +// Replaceable event by d-tag +const replaceableEvent = { + kinds: [30023], + authors: [authorPubkey], + '#d': ['article-slug'] +}; +``` + +### Multiple Filters + +```javascript +// Subscribe with multiple filters (OR logic) +const filters = [ + { kinds: [1], authors: [userPubkey], limit: 20 }, + { kinds: [1], '#p': [userPubkey], limit: 20 } +]; + +pool.subscribeMany(relays, filters, { + onevent(event) { + // Receives events matching ANY filter + } +}); +``` + +## Encryption + +### NIP-04 (Legacy DMs) + +```javascript +import { nip04 } from 'nostr-tools'; + +// Encrypt message +const ciphertext = await nip04.encrypt( + secretKey, + recipientPubkey, + 'Hello, this is secret!' +); + +// Create encrypted DM event +const dmEvent = finalizeEvent({ + kind: 4, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', recipientPubkey]], + content: ciphertext +}, secretKey); + +// Decrypt message +const plaintext = await nip04.decrypt( + secretKey, + senderPubkey, + ciphertext +); +``` + +### NIP-44 (Modern Encryption) + +```javascript +import { nip44 } from 'nostr-tools'; + +// Get conversation key (cache this for multiple messages) +const conversationKey = nip44.getConversationKey( + secretKey, + recipientPubkey +); + +// Encrypt +const ciphertext = nip44.encrypt( + 'Hello with NIP-44!', + conversationKey +); + +// Decrypt +const plaintext = nip44.decrypt( + ciphertext, + conversationKey +); +``` + +## NIP Implementations + +### NIP-05 (DNS Identifier) + +```javascript +import { nip05 } from 'nostr-tools'; + +// Query NIP-05 identifier +const profile = await nip05.queryProfile('alice@example.com'); + +if (profile) { + console.log('Pubkey:', profile.pubkey); + console.log('Relays:', profile.relays); +} + +// Verify NIP-05 for a pubkey +const isValid = await nip05.queryProfile('alice@example.com') + .then(p => p?.pubkey === expectedPubkey); +``` + +### NIP-10 (Reply Threading) + +```javascript +import { nip10 } from 'nostr-tools'; + +// Parse reply tags +const parsed = nip10.parse(event); + +console.log('Root:', parsed.root); // Original event +console.log('Reply:', parsed.reply); // Direct parent +console.log('Mentions:', parsed.mentions); // Other mentions +console.log('Profiles:', parsed.profiles); // Mentioned pubkeys +``` + +### NIP-21 (nostr: URIs) + +```javascript +// Parse nostr: URIs +const uri = 'nostr:npub1...'; +const { type, data } = nip19.decode(uri.replace('nostr:', '')); +``` + +### NIP-27 (Content References) + +```javascript +// Parse nostr:npub and nostr:note references in content +const content = 'Check out nostr:npub1abc... and nostr:note1xyz...'; + +const references = content.match(/nostr:(n[a-z]+1[a-z0-9]+)/g); +references?.forEach(ref => { + const decoded = nip19.decode(ref.replace('nostr:', '')); + console.log(decoded.type, decoded.data); +}); +``` + +### NIP-57 (Zaps) + +```javascript +import { nip57 } from 'nostr-tools'; + +// Validate zap receipt +const zapReceipt = await pool.get(relays, { + kinds: [9735], + '#e': [eventId] +}); + +const validatedZap = await nip57.validateZapRequest(zapReceipt); +``` + +## Utilities + +### Hex and Bytes Conversion + +```javascript +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; + +// Convert secret key to hex +const secretKeyHex = bytesToHex(secretKey); + +// Convert hex back to bytes +const secretKeyBytes = hexToBytes(secretKeyHex); +``` + +### Event ID Calculation + +```javascript +import { getEventHash } from 'nostr-tools/pure'; + +// Calculate event ID without signing +const eventId = getEventHash(unsignedEvent); +``` + +### Signature Operations + +```javascript +import { + getSignature, + verifyEvent +} from 'nostr-tools/pure'; + +// Sign event data +const signature = getSignature(unsignedEvent, secretKey); + +// Verify complete event +const isValid = verifyEvent(signedEvent); +``` + +## Best Practices + +### Connection Management + +1. **Use SimplePool** - Manages connections efficiently +2. **Limit concurrent connections** - Don't connect to too many relays +3. **Handle disconnections** - Implement reconnection logic +4. **Close subscriptions** - Always close when done + +### Event Handling + +1. **Verify events** - Always verify signatures +2. **Deduplicate** - Events may come from multiple relays +3. **Handle replaceable events** - Latest by created_at wins +4. **Validate content** - Don't trust event content blindly + +### Key Security + +1. **Never expose secret keys** - Keep in secure storage +2. **Use NIP-07 in browsers** - Let extensions handle signing +3. **Validate input** - Check key formats before use + +### Performance + +1. **Cache events** - Avoid re-fetching +2. **Use filters wisely** - Be specific, use limits +3. **Batch operations** - Combine related queries +4. **Close idle connections** - Free up resources + +## Common Patterns + +### Building a Feed + +```javascript +const pool = new SimplePool(); +const relays = ['wss://relay.damus.io', 'wss://nos.lol']; + +async function loadFeed(followedPubkeys) { + const events = await pool.querySync(relays, { + kinds: [1, 6], + authors: followedPubkeys, + limit: 100 + }); + + // Sort by timestamp + return events.sort((a, b) => b.created_at - a.created_at); +} +``` + +### Real-time Updates + +```javascript +function subscribeToFeed(followedPubkeys, onEvent) { + return pool.subscribeMany( + relays, + [{ kinds: [1, 6], authors: followedPubkeys }], + { + onevent: onEvent, + oneose() { + console.log('Caught up with stored events'); + } + } + ); +} +``` + +### Profile Loading + +```javascript +async function loadProfile(pubkey) { + const [metadata] = await pool.querySync(relays, { + kinds: [0], + authors: [pubkey], + limit: 1 + }); + + if (metadata) { + return JSON.parse(metadata.content); + } + return null; +} +``` + +### Event Deduplication + +```javascript +const seenEvents = new Set(); + +function handleEvent(event) { + if (seenEvents.has(event.id)) { + return; // Skip duplicate + } + seenEvents.add(event.id); + + // Process event... +} +``` + +## Troubleshooting + +### Common Issues + +**Events not publishing:** +- Check relay is writable +- Verify event is properly signed +- Check relay's accepted kinds + +**Subscription not receiving events:** +- Verify filter syntax +- Check relay has matching events +- Ensure subscription isn't closed + +**Signature verification fails:** +- Check event structure is correct +- Verify keys are in correct format +- Ensure event hasn't been modified + +**NIP-05 lookup fails:** +- Check CORS headers on server +- Verify .well-known path is correct +- Handle network timeouts + +## References + +- **nostr-tools GitHub**: https://github.com/nbd-wtf/nostr-tools +- **Nostr Protocol**: https://github.com/nostr-protocol/nostr +- **NIPs Repository**: https://github.com/nostr-protocol/nips +- **NIP-01 (Basic Protocol)**: https://github.com/nostr-protocol/nips/blob/master/01.md + +## Related Skills + +- **nostr** - Nostr protocol fundamentals +- **svelte** - Building Nostr UIs with Svelte +- **applesauce-core** - Higher-level Nostr client utilities +- **applesauce-signers** - Nostr signing abstractions diff --git a/.claude/skills/rollup/SKILL.md b/.claude/skills/rollup/SKILL.md new file mode 100644 index 0000000..cd694cd --- /dev/null +++ b/.claude/skills/rollup/SKILL.md @@ -0,0 +1,899 @@ +--- +name: rollup +description: This skill should be used when working with Rollup module bundler, including configuration, plugins, code splitting, and build optimization. Provides comprehensive knowledge of Rollup patterns, plugin development, and bundling strategies. +--- + +# Rollup Skill + +This skill provides comprehensive knowledge and patterns for working with Rollup module bundler effectively. + +## When to Use This Skill + +Use this skill when: +- Configuring Rollup for web applications +- Setting up Rollup for library builds +- Working with Rollup plugins +- Implementing code splitting +- Optimizing bundle size +- Troubleshooting build issues +- Integrating Rollup with Svelte or other frameworks +- Developing custom Rollup plugins + +## Core Concepts + +### Rollup Overview + +Rollup is a module bundler that: +- **Tree-shakes by default** - Removes unused code automatically +- **ES module focused** - Native ESM output support +- **Plugin-based** - Extensible architecture +- **Multiple outputs** - Generate multiple formats from single input +- **Code splitting** - Dynamic imports for lazy loading +- **Scope hoisting** - Flattens modules for smaller bundles + +### Basic Configuration + +```javascript +// rollup.config.js +export default { + input: 'src/main.js', + output: { + file: 'dist/bundle.js', + format: 'esm' + } +}; +``` + +### Output Formats + +Rollup supports multiple output formats: + +| Format | Description | Use Case | +|--------|-------------|----------| +| `esm` | ES modules | Modern browsers, bundlers | +| `cjs` | CommonJS | Node.js | +| `iife` | Self-executing function | Script tags | +| `umd` | Universal Module Definition | CDN, both environments | +| `amd` | Asynchronous Module Definition | RequireJS | +| `system` | SystemJS | SystemJS loader | + +## Configuration + +### Full Configuration Options + +```javascript +// rollup.config.js +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import terser from '@rollup/plugin-terser'; + +const production = !process.env.ROLLUP_WATCH; + +export default { + // Entry point(s) + input: 'src/main.js', + + // Output configuration + output: { + // Output file or directory + file: 'dist/bundle.js', + // Or for code splitting: + // dir: 'dist', + + // Output format + format: 'esm', + + // Name for IIFE/UMD builds + name: 'MyBundle', + + // Sourcemap generation + sourcemap: true, + + // Global variables for external imports (IIFE/UMD) + globals: { + jquery: '$' + }, + + // Banner/footer comments + banner: '/* My library v1.0.0 */', + footer: '/* End of bundle */', + + // Chunk naming for code splitting + chunkFileNames: '[name]-[hash].js', + entryFileNames: '[name].js', + + // Manual chunks for code splitting + manualChunks: { + vendor: ['lodash', 'moment'] + }, + + // Interop mode for default exports + interop: 'auto', + + // Preserve modules structure + preserveModules: false, + + // Exports mode + exports: 'auto' // 'default', 'named', 'none', 'auto' + }, + + // External dependencies (not bundled) + external: ['lodash', /^node:/], + + // Plugin array + plugins: [ + resolve({ + browser: true, + dedupe: ['svelte'] + }), + commonjs(), + production && terser() + ], + + // Watch mode options + watch: { + include: 'src/**', + exclude: 'node_modules/**', + clearScreen: false + }, + + // Warning handling + onwarn(warning, warn) { + // Skip certain warnings + if (warning.code === 'CIRCULAR_DEPENDENCY') return; + warn(warning); + }, + + // Preserve entry signatures for code splitting + preserveEntrySignatures: 'strict', + + // Treeshake options + treeshake: { + moduleSideEffects: false, + propertyReadSideEffects: false + } +}; +``` + +### Multiple Outputs + +```javascript +export default { + input: 'src/main.js', + output: [ + { + file: 'dist/bundle.esm.js', + format: 'esm' + }, + { + file: 'dist/bundle.cjs.js', + format: 'cjs' + }, + { + file: 'dist/bundle.umd.js', + format: 'umd', + name: 'MyLibrary' + } + ] +}; +``` + +### Multiple Entry Points + +```javascript +export default { + input: { + main: 'src/main.js', + utils: 'src/utils.js' + }, + output: { + dir: 'dist', + format: 'esm' + } +}; +``` + +### Array of Configurations + +```javascript +export default [ + { + input: 'src/main.js', + output: { file: 'dist/main.js', format: 'esm' } + }, + { + input: 'src/worker.js', + output: { file: 'dist/worker.js', format: 'iife' } + } +]; +``` + +## Essential Plugins + +### @rollup/plugin-node-resolve + +Resolve node_modules imports: + +```javascript +import resolve from '@rollup/plugin-node-resolve'; + +export default { + plugins: [ + resolve({ + // Resolve browser field in package.json + browser: true, + + // Prefer built-in modules + preferBuiltins: true, + + // Only resolve these extensions + extensions: ['.mjs', '.js', '.json', '.node'], + + // Dedupe packages (important for Svelte) + dedupe: ['svelte'], + + // Main fields to check in package.json + mainFields: ['module', 'main', 'browser'], + + // Export conditions + exportConditions: ['svelte', 'browser', 'module', 'import'] + }) + ] +}; +``` + +### @rollup/plugin-commonjs + +Convert CommonJS to ES modules: + +```javascript +import commonjs from '@rollup/plugin-commonjs'; + +export default { + plugins: [ + commonjs({ + // Include specific modules + include: /node_modules/, + + // Exclude specific modules + exclude: ['node_modules/lodash-es/**'], + + // Ignore conditional requires + ignoreDynamicRequires: false, + + // Transform mixed ES/CJS modules + transformMixedEsModules: true, + + // Named exports for specific modules + namedExports: { + 'react': ['createElement', 'Component'] + } + }) + ] +}; +``` + +### @rollup/plugin-terser + +Minify output: + +```javascript +import terser from '@rollup/plugin-terser'; + +export default { + plugins: [ + terser({ + compress: { + drop_console: true, + drop_debugger: true + }, + mangle: true, + format: { + comments: false + } + }) + ] +}; +``` + +### rollup-plugin-svelte + +Compile Svelte components: + +```javascript +import svelte from 'rollup-plugin-svelte'; +import css from 'rollup-plugin-css-only'; + +export default { + plugins: [ + svelte({ + // Enable dev mode + dev: !production, + + // Emit CSS as a separate file + emitCss: true, + + // Preprocess (SCSS, TypeScript, etc.) + preprocess: sveltePreprocess(), + + // Compiler options + compilerOptions: { + dev: !production + }, + + // Custom element mode + customElement: false + }), + + // Extract CSS to separate file + css({ output: 'bundle.css' }) + ] +}; +``` + +### Other Common Plugins + +```javascript +import json from '@rollup/plugin-json'; +import replace from '@rollup/plugin-replace'; +import alias from '@rollup/plugin-alias'; +import image from '@rollup/plugin-image'; +import copy from 'rollup-plugin-copy'; +import livereload from 'rollup-plugin-livereload'; + +export default { + plugins: [ + // Import JSON files + json(), + + // Replace strings in code + replace({ + preventAssignment: true, + 'process.env.NODE_ENV': JSON.stringify('production'), + '__VERSION__': JSON.stringify('1.0.0') + }), + + // Path aliases + alias({ + entries: [ + { find: '@', replacement: './src' }, + { find: 'utils', replacement: './src/utils' } + ] + }), + + // Import images + image(), + + // Copy static files + copy({ + targets: [ + { src: 'public/*', dest: 'dist' } + ] + }), + + // Live reload in dev + !production && livereload('dist') + ] +}; +``` + +## Code Splitting + +### Dynamic Imports + +```javascript +// Automatically creates chunks +async function loadFeature() { + const { feature } = await import('./feature.js'); + feature(); +} +``` + +Configuration for code splitting: + +```javascript +export default { + input: 'src/main.js', + output: { + dir: 'dist', + format: 'esm', + chunkFileNames: 'chunks/[name]-[hash].js' + } +}; +``` + +### Manual Chunks + +```javascript +export default { + output: { + manualChunks: { + // Vendor chunk + vendor: ['lodash', 'moment'], + + // Or use a function for more control + manualChunks(id) { + if (id.includes('node_modules')) { + return 'vendor'; + } + } + } + } +}; +``` + +### Advanced Chunking Strategy + +```javascript +export default { + output: { + manualChunks(id, { getModuleInfo }) { + // Separate chunks by feature + if (id.includes('/features/auth/')) { + return 'auth'; + } + if (id.includes('/features/dashboard/')) { + return 'dashboard'; + } + + // Vendor chunks by package + if (id.includes('node_modules')) { + const match = id.match(/node_modules\/([^/]+)/); + if (match) { + const packageName = match[1]; + // Group small packages + const smallPackages = ['lodash', 'date-fns']; + if (smallPackages.includes(packageName)) { + return 'vendor-utils'; + } + return `vendor-${packageName}`; + } + } + } + } +}; +``` + +## Watch Mode + +### Configuration + +```javascript +export default { + watch: { + // Files to watch + include: 'src/**', + + // Files to ignore + exclude: 'node_modules/**', + + // Don't clear screen on rebuild + clearScreen: false, + + // Rebuild delay + buildDelay: 0, + + // Watch chokidar options + chokidar: { + usePolling: true + } + } +}; +``` + +### CLI Watch Mode + +```bash +# Watch mode +rollup -c -w + +# With environment variable +ROLLUP_WATCH=true rollup -c +``` + +## Plugin Development + +### Plugin Structure + +```javascript +function myPlugin(options = {}) { + return { + // Plugin name (required) + name: 'my-plugin', + + // Build hooks + options(inputOptions) { + // Modify input options + return inputOptions; + }, + + buildStart(inputOptions) { + // Called on build start + }, + + resolveId(source, importer, options) { + // Custom module resolution + if (source === 'virtual-module') { + return source; + } + return null; // Defer to other plugins + }, + + load(id) { + // Load module content + if (id === 'virtual-module') { + return 'export default "Hello"'; + } + return null; + }, + + transform(code, id) { + // Transform module code + if (id.endsWith('.txt')) { + return { + code: `export default ${JSON.stringify(code)}`, + map: null + }; + } + }, + + buildEnd(error) { + // Called when build ends + if (error) { + console.error('Build failed:', error); + } + }, + + // Output generation hooks + renderStart(outputOptions, inputOptions) { + // Called before output generation + }, + + banner() { + return '/* Custom banner */'; + }, + + footer() { + return '/* Custom footer */'; + }, + + renderChunk(code, chunk, options) { + // Transform output chunk + return code; + }, + + generateBundle(options, bundle) { + // Modify output bundle + for (const fileName in bundle) { + const chunk = bundle[fileName]; + if (chunk.type === 'chunk') { + // Modify chunk + } + } + }, + + writeBundle(options, bundle) { + // After bundle is written + }, + + closeBundle() { + // Called when bundle is closed + } + }; +} + +export default myPlugin; +``` + +### Plugin with Rollup Utils + +```javascript +import { createFilter } from '@rollup/pluginutils'; + +function myTransformPlugin(options = {}) { + const filter = createFilter(options.include, options.exclude); + + return { + name: 'my-transform', + + transform(code, id) { + if (!filter(id)) return null; + + // Transform code + const transformed = code.replace(/foo/g, 'bar'); + + return { + code: transformed, + map: null // Or generate sourcemap + }; + } + }; +} +``` + +## Svelte Integration + +### Complete Svelte Setup + +```javascript +// rollup.config.js +import svelte from 'rollup-plugin-svelte'; +import commonjs from '@rollup/plugin-commonjs'; +import resolve from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; +import css from 'rollup-plugin-css-only'; +import livereload from 'rollup-plugin-livereload'; + +const production = !process.env.ROLLUP_WATCH; + +function serve() { + let server; + + function toExit() { + if (server) server.kill(0); + } + + return { + writeBundle() { + if (server) return; + server = require('child_process').spawn( + 'npm', + ['run', 'start', '--', '--dev'], + { + stdio: ['ignore', 'inherit', 'inherit'], + shell: true + } + ); + + process.on('SIGTERM', toExit); + process.on('exit', toExit); + } + }; +} + +export default { + input: 'src/main.js', + output: { + sourcemap: true, + format: 'iife', + name: 'app', + file: 'public/build/bundle.js' + }, + plugins: [ + svelte({ + compilerOptions: { + dev: !production + } + }), + css({ output: 'bundle.css' }), + + resolve({ + browser: true, + dedupe: ['svelte'] + }), + commonjs(), + + // Dev server + !production && serve(), + !production && livereload('public'), + + // Minify in production + production && terser() + ], + watch: { + clearScreen: false + } +}; +``` + +## Best Practices + +### Bundle Optimization + +1. **Enable tree shaking** - Use ES modules +2. **Mark side effects** - Set `sideEffects` in package.json +3. **Use terser** - Minify production builds +4. **Analyze bundles** - Use rollup-plugin-visualizer +5. **Code split** - Lazy load routes and features + +### External Dependencies + +```javascript +export default { + // Don't bundle peer dependencies for libraries + external: [ + 'react', + 'react-dom', + /^lodash\// + ], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM' + } + } +}; +``` + +### Development vs Production + +```javascript +const production = !process.env.ROLLUP_WATCH; + +export default { + plugins: [ + replace({ + preventAssignment: true, + 'process.env.NODE_ENV': JSON.stringify( + production ? 'production' : 'development' + ) + }), + production && terser() + ].filter(Boolean) +}; +``` + +### Error Handling + +```javascript +export default { + onwarn(warning, warn) { + // Ignore circular dependency warnings + if (warning.code === 'CIRCULAR_DEPENDENCY') { + return; + } + + // Ignore unused external imports + if (warning.code === 'UNUSED_EXTERNAL_IMPORT') { + return; + } + + // Treat other warnings as errors + if (warning.code === 'UNRESOLVED_IMPORT') { + throw new Error(warning.message); + } + + // Use default warning handling + warn(warning); + } +}; +``` + +## Common Patterns + +### Library Build + +```javascript +import pkg from './package.json'; + +export default { + input: 'src/index.js', + external: Object.keys(pkg.peerDependencies || {}), + output: [ + { + file: pkg.main, + format: 'cjs', + sourcemap: true + }, + { + file: pkg.module, + format: 'esm', + sourcemap: true + } + ] +}; +``` + +### Application Build + +```javascript +export default { + input: 'src/main.js', + output: { + dir: 'dist', + format: 'esm', + chunkFileNames: 'chunks/[name]-[hash].js', + entryFileNames: '[name]-[hash].js', + sourcemap: true + }, + plugins: [ + // All dependencies bundled + resolve({ browser: true }), + commonjs(), + terser() + ] +}; +``` + +### Web Worker Build + +```javascript +export default [ + // Main application + { + input: 'src/main.js', + output: { + file: 'dist/main.js', + format: 'esm' + }, + plugins: [resolve(), commonjs()] + }, + // Web worker (IIFE format) + { + input: 'src/worker.js', + output: { + file: 'dist/worker.js', + format: 'iife' + }, + plugins: [resolve(), commonjs()] + } +]; +``` + +## Troubleshooting + +### Common Issues + +**Module not found:** +- Check @rollup/plugin-node-resolve is configured +- Verify package is installed +- Check `external` array + +**CommonJS module issues:** +- Add @rollup/plugin-commonjs +- Check `namedExports` configuration +- Try `transformMixedEsModules: true` + +**Circular dependencies:** +- Use `onwarn` to suppress or fix +- Refactor to break cycles +- Check import order + +**Sourcemaps not working:** +- Set `sourcemap: true` in output +- Ensure plugins pass through maps +- Check browser devtools settings + +**Large bundle size:** +- Use rollup-plugin-visualizer +- Check for duplicate dependencies +- Verify tree shaking is working +- Mark unused packages as external + +## CLI Reference + +```bash +# Basic build +rollup -c + +# Watch mode +rollup -c -w + +# Custom config +rollup -c rollup.custom.config.js + +# Output format +rollup src/main.js --format esm --file dist/bundle.js + +# Environment variables +NODE_ENV=production rollup -c + +# Silent mode +rollup -c --silent + +# Generate bundle stats +rollup -c --perf +``` + +## References + +- **Rollup Documentation**: https://rollupjs.org +- **Plugin Directory**: https://github.com/rollup/plugins +- **Awesome Rollup**: https://github.com/rollup/awesome +- **GitHub**: https://github.com/rollup/rollup + +## Related Skills + +- **svelte** - Using Rollup with Svelte +- **typescript** - TypeScript compilation with Rollup +- **nostr-tools** - Bundling Nostr applications diff --git a/.claude/skills/svelte/SKILL.md b/.claude/skills/svelte/SKILL.md new file mode 100644 index 0000000..0d87233 --- /dev/null +++ b/.claude/skills/svelte/SKILL.md @@ -0,0 +1,1004 @@ +--- +name: svelte +description: This skill should be used when working with Svelte 3/4, including components, reactivity, stores, lifecycle, and component communication. Provides comprehensive knowledge of Svelte patterns, best practices, and reactive programming concepts. +--- + +# Svelte 3/4 Skill + +This skill provides comprehensive knowledge and patterns for working with Svelte effectively in modern web applications. + +## When to Use This Skill + +Use this skill when: +- Building Svelte applications and components +- Working with Svelte reactivity and stores +- Implementing component communication patterns +- Managing component lifecycle +- Optimizing Svelte application performance +- Troubleshooting Svelte-specific issues +- Working with Svelte transitions and animations +- Integrating with external libraries + +## Core Concepts + +### Svelte Overview + +Svelte is a compiler-based frontend framework that: +- **Compiles to vanilla JavaScript** - No runtime library shipped to browser +- **Reactive by default** - Variables are reactive, assignments trigger updates +- **Component-based** - Single-file components with `.svelte` extension +- **CSS scoping** - Styles are scoped to components by default +- **Built-in transitions** - Animation primitives included +- **Two-way binding** - Simple data binding with `bind:` + +### Component Structure + +Svelte components have three sections: + +```svelte + + + + + + +``` + +## Reactivity + +### Reactive Declarations + +Use `$:` for reactive statements and computed values: + +```svelte + +``` + +### Reactive Assignments + +Reactivity is triggered by assignments: + +```svelte + +``` + +**Key Points:** +- Array methods like `push`, `pop` need reassignment to trigger updates +- Object property changes need reassignment +- Use spread operator for immutable updates + +## Props + +### Declaring Props + +```svelte + + +

{greeting}, {name}!

+``` + +### Spread Props + +```svelte + + + + + + +``` + +### Prop Types with JSDoc + +```svelte + +``` + +## Events + +### DOM Events + +```svelte + + + + + + + + + + + + + + +``` + +### Component Events + +Dispatch custom events from components: + +```svelte + + + + +``` + +```svelte + + + + +``` + +### Event Forwarding + +```svelte + + + + + +``` + +## Bindings + +### Two-Way Binding + +```svelte + + + + + + + + + + A + B + + + + + + + + + +``` + +### Component Bindings + +```svelte + + + + + +``` + +### Element Bindings + +```svelte + + + + + + +
+ {divWidth} x {divHeight} +
+``` + +## Stores + +### Writable Stores + +```javascript +// stores.js +import { writable } from 'svelte/store'; + +export const count = writable(0); + +// With custom methods +function createCounter() { + const { subscribe, set, update } = writable(0); + + return { + subscribe, + increment: () => update(n => n + 1), + decrement: () => update(n => n - 1), + reset: () => set(0) + }; +} + +export const counter = createCounter(); +``` + +```svelte + + +

Count: {$count}

+ + +

Counter: {$counter}

+ +``` + +### Readable Stores + +```javascript +import { readable } from 'svelte/store'; + +// Time store that updates every second +export const time = readable(new Date(), function start(set) { + const interval = setInterval(() => { + set(new Date()); + }, 1000); + + return function stop() { + clearInterval(interval); + }; +}); +``` + +### Derived Stores + +```javascript +import { derived } from 'svelte/store'; +import { time } from './stores.js'; + +export const elapsed = derived( + time, + $time => Math.round(($time - start) / 1000) +); + +// Derived from multiple stores +export const combined = derived( + [storeA, storeB], + ([$a, $b]) => $a + $b +); + +// Async derived +export const asyncDerived = derived( + source, + ($source, set) => { + fetch(`/api/${$source}`) + .then(r => r.json()) + .then(set); + }, + 'loading...' // initial value +); +``` + +### Store Contract + +Any object with a `subscribe` method is a store: + +```javascript +// Custom store implementation +function createCustomStore(initial) { + let value = initial; + const subscribers = new Set(); + + return { + subscribe(fn) { + subscribers.add(fn); + fn(value); + return () => subscribers.delete(fn); + }, + set(newValue) { + value = newValue; + subscribers.forEach(fn => fn(value)); + } + }; +} +``` + +## Lifecycle + +### Lifecycle Functions + +```svelte + +``` + +**Key Points:** +- `onMount` runs only in browser, not during SSR +- `onMount` callbacks must be called during component initialization +- Use `tick()` to wait for pending state changes to apply to DOM + +## Logic Blocks + +### If Blocks + +```svelte +{#if condition} +

Condition is true

+{:else if otherCondition} +

Other condition is true

+{:else} +

Neither condition is true

+{/if} +``` + +### Each Blocks + +```svelte +{#each items as item} +
  • {item.name}
  • +{/each} + + +{#each items as item, index} +
  • {index}: {item.name}
  • +{/each} + + +{#each items as item (item.id)} +
  • {item.name}
  • +{/each} + + +{#each items as { id, name }} +
  • {id}: {name}
  • +{/each} + + +{#each items as item} +
  • {item.name}
  • +{:else} +

    No items

    +{/each} +``` + +### Await Blocks + +```svelte +{#await promise} +

    Loading...

    +{:then value} +

    The value is {value}

    +{:catch error} +

    Error: {error.message}

    +{/await} + + +{#await promise then value} +

    The value is {value}

    +{/await} +``` + +### Key Blocks + +Force component recreation when value changes: + +```svelte +{#key value} + +{/key} +``` + +## Slots + +### Basic Slots + +```svelte + +
    + + +

    No content provided

    +
    +
    +``` + +```svelte + +

    Card content

    +
    +``` + +### Named Slots + +```svelte + +
    +
    + +
    +
    + +
    +
    + +
    +
    +``` + +```svelte + +

    Page Title

    +

    Main content

    +

    Footer content

    +
    +``` + +### Slot Props + +```svelte + +
      + {#each items as item} +
    • + + {item.name} + +
    • + {/each} +
    +``` + +```svelte + + {index}: {item.name} + +``` + +## Transitions and Animations + +### Transitions + +```svelte + + + +{#if visible} +
    Fades in and out
    +{/if} + + +{#if visible} +
    + Flies in +
    +{/if} + + +{#if visible} +
    + Different transitions +
    +{/if} + + +{#if visible} +
    + Slides with easing +
    +{/if} +``` + +### Custom Transitions + +```javascript +function typewriter(node, { speed = 1 }) { + const valid = node.childNodes.length === 1 + && node.childNodes[0].nodeType === Node.TEXT_NODE; + + if (!valid) { + throw new Error('This transition only works on text nodes'); + } + + const text = node.textContent; + const duration = text.length / (speed * 0.01); + + return { + duration, + tick: t => { + const i = Math.trunc(text.length * t); + node.textContent = text.slice(0, i); + } + }; +} +``` + +### Animations + +Animate elements when they move within an each block: + +```svelte + + +{#each items as item (item.id)} +
  • + {item.name} +
  • +{/each} +``` + +## Actions + +Reusable element-level logic: + +```svelte + + +
    visible = false}> + Click outside to close +
    + + +``` + +## Special Elements + +### svelte:component + +Dynamic component rendering: + +```svelte + + + +``` + +### svelte:element + +Dynamic HTML elements: + +```svelte + + +Dynamic heading +``` + +### svelte:window + +```svelte + + + +``` + +### svelte:body and svelte:head + +```svelte + + + + Page Title + + +``` + +### svelte:options + +```svelte + +``` + +## Context API + +Share data between components without prop drilling: + +```svelte + + +``` + +```svelte + + + +

    Current theme: {theme.color}

    +``` + +**Key Points:** +- Context is not reactive by default +- Use stores in context for reactive values +- Context is available only during component initialization + +## Best Practices + +### Component Design + +1. **Keep components focused** - Single responsibility +2. **Use composition** - Prefer slots over complex props +3. **Extract logic to stores** - Shared state in stores +4. **Use actions for DOM logic** - Reusable element behaviors +5. **Type with JSDoc** - Document prop types + +### Reactivity + +1. **Understand triggers** - Assignments trigger updates +2. **Use immutable patterns** - Spread for arrays/objects +3. **Avoid side effects in reactive statements** - Keep them pure +4. **Use derived stores** - For computed values from stores + +### Performance + +1. **Key each blocks** - Use unique keys for list items +2. **Use immutable option** - When data is immutable +3. **Lazy load components** - Dynamic imports +4. **Minimize store subscriptions** - Unsubscribe when done + +### State Management + +1. **Local state first** - Component variables for local state +2. **Stores for shared state** - Cross-component communication +3. **Context for configuration** - Theme, i18n, etc. +4. **Custom stores for logic** - Encapsulate complex state + +## Common Patterns + +### Async Data Loading + +```svelte + + +{#if loading} +

    Loading...

    +{:else if error} +

    Error: {error.message}

    +{:else} +

    {data}

    +{/if} +``` + +### Form Handling + +```svelte + + +
    + + {#if errors.name}{errors.name}{/if} + + + {#if errors.email}{errors.email}{/if} + + +
    +``` + +### Modal Pattern + +```svelte + + + +{#if open} +
    + +
    +{/if} +``` + +## Troubleshooting + +### Common Issues + +**Reactivity not working:** +- Check for proper assignment (reassign arrays/objects) +- Use `$:` for derived values +- Store subscriptions need `$` prefix + +**Component not updating:** +- Verify prop changes trigger parent re-render +- Check key blocks for forced recreation +- Use `{#key}` to force component recreation + +**Memory leaks:** +- Clean up subscriptions in `onDestroy` +- Return cleanup functions from `onMount` +- Unsubscribe from stores manually if not using `$` + +**Styles not applying:** +- Check for `:global()` if targeting child components +- Verify CSS specificity +- Use `class:` directive properly + +## References + +- **Svelte Documentation**: https://svelte.dev/docs +- **Svelte Tutorial**: https://svelte.dev/tutorial +- **Svelte REPL**: https://svelte.dev/repl +- **Svelte Society**: https://sveltesociety.dev +- **GitHub**: https://github.com/sveltejs/svelte + +## Related Skills + +- **rollup** - Bundling Svelte applications +- **nostr-tools** - Nostr integration in Svelte apps +- **typescript** - TypeScript with Svelte diff --git a/.gitignore b/.gitignore index c3728cb..6f9843b 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ cmd/benchmark/data !*.svelte !.github/** !.github/workflows/** +!.claude/** !app/web/dist/** !app/web/dist/*.js !app/web/dist/*.js.map