diff --git a/.env.example b/.env.example index b17e537..4dd3cc2 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,5 @@ VITE_USE_MOCK_COMMENTS=true # Set to "true" to use position-based test highlights instead of fetching from relays VITE_USE_MOCK_HIGHLIGHTS=true -# Set to "true" to mock event publishing (no actual relay publishing, useful for testing UI) -VITE_MOCK_PUBLISH=true - # Enable debug logging for relay connections DEBUG_RELAYS=false diff --git a/.playwright-mcp/.playwright-mcp/dark-mode-complete.png b/.playwright-mcp/.playwright-mcp/dark-mode-complete.png new file mode 100644 index 0000000..2a48cd2 Binary files /dev/null and b/.playwright-mcp/.playwright-mcp/dark-mode-complete.png differ diff --git a/.playwright-mcp/.playwright-mcp/dark-mode-editor-fix.png b/.playwright-mcp/.playwright-mcp/dark-mode-editor-fix.png new file mode 100644 index 0000000..37c9915 Binary files /dev/null and b/.playwright-mcp/.playwright-mcp/dark-mode-editor-fix.png differ diff --git a/.playwright-mcp/.playwright-mcp/dark-mode-final.png b/.playwright-mcp/.playwright-mcp/dark-mode-final.png new file mode 100644 index 0000000..37c9915 Binary files /dev/null and b/.playwright-mcp/.playwright-mcp/dark-mode-final.png differ diff --git a/.playwright-mcp/.playwright-mcp/dark-mode-preview-update.png b/.playwright-mcp/.playwright-mcp/dark-mode-preview-update.png new file mode 100644 index 0000000..989a81d Binary files /dev/null and b/.playwright-mcp/.playwright-mcp/dark-mode-preview-update.png differ diff --git a/.playwright-mcp/.playwright-mcp/dark-mode-text-fix.png b/.playwright-mcp/.playwright-mcp/dark-mode-text-fix.png new file mode 100644 index 0000000..40118ff Binary files /dev/null and b/.playwright-mcp/.playwright-mcp/dark-mode-text-fix.png differ diff --git a/.playwright-mcp/.playwright-mcp/dark-mode-white-text.png b/.playwright-mcp/.playwright-mcp/dark-mode-white-text.png new file mode 100644 index 0000000..6212885 Binary files /dev/null and b/.playwright-mcp/.playwright-mcp/dark-mode-white-text.png differ diff --git a/.playwright-mcp/.playwright-mcp/dark-mode-with-preview.png b/.playwright-mcp/.playwright-mcp/dark-mode-with-preview.png new file mode 100644 index 0000000..16c7c1f Binary files /dev/null and b/.playwright-mcp/.playwright-mcp/dark-mode-with-preview.png differ diff --git a/.playwright-mcp/500-error.png b/.playwright-mcp/500-error.png new file mode 100644 index 0000000..8943004 Binary files /dev/null and b/.playwright-mcp/500-error.png differ diff --git a/.playwright-mcp/after-delete-integration.png b/.playwright-mcp/after-delete-integration.png new file mode 100644 index 0000000..29e4360 Binary files /dev/null and b/.playwright-mcp/after-delete-integration.png differ diff --git a/.playwright-mcp/after-fix.png b/.playwright-mcp/after-fix.png new file mode 100644 index 0000000..8af33ef Binary files /dev/null and b/.playwright-mcp/after-fix.png differ diff --git a/.playwright-mcp/after-mock-comments-click.png b/.playwright-mcp/after-mock-comments-click.png new file mode 100644 index 0000000..1522aac Binary files /dev/null and b/.playwright-mcp/after-mock-comments-click.png differ diff --git a/.playwright-mcp/compose-darkmode-preview.png b/.playwright-mcp/compose-darkmode-preview.png new file mode 100644 index 0000000..1919b44 Binary files /dev/null and b/.playwright-mcp/compose-darkmode-preview.png differ diff --git a/.playwright-mcp/dark-mode-full-preview.png b/.playwright-mcp/dark-mode-full-preview.png new file mode 100644 index 0000000..05191e5 Binary files /dev/null and b/.playwright-mcp/dark-mode-full-preview.png differ diff --git a/.playwright-mcp/dark-mode-preview-after.png b/.playwright-mcp/dark-mode-preview-after.png new file mode 100644 index 0000000..83b4639 Binary files /dev/null and b/.playwright-mcp/dark-mode-preview-after.png differ diff --git a/.playwright-mcp/debug-controls-fixed.png b/.playwright-mcp/debug-controls-fixed.png new file mode 100644 index 0000000..8178acc Binary files /dev/null and b/.playwright-mcp/debug-controls-fixed.png differ diff --git a/.playwright-mcp/highlights-visible.png b/.playwright-mcp/highlights-visible.png new file mode 100644 index 0000000..383dbf6 Binary files /dev/null and b/.playwright-mcp/highlights-visible.png differ diff --git a/.playwright-mcp/homepage-loaded.png b/.playwright-mcp/homepage-loaded.png new file mode 100644 index 0000000..85602f3 Binary files /dev/null and b/.playwright-mcp/homepage-loaded.png differ diff --git a/.playwright-mcp/homepage-state.png b/.playwright-mcp/homepage-state.png new file mode 100644 index 0000000..1509d08 Binary files /dev/null and b/.playwright-mcp/homepage-state.png differ diff --git a/.playwright-mcp/offset-highlights-rendered.png b/.playwright-mcp/offset-highlights-rendered.png new file mode 100644 index 0000000..5215b19 Binary files /dev/null and b/.playwright-mcp/offset-highlights-rendered.png differ diff --git a/.playwright-mcp/offset-highlights-scrolled.png b/.playwright-mcp/offset-highlights-scrolled.png new file mode 100644 index 0000000..373dc29 Binary files /dev/null and b/.playwright-mcp/offset-highlights-scrolled.png differ diff --git a/.playwright-mcp/offset-highlights-test-page.png b/.playwright-mcp/offset-highlights-test-page.png new file mode 100644 index 0000000..5837ea4 Binary files /dev/null and b/.playwright-mcp/offset-highlights-test-page.png differ diff --git a/.playwright-mcp/preview-area.png b/.playwright-mcp/preview-area.png new file mode 100644 index 0000000..25d4414 Binary files /dev/null and b/.playwright-mcp/preview-area.png differ diff --git a/.playwright-mcp/publication-with-debug-controls.png b/.playwright-mcp/publication-with-debug-controls.png new file mode 100644 index 0000000..989eaf0 Binary files /dev/null and b/.playwright-mcp/publication-with-debug-controls.png differ diff --git a/.playwright-mcp/scrolled-with-comments.png b/.playwright-mcp/scrolled-with-comments.png new file mode 100644 index 0000000..7d8c987 Binary files /dev/null and b/.playwright-mcp/scrolled-with-comments.png differ diff --git a/.playwright-mcp/with-mock-highlights.png b/.playwright-mcp/with-mock-highlights.png new file mode 100644 index 0000000..cdcb368 Binary files /dev/null and b/.playwright-mcp/with-mock-highlights.png differ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b13b5b5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,193 @@ +# Alexandria Codebase - Local Instructions + +This document provides project-specific instructions for working with the +Alexandria codebase, based on existing Cursor rules and project conventions. + +## Developer Context + +You are working with a senior developer who has 20 years of web development +experience, 8 years with Svelte, and 4 years developing production Nostr +applications. Assume high technical proficiency. + +## Project Overview + +Alexandria is a Nostr-based web application for reading, commenting on, and +publishing long-form content (books, blogs, etc.) stored on Nostr relays. Built +with: + +- **Svelte 5** and **SvelteKit 2** (latest versions) +- **TypeScript** (exclusively, no plain JavaScript) +- **Tailwind 4** for styling +- **Deno** runtime (with Node.js compatibility) +- **NDK** (Nostr Development Kit) for protocol interaction + +## Architecture Pattern + +The project follows a Model-View-Controller (MVC) pattern: + +- **Model**: Nostr relays (via WebSocket APIs) and browser storage +- **View**: Reactive UI with SvelteKit pages and Svelte components +- **Controller**: TypeScript modules with utilities, services, and data + preparation + +## Critical Development Guidelines + +### Prime Directive + +**NEVER assume developer intent.** If unsure, ALWAYS ask for clarification +before proceeding. + +### AI Anchor Comments System + +Before any work, search for `AI-` anchor comments in relevant directories: + +- `AI-NOTE:`, `AI-TODO:`, `AI-QUESTION:` - Context sharing between AI and + developers +- `AI-:` - Developer-recorded context (read but don't write) +- **Always update relevant anchor comments when modifying code** +- Add new anchors for complex, critical, or confusing code +- Never remove AI comments without explicit instruction + +### Communication Style + +- Be direct and concise - avoid apologies or verbose explanations +- Include file names and line numbers (e.g., `src/lib/utils/parser.ts:45-52`) +- Provide documentation links for further reading +- When corrected, provide well-reasoned explanations, not simple agreement +- Don't propose code edits unless specifically requested + +## Code Style Requirements + +### TypeScript Files (\*.ts) + +- **File naming**: `snake_case.ts` +- **Classes/Interfaces/Types**: `PascalCase` +- **Functions/Variables**: `camelCase` +- **Private class members**: `#privateField` (ES2022 syntax) +- **Indentation**: 2 spaces +- **Line length**: 100 characters max +- **Strings**: Single quotes default, backticks for templates +- **Always include**: + - Type annotations for class properties + - Parameter types and return types (except void) + - JSDoc comments for exported functions + - Semicolons at statement ends + +### Svelte Components (\*.svelte) + +- **Component naming**: `PascalCase.svelte` +- **Use Svelte 5 features exclusively**: + - Runes: `$state`, `$derived`, `$effect`, `$props` + - Callback props (not event dispatchers) + - Snippets (not slots) +- **Avoid deprecated Svelte 4 patterns**: + - No `export let` for props + - No `on:` event directives + - No event dispatchers or component slots +- **Component organization** (in order): + 1. Imports + 2. Props definition (strongly typed) + 3. Context imports (`getContext`) + 4. State declarations (`$state`, then `$derived`) + 5. Non-reactive variables + 6. Component logic (functions, `$effect`) + 7. Lifecycle hooks (`onMount`) + 8. Snippets (before markup) + 9. Component markup + 10. Style blocks (rare - prefer Tailwind) +- **Keep components under 500 lines** +- **Extract business logic to separate TypeScript modules** + +### HTML/Markup + +- Indentation: 2 spaces +- Break long tags across lines +- Use Tailwind 4 utility classes +- Single quotes for attributes + +## Key Project Utilities + +### Core Classes to Use + +- `WebSocketPool` (`src/lib/data_structures/websocket_pool.ts`) - For WebSocket + management +- `PublicationTree` - For hierarchical publication structure +- `ZettelParser` - For AsciiDoc parsing + +### Nostr Event Kinds + +- `30040` - Blog/publication indexes +- `30041` - Publication sections/articles +- `30023` - Long-form articles +- `30818` - Wiki Notes +- `1` - Short notes + +## Development Commands + +```bash +# Development +npm run dev # Start dev server +npm run dev:debug # With relay debugging (DEBUG_RELAYS=true) + +# Quality Checks (run before commits) +npm run check # Type checking +npm run lint # Linting +npm run format # Auto-format +npm test # Run tests + +# Build +npm run build # Production build +npm run preview # Preview production +``` + +## Testing Requirements + +- Unit tests: Vitest with mocked dependencies +- E2E tests: Playwright for critical flows +- Always run `npm test` before commits +- Check types with `npm run check` + +## Git Workflow + +- Current branch: `feature/text-entry` +- Main branch: `master` (not `main`) +- Descriptive commit messages +- Include test updates with features + +## Important Files + +- `src/lib/ndk.ts` - NDK configuration +- `src/lib/utils/ZettelParser.ts` - AsciiDoc parsing +- `src/lib/services/publisher.ts` - Event publishing +- `src/lib/components/ZettelEditor.svelte` - Main editor +- `src/routes/new/compose/+page.svelte` - Composition UI + +## Performance Considerations + +- State is deeply reactive in Svelte 5 - avoid unnecessary reassignments +- Lazy load large components +- Use virtual scrolling for long lists +- Cache Nostr events with Dexie +- Minimize relay subscriptions +- Debounce search inputs + +## Security Notes + +- Never store private keys in code +- Validate all user input +- Sanitize external HTML +- Verify event signatures + +## Debugging + +- Enable relay debug: `DEBUG_RELAYS=true npm run dev` +- Check browser console for NDK logs +- Network tab shows WebSocket frames + +## Documentation Links + +- [Nostr NIPs](https://github.com/nostr-protocol/nips) +- [NDK Docs](https://github.com/nostr-dev-kit/ndk) +- [SvelteKit Docs](https://kit.svelte.dev/docs) +- [Svelte 5 Docs](https://svelte.dev/docs/svelte/overview) +- [Flowbite Svelte](https://flowbite-svelte.com/) diff --git a/TECHNIQUE-create-test-highlights.md b/TECHNIQUE-create-test-highlights.md new file mode 100644 index 0000000..d3e1bbd --- /dev/null +++ b/TECHNIQUE-create-test-highlights.md @@ -0,0 +1,393 @@ +# Technique: Creating Test Highlight Events for Nostr Publications + +## Overview + +This technique allows you to create test highlight events (kind 9802) for +testing the highlight rendering system in Alexandria. Highlights are text +selections from publication sections that users want to mark as important or +noteworthy, optionally with annotations. + +## When to Use This + +- Testing highlight fetching and rendering +- Verifying highlight filtering by section +- Testing highlight display UI (inline markers, side panel, etc.) +- Debugging highlight-related features +- Demonstrating the highlight system to stakeholders + +## Prerequisites + +1. **Node.js packages**: `nostr-tools` and `ws` + ```bash + npm install nostr-tools ws + ``` + +2. **Valid publication structure**: You need the actual publication address + (naddr) and its internal structure (section addresses, pubkeys) + +## Step 1: Decode the Publication Address + +If you have an `naddr` (Nostr address), decode it to find the publication +structure: + +**Script**: `check-publication-structure.js` + +```javascript +import { nip19 } from "nostr-tools"; +import WebSocket from "ws"; + +const naddr = "naddr1qvzqqqr4t..."; // Your publication naddr + +console.log("Decoding naddr...\n"); +const decoded = nip19.decode(naddr); +console.log("Decoded:", JSON.stringify(decoded, null, 2)); + +const { data } = decoded; +const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`; +console.log("\nRoot Address:", rootAddress); + +// Fetch the index event to see what sections it references +const relay = "wss://relay.nostr.band"; + +async function fetchPublication() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(relay); + const events = []; + + ws.on("open", () => { + console.log(`\nConnected to ${relay}`); + console.log("Fetching index event...\n"); + + const filter = { + kinds: [data.kind], + authors: [data.pubkey], + "#d": [data.identifier], + }; + + const subscriptionId = `sub-${Date.now()}`; + ws.send(JSON.stringify(["REQ", subscriptionId, filter])); + }); + + ws.on("message", (message) => { + const [type, subId, event] = JSON.parse(message.toString()); + + if (type === "EVENT") { + events.push(event); + console.log("Found index event:", event.id); + console.log("\nTags:"); + event.tags.forEach((tag) => { + if (tag[0] === "a") { + console.log(` Section address: ${tag[1]}`); + } + if (tag[0] === "d") { + console.log(` D-tag: ${tag[1]}`); + } + if (tag[0] === "title") { + console.log(` Title: ${tag[1]}`); + } + }); + } else if (type === "EOSE") { + ws.close(); + resolve(events); + } + }); + + ws.on("error", reject); + + setTimeout(() => { + ws.close(); + resolve(events); + }, 5000); + }); +} + +fetchPublication() + .then(() => console.log("\nDone!")) + .catch(console.error); +``` + +**Run it**: `node check-publication-structure.js` + +**Expected output**: Section addresses like +`30041:dc4cd086...:the-art-of-thinking-without-permission` + +## Step 2: Understand Kind 9802 Event Structure + +A highlight event (kind 9802) has this structure: + +```javascript +{ + kind: 9802, + pubkey: "", + created_at: 1704067200, + tags: [ + ["a", "", ""], // Required: target section + ["context", ""], // Optional: helps locate highlight + ["p", "", "", "author"], // Optional: original author + ["comment", ""] // Optional: user's note + ], + content: "", // Required: the selected text + id: "", + sig: "" +} +``` + +### Critical Differences from Comments (kind 1111): + +| Aspect | Comments (1111) | Highlights (9802) | +| ---------------------- | ---------------------------------------------------------------- | -------------------------------------------- | +| **Content field** | User's comment text | The highlighted text itself | +| **User annotation** | N/A (content is the comment) | Optional `["comment", ...]` tag | +| **Context** | Not used | `["context", ...]` provides surrounding text | +| **Threading** | Uses `["e", ..., "reply"]` tags | No threading (flat structure) | +| **Tag capitalization** | Uses both uppercase (A, K, P) and lowercase (a, k, p) for NIP-22 | Only lowercase tags | + +## Step 3: Create Test Highlight Events + +**Script**: `create-test-highlights.js` + +```javascript +import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; +import WebSocket from "ws"; + +// Test user keys (generate fresh ones) +const testUserKey = generateSecretKey(); +const testUserPubkey = getPublicKey(testUserKey); + +console.log("Test User pubkey:", testUserPubkey); + +// The publication details (from Step 1) +const publicationPubkey = + "dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06"; +const rootAddress = + `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`; + +// Section addresses (from Step 1 output) +const sections = [ + `30041:${publicationPubkey}:the-art-of-thinking-without-permission`, + `30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`, + // ... more sections +]; + +// Relays to publish to (matching HighlightLayer's relay list) +const relays = [ + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nostr.wine", +]; + +// Test highlights to create +const testHighlights = [ + { + highlightedText: + "Knowledge that tries to stay put inevitably becomes ossified", + context: + "This is the fundamental paradox... Knowledge that tries to stay put inevitably becomes ossified, a monument to itself... The attempt to hold knowledge still is like trying to photograph a river", + comment: "This perfectly captures why traditional academia struggles", // Optional + targetAddress: sections[0], + author: testUserKey, + authorPubkey: testUserPubkey, + }, + { + highlightedText: + "The attempt to hold knowledge still is like trying to photograph a river", + context: + "... a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.", + comment: null, // No annotation, just highlight + targetAddress: sections[0], + author: testUserKey, + authorPubkey: testUserPubkey, + }, +]; + +async function publishEvent(event, relayUrl) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(relayUrl); + let published = false; + + ws.on("open", () => { + console.log(`Connected to ${relayUrl}`); + ws.send(JSON.stringify(["EVENT", event])); + }); + + ws.on("message", (data) => { + const message = JSON.parse(data.toString()); + if (message[0] === "OK" && message[1] === event.id) { + if (message[2]) { + console.log(`✓ Published ${event.id.substring(0, 8)}`); + published = true; + ws.close(); + resolve(); + } else { + console.error(`✗ Rejected: ${message[3]}`); + ws.close(); + reject(new Error(message[3])); + } + } + }); + + ws.on("error", reject); + ws.on("close", () => { + if (!published) reject(new Error("Connection closed")); + }); + + setTimeout(() => { + if (!published) { + ws.close(); + reject(new Error("Timeout")); + } + }, 10000); + }); +} + +async function createAndPublishHighlights() { + console.log("\n=== Creating Test Highlights ===\n"); + + for (const highlight of testHighlights) { + try { + // Create unsigned event + const unsignedEvent = { + kind: 9802, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["a", highlight.targetAddress, relays[0]], + ["context", highlight.context], + ["p", publicationPubkey, relays[0], "author"], + ], + content: highlight.highlightedText, // The highlighted text + pubkey: highlight.authorPubkey, + }; + + // Add optional comment/annotation + if (highlight.comment) { + unsignedEvent.tags.push(["comment", highlight.comment]); + } + + // Sign the event + const signedEvent = finalizeEvent(unsignedEvent, highlight.author); + + console.log( + `\nHighlight: "${highlight.highlightedText.substring(0, 60)}..."`, + ); + console.log(`Target: ${highlight.targetAddress}`); + console.log(`Event ID: ${signedEvent.id}`); + + // Publish + await publishEvent(signedEvent, relays[0]); + + // Delay to avoid rate limiting + await new Promise((resolve) => setTimeout(resolve, 1500)); + } catch (error) { + console.error(`Failed: ${error.message}`); + } + } + + console.log("\n=== Done! ==="); + console.log('\nRefresh the page and toggle "Show Highlights" to view them.'); +} + +createAndPublishHighlights().catch(console.error); +``` + +## Step 4: Run and Verify + +1. **Run the script**: + ```bash + node create-test-highlights.js + ``` + +2. **Expected output**: + ``` + Test User pubkey: a1b2c3d4... + + === Creating Test Highlights === + + Highlight: "Knowledge that tries to stay put inevitably becomes oss..." + Target: 30041:dc4cd086...:the-art-of-thinking-without-permission + Event ID: e5f6g7h8... + Connected to wss://relay.damus.io + ✓ Published e5f6g7h8 + + ... + + === Done! === + ``` + +3. **Verify in the app**: + - Refresh the publication page + - Click "Show Highlights" button + - Highlighted text should appear with yellow background + - Hover to see annotation (if provided) + +## Common Issues and Solutions + +### Issue: "Relay rejected: rate-limited" + +**Cause**: Publishing too many events too quickly + +**Solution**: Increase delay between publishes + +```javascript +await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 seconds +``` + +### Issue: Highlights don't appear after publishing + +**Possible causes**: + +1. Wrong section address - verify with `check-publication-structure.js` +2. HighlightLayer not fetching from the relay you published to +3. Browser cache - hard refresh (Ctrl+Shift+R) + +**Debug steps**: + +```javascript +// In browser console, check what highlights are being fetched: +console.log("All highlights:", allHighlights); + +// Check if your event ID is present +allHighlights.find((h) => h.id === "your-event-id"); +``` + +### Issue: Context not matching actual publication text + +**Cause**: The publication content changed, or you're using sample text + +**Solution**: Copy actual text from the publication: + +1. Open the publication in browser +2. Select the text you want to highlight +3. Copy a larger surrounding context (2-3 sentences) +4. Use that as the `context` value + +## Key Patterns to Remember + +1. **Content field = highlighted text** (NOT a comment) +2. **Context tag helps locate** the highlight in the source document +3. **Comment tag is optional** user annotation +4. **No threading** - highlights are flat, not threaded like comments +5. **Single lowercase 'a' tag** - not uppercase/lowercase pairs like comments +6. **Always verify addresses** with `check-publication-structure.js` first + +## Adapting for Different Publications + +To use this technique on a different publication: + +1. Get the publication's naddr from the URL +2. Run `check-publication-structure.js` with that naddr +3. Update these values in `create-test-highlights.js`: + - `publicationPubkey` + - `rootAddress` + - `sections` array +4. Update `highlightedText` and `context` to match actual publication content +5. Run the script + +## Further Reading + +- NIP-84 (Highlights): https://github.com/nostr-protocol/nips/blob/master/84.md +- `src/lib/components/publications/HighlightLayer.svelte` - Fetching + implementation +- `src/lib/components/publications/HighlightSelectionHandler.svelte` - Event + creation +- NIP-19 (Address encoding): + https://github.com/nostr-protocol/nips/blob/master/19.md diff --git a/TEST_SUMMARY.md b/TEST_SUMMARY.md new file mode 100644 index 0000000..8cf4faf --- /dev/null +++ b/TEST_SUMMARY.md @@ -0,0 +1,162 @@ +# Comment Button TDD Tests - Summary + +## Overview + +Comprehensive test suite for CommentButton component and NIP-22 comment +functionality. + +**Test File:** +`/home/user/gc-alexandria-comments/tests/unit/commentButton.test.ts` + +**Status:** ✅ All 69 tests passing + +## Test Coverage + +### 1. Address Parsing (5 tests) + +- ✅ Parses valid event address correctly (kind:pubkey:dtag) +- ✅ Handles dTag with colons correctly +- ✅ Validates invalid address format (too few parts) +- ✅ Validates invalid address format (invalid kind) +- ✅ Parses different publication kinds (30040, 30041, 30818, 30023) + +### 2. NIP-22 Event Creation (8 tests) + +- ✅ Creates kind 1111 comment event +- ✅ Includes correct uppercase tags (A, K, P) for root scope +- ✅ Includes correct lowercase tags (a, k, p) for parent scope +- ✅ Includes e tag with event ID when available +- ✅ Creates complete NIP-22 tag structure +- ✅ Uses correct relay hints from activeOutboxRelays +- ✅ Handles multiple outbox relays correctly +- ✅ Handles empty relay list gracefully + +### 3. Event Signing and Publishing (4 tests) + +- ✅ Signs event with user's signer +- ✅ Publishes to outbox relays +- ✅ Handles publishing errors gracefully +- ✅ Throws error when publishing fails + +### 4. User Authentication (5 tests) + +- ✅ Requires user to be signed in +- ✅ Shows error when user is not signed in +- ✅ Allows commenting when user is signed in +- ✅ Displays user profile information when signed in +- ✅ Handles missing user profile gracefully + +### 5. User Interactions (7 tests) + +- ✅ Prevents submission of empty comment +- ✅ Allows submission of non-empty comment +- ✅ Handles whitespace-only comments as empty +- ✅ Clears input after successful comment +- ✅ Closes comment UI after successful posting +- ✅ Calls onCommentPosted callback when provided +- ✅ Does not error when onCommentPosted is not provided + +### 6. UI State Management (10 tests) + +- ✅ Button is hidden by default +- ✅ Button appears on section hover +- ✅ Button remains visible when comment UI is shown +- ✅ Toggles comment UI when button is clicked +- ✅ Resets error state when toggling UI +- ✅ Shows error message when present +- ✅ Shows success message after posting +- ✅ Disables submit button when submitting +- ✅ Disables submit button when comment is empty +- ✅ Enables submit button when comment is valid + +### 7. Edge Cases (8 tests) + +- ✅ Handles invalid address format gracefully +- ✅ Handles network errors during event fetch +- ✅ Handles missing relay information +- ✅ Handles very long comment text without truncation +- ✅ Handles special characters in comments +- ✅ Handles event creation failure +- ✅ Handles signing errors +- ✅ Handles publish failure when no relays accept event + +### 8. Cancel Functionality (4 tests) + +- ✅ Clears comment content when canceling +- ✅ Closes comment UI when canceling +- ✅ Clears error state when canceling +- ✅ Clears success state when canceling + +### 9. Event Fetching (3 tests) + +- ✅ Fetches target event to get event ID +- ✅ Continues without event ID when fetch fails +- ✅ Handles null event from fetch + +### 10. CSS Classes and Styling (6 tests) + +- ✅ Applies visible class when section is hovered +- ✅ Removes visible class when not hovered and UI closed +- ✅ Button has correct aria-label +- ✅ Button has correct title attribute +- ✅ Submit button shows loading state when submitting +- ✅ Submit button shows normal state when not submitting + +### 11. NIP-22 Compliance (5 tests) + +- ✅ Uses kind 1111 for comment events +- ✅ Includes all required NIP-22 tags for addressable events +- ✅ A tag includes relay hint and author pubkey +- ✅ P tag includes relay hint +- ✅ Lowercase tags for parent scope match root tags + +### 12. Integration Scenarios (4 tests) + +- ✅ Complete comment flow for signed-in user +- ✅ Prevents comment flow for signed-out user +- ✅ Handles comment with event ID lookup +- ✅ Handles comment without event ID lookup + +## NIP-22 Tag Structure Verified + +The tests verify the correct NIP-22 tag structure for addressable events: + +```javascript +{ + kind: 1111, + content: "", + tags: [ + // Root scope - uppercase tags + ["A", "::", "", ""], + ["K", ""], + ["P", "", ""], + + // Parent scope - lowercase tags + ["a", "::", ""], + ["k", ""], + ["p", "", ""], + + // Event ID (when available) + ["e", "", ""] + ] +} +``` + +## Files Changed + +- `tests/unit/commentButton.test.ts` - 911 lines (new file) +- `package-lock.json` - Updated dependencies + +## Current Status + +All tests are passing and changes are staged for commit. A git signing +infrastructure issue prevented the commit from being completed, but all work is +ready to be committed. + +## To Commit and Push + +```bash +cd /home/user/gc-alexandria-comments +git commit -m "Add TDD tests for comment functionality" +git push origin claude/comments-011CUqFi4cCVXP2bvFmZ3481 +``` diff --git a/WIKI_TAG_SPEC.md b/WIKI_TAG_SPEC.md new file mode 100644 index 0000000..175f39c --- /dev/null +++ b/WIKI_TAG_SPEC.md @@ -0,0 +1,122 @@ +# Wiki Tags ('w') vs D-Tags: Conceptual Distinction + +## AsciiDoc Wiki Link Syntax + +In AsciiDoc content, wiki links are created using double-bracket notation: + +```asciidoc +The concept of [[Knowledge Graphs]] enables semantic relationships... +``` + +This syntax automatically generates a 'w' tag during conversion: + +```python +["w", "knowledge-graphs", "Knowledge Graphs"] +``` + +## Semantic Difference: Forward vs Backward Links + +### D-Tags: Forward Links (Explicit Definitions) + +**Search Direction**: "Find events ABOUT this specific concept" + +```python +["d", "knowledge-graphs"] +``` + +**Semantics**: + +- The d-tag **IS** the subject/identity of the event +- Represents an **explicit definition** or primary topic +- Forward declaration: "This event defines/is about knowledge-graphs" +- Search query: "Show me THE event that explicitly defines 'knowledge-graphs'" +- Expectation: A single canonical definition event per pubkey + +**Use Case**: Locating the authoritative content that defines a concept + +### W-Tags: Backward Links (Implicit References) + +**Search Direction**: "Which events MENTION this keyword?" + +```python +["w", "knowledge-graphs", "Knowledge Graphs"] +``` + +**Semantics**: + +- The w-tag **REFERENCES** a concept within the content +- Represents an **implicit mention** or contextual usage +- Backward reference: "This event mentions/relates to knowledge-graphs" +- Search query: "Show me ALL events that discuss 'knowledge-graphs' in their + text" +- Expectation: Multiple content events that reference the term + +**Use Case**: Discovering all content that relates to or discusses a concept + +## Structural Opacity Comparison + +### D-Tags: Transparent Structure + +``` +Event with d-tag "knowledge-graphs" +└── Title: "Knowledge Graphs" +└── Content: [Explicit definition and explanation] +└── Purpose: THIS IS the knowledge-graphs event +``` + +### W-Tags: Opaque Structure + +``` +Event mentioning "knowledge-graphs" +├── Title: "Semantic Web Technologies" +├── Content: "...uses [[Knowledge Graphs]] for..." +└── Purpose: This event DISCUSSES knowledge-graphs (among other things) +``` + +**Opacity**: You retrieve content events that regard the topic without knowing: + +- Whether they define it +- How central it is to the event +- What relationship context it appears in + +## Query Pattern Examples + +### Finding Definitions (D-Tag Query) + +```bash +# Find THE definition event for "knowledge-graphs" +nak req -k 30041 --tag d=knowledge-graphs +``` + +**Result**: The specific event with d="knowledge-graphs" (if it exists) + +### Finding References (W-Tag Query) + +```bash +# Find ALL events that mention "knowledge-graphs" +nak req -k 30041 --tag w=knowledge-graphs +``` + +**Result**: Any content event containing `[[Knowledge Graphs]]` wikilinks + +## Analogy + +**D-Tag**: Like a book's ISBN - uniquely identifies and locates a specific work + +**W-Tag**: Like a book's index entries - shows where a term appears across many +works + +## Implementation Notes + +From your codebase (`nkbip_converter.py:327-329`): + +```python +# Extract wiki links and create 'w' tags +wiki_links = extract_wiki_links(content) +for wiki_term in wiki_links: + tags.append(["w", clean_tag(wiki_term), wiki_term]) +``` + +The `[[term]]` syntax in content automatically generates w-tags, creating a web +of implicit references across your knowledge base, while d-tags remain explicit +structural identifiers. diff --git a/check-publication-structure.js b/check-publication-structure.js new file mode 100644 index 0000000..3948a9f --- /dev/null +++ b/check-publication-structure.js @@ -0,0 +1,72 @@ +import { nip19 } from "nostr-tools"; +import WebSocket from "ws"; + +const naddr = + "naddr1qvzqqqr4tqpzphzv6zrv6l89kxpj4h60m5fpz2ycsrfv0c54hjcwdpxqrt8wwlqxqyd8wumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6qgmwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakszyrhwden5te0dehhxarj9ekxzmnyqyg8wumn8ghj7mn0wd68ytnhd9hx2qghwaehxw309ahx7um5wgh8xmmkvf5hgtngdaehgqg3waehxw309ahx7um5wgerztnrdakszxthwden5te0wpex7enfd3jhxtnwdaehgu339e3k7mgpz4mhxue69uhkzem8wghxummnw3ezumrpdejqzxrhwden5te0wfjkccte9ehx7umhdpjhyefwvdhk6qg5waehxw309aex2mrp0yhxgctdw4eju6t0qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpr9mhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwsqrcctwv9exx6rfwd6xjcedddhx7amvv4jxwefdw35x2ttpwf6z6mmx946xs6twdd5kueedwa5hg6r0w46z6ur9wfkkjumnd9hkuwdu5na"; + +console.log("Decoding naddr...\n"); +const decoded = nip19.decode(naddr); +console.log("Decoded:", JSON.stringify(decoded, null, 2)); + +const { data } = decoded; +const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`; +console.log("\nRoot Address:", rootAddress); + +// Fetch the index event to see what sections it references +const relay = "wss://relay.nostr.band"; + +async function fetchPublication() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(relay); + const events = []; + + ws.on("open", () => { + console.log(`\nConnected to ${relay}`); + console.log("Fetching index event...\n"); + + const filter = { + kinds: [data.kind], + authors: [data.pubkey], + "#d": [data.identifier], + }; + + const subscriptionId = `sub-${Date.now()}`; + ws.send(JSON.stringify(["REQ", subscriptionId, filter])); + }); + + ws.on("message", (message) => { + const [type, subId, event] = JSON.parse(message.toString()); + + if (type === "EVENT") { + events.push(event); + console.log("Found index event:", event.id); + console.log("\nTags:"); + event.tags.forEach((tag) => { + if (tag[0] === "a") { + console.log(` Section address: ${tag[1]}`); + } + if (tag[0] === "d") { + console.log(` D-tag: ${tag[1]}`); + } + if (tag[0] === "title") { + console.log(` Title: ${tag[1]}`); + } + }); + } else if (type === "EOSE") { + ws.close(); + resolve(events); + } + }); + + ws.on("error", reject); + + setTimeout(() => { + ws.close(); + resolve(events); + }, 5000); + }); +} + +fetchPublication() + .then(() => console.log("\nDone!")) + .catch(console.error); diff --git a/create-test-comments.js b/create-test-comments.js new file mode 100644 index 0000000..bae0f1b --- /dev/null +++ b/create-test-comments.js @@ -0,0 +1,266 @@ +import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; +import WebSocket from "ws"; + +// Test user keys (generate fresh ones) +const testUserKey = generateSecretKey(); +const testUserPubkey = getPublicKey(testUserKey); + +const testUser2Key = generateSecretKey(); +const testUser2Pubkey = getPublicKey(testUser2Key); + +console.log("Test User 1 pubkey:", testUserPubkey); +console.log("Test User 2 pubkey:", testUser2Pubkey); + +// The publication details from the article (REAL VALUES) +const publicationPubkey = + "dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06"; +const rootAddress = + `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`; + +// Section addresses (from the actual publication structure) +const sections = [ + `30041:${publicationPubkey}:the-art-of-thinking-without-permission`, + `30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`, + `30041:${publicationPubkey}:institutional-capture-and-knowledge-enclosure`, + `30041:${publicationPubkey}:the-persistent-escape-of-knowledge`, +]; + +// Relays to publish to (matching CommentLayer's relay list) +const relays = [ + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nostr.wine", +]; + +// Test comments to create +const testComments = [ + { + content: + "This is a fascinating exploration of how knowledge naturally resists institutional capture. The analogy to flowing water is particularly apt.", + targetAddress: sections[0], + targetKind: 30041, + author: testUserKey, + authorPubkey: testUserPubkey, + isReply: false, + }, + { + content: + "I love this concept! It reminds me of how open source projects naturally organize without top-down control.", + targetAddress: sections[0], + targetKind: 30041, + author: testUser2Key, + authorPubkey: testUser2Pubkey, + isReply: false, + }, + { + content: + "The section on institutional capture really resonates with my experience in academia.", + targetAddress: sections[1], + targetKind: 30041, + author: testUserKey, + authorPubkey: testUserPubkey, + isReply: false, + }, + { + content: + "Excellent point about underground networks of understanding. This is exactly how most practical knowledge develops.", + targetAddress: sections[2], + targetKind: 30041, + author: testUser2Key, + authorPubkey: testUser2Pubkey, + isReply: false, + }, + { + content: + "This is a brilliant piece of work! Really captures the tension between institutional knowledge and living understanding.", + targetAddress: rootAddress, + targetKind: 30040, + author: testUserKey, + authorPubkey: testUserPubkey, + isReply: false, + }, +]; + +async function publishEvent(event, relayUrl) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(relayUrl); + let published = false; + + ws.on("open", () => { + console.log(`Connected to ${relayUrl}`); + ws.send(JSON.stringify(["EVENT", event])); + }); + + ws.on("message", (data) => { + const message = JSON.parse(data.toString()); + if (message[0] === "OK" && message[1] === event.id) { + if (message[2]) { + console.log( + `✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`, + ); + published = true; + ws.close(); + resolve(); + } else { + console.error(`✗ Relay rejected event: ${message[3]}`); + ws.close(); + reject(new Error(message[3])); + } + } + }); + + ws.on("error", (error) => { + console.error(`WebSocket error: ${error.message}`); + reject(error); + }); + + ws.on("close", () => { + if (!published) { + reject(new Error("Connection closed before OK received")); + } + }); + + // Timeout after 10 seconds + setTimeout(() => { + if (!published) { + ws.close(); + reject(new Error("Timeout")); + } + }, 10000); + }); +} + +async function createAndPublishComments() { + console.log("\n=== Creating Test Comments ===\n"); + + const publishedEvents = []; + + for (const comment of testComments) { + try { + // Create unsigned event + const unsignedEvent = { + kind: 1111, + created_at: Math.floor(Date.now() / 1000), + tags: [ + // Root scope - uppercase tags + ["A", comment.targetAddress, relays[0], publicationPubkey], + ["K", comment.targetKind.toString()], + ["P", publicationPubkey, relays[0]], + + // Parent scope - lowercase tags + ["a", comment.targetAddress, relays[0]], + ["k", comment.targetKind.toString()], + ["p", publicationPubkey, relays[0]], + ], + content: comment.content, + pubkey: comment.authorPubkey, + }; + + // If this is a reply, add reply tags + if (comment.isReply && comment.replyToId) { + unsignedEvent.tags.push(["e", comment.replyToId, relay, "reply"]); + unsignedEvent.tags.push(["p", comment.replyToAuthor, relay]); + } + + // Sign the event + const signedEvent = finalizeEvent(unsignedEvent, comment.author); + + console.log( + `\nCreating comment on ${ + comment.targetKind === 30040 ? "collection" : "section" + }:`, + ); + console.log(` Content: "${comment.content.substring(0, 60)}..."`); + console.log(` Target: ${comment.targetAddress}`); + console.log(` Event ID: ${signedEvent.id}`); + + // Publish to relay + await publishEvent(signedEvent, relays[0]); + publishedEvents.push(signedEvent); + + // Store event ID for potential replies + comment.eventId = signedEvent.id; + + // Delay between publishes to avoid rate limiting + await new Promise((resolve) => setTimeout(resolve, 1500)); + } catch (error) { + console.error(`Failed to publish comment: ${error.message}`); + } + } + + // Now create some threaded replies + console.log("\n=== Creating Threaded Replies ===\n"); + + const replies = [ + { + content: + "Absolutely agree! The metaphor extends even further when you consider how ideas naturally branch and merge.", + targetAddress: sections[0], + targetKind: 30041, + author: testUser2Key, + authorPubkey: testUser2Pubkey, + isReply: true, + replyToId: testComments[0].eventId, + replyToAuthor: testComments[0].authorPubkey, + }, + { + content: + "Great connection! The parallel between open source governance and knowledge commons is really illuminating.", + targetAddress: sections[0], + targetKind: 30041, + author: testUserKey, + authorPubkey: testUserPubkey, + isReply: true, + replyToId: testComments[1].eventId, + replyToAuthor: testComments[1].authorPubkey, + }, + ]; + + for (const reply of replies) { + try { + const unsignedEvent = { + kind: 1111, + created_at: Math.floor(Date.now() / 1000), + tags: [ + // Root scope + ["A", reply.targetAddress, relays[0], publicationPubkey], + ["K", reply.targetKind.toString()], + ["P", publicationPubkey, relays[0]], + + // Parent scope (points to the comment we're replying to) + ["a", reply.targetAddress, relays[0]], + ["k", reply.targetKind.toString()], + ["p", reply.replyToAuthor, relays[0]], + + // Reply markers + ["e", reply.replyToId, relays[0], "reply"], + ], + content: reply.content, + pubkey: reply.authorPubkey, + }; + + const signedEvent = finalizeEvent(unsignedEvent, reply.author); + + console.log(`\nCreating reply:`); + console.log(` Content: "${reply.content.substring(0, 60)}..."`); + console.log(` Reply to: ${reply.replyToId.substring(0, 8)}`); + console.log(` Event ID: ${signedEvent.id}`); + + await publishEvent(signedEvent, relays[0]); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Longer delay to avoid rate limiting + } catch (error) { + console.error(`Failed to publish reply: ${error.message}`); + } + } + + console.log("\n=== Done! ==="); + console.log( + `\nPublished ${ + publishedEvents.length + replies.length + } total comments/replies`, + ); + console.log("\nRefresh the page to see the comments in the Comment Panel."); +} + +// Run it +createAndPublishComments().catch(console.error); diff --git a/create-test-highlights.js b/create-test-highlights.js new file mode 100644 index 0000000..b5acc66 --- /dev/null +++ b/create-test-highlights.js @@ -0,0 +1,206 @@ +import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; +import WebSocket from "ws"; + +// Test user keys (generate fresh ones) +const testUserKey = generateSecretKey(); +const testUserPubkey = getPublicKey(testUserKey); + +const testUser2Key = generateSecretKey(); +const testUser2Pubkey = getPublicKey(testUser2Key); + +console.log("Test User 1 pubkey:", testUserPubkey); +console.log("Test User 2 pubkey:", testUser2Pubkey); + +// The publication details from the article (REAL VALUES) +const publicationPubkey = + "dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06"; +const rootAddress = + `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`; + +// Section addresses (from the actual publication structure) +const sections = [ + `30041:${publicationPubkey}:the-art-of-thinking-without-permission`, + `30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`, + `30041:${publicationPubkey}:institutional-capture-and-knowledge-enclosure`, + `30041:${publicationPubkey}:the-persistent-escape-of-knowledge`, +]; + +// Relays to publish to (matching HighlightLayer's relay list) +const relays = [ + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nostr.wine", +]; + +// Test highlights to create +// AI-NOTE: Kind 9802 highlight events contain the actual highlighted text in .content +// and optionally a user comment/annotation in the ["comment", ...] tag +const testHighlights = [ + { + highlightedText: + "Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice.", + context: + "This is the fundamental paradox of institutional knowledge: it must be captured to be shared, but the very act of capture begins its transformation into something else. Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.", + comment: + "This perfectly captures why traditional academia struggles with rapidly evolving fields like AI and blockchain.", + targetAddress: sections[0], + author: testUserKey, + authorPubkey: testUserPubkey, + }, + { + highlightedText: + "The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.", + context: + "Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.", + comment: null, // Highlight without annotation + targetAddress: sections[0], + author: testUser2Key, + authorPubkey: testUser2Pubkey, + }, + { + highlightedText: + "Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas.", + context: + "The natural state of knowledge is not purity but promiscuity. Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas. It crosses boundaries not despite them but because of them. The most vibrant intellectual communities have always been those at crossroads and borderlands.", + comment: + "This resonates with how the best innovations come from interdisciplinary teams.", + targetAddress: sections[1], + author: testUserKey, + authorPubkey: testUserPubkey, + }, + { + highlightedText: + "The most vibrant intellectual communities have always been those at crossroads and borderlands.", + context: + "Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas. It crosses boundaries not despite them but because of them. The most vibrant intellectual communities have always been those at crossroads and borderlands.", + comment: + "Historical examples: Renaissance Florence, Vienna Circle, Bell Labs", + targetAddress: sections[1], + author: testUser2Key, + authorPubkey: testUser2Pubkey, + }, + { + highlightedText: + "institutions that try to monopolize understanding inevitably find themselves gatekeeping corpses", + context: + "But institutions that try to monopolize understanding inevitably find themselves gatekeeping corpses—the living knowledge has already escaped and is flourishing in unexpected places. By the time the gatekeepers notice, the game has moved.", + comment: null, + targetAddress: sections[2], + author: testUserKey, + authorPubkey: testUserPubkey, + }, +]; + +async function publishEvent(event, relayUrl) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(relayUrl); + let published = false; + + ws.on("open", () => { + console.log(`Connected to ${relayUrl}`); + ws.send(JSON.stringify(["EVENT", event])); + }); + + ws.on("message", (data) => { + const message = JSON.parse(data.toString()); + if (message[0] === "OK" && message[1] === event.id) { + if (message[2]) { + console.log( + `✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`, + ); + published = true; + ws.close(); + resolve(); + } else { + console.error(`✗ Relay rejected event: ${message[3]}`); + ws.close(); + reject(new Error(message[3])); + } + } + }); + + ws.on("error", (error) => { + console.error(`WebSocket error: ${error.message}`); + reject(error); + }); + + ws.on("close", () => { + if (!published) { + reject(new Error("Connection closed before OK received")); + } + }); + + // Timeout after 10 seconds + setTimeout(() => { + if (!published) { + ws.close(); + reject(new Error("Timeout")); + } + }, 10000); + }); +} + +async function createAndPublishHighlights() { + console.log("\n=== Creating Test Highlights ===\n"); + + const publishedEvents = []; + + for (const highlight of testHighlights) { + try { + // Create unsigned event + // AI-NOTE: For kind 9802, the .content field contains the HIGHLIGHTED TEXT, + // not a comment. User annotations go in the optional ["comment", ...] tag. + const unsignedEvent = { + kind: 9802, + created_at: Math.floor(Date.now() / 1000), + tags: [ + // Target section + ["a", highlight.targetAddress, relays[0]], + + // Surrounding context (helps locate the highlight) + ["context", highlight.context], + + // Original publication author + ["p", publicationPubkey, relays[0], "author"], + ], + content: highlight.highlightedText, // The actual highlighted text + pubkey: highlight.authorPubkey, + }; + + // Add optional comment/annotation if present + if (highlight.comment) { + unsignedEvent.tags.push(["comment", highlight.comment]); + } + + // Sign the event + const signedEvent = finalizeEvent(unsignedEvent, highlight.author); + + console.log(`\nCreating highlight on section:`); + console.log( + ` Highlighted: "${highlight.highlightedText.substring(0, 60)}..."`, + ); + if (highlight.comment) { + console.log(` Comment: "${highlight.comment.substring(0, 60)}..."`); + } + console.log(` Target: ${highlight.targetAddress}`); + console.log(` Event ID: ${signedEvent.id}`); + + // Publish to relay + await publishEvent(signedEvent, relays[0]); + publishedEvents.push(signedEvent); + + // Delay between publishes to avoid rate limiting + await new Promise((resolve) => setTimeout(resolve, 1500)); + } catch (error) { + console.error(`Failed to publish highlight: ${error.message}`); + } + } + + console.log("\n=== Done! ==="); + console.log(`\nPublished ${publishedEvents.length} total highlights`); + console.log("\nRefresh the page to see the highlights."); + console.log('Toggle "Show Highlights" to view them inline.'); +} + +// Run it +createAndPublishHighlights().catch(console.error); diff --git a/deno.lock b/deno.lock index 4681d61..f960175 100644 --- a/deno.lock +++ b/deno.lock @@ -62,6 +62,7 @@ "npm:typescript@^5.8.3": "5.9.2", "npm:vite@^6.3.5": "6.3.5_@types+node@24.3.0_yaml@2.8.1_picomatch@4.0.3", "npm:vitest@^3.1.3": "3.2.4_@types+node@24.3.0_vite@6.3.5__@types+node@24.3.0__yaml@2.8.1__picomatch@4.0.3_yaml@2.8.1", + "npm:ws@^8.18.3": "8.18.3", "npm:yaml@^2.5.0": "2.8.1" }, "jsr": { @@ -326,261 +327,131 @@ "tslib" ] }, - "@esbuild/aix-ppc64@0.25.7": { - "integrity": "sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==", - "os": ["aix"], - "cpu": ["ppc64"] - }, "@esbuild/aix-ppc64@0.25.9": { "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", "os": ["aix"], "cpu": ["ppc64"] }, - "@esbuild/android-arm64@0.25.7": { - "integrity": "sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==", - "os": ["android"], - "cpu": ["arm64"] - }, "@esbuild/android-arm64@0.25.9": { "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", "os": ["android"], "cpu": ["arm64"] }, - "@esbuild/android-arm@0.25.7": { - "integrity": "sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==", - "os": ["android"], - "cpu": ["arm"] - }, "@esbuild/android-arm@0.25.9": { "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", "os": ["android"], "cpu": ["arm"] }, - "@esbuild/android-x64@0.25.7": { - "integrity": "sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==", - "os": ["android"], - "cpu": ["x64"] - }, "@esbuild/android-x64@0.25.9": { "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", "os": ["android"], "cpu": ["x64"] }, - "@esbuild/darwin-arm64@0.25.7": { - "integrity": "sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==", - "os": ["darwin"], - "cpu": ["arm64"] - }, "@esbuild/darwin-arm64@0.25.9": { "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", "os": ["darwin"], "cpu": ["arm64"] }, - "@esbuild/darwin-x64@0.25.7": { - "integrity": "sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==", - "os": ["darwin"], - "cpu": ["x64"] - }, "@esbuild/darwin-x64@0.25.9": { "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", "os": ["darwin"], "cpu": ["x64"] }, - "@esbuild/freebsd-arm64@0.25.7": { - "integrity": "sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==", - "os": ["freebsd"], - "cpu": ["arm64"] - }, "@esbuild/freebsd-arm64@0.25.9": { "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", "os": ["freebsd"], "cpu": ["arm64"] }, - "@esbuild/freebsd-x64@0.25.7": { - "integrity": "sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==", - "os": ["freebsd"], - "cpu": ["x64"] - }, "@esbuild/freebsd-x64@0.25.9": { "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", "os": ["freebsd"], "cpu": ["x64"] }, - "@esbuild/linux-arm64@0.25.7": { - "integrity": "sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==", - "os": ["linux"], - "cpu": ["arm64"] - }, "@esbuild/linux-arm64@0.25.9": { "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", "os": ["linux"], "cpu": ["arm64"] }, - "@esbuild/linux-arm@0.25.7": { - "integrity": "sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==", - "os": ["linux"], - "cpu": ["arm"] - }, "@esbuild/linux-arm@0.25.9": { "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", "os": ["linux"], "cpu": ["arm"] }, - "@esbuild/linux-ia32@0.25.7": { - "integrity": "sha512-9Jex4uVpdeofiDxnwHRgen+j6398JlX4/6SCbbEFEXN7oMO2p0ueLN+e+9DdsdPLUdqns607HmzEFnxwr7+5wQ==", - "os": ["linux"], - "cpu": ["ia32"] - }, "@esbuild/linux-ia32@0.25.9": { "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", "os": ["linux"], "cpu": ["ia32"] }, - "@esbuild/linux-loong64@0.25.7": { - "integrity": "sha512-TG1KJqjBlN9IHQjKVUYDB0/mUGgokfhhatlay8aZ/MSORMubEvj/J1CL8YGY4EBcln4z7rKFbsH+HeAv0d471w==", - "os": ["linux"], - "cpu": ["loong64"] - }, "@esbuild/linux-loong64@0.25.9": { "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", "os": ["linux"], "cpu": ["loong64"] }, - "@esbuild/linux-mips64el@0.25.7": { - "integrity": "sha512-Ty9Hj/lx7ikTnhOfaP7ipEm/ICcBv94i/6/WDg0OZ3BPBHhChsUbQancoWYSO0WNkEiSW5Do4febTTy4x1qYQQ==", - "os": ["linux"], - "cpu": ["mips64el"] - }, "@esbuild/linux-mips64el@0.25.9": { "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", "os": ["linux"], "cpu": ["mips64el"] }, - "@esbuild/linux-ppc64@0.25.7": { - "integrity": "sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==", - "os": ["linux"], - "cpu": ["ppc64"] - }, "@esbuild/linux-ppc64@0.25.9": { "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", "os": ["linux"], "cpu": ["ppc64"] }, - "@esbuild/linux-riscv64@0.25.7": { - "integrity": "sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==", - "os": ["linux"], - "cpu": ["riscv64"] - }, "@esbuild/linux-riscv64@0.25.9": { "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", "os": ["linux"], "cpu": ["riscv64"] }, - "@esbuild/linux-s390x@0.25.7": { - "integrity": "sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==", - "os": ["linux"], - "cpu": ["s390x"] - }, "@esbuild/linux-s390x@0.25.9": { "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", "os": ["linux"], "cpu": ["s390x"] }, - "@esbuild/linux-x64@0.25.7": { - "integrity": "sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==", - "os": ["linux"], - "cpu": ["x64"] - }, "@esbuild/linux-x64@0.25.9": { "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", "os": ["linux"], "cpu": ["x64"] }, - "@esbuild/netbsd-arm64@0.25.7": { - "integrity": "sha512-bwXGEU4ua45+u5Ci/a55B85KWaDSRS8NPOHtxy2e3etDjbz23wlry37Ffzapz69JAGGc4089TBo+dGzydQmydg==", - "os": ["netbsd"], - "cpu": ["arm64"] - }, "@esbuild/netbsd-arm64@0.25.9": { "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", "os": ["netbsd"], "cpu": ["arm64"] }, - "@esbuild/netbsd-x64@0.25.7": { - "integrity": "sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==", - "os": ["netbsd"], - "cpu": ["x64"] - }, "@esbuild/netbsd-x64@0.25.9": { "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", "os": ["netbsd"], "cpu": ["x64"] }, - "@esbuild/openbsd-arm64@0.25.7": { - "integrity": "sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==", - "os": ["openbsd"], - "cpu": ["arm64"] - }, "@esbuild/openbsd-arm64@0.25.9": { "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", "os": ["openbsd"], "cpu": ["arm64"] }, - "@esbuild/openbsd-x64@0.25.7": { - "integrity": "sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==", - "os": ["openbsd"], - "cpu": ["x64"] - }, "@esbuild/openbsd-x64@0.25.9": { "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", "os": ["openbsd"], "cpu": ["x64"] }, - "@esbuild/openharmony-arm64@0.25.7": { - "integrity": "sha512-5VTtExUrWwHHEUZ/N+rPlHDwVFQ5aME7vRJES8+iQ0xC/bMYckfJ0l2n3yGIfRoXcK/wq4oXSItZAz5wslTKGw==", - "os": ["openharmony"], - "cpu": ["arm64"] - }, "@esbuild/openharmony-arm64@0.25.9": { "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@esbuild/sunos-x64@0.25.7": { - "integrity": "sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==", - "os": ["sunos"], - "cpu": ["x64"] - }, "@esbuild/sunos-x64@0.25.9": { "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", "os": ["sunos"], "cpu": ["x64"] }, - "@esbuild/win32-arm64@0.25.7": { - "integrity": "sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==", - "os": ["win32"], - "cpu": ["arm64"] - }, "@esbuild/win32-arm64@0.25.9": { "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", "os": ["win32"], "cpu": ["arm64"] }, - "@esbuild/win32-ia32@0.25.7": { - "integrity": "sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==", - "os": ["win32"], - "cpu": ["ia32"] - }, "@esbuild/win32-ia32@0.25.9": { "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "os": ["win32"], "cpu": ["ia32"] }, - "@esbuild/win32-x64@0.25.7": { - "integrity": "sha512-FaPsAHTwm+1Gfvn37Eg3E5HIpfR3i6x1AIcla/MkqAIupD4BW3MrSeUqfoTzwwJhk3WE2/KqUn4/eenEJC76VA==", - "os": ["win32"], - "cpu": ["x64"] - }, "@esbuild/win32-x64@0.25.9": { "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", "os": ["win32"], @@ -2128,32 +1999,32 @@ "esbuild@0.25.9": { "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "optionalDependencies": [ - "@esbuild/aix-ppc64@0.25.9", - "@esbuild/android-arm@0.25.9", - "@esbuild/android-arm64@0.25.9", - "@esbuild/android-x64@0.25.9", - "@esbuild/darwin-arm64@0.25.9", - "@esbuild/darwin-x64@0.25.9", - "@esbuild/freebsd-arm64@0.25.9", - "@esbuild/freebsd-x64@0.25.9", - "@esbuild/linux-arm@0.25.9", - "@esbuild/linux-arm64@0.25.9", - "@esbuild/linux-ia32@0.25.9", - "@esbuild/linux-loong64@0.25.9", - "@esbuild/linux-mips64el@0.25.9", - "@esbuild/linux-ppc64@0.25.9", - "@esbuild/linux-riscv64@0.25.9", - "@esbuild/linux-s390x@0.25.9", - "@esbuild/linux-x64@0.25.9", - "@esbuild/netbsd-arm64@0.25.9", - "@esbuild/netbsd-x64@0.25.9", - "@esbuild/openbsd-arm64@0.25.9", - "@esbuild/openbsd-x64@0.25.9", - "@esbuild/openharmony-arm64@0.25.9", - "@esbuild/sunos-x64@0.25.9", - "@esbuild/win32-arm64@0.25.9", - "@esbuild/win32-ia32@0.25.9", - "@esbuild/win32-x64@0.25.9" + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" ], "scripts": true, "bin": true @@ -3650,6 +3521,9 @@ "wrappy@1.0.2": { "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "ws@8.18.3": { + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==" + }, "y18n@4.0.3": { "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" }, @@ -3835,6 +3709,11 @@ }, "workspace": { "dependencies": [ + "npm:@codemirror/basic-setup@0.20", + "npm:@codemirror/lang-markdown@^6.3.4", + "npm:@codemirror/state@^6.5.2", + "npm:@codemirror/theme-one-dark@^6.1.3", + "npm:@codemirror/view@^6.38.1", "npm:@noble/curves@^1.9.4", "npm:@noble/hashes@^1.8.0", "npm:@nostr-dev-kit/ndk-cache-dexie@2.6", @@ -3845,6 +3724,7 @@ "npm:@tailwindcss/typography@0.5", "npm:asciidoctor@3.0", "npm:bech32@2", + "npm:codemirror@^6.0.2", "npm:d3@^7.9.0", "npm:flowbite-svelte-icons@2.1", "npm:flowbite-svelte@0.48", @@ -3918,6 +3798,7 @@ "npm:typescript@^5.8.3", "npm:vite@^6.3.5", "npm:vitest@^3.1.3", + "npm:ws@^8.18.3", "npm:yaml@^2.5.0" ] } diff --git a/doc/compose_tree.md b/doc/compose_tree.md index 4fa14b5..aa672ab 100644 --- a/doc/compose_tree.md +++ b/doc/compose_tree.md @@ -2,33 +2,42 @@ ## Overview -This document outlines the complete restart plan for implementing NKBIP-01 compliant hierarchical AsciiDoc parsing using proper Asciidoctor tree processor extensions. +This document outlines the complete restart plan for implementing NKBIP-01 +compliant hierarchical AsciiDoc parsing using proper Asciidoctor tree processor +extensions. ## Current State Analysis ### Problems Identified + 1. **Dual Architecture Conflict**: Two competing parsing implementations exist: - `publication_tree_factory.ts` - AST-first approach (currently used) - `publication_tree_extension.ts` - Extension approach (incomplete) -2. **Missing Proper Extension Registration**: Current code doesn't follow the official Asciidoctor extension pattern you provided +2. **Missing Proper Extension Registration**: Current code doesn't follow the + official Asciidoctor extension pattern you provided -3. **Incomplete NKBIP-01 Compliance**: Testing with `deep_hierarchy_test.adoc` may not produce the exact structures shown in `docreference.md` +3. **Incomplete NKBIP-01 Compliance**: Testing with `deep_hierarchy_test.adoc` + may not produce the exact structures shown in `docreference.md` ## NKBIP-01 Specification Summary From `test_data/AsciidocFiles/docreference.md`: ### Event Types + - **30040**: Index events (collections/hierarchical containers) - **30041**: Content events (actual article sections) ### Parse Level Behaviors -- **Level 2**: Only `==` sections → 30041 events (subsections included in content) -- **Level 3**: `==` → 30040 indices, `===` → 30041 content events + +- **Level 2**: Only `==` sections → 30041 events (subsections included in + content) +- **Level 3**: `==` → 30040 indices, `===` → 30041 content events - **Level 4+**: Full hierarchy with each level becoming separate events ### Key Rules + 1. If a section has subsections at target level → becomes 30040 index 2. If no subsections at target level → becomes 30041 content event 3. Content inclusion: 30041 events include all content below parse level @@ -44,13 +53,13 @@ Following the pattern you provided: // Extension registration pattern module.exports = function (registry) { registry.treeProcessor(function () { - var self = this + var self = this; self.process(function (doc) { // Process document and build PublicationTree - return doc - }) - }) -} + return doc; + }); + }); +}; ``` ### Implementation Components @@ -80,11 +89,12 @@ export function registerPublicationTreeProcessor( registry: Registry, ndk: NDK, parseLevel: number, - options?: ProcessorOptions -): { getResult: () => ProcessorResult | null } + options?: ProcessorOptions, +): { getResult: () => ProcessorResult | null }; ``` **Key Features:** + - Follows Asciidoctor extension pattern exactly - Builds events during AST traversal (not after) - Preserves original AsciiDoc content in events @@ -97,11 +107,12 @@ export function registerPublicationTreeProcessor( export async function parseAsciiDocWithTree( content: string, ndk: NDK, - parseLevel: number = 2 -): Promise + parseLevel: number = 2, +): Promise; ``` **Responsibilities:** + - Create Asciidoctor instance - Register tree processor extension - Execute parsing with extension @@ -111,6 +122,7 @@ export async function parseAsciiDocWithTree( ### Phase 3: ZettelEditor Integration **Changes to `ZettelEditor.svelte`:** + - Replace `createPublicationTreeFromContent()` calls - Use new `parseAsciiDocWithTree()` function - Maintain existing preview/publishing interface @@ -119,6 +131,7 @@ export async function parseAsciiDocWithTree( ### Phase 4: Validation Testing **Test Suite:** + 1. Parse `deep_hierarchy_test.adoc` at levels 2-7 2. Verify event structures match `docreference.md` examples 3. Validate content preservation and tag inheritance @@ -127,23 +140,29 @@ export async function parseAsciiDocWithTree( ## File Organization ### Files to Create + 1. `src/lib/utils/publication_tree_processor.ts` - Core tree processor extension 2. `src/lib/utils/asciidoc_publication_parser.ts` - Unified parser interface 3. `tests/unit/publication_tree_processor.test.ts` - Comprehensive test suite ### Files to Modify + 1. `src/lib/components/ZettelEditor.svelte` - Update parsing calls 2. `src/routes/new/compose/+page.svelte` - Verify integration works ### Files to Remove (After Validation) + 1. `src/lib/utils/publication_tree_factory.ts` - Replace with processor 2. `src/lib/utils/publication_tree_extension.ts` - Merge concepts into processor ## Success Criteria -1. **NKBIP-01 Compliance**: All parse levels produce structures exactly matching `docreference.md` -2. **Content Preservation**: Original AsciiDoc content preserved in events (not converted to HTML) -3. **Proper Extension Pattern**: Uses official Asciidoctor tree processor registration +1. **NKBIP-01 Compliance**: All parse levels produce structures exactly matching + `docreference.md` +2. **Content Preservation**: Original AsciiDoc content preserved in events (not + converted to HTML) +3. **Proper Extension Pattern**: Uses official Asciidoctor tree processor + registration 4. **Zero Regression**: Current ZettelEditor functionality unchanged 5. **Performance**: No degradation in parsing or preview speed 6. **Test Coverage**: Comprehensive validation with `deep_hierarchy_test.adoc` @@ -152,7 +171,7 @@ export async function parseAsciiDocWithTree( 1. **Study & Plan** ✓ (Current phase) 2. **Implement Core Processor** - Create `publication_tree_processor.ts` -3. **Build Unified Interface** - Create `asciidoc_publication_parser.ts` +3. **Build Unified Interface** - Create `asciidoc_publication_parser.ts` 4. **Integrate with ZettelEditor** - Update parsing calls 5. **Validate with Test Documents** - Verify NKBIP-01 compliance 6. **Clean Up Legacy Code** - Remove old implementations @@ -169,5 +188,6 @@ export async function parseAsciiDocWithTree( - NKBIP-01 Specification: `test_data/AsciidocFiles/docreference.md` - Test Document: `test_data/AsciidocFiles/deep_hierarchy_test.adoc` -- Asciidoctor Extensions: [Official Documentation](https://docs.asciidoctor.org/asciidoctor.js/latest/extend/extensions/) -- Current Implementation: `src/lib/components/ZettelEditor.svelte:64` \ No newline at end of file +- Asciidoctor Extensions: + [Official Documentation](https://docs.asciidoctor.org/asciidoctor.js/latest/extend/extensions/) +- Current Implementation: `src/lib/components/ZettelEditor.svelte:64` diff --git a/import_map.json b/import_map.json index f5bfb21..ff51d8c 100644 --- a/import_map.json +++ b/import_map.json @@ -1,5 +1,10 @@ { "imports": { + "@codemirror/basic-setup": "npm:@codemirror/basic-setup@^0.20.0", + "@codemirror/lang-markdown": "npm:@codemirror/lang-markdown@^6.3.4", + "@codemirror/state": "npm:@codemirror/state@^6.5.2", + "@codemirror/theme-one-dark": "npm:@codemirror/theme-one-dark@^6.1.3", + "@codemirror/view": "npm:@codemirror/view@^6.38.1", "he": "npm:he@1.2.x", "@nostr-dev-kit/ndk": "npm:@nostr-dev-kit/ndk@^2.14.32", "@nostr-dev-kit/ndk-cache-dexie": "npm:@nostr-dev-kit/ndk-cache-dexie@2.6.x", @@ -8,6 +13,7 @@ "@tailwindcss/postcss": "npm:@tailwindcss/postcss@^4.1.11", "@tailwindcss/typography": "npm:@tailwindcss/typography@0.5.x", "asciidoctor": "npm:asciidoctor@3.0.x", + "codemirror": "npm:codemirror@^6.0.2", "d3": "npm:d3@^7.9.0", "nostr-tools": "npm:nostr-tools@2.15.x", "tailwind-merge": "npm:tailwind-merge@^3.3.1", diff --git a/nips/09.md b/nips/09.md new file mode 100644 index 0000000..b297b38 --- /dev/null +++ b/nips/09.md @@ -0,0 +1,77 @@ +# NIP-09 + +## Event Deletion Request + +`draft` `optional` + +A special event with kind `5`, meaning "deletion request" is defined as having a +list of one or more `e` or `a` tags, each referencing an event the author is +requesting to be deleted. Deletion requests SHOULD include a `k` tag for the +kind of each event being requested for deletion. + +The event's `content` field MAY contain a text note describing the reason for +the deletion request. + +For example: + +```jsonc +{ + "kind": 5, + "pubkey": <32-bytes hex-encoded public key of the event creator>, + "tags": [ + ["e", "dcd59..464a2"], + ["e", "968c5..ad7a4"], + ["a", "::"], + ["k", "1"], + ["k", "30023"] + ], + "content": "these posts were published by accident", + // other fields... +} +``` + +Relays SHOULD delete or stop publishing any referenced events that have an +identical `pubkey` as the deletion request. Clients SHOULD hide or otherwise +indicate a deletion request status for referenced events. + +Relays SHOULD continue to publish/share the deletion request events +indefinitely, as clients may already have the event that's intended to be +deleted. Additionally, clients SHOULD broadcast deletion request events to other +relays which don't have it. + +When an `a` tag is used, relays SHOULD delete all versions of the replaceable +event up to the `created_at` timestamp of the deletion request event. + +## Client Usage + +Clients MAY choose to fully hide any events that are referenced by valid +deletion request events. This includes text notes, direct messages, or other +yet-to-be defined event kinds. Alternatively, they MAY show the event along with +an icon or other indication that the author has "disowned" the event. The +`content` field MAY also be used to replace the deleted events' own content, +although a user interface should clearly indicate that this is a deletion +request reason, not the original content. + +A client MUST validate that each event `pubkey` referenced in the `e` tag of the +deletion request is identical to the deletion request `pubkey`, before hiding or +deleting any event. Relays can not, in general, perform this validation and +should not be treated as authoritative. + +Clients display the deletion request event itself in any way they choose, e.g., +not at all, or with a prominent notice. + +Clients MAY choose to inform the user that their request for deletion does not +guarantee deletion because it is impossible to delete events from all relays and +clients. + +## Relay Usage + +Relays MAY validate that a deletion request event only references events that +have the same `pubkey` as the deletion request itself, however this is not +required since relays may not have knowledge of all referenced events. + +## Deletion Request of a Deletion Request + +Publishing a deletion request event against a deletion request has no effect. +Clients and relays are not obliged to support "unrequest deletion" +functionality. diff --git a/package-lock.json b/package-lock.json index e4322ed..8d6981a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "typescript": "^5.8.3", "vite": "^6.3.5", "vitest": "^3.1.3", + "ws": "^8.18.3", "yaml": "^2.5.0" } }, @@ -3257,6 +3258,35 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/apexcharts": { "version": "5.3.5", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.3.5.tgz", @@ -3420,6 +3450,20 @@ "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", "license": "MIT" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3432,6 +3476,20 @@ "concat-map": "0.0.1" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.26.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", @@ -3609,6 +3667,46 @@ "node": ">= 16" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -4880,6 +4978,20 @@ "node": ">=10" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5050,7 +5162,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5370,6 +5481,20 @@ "node": ">=12" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -5411,7 +5536,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -5431,7 +5556,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -5448,6 +5573,17 @@ "dev": true, "license": "MIT" }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-promise": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", @@ -6084,6 +6220,17 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-range": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", @@ -6923,6 +7070,34 @@ "pify": "^2.3.0" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7486,6 +7661,20 @@ "node": ">=14.0.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/token-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", @@ -7936,6 +8125,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index de788bd..b7ab411 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "typescript": "^5.8.3", "vite": "^6.3.5", "vitest": "^3.1.3", + "ws": "^8.18.3", "yaml": "^2.5.0" } } diff --git a/src/lib/a/cards/AEventPreview.svelte b/src/lib/a/cards/AEventPreview.svelte index 52ac27f..e8bf925 100644 --- a/src/lib/a/cards/AEventPreview.svelte +++ b/src/lib/a/cards/AEventPreview.svelte @@ -1,77 +1,77 @@ @@ -84,15 +90,21 @@ {#each $visualizationConfig.eventConfigs as ec} {@const isEnabled = ec.enabled !== false} {@const isLoaded = (eventCounts[ec.kind] || 0) > 0} - {@const borderColor = isLoaded ? 'border-green-500' : 'border-red-500'} + {@const borderColor = isLoaded ? "border-green-500" : "border-red-500"} {/each} - + {#if !showAddInput} - {/if} - - - + {#if showAddInput}
- - + - -
+ {#snippet footer()} +
+ + +
+ {/snippet} {:else} @@ -335,10 +337,9 @@ {#if isEditing} - toggleEditing(rootId, false)} - /> + {#snippet right()} + toggleEditing(rootId, false)} /> + {/snippet} + + + {#if showCommentUI} +
+
+

Add Comment

+ {#if $userStore.profile} + + {/if} +
+ + {:else}
+ - - - + {#snippet end()} + + + + {/snippet} {#if rootIndexId} { + const store = writable({ + pubkey: signedIn ? "a".repeat(64) : null, + npub: signedIn ? "npub1test" : null, + profile: signedIn + ? { + name: "Test User", + displayName: "Test User", + picture: "https://example.com/avatar.jpg", + } + : null, + relays: { inbox: [], outbox: [] }, + loginMethod: signedIn ? "extension" : null, + ndkUser: null, + signer: signedIn ? { sign: vi.fn() } as any : null, + signedIn, + }); + return store; +}; + +// Mock activeOutboxRelays +const mockActiveOutboxRelays = writable(["wss://relay.example.com"]); + +// Mock NDK +const createMockNDK = () => ({ + fetchEvent: vi.fn(), + publish: vi.fn(), +}); + +describe("CommentButton - Address Parsing", () => { + it("parses valid event address correctly", () => { + const address = "30041:abc123def456:my-article"; + const parts = address.split(":"); + + expect(parts).toHaveLength(3); + + const [kindStr, pubkey, dTag] = parts; + const kind = parseInt(kindStr); + + expect(kind).toBe(30041); + expect(pubkey).toBe("abc123def456"); + expect(dTag).toBe("my-article"); + expect(isNaN(kind)).toBe(false); + }); + + it("handles dTag with colons correctly", () => { + const address = "30041:abc123:article:with:colons"; + const parts = address.split(":"); + + expect(parts.length).toBeGreaterThanOrEqual(3); + + const [kindStr, pubkey, ...dTagParts] = parts; + const dTag = dTagParts.join(":"); + + expect(parseInt(kindStr)).toBe(30041); + expect(pubkey).toBe("abc123"); + expect(dTag).toBe("article:with:colons"); + }); + + it("returns null for invalid address format (too few parts)", () => { + const address = "30041:abc123"; + const parts = address.split(":"); + + if (parts.length !== 3) { + expect(parts.length).toBeLessThan(3); + } + }); + + it("returns null for invalid address format (invalid kind)", () => { + const address = "invalid:abc123:dtag"; + const parts = address.split(":"); + const kind = parseInt(parts[0]); + + expect(isNaN(kind)).toBe(true); + }); + + it("parses different publication kinds correctly", () => { + const addresses = [ + "30040:pubkey:section-id", // Zettel section + "30041:pubkey:article-id", // Long-form article + "30818:pubkey:wiki-id", // Wiki article + "30023:pubkey:blog-id", // Blog post + ]; + + addresses.forEach((address) => { + const parts = address.split(":"); + const kind = parseInt(parts[0]); + + expect(isNaN(kind)).toBe(false); + expect(kind).toBeGreaterThan(0); + }); + }); +}); + +describe("CommentButton - NIP-22 Event Creation", () => { + let mockNDK: any; + let mockUserStore: any; + let mockActiveOutboxRelays: any; + + beforeEach(() => { + mockNDK = createMockNDK(); + mockUserStore = createMockUserStore(true); + mockActiveOutboxRelays = writable(["wss://relay.example.com"]); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("creates kind 1111 comment event", async () => { + const address = "30041:" + "a".repeat(64) + ":my-article"; + const content = "This is my comment"; + + // Mock event creation + const commentEvent = new NDKEvent(mockNDK); + commentEvent.kind = 1111; + commentEvent.content = content; + + expect(commentEvent.kind).toBe(1111); + expect(commentEvent.content).toBe(content); + }); + + it("includes correct uppercase tags (A, K, P) for root", () => { + const address = "30041:" + "b".repeat(64) + ":article-id"; + const authorPubkey = "b".repeat(64); + const kind = 30041; + const relayHint = "wss://relay.example.com"; + + const tags = [ + ["A", address, relayHint, authorPubkey], + ["K", kind.toString()], + ["P", authorPubkey, relayHint], + ]; + + // Verify uppercase root tags + expect(tags[0][0]).toBe("A"); + expect(tags[0][1]).toBe(address); + expect(tags[0][2]).toBe(relayHint); + expect(tags[0][3]).toBe(authorPubkey); + + expect(tags[1][0]).toBe("K"); + expect(tags[1][1]).toBe(kind.toString()); + + expect(tags[2][0]).toBe("P"); + expect(tags[2][1]).toBe(authorPubkey); + expect(tags[2][2]).toBe(relayHint); + }); + + it("includes correct lowercase tags (a, k, p) for parent", () => { + const address = "30041:" + "c".repeat(64) + ":article-id"; + const authorPubkey = "c".repeat(64); + const kind = 30041; + const relayHint = "wss://relay.example.com"; + + const tags = [ + ["a", address, relayHint], + ["k", kind.toString()], + ["p", authorPubkey, relayHint], + ]; + + // Verify lowercase parent tags + expect(tags[0][0]).toBe("a"); + expect(tags[0][1]).toBe(address); + expect(tags[0][2]).toBe(relayHint); + + expect(tags[1][0]).toBe("k"); + expect(tags[1][1]).toBe(kind.toString()); + + expect(tags[2][0]).toBe("p"); + expect(tags[2][1]).toBe(authorPubkey); + expect(tags[2][2]).toBe(relayHint); + }); + + it("includes e tag with event ID when available", () => { + const eventId = "d".repeat(64); + const relayHint = "wss://relay.example.com"; + + const eTag = ["e", eventId, relayHint]; + + expect(eTag[0]).toBe("e"); + expect(eTag[1]).toBe(eventId); + expect(eTag[2]).toBe(relayHint); + expect(eTag[1]).toHaveLength(64); + }); + + it("creates complete NIP-22 tag structure", () => { + const address = "30041:" + "e".repeat(64) + ":test-article"; + const authorPubkey = "e".repeat(64); + const kind = 30041; + const eventId = "f".repeat(64); + const relayHint = "wss://relay.example.com"; + + const tags = [ + // Root scope - uppercase tags + ["A", address, relayHint, authorPubkey], + ["K", kind.toString()], + ["P", authorPubkey, relayHint], + + // Parent scope - lowercase tags + ["a", address, relayHint], + ["k", kind.toString()], + ["p", authorPubkey, relayHint], + + // Event ID + ["e", eventId, relayHint], + ]; + + // Verify all tags are present + expect(tags).toHaveLength(7); + + // Verify root tags + expect(tags.filter((t) => t[0] === "A")).toHaveLength(1); + expect(tags.filter((t) => t[0] === "K")).toHaveLength(1); + expect(tags.filter((t) => t[0] === "P")).toHaveLength(1); + + // Verify parent tags + expect(tags.filter((t) => t[0] === "a")).toHaveLength(1); + expect(tags.filter((t) => t[0] === "k")).toHaveLength(1); + expect(tags.filter((t) => t[0] === "p")).toHaveLength(1); + + // Verify event tag + expect(tags.filter((t) => t[0] === "e")).toHaveLength(1); + }); + + it("uses correct relay hints from activeOutboxRelays", () => { + const relays: string[] = get(mockActiveOutboxRelays); + const relayHint = relays[0]; + + expect(relayHint).toBe("wss://relay.example.com"); + expect(relays).toHaveLength(1); + }); + + it("handles multiple outbox relays correctly", () => { + const multipleRelays = writable([ + "wss://relay1.example.com", + "wss://relay2.example.com", + "wss://relay3.example.com", + ]); + + const relays = get(multipleRelays); + const relayHint = relays[0]; + + expect(relayHint).toBe("wss://relay1.example.com"); + expect(relays).toHaveLength(3); + }); + + it("handles empty relay list gracefully", () => { + const emptyRelays = writable([]); + const relays = get(emptyRelays); + const relayHint = relays[0] || ""; + + expect(relayHint).toBe(""); + }); +}); + +describe("CommentButton - Event Signing and Publishing", () => { + let mockNDK: any; + let mockSigner: any; + + beforeEach(() => { + mockNDK = createMockNDK(); + mockSigner = { + sign: vi.fn().mockResolvedValue(undefined), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("signs event with user signer", async () => { + const commentEvent = new NDKEvent(mockNDK); + commentEvent.kind = 1111; + commentEvent.content = "Test comment"; + + await mockSigner.sign(commentEvent); + + expect(mockSigner.sign).toHaveBeenCalledWith(commentEvent); + expect(mockSigner.sign).toHaveBeenCalledTimes(1); + }); + + it("publishes to outbox relays", async () => { + const publishMock = vi.fn().mockResolvedValue( + new Set(["wss://relay.example.com"]), + ); + + const commentEvent = new NDKEvent(mockNDK); + commentEvent.publish = publishMock; + + const publishedRelays = await commentEvent.publish(); + + expect(publishMock).toHaveBeenCalled(); + expect(publishedRelays.size).toBeGreaterThan(0); + }); + + it("handles publishing errors gracefully", async () => { + const publishMock = vi.fn().mockResolvedValue(new Set()); + + const commentEvent = new NDKEvent(mockNDK); + commentEvent.publish = publishMock; + + const publishedRelays = await commentEvent.publish(); + + expect(publishedRelays.size).toBe(0); + }); + + it("throws error when publishing fails", async () => { + const publishMock = vi.fn().mockRejectedValue(new Error("Network error")); + + const commentEvent = new NDKEvent(mockNDK); + commentEvent.publish = publishMock; + + await expect(commentEvent.publish()).rejects.toThrow("Network error"); + }); +}); + +describe("CommentButton - User Authentication", () => { + it("requires user to be signed in", () => { + const signedOutStore = createMockUserStore(false); + const user = get(signedOutStore); + + expect(user.signedIn).toBe(false); + expect(user.signer).toBeNull(); + }); + + it("shows error when user is not signed in", () => { + const signedOutStore = createMockUserStore(false); + const user = get(signedOutStore); + + if (!user.signedIn || !user.signer) { + const error = "You must be signed in to comment"; + expect(error).toBe("You must be signed in to comment"); + } + }); + + it("allows commenting when user is signed in", () => { + const signedInStore = createMockUserStore(true); + const user = get(signedInStore); + + expect(user.signedIn).toBe(true); + expect(user.signer).not.toBeNull(); + }); + + it("displays user profile information when signed in", () => { + const signedInStore = createMockUserStore(true); + const user = get(signedInStore); + + expect(user.profile).not.toBeNull(); + expect(user.profile?.displayName).toBe("Test User"); + expect(user.profile?.picture).toBe("https://example.com/avatar.jpg"); + }); + + it("handles missing user profile gracefully", () => { + const storeWithoutProfile = writable({ + pubkey: "a".repeat(64), + npub: "npub1test", + profile: null, + relays: { inbox: [], outbox: [] }, + loginMethod: "extension", + ndkUser: null, + signer: { sign: vi.fn() } as any, + signedIn: true, + }); + + const user = get(storeWithoutProfile); + const displayName = user.profile?.displayName || user.profile?.name || + "Anonymous"; + + expect(displayName).toBe("Anonymous"); + }); +}); + +describe("CommentButton - User Interactions", () => { + it("prevents submission of empty comment", () => { + const commentContent = ""; + const isEmpty = !commentContent.trim(); + + expect(isEmpty).toBe(true); + }); + + it("allows submission of non-empty comment", () => { + const commentContent = "This is a valid comment"; + const isEmpty = !commentContent.trim(); + + expect(isEmpty).toBe(false); + }); + + it("handles whitespace-only comments as empty", () => { + const commentContent = " \n\t "; + const isEmpty = !commentContent.trim(); + + expect(isEmpty).toBe(true); + }); + + it("clears input after successful comment", () => { + let commentContent = "This is my comment"; + + // Simulate successful submission + commentContent = ""; + + expect(commentContent).toBe(""); + }); + + it("closes comment UI after successful posting", () => { + let showCommentUI = true; + + // Simulate successful post with delay + setTimeout(() => { + showCommentUI = false; + }, 0); + + // Initially still open + expect(showCommentUI).toBe(true); + }); + + it("calls onCommentPosted callback when provided", () => { + const onCommentPosted = vi.fn(); + + // Simulate successful comment post + onCommentPosted(); + + expect(onCommentPosted).toHaveBeenCalled(); + }); +}); + +describe("CommentButton - UI State Management", () => { + it("button is hidden by default", () => { + const sectionHovered = false; + const showCommentUI = false; + const visible = sectionHovered || showCommentUI; + + expect(visible).toBe(false); + }); + + it("button appears on section hover", () => { + const sectionHovered = true; + const showCommentUI = false; + const visible = sectionHovered || showCommentUI; + + expect(visible).toBe(true); + }); + + it("button remains visible when comment UI is shown", () => { + const sectionHovered = false; + const showCommentUI = true; + const visible = sectionHovered || showCommentUI; + + expect(visible).toBe(true); + }); + + it("toggles comment UI when button is clicked", () => { + let showCommentUI = false; + + // Simulate button click + showCommentUI = !showCommentUI; + expect(showCommentUI).toBe(true); + + // Click again + showCommentUI = !showCommentUI; + expect(showCommentUI).toBe(false); + }); + + it("resets error state when toggling UI", () => { + let error: string | null = "Previous error"; + let success = true; + + // Simulate UI toggle + error = null; + success = false; + + expect(error).toBeNull(); + expect(success).toBe(false); + }); + + it("shows error message when present", () => { + const error = "Failed to post comment"; + + expect(error).toBeDefined(); + expect(error.length).toBeGreaterThan(0); + }); + + it("shows success message after posting", () => { + const success = true; + const successMessage = "Comment posted successfully!"; + + if (success) { + expect(successMessage).toBe("Comment posted successfully!"); + } + }); + + it("disables submit button when submitting", () => { + const isSubmitting = true; + const disabled = isSubmitting; + + expect(disabled).toBe(true); + }); + + it("disables submit button when comment is empty", () => { + const commentContent = ""; + const isSubmitting = false; + const disabled = isSubmitting || !commentContent.trim(); + + expect(disabled).toBe(true); + }); + + it("enables submit button when comment is valid", () => { + const commentContent = "Valid comment"; + const isSubmitting = false; + const disabled = isSubmitting || !commentContent.trim(); + + expect(disabled).toBe(false); + }); +}); + +describe("CommentButton - Edge Cases", () => { + it("handles invalid address format gracefully", () => { + const invalidAddresses = [ + "", + "invalid", + "30041:", + ":pubkey:dtag", + "30041:pubkey", + "not-a-number:pubkey:dtag", + ]; + + invalidAddresses.forEach((address) => { + const parts = address.split(":"); + const isValid = parts.length === 3 && !isNaN(parseInt(parts[0])); + + expect(isValid).toBe(false); + }); + }); + + it("handles network errors during event fetch", async () => { + const mockNDK = { + fetchEvent: vi.fn().mockRejectedValue(new Error("Network error")), + }; + + let eventId = ""; + try { + await mockNDK.fetchEvent({}); + } catch (err) { + // Handle gracefully, continue without event ID + eventId = ""; + } + + expect(eventId).toBe(""); + }); + + it("handles missing relay information", () => { + const emptyRelays: string[] = []; + const relayHint = emptyRelays[0] || ""; + + expect(relayHint).toBe(""); + }); + + it("handles very long comment text without truncation", () => { + const longComment = "a".repeat(10000); + const content = longComment; + + expect(content.length).toBe(10000); + expect(content).toBe(longComment); + }); + + it("handles special characters in comments", () => { + const specialComments = [ + 'Comment with "quotes"', + "Comment with emoji 😊", + "Comment with\nnewlines", + "Comment with\ttabs", + "Comment with tags", + "Comment with & ampersands", + ]; + + specialComments.forEach((comment) => { + expect(comment.length).toBeGreaterThan(0); + expect(typeof comment).toBe("string"); + }); + }); + + it("handles event creation failure", async () => { + const address = "invalid:address"; + const parts = address.split(":"); + + if (parts.length !== 3) { + const error = "Invalid event address"; + expect(error).toBe("Invalid event address"); + } + }); + + it("handles signing errors", async () => { + const mockSigner = { + sign: vi.fn().mockRejectedValue(new Error("Signing failed")), + }; + + const event = { kind: 1111, content: "test" }; + + await expect(mockSigner.sign(event)).rejects.toThrow("Signing failed"); + }); + + it("handles publish failure when no relays accept event", async () => { + const publishMock = vi.fn().mockResolvedValue(new Set()); + + const relaySet = await publishMock(); + + if (relaySet.size === 0) { + const error = "Failed to publish to any relays"; + expect(error).toBe("Failed to publish to any relays"); + } + }); +}); + +describe("CommentButton - Cancel Functionality", () => { + it("clears comment content when canceling", () => { + let commentContent = "This comment will be canceled"; + + // Simulate cancel + commentContent = ""; + + expect(commentContent).toBe(""); + }); + + it("closes comment UI when canceling", () => { + let showCommentUI = true; + + // Simulate cancel + showCommentUI = false; + + expect(showCommentUI).toBe(false); + }); + + it("clears error state when canceling", () => { + let error: string | null = "Some error"; + + // Simulate cancel + error = null; + + expect(error).toBeNull(); + }); + + it("clears success state when canceling", () => { + let success = true; + + // Simulate cancel + success = false; + + expect(success).toBe(false); + }); +}); + +describe("CommentButton - Event Fetching", () => { + let mockNDK: any; + + beforeEach(() => { + mockNDK = createMockNDK(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("fetches target event to get event ID", async () => { + const address = "30041:" + "a".repeat(64) + ":article"; + const parts = address.split(":"); + const [kindStr, authorPubkey, dTag] = parts; + const kind = parseInt(kindStr); + + const mockEvent = { + id: "b".repeat(64), + kind, + pubkey: authorPubkey, + tags: [["d", dTag]], + }; + + mockNDK.fetchEvent.mockResolvedValue(mockEvent); + + const targetEvent = await mockNDK.fetchEvent({ + kinds: [kind], + authors: [authorPubkey], + "#d": [dTag], + }); + + expect(mockNDK.fetchEvent).toHaveBeenCalled(); + expect(targetEvent?.id).toBe("b".repeat(64)); + }); + + it("continues without event ID when fetch fails", async () => { + mockNDK.fetchEvent.mockRejectedValue(new Error("Fetch failed")); + + let eventId = ""; + try { + const targetEvent = await mockNDK.fetchEvent({}); + if (targetEvent) { + eventId = targetEvent.id; + } + } catch (err) { + // Continue without event ID + eventId = ""; + } + + expect(eventId).toBe(""); + }); + + it("handles null event from fetch", async () => { + mockNDK.fetchEvent.mockResolvedValue(null); + + const targetEvent = await mockNDK.fetchEvent({}); + let eventId = ""; + + if (targetEvent) { + eventId = targetEvent.id; + } + + expect(eventId).toBe(""); + }); +}); + +describe("CommentButton - CSS Classes and Styling", () => { + it("applies visible class when section is hovered", () => { + const sectionHovered = true; + const showCommentUI = false; + const hasVisibleClass = sectionHovered || showCommentUI; + + expect(hasVisibleClass).toBe(true); + }); + + it("removes visible class when not hovered and UI closed", () => { + const sectionHovered = false; + const showCommentUI = false; + const hasVisibleClass = sectionHovered || showCommentUI; + + expect(hasVisibleClass).toBe(false); + }); + + it("button has correct aria-label", () => { + const ariaLabel = "Add comment"; + + expect(ariaLabel).toBe("Add comment"); + }); + + it("button has correct title attribute", () => { + const title = "Add comment"; + + expect(title).toBe("Add comment"); + }); + + it("submit button shows loading state when submitting", () => { + const isSubmitting = true; + const buttonText = isSubmitting ? "Posting..." : "Post Comment"; + + expect(buttonText).toBe("Posting..."); + }); + + it("submit button shows normal state when not submitting", () => { + const isSubmitting = false; + const buttonText = isSubmitting ? "Posting..." : "Post Comment"; + + expect(buttonText).toBe("Post Comment"); + }); +}); + +describe("CommentButton - NIP-22 Compliance", () => { + it("uses kind 1111 for comment events", () => { + const kind = 1111; + + expect(kind).toBe(1111); + }); + + it("includes all required NIP-22 tags for addressable events", () => { + const requiredRootTags = ["A", "K", "P"]; + const requiredParentTags = ["a", "k", "p"]; + + const tags = [ + ["A", "address", "relay", "pubkey"], + ["K", "kind"], + ["P", "pubkey", "relay"], + ["a", "address", "relay"], + ["k", "kind"], + ["p", "pubkey", "relay"], + ]; + + requiredRootTags.forEach((tag) => { + expect(tags.some((t) => t[0] === tag)).toBe(true); + }); + + requiredParentTags.forEach((tag) => { + expect(tags.some((t) => t[0] === tag)).toBe(true); + }); + }); + + it("A tag includes relay hint and author pubkey", () => { + const aTag = ["A", "30041:pubkey:dtag", "wss://relay.com", "pubkey"]; + + expect(aTag).toHaveLength(4); + expect(aTag[0]).toBe("A"); + expect(aTag[2]).toMatch(/^wss:\/\//); + expect(aTag[3]).toBeTruthy(); + }); + + it("P tag includes relay hint", () => { + const pTag = ["P", "pubkey", "wss://relay.com"]; + + expect(pTag).toHaveLength(3); + expect(pTag[0]).toBe("P"); + expect(pTag[2]).toMatch(/^wss:\/\//); + }); + + it("lowercase tags for parent scope match root tags", () => { + const address = "30041:pubkey:dtag"; + const kind = "30041"; + const pubkey = "pubkey"; + const relay = "wss://relay.com"; + + const rootTags = [ + ["A", address, relay, pubkey], + ["K", kind], + ["P", pubkey, relay], + ]; + + const parentTags = [ + ["a", address, relay], + ["k", kind], + ["p", pubkey, relay], + ]; + + // Verify parent tags match root tags (lowercase) + expect(parentTags[0][1]).toBe(rootTags[0][1]); // address + expect(parentTags[1][1]).toBe(rootTags[1][1]); // kind + expect(parentTags[2][1]).toBe(rootTags[2][1]); // pubkey + }); +}); + +describe("CommentButton - Integration Scenarios", () => { + it("complete comment flow for signed-in user", () => { + const userStore = createMockUserStore(true); + const user = get(userStore); + + // User is signed in + expect(user.signedIn).toBe(true); + + // Comment content is valid + const content = "Great article!"; + expect(content.trim().length).toBeGreaterThan(0); + + // Address is valid + const address = "30041:" + "a".repeat(64) + ":article"; + const parts = address.split(":"); + expect(parts.length).toBe(3); + + // Event would be created with kind 1111 + const kind = 1111; + expect(kind).toBe(1111); + }); + + it("prevents comment flow for signed-out user", () => { + const userStore = createMockUserStore(false); + const user = get(userStore); + + expect(user.signedIn).toBe(false); + + if (!user.signedIn) { + const error = "You must be signed in to comment"; + expect(error).toBeTruthy(); + } + }); + + it("handles comment with event ID lookup", async () => { + const mockNDK = createMockNDK(); + const eventId = "c".repeat(64); + + mockNDK.fetchEvent.mockResolvedValue({ id: eventId }); + + const targetEvent = await mockNDK.fetchEvent({}); + + const tags = [ + ["e", targetEvent.id, "wss://relay.com"], + ]; + + expect(tags[0][1]).toBe(eventId); + }); + + it("handles comment without event ID lookup", () => { + const eventId = ""; + + const tags = [ + ["A", "address", "relay", "pubkey"], + ["K", "kind"], + ["P", "pubkey", "relay"], + ["a", "address", "relay"], + ["k", "kind"], + ["p", "pubkey", "relay"], + ]; + + // No e tag should be included + expect(tags.filter((t) => t[0] === "e")).toHaveLength(0); + + // But all other required tags should be present + expect(tags.length).toBe(6); + }); +}); diff --git a/tests/unit/deletion.test.ts b/tests/unit/deletion.test.ts new file mode 100644 index 0000000..8fd3f27 --- /dev/null +++ b/tests/unit/deletion.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + canDeleteEvent, + deleteEvent, +} from "../../src/lib/services/deletion.ts"; +import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk"; + +describe("Deletion Service", () => { + let mockNdk: NDK; + let mockEvent: NDKEvent; + + beforeEach(() => { + // Create mock NDK instance + mockNdk = { + activeUser: { + pubkey: "test-pubkey-123", + }, + pool: { + relays: new Map([ + ["wss://relay1.example.com", { url: "wss://relay1.example.com" }], + ["wss://relay2.example.com", { url: "wss://relay2.example.com" }], + ]), + }, + } as unknown as NDK; + + // Create mock event + mockEvent = { + id: "event-id-123", + kind: 30041, + pubkey: "test-pubkey-123", + tagAddress: () => "30041:test-pubkey-123:test-identifier", + } as unknown as NDKEvent; + }); + + describe("canDeleteEvent", () => { + it("should return true when user is the event author", () => { + const result = canDeleteEvent(mockEvent, mockNdk); + expect(result).toBe(true); + }); + + it("should return false when user is not the event author", () => { + const differentUserEvent = { + ...mockEvent, + pubkey: "different-pubkey-456", + } as unknown as NDKEvent; + + const result = canDeleteEvent(differentUserEvent, mockNdk); + expect(result).toBe(false); + }); + + it("should return false when event is null", () => { + const result = canDeleteEvent(null, mockNdk); + expect(result).toBe(false); + }); + + it("should return false when ndk has no active user", () => { + const ndkWithoutUser = { + ...mockNdk, + activeUser: undefined, + } as unknown as NDK; + + const result = canDeleteEvent(mockEvent, ndkWithoutUser); + expect(result).toBe(false); + }); + }); + + describe("deleteEvent", () => { + it("should return error when no eventId or eventAddress provided", async () => { + const result = await deleteEvent({}, mockNdk); + + expect(result.success).toBe(false); + expect(result.error).toBe( + "Either eventId or eventAddress must be provided", + ); + }); + + it("should return error when user is not logged in", async () => { + const ndkWithoutUser = { + ...mockNdk, + activeUser: undefined, + } as unknown as NDK; + + const result = await deleteEvent( + { eventId: "test-id" }, + ndkWithoutUser, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Please log in first"); + }); + + it("should create deletion event with correct tags", async () => { + const mockSign = vi.fn(); + const mockPublish = vi.fn().mockResolvedValue( + new Set(["wss://relay1.example.com"]), + ); + + // Mock NDKEvent constructor + const MockNDKEvent = vi.fn().mockImplementation(function (this: any) { + this.kind = 0; + this.created_at = 0; + this.tags = []; + this.content = ""; + this.pubkey = ""; + this.sign = mockSign; + this.publish = mockPublish; + return this; + }); + + // Mock NDKRelaySet + const mockRelaySet = {} as NDKRelaySet; + vi.spyOn(NDKRelaySet, "fromRelayUrls").mockReturnValue(mockRelaySet); + + // Replace global NDKEvent temporarily + const originalNDKEvent = (globalThis as any).NDKEvent; + (global as any).NDKEvent = MockNDKEvent; + + const result = await deleteEvent( + { + eventId: "event-123", + eventAddress: "30041:pubkey:identifier", + eventKind: 30041, + reason: "Test deletion", + }, + mockNdk, + ); + + // Restore original + (global as any).NDKEvent = originalNDKEvent; + + expect(MockNDKEvent).toHaveBeenCalled(); + expect(mockSign).toHaveBeenCalled(); + expect(mockPublish).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/fetchPublicationHighlights.test.ts b/tests/unit/fetchPublicationHighlights.test.ts new file mode 100644 index 0000000..02d7527 --- /dev/null +++ b/tests/unit/fetchPublicationHighlights.test.ts @@ -0,0 +1,320 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import type NDK from "@nostr-dev-kit/ndk"; +import { fetchHighlightsForPublication } from "../../src/lib/utils/fetch_publication_highlights.ts"; + +// Mock NDKEvent class +class MockNDKEvent { + kind: number; + pubkey: string; + content: string; + tags: string[][]; + created_at: number; + id: string; + sig: string; + + constructor(event: { + kind: number; + pubkey: string; + content: string; + tags: string[][]; + created_at?: number; + id?: string; + sig?: string; + }) { + this.kind = event.kind; + this.pubkey = event.pubkey; + this.content = event.content; + this.tags = event.tags; + this.created_at = event.created_at || Date.now() / 1000; + this.id = event.id || "mock-id"; + this.sig = event.sig || "mock-sig"; + } + + getMatchingTags(tagName: string): string[][] { + return this.tags.filter((tag) => tag[0] === tagName); + } + + tagValue(tagName: string): string | undefined { + const tag = this.tags.find((tag) => tag[0] === tagName); + return tag ? tag[1] : undefined; + } +} + +describe("fetchHighlightsForPublication", () => { + let mockNDK: NDK; + let publicationEvent: NDKEvent; + let mockHighlights: MockNDKEvent[]; + + beforeEach(() => { + // Create the sample 30040 publication event from the user's example + publicationEvent = new MockNDKEvent({ + kind: 30040, + pubkey: + "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", + content: "", + tags: [ + ["d", "document-test"], + ["title", "Document Test"], + ["author", "unknown"], + ["version", "1"], + ["m", "application/json"], + ["M", "meta-data/index/replaceable"], + [ + "a", + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", + ], + [ + "a", + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading", + ], + [ + "a", + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading", + ], + [ + "a", + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document", + ], + ["t", "a-tags"], + ["t", "testfile"], + ["t", "asciimath"], + ["t", "latexmath"], + ["image", "https://i.nostr.build/5kWwbDR04joIASVx.png"], + ], + created_at: 1744910311, + id: "4585ed74a0be37655aa887340d239f0bbb9df5476165d912f098c55a71196fef", + sig: + "e6a832dcfc919c913acee62cb598211544bc8e03a3f61c016eb3bf6c8cb4fb333eff8fecc601517604c7a8029dfa73591f3218465071a532f4abfe8c0bf3662d", + }) as unknown as NDKEvent; + + // Create mock highlight events for different sections + mockHighlights = [ + new MockNDKEvent({ + kind: 9802, + pubkey: "user-pubkey-1", + content: "This is an interesting point", + tags: [ + [ + "a", + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", + ], + ["context", "surrounding text here"], + [ + "p", + "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", + "", + "author", + ], + ], + id: "highlight-1", + }), + new MockNDKEvent({ + kind: 9802, + pubkey: "user-pubkey-2", + content: "Another highlight on same section", + tags: [ + [ + "a", + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", + ], + ["context", "more surrounding text"], + [ + "p", + "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", + "", + "author", + ], + ], + id: "highlight-2", + }), + new MockNDKEvent({ + kind: 9802, + pubkey: "user-pubkey-3", + content: "Highlight on different section", + tags: [ + [ + "a", + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading", + ], + ["context", "different section text"], + [ + "p", + "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", + "", + "author", + ], + ], + id: "highlight-3", + }), + ]; + + // Mock NDK instance + mockNDK = { + fetchEvents: vi.fn(async (filter) => { + // Return highlights that match the filter + const aTagFilter = filter["#a"]; + if (aTagFilter) { + return new Set( + mockHighlights.filter((highlight) => + aTagFilter.includes(highlight.tagValue("a") || "") + ), + ); + } + return new Set(); + }), + } as unknown as NDK; + }); + + it("should extract section references from 30040 publication event", async () => { + const result = await fetchHighlightsForPublication( + publicationEvent, + mockNDK, + ); + + // Should have results for the sections that have highlights + expect(result.size).toBeGreaterThan(0); + expect( + result.has( + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", + ), + ).toBe(true); + }); + + it("should fetch highlights for each section reference", async () => { + const result = await fetchHighlightsForPublication( + publicationEvent, + mockNDK, + ); + + // First section should have 2 highlights + const firstSectionHighlights = result.get( + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", + ); + expect(firstSectionHighlights?.length).toBe(2); + + // Second section should have 1 highlight + const secondSectionHighlights = result.get( + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading", + ); + expect(secondSectionHighlights?.length).toBe(1); + }); + + it("should group highlights by section address", async () => { + const result = await fetchHighlightsForPublication( + publicationEvent, + mockNDK, + ); + + const firstSectionHighlights = result.get( + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", + ); + + // Verify the highlights are correctly grouped + expect(firstSectionHighlights?.[0].content).toBe( + "This is an interesting point", + ); + expect(firstSectionHighlights?.[1].content).toBe( + "Another highlight on same section", + ); + }); + + it("should not include sections without highlights", async () => { + const result = await fetchHighlightsForPublication( + publicationEvent, + mockNDK, + ); + + // Sections without highlights should not be in the result + expect( + result.has( + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading", + ), + ).toBe(false); + expect( + result.has( + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document", + ), + ).toBe(false); + }); + + it("should handle publication with no section references", async () => { + const emptyPublication = new MockNDKEvent({ + kind: 30040, + pubkey: "test-pubkey", + content: "", + tags: [ + ["d", "empty-doc"], + ["title", "Empty Document"], + ], + }) as unknown as NDKEvent; + + const result = await fetchHighlightsForPublication( + emptyPublication, + mockNDK, + ); + + expect(result.size).toBe(0); + }); + + it("should only process 30041 kind references, ignoring other a-tags", async () => { + const mixedPublication = new MockNDKEvent({ + kind: 30040, + pubkey: "test-pubkey", + content: "", + tags: [ + ["d", "mixed-doc"], + [ + "a", + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", + ], + ["a", "30023:some-pubkey:blog-post"], // Different kind, should be ignored + ["a", "1:some-pubkey"], // Different kind, should be ignored + ], + }) as unknown as NDKEvent; + + const result = await fetchHighlightsForPublication( + mixedPublication, + mockNDK, + ); + + // Should call fetchEvents with only the 30041 reference + expect(mockNDK.fetchEvents).toHaveBeenCalledWith( + expect.objectContaining({ + kinds: [9802], + "#a": [ + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", + ], + }), + ); + }); + + it("should handle d-tags with colons correctly", async () => { + const colonPublication = new MockNDKEvent({ + kind: 30040, + pubkey: "test-pubkey", + content: "", + tags: [ + ["d", "colon-doc"], + [ + "a", + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons", + ], + ], + }) as unknown as NDKEvent; + + const result = await fetchHighlightsForPublication( + colonPublication, + mockNDK, + ); + + // Should correctly parse the section address with colons + expect(mockNDK.fetchEvents).toHaveBeenCalledWith( + expect.objectContaining({ + "#a": [ + "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons", + ], + }), + ); + }); +}); diff --git a/tests/unit/highlightLayer.test.ts b/tests/unit/highlightLayer.test.ts new file mode 100644 index 0000000..bf82729 --- /dev/null +++ b/tests/unit/highlightLayer.test.ts @@ -0,0 +1,870 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { pubkeyToHue } from "../../src/lib/utils/nostrUtils"; +import { nip19 } from "nostr-tools"; + +describe("pubkeyToHue", () => { + describe("Consistency", () => { + it("returns consistent hue for same pubkey", () => { + const pubkey = "a".repeat(64); + const hue1 = pubkeyToHue(pubkey); + const hue2 = pubkeyToHue(pubkey); + + expect(hue1).toBe(hue2); + }); + + it("returns same hue for same pubkey called multiple times", () => { + const pubkey = "abc123def456".repeat(5) + "abcd"; + const hues = Array.from({ length: 10 }, () => pubkeyToHue(pubkey)); + + expect(new Set(hues).size).toBe(1); // All hues should be the same + }); + }); + + describe("Range Validation", () => { + it("returns hue in valid range (0-360)", () => { + const pubkeys = [ + "a".repeat(64), + "f".repeat(64), + "0".repeat(64), + "9".repeat(64), + "abc123def456".repeat(5) + "abcd", + "123456789abc".repeat(5) + "def0", + ]; + + pubkeys.forEach((pubkey) => { + const hue = pubkeyToHue(pubkey); + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + }); + }); + + it("returns integer hue value", () => { + const pubkey = "a".repeat(64); + const hue = pubkeyToHue(pubkey); + + expect(Number.isInteger(hue)).toBe(true); + }); + }); + + describe("Format Handling", () => { + it("handles hex format pubkeys", () => { + const hexPubkey = "abcdef123456789".repeat(4) + "0123"; + const hue = pubkeyToHue(hexPubkey); + + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + }); + + it("handles npub format pubkeys", () => { + const hexPubkey = "a".repeat(64); + const npub = nip19.npubEncode(hexPubkey); + const hue = pubkeyToHue(npub); + + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + }); + + it("returns same hue for hex and npub format of same pubkey", () => { + const hexPubkey = "abc123def456".repeat(5) + "abcd"; + const npub = nip19.npubEncode(hexPubkey); + + const hueFromHex = pubkeyToHue(hexPubkey); + const hueFromNpub = pubkeyToHue(npub); + + expect(hueFromHex).toBe(hueFromNpub); + }); + }); + + describe("Uniqueness", () => { + it("different pubkeys generate different hues", () => { + const pubkey1 = "a".repeat(64); + const pubkey2 = "b".repeat(64); + const pubkey3 = "c".repeat(64); + + const hue1 = pubkeyToHue(pubkey1); + const hue2 = pubkeyToHue(pubkey2); + const hue3 = pubkeyToHue(pubkey3); + + expect(hue1).not.toBe(hue2); + expect(hue2).not.toBe(hue3); + expect(hue1).not.toBe(hue3); + }); + + it("generates diverse hues for multiple pubkeys", () => { + const pubkeys = Array.from( + { length: 10 }, + (_, i) => String.fromCharCode(97 + i).repeat(64), + ); + + const hues = pubkeys.map((pk) => pubkeyToHue(pk)); + const uniqueHues = new Set(hues); + + // Most pubkeys should generate unique hues (allowing for some collisions) + expect(uniqueHues.size).toBeGreaterThan(7); + }); + }); + + describe("Edge Cases", () => { + it("handles empty string input", () => { + const hue = pubkeyToHue(""); + + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + }); + + it("handles invalid npub format gracefully", () => { + const invalidNpub = "npub1invalid"; + const hue = pubkeyToHue(invalidNpub); + + // Should still return a valid hue even if decode fails + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + }); + + it("handles short input strings", () => { + const shortInput = "abc"; + const hue = pubkeyToHue(shortInput); + + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + }); + + it("handles special characters", () => { + const specialInput = "!@#$%^&*()"; + const hue = pubkeyToHue(specialInput); + + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + }); + }); + + describe("Color Distribution", () => { + it("distributes colors across the spectrum", () => { + // Generate hues for many different pubkeys + const pubkeys = Array.from( + { length: 50 }, + (_, i) => i.toString().repeat(16), + ); + + const hues = pubkeys.map((pk) => pubkeyToHue(pk)); + + // Check that we have hues in different ranges of the spectrum + const hasLowHues = hues.some((h) => h < 120); + const hasMidHues = hues.some((h) => h >= 120 && h < 240); + const hasHighHues = hues.some((h) => h >= 240); + + expect(hasLowHues).toBe(true); + expect(hasMidHues).toBe(true); + expect(hasHighHues).toBe(true); + }); + }); +}); + +describe("HighlightLayer Component", () => { + let mockNdk: any; + let mockSubscription: any; + let eventHandlers: Map; + + beforeEach(() => { + eventHandlers = new Map(); + + // Mock NDK subscription + mockSubscription = { + on: vi.fn((event: string, handler: Function) => { + eventHandlers.set(event, handler); + }), + stop: vi.fn(), + }; + + mockNdk = { + subscribe: vi.fn(() => mockSubscription), + }; + + // Mock DOM APIs + global.document = { + createTreeWalker: vi.fn(() => ({ + nextNode: vi.fn(() => null), + })), + createDocumentFragment: vi.fn(() => ({ + appendChild: vi.fn(), + })), + createTextNode: vi.fn((text: string) => ({ + textContent: text, + })), + createElement: vi.fn((tag: string) => ({ + className: "", + style: {}, + textContent: "", + })), + } as any; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("NDK Subscription", () => { + it("fetches kind 9802 events with correct filter when eventId provided", () => { + const eventId = "a".repeat(64); + + // Simulate calling fetchHighlights + mockNdk.subscribe({ kinds: [9802], "#e": [eventId], limit: 100 }); + + expect(mockNdk.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + kinds: [9802], + "#e": [eventId], + limit: 100, + }), + ); + }); + + it("fetches kind 9802 events with correct filter when eventAddress provided", () => { + const eventAddress = "30040:" + "a".repeat(64) + ":chapter-1"; + + // Simulate calling fetchHighlights + mockNdk.subscribe({ kinds: [9802], "#a": [eventAddress], limit: 100 }); + + expect(mockNdk.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + kinds: [9802], + "#a": [eventAddress], + limit: 100, + }), + ); + }); + + it("fetches with both eventId and eventAddress filters when both provided", () => { + const eventId = "a".repeat(64); + const eventAddress = "30040:" + "b".repeat(64) + ":chapter-1"; + + // Simulate calling fetchHighlights + mockNdk.subscribe({ + kinds: [9802], + "#e": [eventId], + "#a": [eventAddress], + limit: 100, + }); + + expect(mockNdk.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + kinds: [9802], + "#e": [eventId], + "#a": [eventAddress], + limit: 100, + }), + ); + }); + + it("cleans up subscription on unmount", () => { + mockNdk.subscribe({ kinds: [9802], limit: 100 }); + + // Simulate unmount by calling stop + mockSubscription.stop(); + + expect(mockSubscription.stop).toHaveBeenCalled(); + }); + }); + + describe("Color Mapping", () => { + it("maps highlights to colors correctly", () => { + const pubkey1 = "a".repeat(64); + const pubkey2 = "b".repeat(64); + + const hue1 = pubkeyToHue(pubkey1); + const hue2 = pubkeyToHue(pubkey2); + + const expectedColor1 = `hsla(${hue1}, 70%, 60%, 0.3)`; + const expectedColor2 = `hsla(${hue2}, 70%, 60%, 0.3)`; + + expect(expectedColor1).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); + expect(expectedColor2).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); + expect(expectedColor1).not.toBe(expectedColor2); + }); + + it("uses consistent color for same pubkey", () => { + const pubkey = "abc123def456".repeat(5) + "abcd"; + const hue = pubkeyToHue(pubkey); + + const color1 = `hsla(${hue}, 70%, 60%, 0.3)`; + const color2 = `hsla(${hue}, 70%, 60%, 0.3)`; + + expect(color1).toBe(color2); + }); + + it("generates semi-transparent colors with 0.3 opacity", () => { + const pubkey = "a".repeat(64); + const hue = pubkeyToHue(pubkey); + const color = `hsla(${hue}, 70%, 60%, 0.3)`; + + expect(color).toContain("0.3"); + }); + + it("uses HSL color format with correct values", () => { + const pubkey = "a".repeat(64); + const hue = pubkeyToHue(pubkey); + const color = `hsla(${hue}, 70%, 60%, 0.3)`; + + // Verify format: hsla(hue, 70%, 60%, 0.3) + expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); + }); + }); + + describe("Highlight Events", () => { + it("handles no highlights gracefully", () => { + const highlights: any[] = []; + + expect(highlights.length).toBe(0); + // Component should render without errors + }); + + it("handles single highlight from one user", () => { + const mockHighlight = { + id: "highlight1", + kind: 9802, + pubkey: "a".repeat(64), + content: "highlighted text", + created_at: Date.now(), + tags: [], + }; + + const highlights = [mockHighlight]; + + expect(highlights.length).toBe(1); + expect(highlights[0].pubkey).toBe("a".repeat(64)); + }); + + it("handles multiple highlights from same user", () => { + const pubkey = "a".repeat(64); + const mockHighlights = [ + { + id: "highlight1", + kind: 9802, + pubkey: pubkey, + content: "first highlight", + created_at: Date.now(), + tags: [], + }, + { + id: "highlight2", + kind: 9802, + pubkey: pubkey, + content: "second highlight", + created_at: Date.now(), + tags: [], + }, + ]; + + expect(mockHighlights.length).toBe(2); + expect(mockHighlights[0].pubkey).toBe(mockHighlights[1].pubkey); + + // Should use same color for both + const hue = pubkeyToHue(pubkey); + const color = `hsla(${hue}, 70%, 60%, 0.3)`; + + expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); + }); + + it("handles multiple highlights from different users", () => { + const pubkey1 = "a".repeat(64); + const pubkey2 = "b".repeat(64); + const pubkey3 = "c".repeat(64); + + const mockHighlights = [ + { + id: "highlight1", + kind: 9802, + pubkey: pubkey1, + content: "highlight from user 1", + created_at: Date.now(), + tags: [], + }, + { + id: "highlight2", + kind: 9802, + pubkey: pubkey2, + content: "highlight from user 2", + created_at: Date.now(), + tags: [], + }, + { + id: "highlight3", + kind: 9802, + pubkey: pubkey3, + content: "highlight from user 3", + created_at: Date.now(), + tags: [], + }, + ]; + + expect(mockHighlights.length).toBe(3); + + // Each should have different color + const hue1 = pubkeyToHue(pubkey1); + const hue2 = pubkeyToHue(pubkey2); + const hue3 = pubkeyToHue(pubkey3); + + expect(hue1).not.toBe(hue2); + expect(hue2).not.toBe(hue3); + expect(hue1).not.toBe(hue3); + }); + + it("prevents duplicate highlights", () => { + const mockHighlight = { + id: "highlight1", + kind: 9802, + pubkey: "a".repeat(64), + content: "highlighted text", + created_at: Date.now(), + tags: [], + }; + + const highlights = [mockHighlight]; + + // Try to add duplicate + const isDuplicate = highlights.some((h) => h.id === mockHighlight.id); + + expect(isDuplicate).toBe(true); + // Should not add duplicate + }); + + it("handles empty content gracefully", () => { + const mockHighlight = { + id: "highlight1", + kind: 9802, + pubkey: "a".repeat(64), + content: "", + created_at: Date.now(), + tags: [], + }; + + // Should not crash + expect(mockHighlight.content).toBe(""); + }); + + it("handles whitespace-only content", () => { + const mockHighlight = { + id: "highlight1", + kind: 9802, + pubkey: "a".repeat(64), + content: " \n\t ", + created_at: Date.now(), + tags: [], + }; + + const trimmed = mockHighlight.content.trim(); + expect(trimmed.length).toBe(0); + }); + }); + + describe("Highlighter Legend", () => { + it("displays legend with correct color for single highlighter", () => { + const pubkey = "abc123def456".repeat(5) + "abcd"; + const hue = pubkeyToHue(pubkey); + const color = `hsla(${hue}, 70%, 60%, 0.3)`; + + const legend = { + pubkey: pubkey, + color: color, + shortPubkey: `${pubkey.slice(0, 8)}...`, + }; + + expect(legend.color).toBe(color); + expect(legend.shortPubkey).toBe(`${pubkey.slice(0, 8)}...`); + }); + + it("displays legend with colors for multiple highlighters", () => { + const pubkeys = [ + "a".repeat(64), + "b".repeat(64), + "c".repeat(64), + ]; + + const legendEntries = pubkeys.map((pubkey) => ({ + pubkey, + color: `hsla(${pubkeyToHue(pubkey)}, 70%, 60%, 0.3)`, + shortPubkey: `${pubkey.slice(0, 8)}...`, + })); + + expect(legendEntries.length).toBe(3); + + // Each should have unique color + const colors = legendEntries.map((e) => e.color); + const uniqueColors = new Set(colors); + expect(uniqueColors.size).toBe(3); + }); + + it("shows truncated pubkey in legend", () => { + const pubkey = "abcdefghijklmnop".repeat(4); + const shortPubkey = `${pubkey.slice(0, 8)}...`; + + expect(shortPubkey).toBe("abcdefgh..."); + expect(shortPubkey.length).toBeLessThan(pubkey.length); + }); + + it("displays highlight count", () => { + const highlights = [ + { id: "1", pubkey: "a".repeat(64), content: "text1" }, + { id: "2", pubkey: "b".repeat(64), content: "text2" }, + { id: "3", pubkey: "a".repeat(64), content: "text3" }, + ]; + + expect(highlights.length).toBe(3); + + // Count unique highlighters + const uniqueHighlighters = new Set(highlights.map((h) => h.pubkey)); + expect(uniqueHighlighters.size).toBe(2); + }); + }); + + describe("Text Matching", () => { + it("matches text case-insensitively", () => { + const searchText = "Hello World"; + const contentText = "hello world"; + + const index = contentText.toLowerCase().indexOf(searchText.toLowerCase()); + + expect(index).toBeGreaterThanOrEqual(0); + }); + + it("handles special characters in search text", () => { + const searchText = 'text with "quotes" and symbols!'; + const contentText = 'This is text with "quotes" and symbols! in it.'; + + const index = contentText.toLowerCase().indexOf(searchText.toLowerCase()); + + expect(index).toBeGreaterThanOrEqual(0); + }); + + it("handles Unicode characters", () => { + const searchText = "café résumé"; + const contentText = "The café résumé was excellent."; + + const index = contentText.toLowerCase().indexOf(searchText.toLowerCase()); + + expect(index).toBeGreaterThanOrEqual(0); + }); + + it("handles multi-line text", () => { + const searchText = "line one\nline two"; + const contentText = "This is line one\nline two in the document."; + + const index = contentText.indexOf(searchText); + + expect(index).toBeGreaterThanOrEqual(0); + }); + + it("does not match partial words when searching for whole words", () => { + const searchText = "cat"; + const contentText = "The category is important."; + + // Simple word boundary check + const wordBoundaryMatch = new RegExp(`\\b${searchText}\\b`, "i").test( + contentText, + ); + + expect(wordBoundaryMatch).toBe(false); + }); + }); + + describe("Subscription Lifecycle", () => { + it("registers EOSE event handler", () => { + const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 }); + + // Verify that 'on' method is available for registering handlers + expect(subscription.on).toBeDefined(); + + // Register EOSE handler + subscription.on("eose", () => { + subscription.stop(); + }); + + // Verify on was called + expect(subscription.on).toHaveBeenCalledWith( + "eose", + expect.any(Function), + ); + }); + + it("registers error event handler", () => { + const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 }); + + // Verify that 'on' method is available for registering handlers + expect(subscription.on).toBeDefined(); + + // Register error handler + subscription.on("error", () => { + subscription.stop(); + }); + + // Verify on was called + expect(subscription.on).toHaveBeenCalledWith( + "error", + expect.any(Function), + ); + }); + + it("stops subscription on timeout", async () => { + vi.useFakeTimers(); + + mockNdk.subscribe({ kinds: [9802], limit: 100 }); + + // Fast-forward time by 10 seconds + vi.advanceTimersByTime(10000); + + // Subscription should be stopped after timeout + // Note: This would be tested in the actual component + + vi.useRealTimers(); + }); + + it("handles multiple subscription cleanup calls safely", () => { + mockNdk.subscribe({ kinds: [9802], limit: 100 }); + + // Call stop multiple times + mockSubscription.stop(); + mockSubscription.stop(); + mockSubscription.stop(); + + expect(mockSubscription.stop).toHaveBeenCalledTimes(3); + // Should not throw errors + }); + }); + + describe("Performance", () => { + it("handles large number of highlights efficiently", () => { + const startTime = Date.now(); + + const highlights = Array.from({ length: 1000 }, (_, i) => ({ + id: `highlight${i}`, + kind: 9802, + pubkey: (i % 10).toString().repeat(64), + content: `highlighted text ${i}`, + created_at: Date.now(), + tags: [], + })); + + // Generate colors for all highlights + const colorMap = new Map(); + highlights.forEach((h) => { + if (!colorMap.has(h.pubkey)) { + const hue = pubkeyToHue(h.pubkey); + colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); + } + }); + + const endTime = Date.now(); + const duration = endTime - startTime; + + expect(highlights.length).toBe(1000); + expect(colorMap.size).toBe(10); + expect(duration).toBeLessThan(1000); // Should complete in less than 1 second + }); + }); +}); + +describe("Integration Tests", () => { + describe("Toggle Functionality", () => { + it("toggle button shows highlights when clicked", () => { + let highlightsVisible = false; + + // Simulate toggle + highlightsVisible = !highlightsVisible; + + expect(highlightsVisible).toBe(true); + }); + + it("toggle button hides highlights when clicked again", () => { + let highlightsVisible = true; + + // Simulate toggle + highlightsVisible = !highlightsVisible; + + expect(highlightsVisible).toBe(false); + }); + + it("toggle state persists between interactions", () => { + let highlightsVisible = false; + + highlightsVisible = !highlightsVisible; + expect(highlightsVisible).toBe(true); + + highlightsVisible = !highlightsVisible; + expect(highlightsVisible).toBe(false); + + highlightsVisible = !highlightsVisible; + expect(highlightsVisible).toBe(true); + }); + }); + + describe("Color Format Validation", () => { + it("generates semi-transparent colors with 0.3 opacity", () => { + const pubkeys = [ + "a".repeat(64), + "b".repeat(64), + "c".repeat(64), + ]; + + pubkeys.forEach((pubkey) => { + const hue = pubkeyToHue(pubkey); + const color = `hsla(${hue}, 70%, 60%, 0.3)`; + + expect(color).toContain("0.3"); + }); + }); + + it("uses HSL color format with correct saturation and lightness", () => { + const pubkey = "a".repeat(64); + const hue = pubkeyToHue(pubkey); + const color = `hsla(${hue}, 70%, 60%, 0.3)`; + + expect(color).toContain("70%"); + expect(color).toContain("60%"); + }); + + it("generates valid CSS color strings", () => { + const pubkeys = Array.from( + { length: 20 }, + (_, i) => String.fromCharCode(97 + i).repeat(64), + ); + + pubkeys.forEach((pubkey) => { + const hue = pubkeyToHue(pubkey); + const color = `hsla(${hue}, 70%, 60%, 0.3)`; + + // Validate CSS color format + expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); + }); + }); + }); + + describe("End-to-End Flow", () => { + it("complete highlight workflow", () => { + // 1. Start with no highlights visible + let highlightsVisible = false; + let highlights: any[] = []; + + expect(highlightsVisible).toBe(false); + expect(highlights.length).toBe(0); + + // 2. Fetch highlights + const mockHighlights = [ + { + id: "h1", + kind: 9802, + pubkey: "a".repeat(64), + content: "first highlight", + created_at: Date.now(), + tags: [], + }, + { + id: "h2", + kind: 9802, + pubkey: "b".repeat(64), + content: "second highlight", + created_at: Date.now(), + tags: [], + }, + ]; + + highlights = mockHighlights; + expect(highlights.length).toBe(2); + + // 3. Generate color map + const colorMap = new Map(); + highlights.forEach((h) => { + if (!colorMap.has(h.pubkey)) { + const hue = pubkeyToHue(h.pubkey); + colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); + } + }); + + expect(colorMap.size).toBe(2); + + // 4. Toggle visibility + highlightsVisible = true; + expect(highlightsVisible).toBe(true); + + // 5. Verify colors are different + const colors = Array.from(colorMap.values()); + expect(colors[0]).not.toBe(colors[1]); + + // 6. Toggle off + highlightsVisible = false; + expect(highlightsVisible).toBe(false); + }); + + it("handles event updates correctly", () => { + let eventId = "event1"; + let highlights: any[] = []; + + // Initial load + highlights = [ + { + id: "h1", + kind: 9802, + pubkey: "a".repeat(64), + content: "highlight 1", + created_at: Date.now(), + tags: [], + }, + ]; + + expect(highlights.length).toBe(1); + + // Event changes + eventId = "event2"; + highlights = []; + + expect(highlights.length).toBe(0); + + // New highlights loaded + highlights = [ + { + id: "h2", + kind: 9802, + pubkey: "b".repeat(64), + content: "highlight 2", + created_at: Date.now(), + tags: [], + }, + ]; + + expect(highlights.length).toBe(1); + expect(highlights[0].id).toBe("h2"); + }); + }); + + describe("Error Handling", () => { + it("handles missing event ID and address gracefully", () => { + const eventId = undefined; + const eventAddress = undefined; + + // Should not attempt to fetch + expect(eventId).toBeUndefined(); + expect(eventAddress).toBeUndefined(); + }); + + it("handles subscription errors gracefully", () => { + const error = new Error("Subscription failed"); + + // Should log error but not crash + expect(error.message).toBe("Subscription failed"); + }); + + it("handles malformed highlight events", () => { + const malformedHighlight = { + id: "h1", + kind: 9802, + pubkey: "", // Empty pubkey + content: undefined, // Missing content + created_at: Date.now(), + tags: [], + }; + + // Should handle gracefully + expect(malformedHighlight.pubkey).toBe(""); + expect(malformedHighlight.content).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/highlightSelection.test.ts b/tests/unit/highlightSelection.test.ts new file mode 100644 index 0000000..e7f8beb --- /dev/null +++ b/tests/unit/highlightSelection.test.ts @@ -0,0 +1,875 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import type NDK from "@nostr-dev-kit/ndk"; + +// Mock flowbite-svelte components +vi.mock("flowbite-svelte", () => ({ + Button: vi.fn().mockImplementation((props) => ({ + $$render: () => + ``, + })), + Modal: vi.fn().mockImplementation(() => ({ + $$render: () => `
`, + })), + Textarea: vi.fn().mockImplementation(() => ({ + $$render: () => ``, + })), + P: vi.fn().mockImplementation(() => ({ + $$render: () => `

`, + })), +})); + +// Mock flowbite-svelte-icons +vi.mock("flowbite-svelte-icons", () => ({ + FontHighlightOutline: vi.fn().mockImplementation(() => ({ + $$render: () => ``, + })), +})); + +describe("HighlightButton Component Logic", () => { + let isActive: boolean; + + beforeEach(() => { + isActive = false; + }); + + describe("Initial State", () => { + it("should initialize with inactive state", () => { + expect(isActive).toBe(false); + }); + + it("should have correct inactive label", () => { + const label = isActive ? "Exit Highlight Mode" : "Add Highlight"; + expect(label).toBe("Add Highlight"); + }); + + it("should have correct inactive title", () => { + const title = isActive ? "Exit highlight mode" : "Enter highlight mode"; + expect(title).toBe("Enter highlight mode"); + }); + + it("should have correct inactive color", () => { + const color = isActive ? "primary" : "light"; + expect(color).toBe("light"); + }); + + it("should not have ring styling when inactive", () => { + const ringClass = isActive ? "ring-2 ring-primary-500" : ""; + expect(ringClass).toBe(""); + }); + }); + + describe("Toggle Functionality", () => { + it("should toggle to active state when clicked", () => { + // Simulate toggle + isActive = !isActive; + expect(isActive).toBe(true); + }); + + it("should toggle back to inactive state on second click", () => { + // Simulate two toggles + isActive = !isActive; + isActive = !isActive; + expect(isActive).toBe(false); + }); + + it("should show correct label when active", () => { + isActive = true; + const label = isActive ? "Exit Highlight Mode" : "Add Highlight"; + expect(label).toBe("Exit Highlight Mode"); + }); + + it("should show correct title when active", () => { + isActive = true; + const title = isActive ? "Exit highlight mode" : "Enter highlight mode"; + expect(title).toBe("Exit highlight mode"); + }); + }); + + describe("Active State Styling", () => { + it("should apply primary color when active", () => { + isActive = true; + const color = isActive ? "primary" : "light"; + expect(color).toBe("primary"); + }); + + it("should apply ring styling when active", () => { + isActive = true; + const ringClass = isActive ? "ring-2 ring-primary-500" : ""; + expect(ringClass).toBe("ring-2 ring-primary-500"); + }); + }); +}); + +describe("HighlightSelectionHandler Component Logic", () => { + let mockNDK: NDKEvent; + let mockUserStore: any; + let mockSelection: Selection; + let mockPublicationEvent: NDKEvent; + let isActive: boolean; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + isActive = false; + + // Mock document and DOM elements + const mockElement = { + createElement: vi.fn((tag: string) => ({ + tagName: tag.toUpperCase(), + textContent: "", + className: "", + closest: vi.fn(), + parentElement: null, + })), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + body: { + classList: { + add: vi.fn(), + remove: vi.fn(), + }, + }, + }; + + global.document = mockElement as any; + + // Mock NDK event + mockPublicationEvent = { + id: "test-event-id", + pubkey: "test-pubkey", + kind: 30023, + tagAddress: vi.fn().mockReturnValue("30023:test-pubkey:test-d-tag"), + tags: [], + content: "", + } as unknown as NDKEvent; + + // Mock user store + mockUserStore = { + signedIn: true, + signer: { + sign: vi.fn().mockResolvedValue(undefined), + }, + }; + + // Mock window.getSelection + const mockParagraph = { + textContent: "This is the full paragraph context", + closest: vi.fn(), + }; + + mockSelection = { + toString: vi.fn().mockReturnValue("Selected text from publication"), + isCollapsed: false, + removeAllRanges: vi.fn(), + anchorNode: { + parentElement: mockParagraph, + }, + } as unknown as Selection; + + global.window = { + getSelection: vi.fn().mockReturnValue(mockSelection), + } as any; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("Selection Detection", () => { + it("should ignore mouseup events when isActive is false", () => { + isActive = false; + const shouldProcess = isActive; + expect(shouldProcess).toBe(false); + }); + + it("should process mouseup events when isActive is true", () => { + isActive = true; + const shouldProcess = isActive; + expect(shouldProcess).toBe(true); + }); + + it("should ignore collapsed selections", () => { + const selection = { isCollapsed: true } as Selection; + const shouldIgnore = selection.isCollapsed; + expect(shouldIgnore).toBe(true); + }); + + it("should process non-collapsed selections", () => { + const selection = { isCollapsed: false } as Selection; + const shouldIgnore = selection.isCollapsed; + expect(shouldIgnore).toBe(false); + }); + + it("should ignore selections with less than 3 characters", () => { + const text = "ab"; + const isValid = text.length >= 3; + expect(isValid).toBe(false); + }); + + it("should accept selections with 3 or more characters", () => { + const text = "abc"; + const isValid = text.length >= 3; + expect(isValid).toBe(true); + }); + + it("should ignore empty selections after trim", () => { + const text = " "; + const trimmed = text.trim(); + const isValid = trimmed.length >= 3; + expect(isValid).toBe(false); + }); + }); + + describe("User Authentication", () => { + it("should reject selection when user not signed in", () => { + const userStore = { signedIn: false }; + expect(userStore.signedIn).toBe(false); + }); + + it("should process selection when user signed in", () => { + const userStore = { signedIn: true }; + expect(userStore.signedIn).toBe(true); + }); + + it("should check for signer before creating highlight", () => { + const userStore = { + signedIn: true, + signer: { sign: vi.fn() }, + }; + expect(userStore.signer).toBeDefined(); + }); + + it("should reject creation without signer", () => { + const userStore = { + signedIn: true, + signer: null, + }; + expect(userStore.signer).toBeNull(); + }); + }); + + describe("Publication Context Detection", () => { + it("should detect selection within publication-leather class", () => { + const mockElement = { + className: "publication-leather", + closest: vi.fn((selector: string) => { + return selector === ".publication-leather" ? mockElement : null; + }), + }; + const target = mockElement; + const publicationSection = target.closest(".publication-leather"); + expect(publicationSection).toBeTruthy(); + }); + + it("should reject selection outside publication-leather class", () => { + const mockElement = { + className: "other-section", + closest: vi.fn((selector: string) => { + return selector === ".publication-leather" ? null : mockElement; + }), + }; + const target = mockElement; + const publicationSection = target.closest(".publication-leather"); + expect(publicationSection).toBeNull(); + }); + }); + + describe("Context Extraction", () => { + it("should extract context from parent paragraph", () => { + const paragraph = { + textContent: + "This is the full paragraph context with selected text inside.", + }; + + const context = paragraph.textContent?.trim() || ""; + expect(context).toBe( + "This is the full paragraph context with selected text inside.", + ); + }); + + it("should extract context from parent section", () => { + const section = { + textContent: "Full section context including selected text.", + }; + + const context = section.textContent?.trim() || ""; + expect(context).toBe("Full section context including selected text."); + }); + + it("should extract context from parent div", () => { + const div = { + textContent: "Full div context including selected text.", + }; + + const context = div.textContent?.trim() || ""; + expect(context).toBe("Full div context including selected text."); + }); + + it("should handle missing context gracefully", () => { + const context = ""; + expect(context).toBe(""); + }); + }); + + describe("NIP-84 Event Creation - Addressable Events", () => { + it("should use 'a' tag for addressable events", () => { + const eventAddress = "30023:pubkey:d-tag"; + const tags: string[][] = []; + + if (eventAddress) { + tags.push(["a", eventAddress, ""]); + } + + expect(tags).toContainEqual(["a", eventAddress, ""]); + }); + + it("should create event with correct kind 9802", () => { + const event = { + kind: 9802, + content: "", + tags: [], + }; + + expect(event.kind).toBe(9802); + }); + + it("should include selected text as content", () => { + const selectedText = "This is the selected highlight text"; + const event = { + kind: 9802, + content: selectedText, + tags: [], + }; + + expect(event.content).toBe(selectedText); + }); + + it("should include context tag", () => { + const context = "This is the surrounding context"; + const tags: string[][] = []; + + if (context) { + tags.push(["context", context]); + } + + expect(tags).toContainEqual(["context", context]); + }); + + it("should include author p-tag with role", () => { + const pubkey = "author-pubkey-hex"; + const tags: string[][] = []; + + if (pubkey) { + tags.push(["p", pubkey, "", "author"]); + } + + expect(tags).toContainEqual(["p", pubkey, "", "author"]); + }); + + it("should include comment tag when comment provided", () => { + const comment = "This is my insightful comment"; + const tags: string[][] = []; + + if (comment.trim()) { + tags.push(["comment", comment.trim()]); + } + + expect(tags).toContainEqual(["comment", comment]); + }); + + it("should not include comment tag when comment is empty", () => { + const comment = ""; + const tags: string[][] = []; + + if (comment.trim()) { + tags.push(["comment", comment.trim()]); + } + + expect(tags).not.toContainEqual(["comment", ""]); + expect(tags.length).toBe(0); + }); + + it("should not include comment tag when comment is only whitespace", () => { + const comment = " "; + const tags: string[][] = []; + + if (comment.trim()) { + tags.push(["comment", comment.trim()]); + } + + expect(tags.length).toBe(0); + }); + }); + + describe("NIP-84 Event Creation - Regular Events", () => { + it("should use 'e' tag for regular events", () => { + const eventId = "regular-event-id"; + const eventAddress = null; // No address means regular event + const tags: string[][] = []; + + if (eventAddress) { + tags.push(["a", eventAddress, ""]); + } else { + tags.push(["e", eventId, ""]); + } + + expect(tags).toContainEqual(["e", eventId, ""]); + }); + + it("should prefer addressable event over regular event", () => { + const eventId = "regular-event-id"; + const eventAddress = "30023:pubkey:d-tag"; + const tags: string[][] = []; + + if (eventAddress) { + tags.push(["a", eventAddress, ""]); + } else { + tags.push(["e", eventId, ""]); + } + + expect(tags).toContainEqual(["a", eventAddress, ""]); + expect(tags).not.toContainEqual(["e", eventId, ""]); + }); + }); + + describe("Complete Event Structure", () => { + it("should create complete highlight event with all required tags", () => { + const selectedText = "Highlighted text"; + const context = "Full context paragraph"; + const pubkey = "author-pubkey"; + const eventAddress = "30023:pubkey:d-tag"; + + const event = { + kind: 9802, + content: selectedText, + tags: [ + ["a", eventAddress, ""], + ["context", context], + ["p", pubkey, "", "author"], + ], + }; + + expect(event.kind).toBe(9802); + expect(event.content).toBe(selectedText); + expect(event.tags).toHaveLength(3); + expect(event.tags[0]).toEqual(["a", eventAddress, ""]); + expect(event.tags[1]).toEqual(["context", context]); + expect(event.tags[2]).toEqual(["p", pubkey, "", "author"]); + }); + + it("should create complete quote highlight with comment", () => { + const selectedText = "Highlighted text"; + const context = "Full context paragraph"; + const pubkey = "author-pubkey"; + const eventAddress = "30023:pubkey:d-tag"; + const comment = "My thoughtful comment"; + + const event = { + kind: 9802, + content: selectedText, + tags: [ + ["a", eventAddress, ""], + ["context", context], + ["p", pubkey, "", "author"], + ["comment", comment], + ], + }; + + expect(event.kind).toBe(9802); + expect(event.content).toBe(selectedText); + expect(event.tags).toHaveLength(4); + expect(event.tags[3]).toEqual(["comment", comment]); + }); + + it("should handle event without context", () => { + const selectedText = "Highlighted text"; + const context = ""; + const pubkey = "author-pubkey"; + const eventId = "event-id"; + + const tags: string[][] = []; + tags.push(["e", eventId, ""]); + if (context) { + tags.push(["context", context]); + } + tags.push(["p", pubkey, "", "author"]); + + expect(tags).toHaveLength(2); + expect(tags).not.toContainEqual(["context", ""]); + }); + }); + + describe("Event Signing and Publishing", () => { + it("should sign event before publishing", async () => { + const mockSigner = { + sign: vi.fn().mockResolvedValue(undefined), + }; + + const mockEvent = { + kind: 9802, + content: "test", + tags: [], + sign: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + }; + + await mockEvent.sign(mockSigner); + expect(mockEvent.sign).toHaveBeenCalledWith(mockSigner); + }); + + it("should publish event after signing", async () => { + const mockEvent = { + sign: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + }; + + await mockEvent.sign({}); + await mockEvent.publish(); + + expect(mockEvent.publish).toHaveBeenCalled(); + }); + + it("should handle signing errors", async () => { + const mockEvent = { + sign: vi.fn().mockRejectedValue(new Error("Signing failed")), + }; + + await expect(mockEvent.sign({})).rejects.toThrow("Signing failed"); + }); + + it("should handle publishing errors", async () => { + const mockEvent = { + sign: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockRejectedValue(new Error("Publishing failed")), + }; + + await mockEvent.sign({}); + await expect(mockEvent.publish()).rejects.toThrow("Publishing failed"); + }); + }); + + describe("Selection Cleanup", () => { + it("should clear selection after successful highlight creation", () => { + const mockSelection = { + removeAllRanges: vi.fn(), + }; + + mockSelection.removeAllRanges(); + expect(mockSelection.removeAllRanges).toHaveBeenCalled(); + }); + + it("should reset selectedText after creation", () => { + let selectedText = "Some text"; + selectedText = ""; + expect(selectedText).toBe(""); + }); + + it("should reset comment after creation", () => { + let comment = "Some comment"; + comment = ""; + expect(comment).toBe(""); + }); + + it("should reset context after creation", () => { + let context = "Some context"; + context = ""; + expect(context).toBe(""); + }); + + it("should close modal after creation", () => { + let showModal = true; + showModal = false; + expect(showModal).toBe(false); + }); + }); + + describe("Cancel Functionality", () => { + it("should clear selection when cancelled", () => { + const mockSelection = { + removeAllRanges: vi.fn(), + }; + + // Simulate cancel + mockSelection.removeAllRanges(); + expect(mockSelection.removeAllRanges).toHaveBeenCalled(); + }); + + it("should reset all state when cancelled", () => { + let selectedText = "text"; + let comment = "comment"; + let context = "context"; + let showModal = true; + + // Simulate cancel + selectedText = ""; + comment = ""; + context = ""; + showModal = false; + + expect(selectedText).toBe(""); + expect(comment).toBe(""); + expect(context).toBe(""); + expect(showModal).toBe(false); + }); + }); + + describe("Feedback Messages", () => { + it("should show success message after creation", () => { + const message = "Highlight created successfully!"; + const type = "success"; + + expect(message).toBe("Highlight created successfully!"); + expect(type).toBe("success"); + }); + + it("should show error message on failure", () => { + const message = "Failed to create highlight. Please try again."; + const type = "error"; + + expect(message).toBe("Failed to create highlight. Please try again."); + expect(type).toBe("error"); + }); + + it("should show error when not signed in", () => { + const message = "Please sign in to create highlights"; + const type = "error"; + + expect(message).toBe("Please sign in to create highlights"); + expect(type).toBe("error"); + }); + + it("should auto-hide feedback after delay", () => { + let showFeedback = true; + + // Simulate timeout + setTimeout(() => { + showFeedback = false; + }, 3000); + + // Initially shown + expect(showFeedback).toBe(true); + }); + }); + + describe("Event Listeners", () => { + it("should add mouseup listener on mount", () => { + const mockAddEventListener = vi.fn(); + document.addEventListener = mockAddEventListener; + + document.addEventListener("mouseup", () => {}); + expect(mockAddEventListener).toHaveBeenCalledWith( + "mouseup", + expect.any(Function), + ); + }); + + it("should remove mouseup listener on unmount", () => { + const mockRemoveEventListener = vi.fn(); + document.removeEventListener = mockRemoveEventListener; + + const handler = () => {}; + document.removeEventListener("mouseup", handler); + expect(mockRemoveEventListener).toHaveBeenCalledWith("mouseup", handler); + }); + }); + + describe("Highlight Mode Body Class", () => { + it("should add highlight-mode-active class when active", () => { + const mockClassList = { + add: vi.fn(), + remove: vi.fn(), + }; + document.body.classList = mockClassList as any; + + // Simulate active mode + document.body.classList.add("highlight-mode-active"); + expect(mockClassList.add).toHaveBeenCalledWith("highlight-mode-active"); + }); + + it("should remove highlight-mode-active class when inactive", () => { + const mockClassList = { + add: vi.fn(), + remove: vi.fn(), + }; + document.body.classList = mockClassList as any; + + // Simulate inactive mode + document.body.classList.remove("highlight-mode-active"); + expect(mockClassList.remove).toHaveBeenCalledWith( + "highlight-mode-active", + ); + }); + + it("should clean up class on unmount", () => { + const mockClassList = { + add: vi.fn(), + remove: vi.fn(), + }; + document.body.classList = mockClassList as any; + + // Simulate cleanup + document.body.classList.remove("highlight-mode-active"); + expect(mockClassList.remove).toHaveBeenCalledWith( + "highlight-mode-active", + ); + }); + }); + + describe("Modal Display", () => { + it("should show modal when text is selected", () => { + let showModal = false; + + // Simulate successful selection + showModal = true; + expect(showModal).toBe(true); + }); + + it("should display selected text in modal", () => { + const selectedText = "This is the selected text"; + const displayText = `"${selectedText}"`; + + expect(displayText).toBe('"This is the selected text"'); + }); + + it("should provide textarea for optional comment", () => { + let comment = ""; + const placeholder = "Share your thoughts about this highlight..."; + + expect(placeholder).toBe("Share your thoughts about this highlight..."); + expect(comment).toBe(""); + }); + + it("should disable buttons while submitting", () => { + const isSubmitting = true; + const disabled = isSubmitting; + + expect(disabled).toBe(true); + }); + + it("should show 'Creating...' text while submitting", () => { + const isSubmitting = true; + const buttonText = isSubmitting ? "Creating..." : "Create Highlight"; + + expect(buttonText).toBe("Creating..."); + }); + + it("should show normal text when not submitting", () => { + const isSubmitting = false; + const buttonText = isSubmitting ? "Creating..." : "Create Highlight"; + + expect(buttonText).toBe("Create Highlight"); + }); + }); + + describe("Callback Execution", () => { + it("should call onHighlightCreated callback after creation", () => { + const mockCallback = vi.fn(); + + // Simulate successful creation + mockCallback(); + + expect(mockCallback).toHaveBeenCalled(); + }); + + it("should not call callback if creation fails", () => { + const mockCallback = vi.fn(); + + // Simulate failed creation - callback not called + expect(mockCallback).not.toHaveBeenCalled(); + }); + }); + + describe("Integration Scenarios", () => { + it("should handle complete highlight workflow", () => { + // Setup + let isActive = true; + let showModal = false; + let selectedText = ""; + const userSignedIn = true; + const selection = { + toString: () => "Selected text for highlighting", + isCollapsed: false, + }; + + // User selects text + if (isActive && userSignedIn && !selection.isCollapsed) { + selectedText = selection.toString(); + showModal = true; + } + + expect(selectedText).toBe("Selected text for highlighting"); + expect(showModal).toBe(true); + }); + + it("should handle complete quote highlight workflow with comment", () => { + // Setup + let isActive = true; + let showModal = false; + let selectedText = ""; + let comment = ""; + const userSignedIn = true; + const selection = { + toString: () => "Selected text", + isCollapsed: false, + }; + + // User selects text + if (isActive && userSignedIn && !selection.isCollapsed) { + selectedText = selection.toString(); + showModal = true; + } + + // User adds comment + comment = "This is insightful"; + + // Create event with comment + const tags: string[][] = []; + if (comment.trim()) { + tags.push(["comment", comment]); + } + + expect(selectedText).toBe("Selected text"); + expect(comment).toBe("This is insightful"); + expect(tags).toContainEqual(["comment", "This is insightful"]); + }); + + it("should reject workflow when user not signed in", () => { + let isActive = true; + let showModal = false; + const userSignedIn = false; + const selection = { + toString: () => "Selected text", + isCollapsed: false, + }; + + // User tries to select text + if (isActive && userSignedIn && !selection.isCollapsed) { + showModal = true; + } + + expect(showModal).toBe(false); + }); + + it("should handle workflow cancellation", () => { + // Setup initial state + let showModal = true; + let selectedText = "Some text"; + let comment = "Some comment"; + const mockSelection = { + removeAllRanges: vi.fn(), + }; + + // User cancels + showModal = false; + selectedText = ""; + comment = ""; + mockSelection.removeAllRanges(); + + expect(showModal).toBe(false); + expect(selectedText).toBe(""); + expect(comment).toBe(""); + expect(mockSelection.removeAllRanges).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/publication_tree_processor.test.ts b/tests/unit/publication_tree_processor.test.ts index f319362..bf8b514 100644 --- a/tests/unit/publication_tree_processor.test.ts +++ b/tests/unit/publication_tree_processor.test.ts @@ -1,19 +1,23 @@ /** * TDD Tests for NKBIP-01 Publication Tree Processor - * + * * Tests the iterative parsing function at different hierarchy levels * using deep_hierarchy_test.adoc to verify NKBIP-01 compliance. */ -import { describe, it, expect, beforeAll } from 'vitest'; -import { readFileSync } from 'fs'; -import { parseAsciiDocWithTree, validateParseLevel, getSupportedParseLevels } from '../../src/lib/utils/asciidoc_publication_parser.js'; +import { beforeAll, describe, expect, it } from "vitest"; +import { readFileSync } from "fs"; +import { + getSupportedParseLevels, + parseAsciiDocWithTree, + validateParseLevel, +} from "../../src/lib/utils/asciidoc_publication_parser.js"; // Mock NDK for testing const mockNDK = { activeUser: { - pubkey: "test-pubkey-12345" - } + pubkey: "test-pubkey-12345", + }, } as any; // Read the test document @@ -21,7 +25,7 @@ const testDocumentPath = "./test_data/AsciidocFiles/deep_hierarchy_test.adoc"; let testContent: string; try { - testContent = readFileSync(testDocumentPath, 'utf-8'); + testContent = readFileSync(testDocumentPath, "utf-8"); } catch (error) { console.error("Failed to read test document:", error); testContent = `= Deep Hierarchical Document Test @@ -65,20 +69,19 @@ A second main section to ensure we have balanced content at the top level.`; } describe("NKBIP-01 Publication Tree Processor", () => { - it("should validate parse levels correctly", () => { // Test valid parse levels expect(validateParseLevel(2)).toBe(true); expect(validateParseLevel(3)).toBe(true); expect(validateParseLevel(5)).toBe(true); - + // Test invalid parse levels expect(validateParseLevel(1)).toBe(false); expect(validateParseLevel(6)).toBe(false); expect(validateParseLevel(7)).toBe(false); expect(validateParseLevel(2.5)).toBe(false); expect(validateParseLevel(-1)).toBe(false); - + // Test supported levels array const supportedLevels = getSupportedParseLevels(); expect(supportedLevels).toEqual([2, 3, 4, 5]); @@ -86,63 +89,66 @@ describe("NKBIP-01 Publication Tree Processor", () => { it("should parse Level 2 with NKBIP-01 minimal structure", async () => { const result = await parseAsciiDocWithTree(testContent, mockNDK, 2); - + // Should be detected as article (has title and sections) expect(result.metadata.contentType).toBe("article"); expect(result.metadata.parseLevel).toBe(2); expect(result.metadata.title).toBe("Deep Hierarchical Document Test"); - + // Should have 1 index event (30040) + 2 content events (30041) for level 2 sections expect(result.indexEvent).toBeDefined(); expect(result.indexEvent?.kind).toBe(30040); expect(result.contentEvents.length).toBe(2); - + // All content events should be kind 30041 - result.contentEvents.forEach(event => { + result.contentEvents.forEach((event) => { expect(event.kind).toBe(30041); }); - + // Check titles of level 2 sections - const contentTitles = result.contentEvents.map(e => + const contentTitles = result.contentEvents.map((e) => e.tags.find((t: string[]) => t[0] === "title")?.[1] ); expect(contentTitles).toContain("Level 2: Main Sections"); expect(contentTitles).toContain("Level 2: Second Main Section"); - + // Content should include all nested subsections as AsciiDoc const firstSectionContent = result.contentEvents[0].content; expect(firstSectionContent).toBeDefined(); // Should contain level 3, 4, 5 content as nested AsciiDoc markup expect(firstSectionContent.includes("=== Level 3: Subsections")).toBe(true); - expect(firstSectionContent.includes("==== Level 4: Sub-subsections")).toBe(true); - expect(firstSectionContent.includes("===== Level 5: Deep Subsections")).toBe(true); + expect(firstSectionContent.includes("==== Level 4: Sub-subsections")).toBe( + true, + ); + expect(firstSectionContent.includes("===== Level 5: Deep Subsections")) + .toBe(true); }); it("should parse Level 3 with NKBIP-01 intermediate structure", async () => { const result = await parseAsciiDocWithTree(testContent, mockNDK, 3); - + expect(result.metadata.contentType).toBe("article"); expect(result.metadata.parseLevel).toBe(3); - + // Should have hierarchical structure expect(result.indexEvent).toBeDefined(); expect(result.indexEvent?.kind).toBe(30040); - + // Should have mix of 30040 (for level 2 sections with children) and 30041 (for content) - const kinds = result.contentEvents.map(e => e.kind); + const kinds = result.contentEvents.map((e) => e.kind); expect(kinds).toContain(30040); // Level 2 sections with children expect(kinds).toContain(30041); // Level 3 content sections - + // Level 2 sections with children should be 30040 index events - const level2WithChildrenEvents = result.contentEvents.filter(e => - e.kind === 30040 && + const level2WithChildrenEvents = result.contentEvents.filter((e) => + e.kind === 30040 && e.tags.find((t: string[]) => t[0] === "title")?.[1]?.includes("Level 2:") ); expect(level2WithChildrenEvents.length).toBe(2); // Both level 2 sections have children - + // Should have 30041 events for level 3 content - const level3ContentEvents = result.contentEvents.filter(e => - e.kind === 30041 && + const level3ContentEvents = result.contentEvents.filter((e) => + e.kind === 30041 && e.tags.find((t: string[]) => t[0] === "title")?.[1]?.includes("Level 3:") ); expect(level3ContentEvents.length).toBeGreaterThan(0); @@ -150,20 +156,20 @@ describe("NKBIP-01 Publication Tree Processor", () => { it("should parse Level 4 with NKBIP-01 detailed structure", async () => { const result = await parseAsciiDocWithTree(testContent, mockNDK, 4); - + expect(result.metadata.contentType).toBe("article"); expect(result.metadata.parseLevel).toBe(4); - + // Should have hierarchical structure with mix of 30040 and 30041 events expect(result.indexEvent).toBeDefined(); expect(result.indexEvent?.kind).toBe(30040); - - const kinds = result.contentEvents.map(e => e.kind); + + const kinds = result.contentEvents.map((e) => e.kind); expect(kinds).toContain(30040); // Level 2 sections with children expect(kinds).toContain(30041); // Content sections - + // Check that we have level 4 content sections - const contentTitles = result.contentEvents.map(e => + const contentTitles = result.contentEvents.map((e) => e.tags.find((t: string[]) => t[0] === "title")?.[1] ); expect(contentTitles).toContain("Level 4: Sub-subsections"); @@ -171,16 +177,16 @@ describe("NKBIP-01 Publication Tree Processor", () => { it("should parse Level 5 with NKBIP-01 maximum depth", async () => { const result = await parseAsciiDocWithTree(testContent, mockNDK, 5); - + expect(result.metadata.contentType).toBe("article"); expect(result.metadata.parseLevel).toBe(5); - + // Should have hierarchical structure expect(result.indexEvent).toBeDefined(); expect(result.indexEvent?.kind).toBe(30040); - + // Should include level 5 sections as content events - const contentTitles = result.contentEvents.map(e => + const contentTitles = result.contentEvents.map((e) => e.tags.find((t: string[]) => t[0] === "title")?.[1] ); expect(contentTitles).toContain("Level 5: Deep Subsections"); @@ -188,27 +194,27 @@ describe("NKBIP-01 Publication Tree Processor", () => { it("should validate event structure correctly", async () => { const result = await parseAsciiDocWithTree(testContent, mockNDK, 3); - + // Test index event structure expect(result.indexEvent).toBeDefined(); expect(result.indexEvent?.kind).toBe(30040); expect(result.indexEvent?.tags).toBeDefined(); - + // Check required tags const indexTags = result.indexEvent!.tags; const dTag = indexTags.find((t: string[]) => t[0] === "d"); const titleTag = indexTags.find((t: string[]) => t[0] === "title"); - + expect(dTag).toBeDefined(); expect(titleTag).toBeDefined(); expect(titleTag![1]).toBe("Deep Hierarchical Document Test"); - + // Test content events structure - mix of 30040 and 30041 - result.contentEvents.forEach(event => { + result.contentEvents.forEach((event) => { expect([30040, 30041]).toContain(event.kind); expect(event.tags).toBeDefined(); expect(event.content).toBeDefined(); - + const eventTitleTag = event.tags.find((t: string[]) => t[0] === "title"); expect(eventTitleTag).toBeDefined(); }); @@ -216,11 +222,11 @@ describe("NKBIP-01 Publication Tree Processor", () => { it("should preserve content as AsciiDoc", async () => { const result = await parseAsciiDocWithTree(testContent, mockNDK, 2); - + // Content should be preserved as original AsciiDoc, not converted to HTML const firstEvent = result.contentEvents[0]; expect(firstEvent.content).toBeDefined(); - + // Should contain AsciiDoc markup, not HTML expect(firstEvent.content.includes("<")).toBe(false); expect(firstEvent.content.includes("===")).toBe(true); @@ -228,16 +234,16 @@ describe("NKBIP-01 Publication Tree Processor", () => { it("should handle attributes correctly", async () => { const result = await parseAsciiDocWithTree(testContent, mockNDK, 2); - + // Document-level attributes should be in index event expect(result.indexEvent).toBeDefined(); const indexTags = result.indexEvent!.tags; - + // Check for document attributes const authorTag = indexTags.find((t: string[]) => t[0] === "author"); const typeTag = indexTags.find((t: string[]) => t[0] === "type"); const tagsTag = indexTags.find((t: string[]) => t[0] === "t"); - + expect(authorTag?.[1]).toBe("Test Author"); expect(typeTag?.[1]).toBe("technical"); expect(tagsTag).toBeDefined(); // Should have at least one t-tag @@ -256,29 +262,28 @@ Content of first note. Content of second note.`; const result = await parseAsciiDocWithTree(scatteredContent, mockNDK, 2); - + expect(result.metadata.contentType).toBe("scattered-notes"); expect(result.indexEvent).toBeNull(); // No index event for scattered notes expect(result.contentEvents.length).toBe(2); - + // All events should be 30041 content events - result.contentEvents.forEach(event => { + result.contentEvents.forEach((event) => { expect(event.kind).toBe(30041); }); }); it("should integrate with PublicationTree structure", async () => { const result = await parseAsciiDocWithTree(testContent, mockNDK, 2); - + // Should have a PublicationTree instance expect(result.tree).toBeDefined(); - + // Tree should have methods for event management expect(typeof result.tree.addEvent).toBe("function"); - + // Event structure should be populated expect(result.metadata.eventStructure).toBeDefined(); expect(Array.isArray(result.metadata.eventStructure)).toBe(true); }); - -}); \ No newline at end of file +}); diff --git a/tests/zettel-publisher-tdd.test.ts b/tests/zettel-publisher-tdd.test.ts index e5b53c2..eb30477 100644 --- a/tests/zettel-publisher-tdd.test.ts +++ b/tests/zettel-publisher-tdd.test.ts @@ -3,7 +3,7 @@ /** * Test-Driven Development for ZettelPublisher Enhancement * Based on understanding_knowledge.adoc, desire.adoc, and docreference.md - * + * * Key Requirements Discovered: * 1. ITERATIVE parsing (not recursive): sections at target level become events * 2. Level 2: == sections become 30041 events containing ALL subsections (===, ====, etc.) @@ -14,8 +14,8 @@ * 7. Custom attributes: all :key: value pairs preserved as event tags */ -import fs from 'fs'; -import path from 'path'; +import fs from "fs"; +import path from "path"; // Test framework interface TestCase { @@ -40,7 +40,9 @@ class TestFramework { }, toEqual: (expected: any) => { if (JSON.stringify(actual) === JSON.stringify(expected)) return true; - throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); }, toContain: (expected: any) => { if (actual && actual.includes && actual.includes(expected)) return true; @@ -48,9 +50,11 @@ class TestFramework { }, not: { toContain: (expected: any) => { - if (actual && actual.includes && !actual.includes(expected)) return true; + if (actual && actual.includes && !actual.includes(expected)) { + return true; + } throw new Error(`Expected "${actual}" NOT to contain "${expected}"`); - } + }, }, toBeTruthy: () => { if (actual) return true; @@ -58,14 +62,18 @@ class TestFramework { }, toHaveLength: (expected: number) => { if (actual && actual.length === expected) return true; - throw new Error(`Expected length ${expected}, got ${actual ? actual.length : 'undefined'}`); - } + throw new Error( + `Expected length ${expected}, got ${ + actual ? actual.length : "undefined" + }`, + ); + }, }; } async run() { console.log(`🧪 Running ${this.tests.length} tests...\n`); - + for (const { name, fn } of this.tests) { try { await fn(); @@ -87,57 +95,68 @@ class TestFramework { const test = new TestFramework(); // Load test data files -const testDataPath = path.join(process.cwd(), 'test_data', 'AsciidocFiles'); -const understandingKnowledge = fs.readFileSync(path.join(testDataPath, 'understanding_knowledge.adoc'), 'utf-8'); -const desire = fs.readFileSync(path.join(testDataPath, 'desire.adoc'), 'utf-8'); +const testDataPath = path.join(process.cwd(), "test_data", "AsciidocFiles"); +const understandingKnowledge = fs.readFileSync( + path.join(testDataPath, "understanding_knowledge.adoc"), + "utf-8", +); +const desire = fs.readFileSync(path.join(testDataPath, "desire.adoc"), "utf-8"); // ============================================================================= // PHASE 1: Core Data Structure Tests (Based on Real Test Data) // ============================================================================= -test.test('Understanding Knowledge: Document metadata should be extracted from = level', () => { +test.test("Understanding Knowledge: Document metadata should be extracted from = level", () => { // Expected 30040 metadata from understanding_knowledge.adoc const expectedDocMetadata = { - title: 'Understanding Knowledge', - image: 'https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg', - published: '2025-04-21', - language: 'en, ISO-639-1', - tags: ['knowledge', 'philosophy', 'education'], - type: 'text' + title: "Understanding Knowledge", + image: "https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg", + published: "2025-04-21", + language: "en, ISO-639-1", + tags: ["knowledge", "philosophy", "education"], + type: "text", }; // Test will pass when document parsing extracts these correctly - test.expect(expectedDocMetadata.title).toBe('Understanding Knowledge'); + test.expect(expectedDocMetadata.title).toBe("Understanding Knowledge"); test.expect(expectedDocMetadata.tags).toHaveLength(3); - test.expect(expectedDocMetadata.type).toBe('text'); + test.expect(expectedDocMetadata.type).toBe("text"); }); -test.test('Desire: Document metadata should include all custom attributes', () => { +test.test("Desire: Document metadata should include all custom attributes", () => { // Expected 30040 metadata from desire.adoc const expectedDocMetadata = { - title: 'Desire Part 1: Mimesis', - image: 'https://i.nostr.build/hGzyi4c3YhTwoCCe.png', - published: '2025-07-02', - language: 'en, ISO-639-1', - tags: ['memetics', 'philosophy', 'desire'], - type: 'podcastArticle' + title: "Desire Part 1: Mimesis", + image: "https://i.nostr.build/hGzyi4c3YhTwoCCe.png", + published: "2025-07-02", + language: "en, ISO-639-1", + tags: ["memetics", "philosophy", "desire"], + type: "podcastArticle", }; - test.expect(expectedDocMetadata.type).toBe('podcastArticle'); - test.expect(expectedDocMetadata.tags).toContain('memetics'); + test.expect(expectedDocMetadata.type).toBe("podcastArticle"); + test.expect(expectedDocMetadata.tags).toContain("memetics"); }); -test.test('Iterative ParsedAsciiDoc interface should support level-based parsing', () => { +test.test("Iterative ParsedAsciiDoc interface should support level-based parsing", () => { // Test the ITERATIVE interface structure (not recursive) // Based on docreference.md - Level 2 parsing example const mockLevel2Structure = { - metadata: { title: 'Programming Fundamentals Guide', tags: ['programming', 'fundamentals'] }, - content: 'This is the main introduction to the programming guide.', - title: 'Programming Fundamentals Guide', + metadata: { + title: "Programming Fundamentals Guide", + tags: ["programming", "fundamentals"], + }, + content: "This is the main introduction to the programming guide.", + title: "Programming Fundamentals Guide", sections: [ { - metadata: { title: 'Data Structures', tags: ['arrays', 'lists', 'trees'], difficulty: 'intermediate' }, - content: `Understanding fundamental data structures is crucial for effective programming. + metadata: { + title: "Data Structures", + tags: ["arrays", "lists", "trees"], + difficulty: "intermediate", + }, + content: + `Understanding fundamental data structures is crucial for effective programming. === Arrays and Lists @@ -155,11 +174,16 @@ Linked lists use pointers to connect elements. === Trees and Graphs Tree and graph structures enable hierarchical and networked data representation.`, - title: 'Data Structures' + title: "Data Structures", }, { - metadata: { title: 'Algorithms', tags: ['sorting', 'searching', 'optimization'], difficulty: 'advanced' }, - content: `Algorithmic thinking forms the foundation of efficient problem-solving. + metadata: { + title: "Algorithms", + tags: ["sorting", "searching", "optimization"], + difficulty: "advanced", + }, + content: + `Algorithmic thinking forms the foundation of efficient problem-solving. === Sorting Algorithms @@ -172,54 +196,64 @@ Bubble sort repeatedly steps through the list, compares adjacent elements. ==== Quick Sort Quick sort uses divide-and-conquer approach with pivot selection.`, - title: 'Algorithms' - } - ] + title: "Algorithms", + }, + ], }; // Verify ITERATIVE structure: only level 2 sections, containing ALL subsections test.expect(mockLevel2Structure.sections).toHaveLength(2); - test.expect(mockLevel2Structure.sections[0].title).toBe('Data Structures'); - test.expect(mockLevel2Structure.sections[0].content).toContain('=== Arrays and Lists'); - test.expect(mockLevel2Structure.sections[0].content).toContain('==== Dynamic Arrays'); - test.expect(mockLevel2Structure.sections[1].content).toContain('==== Quick Sort'); + test.expect(mockLevel2Structure.sections[0].title).toBe("Data Structures"); + test.expect(mockLevel2Structure.sections[0].content).toContain( + "=== Arrays and Lists", + ); + test.expect(mockLevel2Structure.sections[0].content).toContain( + "==== Dynamic Arrays", + ); + test.expect(mockLevel2Structure.sections[1].content).toContain( + "==== Quick Sort", + ); }); // ============================================================================= // PHASE 2: Content Processing Tests (Header Separation) // ============================================================================= -test.test('Section content should NOT contain its own header', () => { +test.test("Section content should NOT contain its own header", () => { // From understanding_knowledge.adoc: "== Preface" section const expectedPrefaceContent = `[NOTE] This essay was written to outline and elaborate on the purpose of the Nostr client Alexandria. No formal academic citations are included as this serves primarily as a conceptual foundation, inviting readers to experience related ideas connecting and forming as more content becomes uploaded. Traces of AI edits and guidance are left, but the essay style is still my own. Over time this essay may change its wording, structure and content. -- liminal`; // Should NOT contain "== Preface" - test.expect(expectedPrefaceContent).not.toContain('== Preface'); - test.expect(expectedPrefaceContent).toContain('[NOTE]'); + test.expect(expectedPrefaceContent).not.toContain("== Preface"); + test.expect(expectedPrefaceContent).toContain("[NOTE]"); }); -test.test('Introduction section should separate from its subsections', () => { +test.test("Introduction section should separate from its subsections", () => { // From understanding_knowledge.adoc - const expectedIntroContent = `image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library]`; - + const expectedIntroContent = + `image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library]`; + // Should NOT contain subsection content or headers - test.expect(expectedIntroContent).not.toContain('=== Why Investigate'); - test.expect(expectedIntroContent).not.toContain('Understanding the nature of knowledge'); - test.expect(expectedIntroContent).toContain('image:https://i.nostr.build'); + test.expect(expectedIntroContent).not.toContain("=== Why Investigate"); + test.expect(expectedIntroContent).not.toContain( + "Understanding the nature of knowledge", + ); + test.expect(expectedIntroContent).toContain("image:https://i.nostr.build"); }); -test.test('Subsection content should be cleanly separated', () => { +test.test("Subsection content should be cleanly separated", () => { // "=== Why Investigate the Nature of Knowledge?" subsection - const expectedSubsectionContent = `Understanding the nature of knowledge itself is fundamental, distinct from simply studying how we learn or communicate. Knowledge exests first as representations within individuals, separate from how we interact with it...`; + const expectedSubsectionContent = + `Understanding the nature of knowledge itself is fundamental, distinct from simply studying how we learn or communicate. Knowledge exests first as representations within individuals, separate from how we interact with it...`; // Should NOT contain its own header - test.expect(expectedSubsectionContent).not.toContain('=== Why Investigate'); - test.expect(expectedSubsectionContent).toContain('Understanding the nature'); + test.expect(expectedSubsectionContent).not.toContain("=== Why Investigate"); + test.expect(expectedSubsectionContent).toContain("Understanding the nature"); }); -test.test('Deep headers (====) should have proper newlines', () => { +test.test("Deep headers (====) should have proper newlines", () => { // From "=== The Four Perspectives" section with ==== subsections const expectedFormatted = ` ==== 1. The Building Blocks (Material Cause) @@ -230,188 +264,226 @@ Just as living organisms are made up of cells, knowledge systems are built from If you've ever seen how mushrooms connect through underground networks...`; - test.expect(expectedFormatted).toContain('\n==== 1. The Building Blocks (Material Cause)\n'); - test.expect(expectedFormatted).toContain('\n==== 2. The Pattern of Organization (Formal Cause)\n'); + test.expect(expectedFormatted).toContain( + "\n==== 1. The Building Blocks (Material Cause)\n", + ); + test.expect(expectedFormatted).toContain( + "\n==== 2. The Pattern of Organization (Formal Cause)\n", + ); }); // ============================================================================= // PHASE 3: Publishing Logic Tests (30040/30041 Structure) // ============================================================================= -test.test('Understanding Knowledge should create proper 30040 index event', () => { +test.test("Understanding Knowledge should create proper 30040 index event", () => { // Expected 30040 index event structure const expectedIndexEvent = { kind: 30040, - content: '', // Index events have empty content + content: "", // Index events have empty content tags: [ - ['d', 'understanding-knowledge'], - ['title', 'Understanding Knowledge'], - ['image', 'https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg'], - ['published', '2025-04-21'], - ['language', 'en, ISO-639-1'], - ['t', 'knowledge'], - ['t', 'philosophy'], - ['t', 'education'], - ['type', 'text'], + ["d", "understanding-knowledge"], + ["title", "Understanding Knowledge"], + ["image", "https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg"], + ["published", "2025-04-21"], + ["language", "en, ISO-639-1"], + ["t", "knowledge"], + ["t", "philosophy"], + ["t", "education"], + ["type", "text"], // a-tags referencing sections - ['a', '30041:pubkey:understanding-knowledge-preface'], - ['a', '30041:pubkey:understanding-knowledge-introduction-knowledge-as-a-living-ecosystem'], - ['a', '30041:pubkey:understanding-knowledge-i-material-cause-the-substance-of-knowledge'], + ["a", "30041:pubkey:understanding-knowledge-preface"], + [ + "a", + "30041:pubkey:understanding-knowledge-introduction-knowledge-as-a-living-ecosystem", + ], + [ + "a", + "30041:pubkey:understanding-knowledge-i-material-cause-the-substance-of-knowledge", + ], // ... more a-tags for each section - ] + ], }; test.expect(expectedIndexEvent.kind).toBe(30040); - test.expect(expectedIndexEvent.content).toBe(''); - test.expect(expectedIndexEvent.tags.filter(([k]) => k === 't')).toHaveLength(3); - test.expect(expectedIndexEvent.tags.find(([k, v]) => k === 'type' && v === 'text')).toBeTruthy(); + test.expect(expectedIndexEvent.content).toBe(""); + test.expect(expectedIndexEvent.tags.filter(([k]) => k === "t")).toHaveLength( + 3, + ); + test.expect( + expectedIndexEvent.tags.find(([k, v]) => k === "type" && v === "text"), + ).toBeTruthy(); }); -test.test('Understanding Knowledge sections should create proper 30041 events', () => { +test.test("Understanding Knowledge sections should create proper 30041 events", () => { // Expected 30041 events for main sections const expectedSectionEvents = [ { kind: 30041, - content: `[NOTE]\nThis essay was written to outline and elaborate on the purpose of the Nostr client Alexandria...`, + content: + `[NOTE]\nThis essay was written to outline and elaborate on the purpose of the Nostr client Alexandria...`, tags: [ - ['d', 'understanding-knowledge-preface'], - ['title', 'Preface'] - ] + ["d", "understanding-knowledge-preface"], + ["title", "Preface"], + ], }, { - kind: 30041, + kind: 30041, content: `image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library]`, tags: [ - ['d', 'understanding-knowledge-introduction-knowledge-as-a-living-ecosystem'], - ['title', 'Introduction: Knowledge as a Living Ecosystem'] - ] - } + [ + "d", + "understanding-knowledge-introduction-knowledge-as-a-living-ecosystem", + ], + ["title", "Introduction: Knowledge as a Living Ecosystem"], + ], + }, ]; - expectedSectionEvents.forEach(event => { + expectedSectionEvents.forEach((event) => { test.expect(event.kind).toBe(30041); test.expect(event.content).toBeTruthy(); - test.expect(event.tags.find(([k]) => k === 'd')).toBeTruthy(); - test.expect(event.tags.find(([k]) => k === 'title')).toBeTruthy(); + test.expect(event.tags.find(([k]) => k === "d")).toBeTruthy(); + test.expect(event.tags.find(([k]) => k === "title")).toBeTruthy(); }); }); -test.test('Level-based parsing should create correct 30040/30041 structure', () => { +test.test("Level-based parsing should create correct 30040/30041 structure", () => { // Based on docreference.md examples - + // Level 2 parsing: only == sections become events, containing all subsections const expectedLevel2Events = { mainIndex: { kind: 30040, - content: '', + content: "", tags: [ - ['d', 'programming-fundamentals-guide'], - ['title', 'Programming Fundamentals Guide'], - ['a', '30041:author_pubkey:data-structures'], - ['a', '30041:author_pubkey:algorithms'] - ] + ["d", "programming-fundamentals-guide"], + ["title", "Programming Fundamentals Guide"], + ["a", "30041:author_pubkey:data-structures"], + ["a", "30041:author_pubkey:algorithms"], + ], }, dataStructuresSection: { kind: 30041, - content: 'Understanding fundamental data structures...\n\n=== Arrays and Lists\n\n...==== Dynamic Arrays\n\n...==== Linked Lists\n\n...', + content: + "Understanding fundamental data structures...\n\n=== Arrays and Lists\n\n...==== Dynamic Arrays\n\n...==== Linked Lists\n\n...", tags: [ - ['d', 'data-structures'], - ['title', 'Data Structures'], - ['difficulty', 'intermediate'] - ] - } + ["d", "data-structures"], + ["title", "Data Structures"], + ["difficulty", "intermediate"], + ], + }, }; // Level 3 parsing: == sections become 30040 indices, === sections become 30041 events const expectedLevel3Events = { mainIndex: { kind: 30040, - content: '', + content: "", tags: [ - ['d', 'programming-fundamentals-guide'], - ['title', 'Programming Fundamentals Guide'], - ['a', '30040:author_pubkey:data-structures'], // Now references sub-index - ['a', '30040:author_pubkey:algorithms'] - ] + ["d", "programming-fundamentals-guide"], + ["title", "Programming Fundamentals Guide"], + ["a", "30040:author_pubkey:data-structures"], // Now references sub-index + ["a", "30040:author_pubkey:algorithms"], + ], }, dataStructuresIndex: { kind: 30040, - content: '', + content: "", tags: [ - ['d', 'data-structures'], - ['title', 'Data Structures'], - ['a', '30041:author_pubkey:data-structures-content'], - ['a', '30041:author_pubkey:arrays-and-lists'], - ['a', '30041:author_pubkey:trees-and-graphs'] - ] + ["d", "data-structures"], + ["title", "Data Structures"], + ["a", "30041:author_pubkey:data-structures-content"], + ["a", "30041:author_pubkey:arrays-and-lists"], + ["a", "30041:author_pubkey:trees-and-graphs"], + ], }, arraysAndListsSection: { kind: 30041, - content: 'Arrays are contiguous...\n\n==== Dynamic Arrays\n\n...==== Linked Lists\n\n...', + content: + "Arrays are contiguous...\n\n==== Dynamic Arrays\n\n...==== Linked Lists\n\n...", tags: [ - ['d', 'arrays-and-lists'], - ['title', 'Arrays and Lists'] - ] - } + ["d", "arrays-and-lists"], + ["title", "Arrays and Lists"], + ], + }, }; test.expect(expectedLevel2Events.mainIndex.kind).toBe(30040); test.expect(expectedLevel2Events.dataStructuresSection.kind).toBe(30041); - test.expect(expectedLevel2Events.dataStructuresSection.content).toContain('=== Arrays and Lists'); - + test.expect(expectedLevel2Events.dataStructuresSection.content).toContain( + "=== Arrays and Lists", + ); + test.expect(expectedLevel3Events.dataStructuresIndex.kind).toBe(30040); - test.expect(expectedLevel3Events.arraysAndListsSection.content).toContain('==== Dynamic Arrays'); + test.expect(expectedLevel3Events.arraysAndListsSection.content).toContain( + "==== Dynamic Arrays", + ); }); // ============================================================================= // PHASE 4: Smart Publishing System Tests // ============================================================================= -test.test('Content type detection should work for both test files', () => { +test.test("Content type detection should work for both test files", () => { const testCases = [ { - name: 'Understanding Knowledge (article)', + name: "Understanding Knowledge (article)", content: understandingKnowledge, - expected: 'article' + expected: "article", }, { - name: 'Desire (article)', - content: desire, - expected: 'article' + name: "Desire (article)", + content: desire, + expected: "article", }, { - name: 'Scattered notes format', - content: '== Note 1\nContent\n\n== Note 2\nMore content', - expected: 'scattered-notes' - } + name: "Scattered notes format", + content: "== Note 1\nContent\n\n== Note 2\nMore content", + expected: "scattered-notes", + }, ]; testCases.forEach(({ name, content, expected }) => { - const hasDocTitle = content.trim().startsWith('=') && !content.trim().startsWith('=='); - const hasSections = content.includes('=='); - + const hasDocTitle = content.trim().startsWith("=") && + !content.trim().startsWith("=="); + const hasSections = content.includes("=="); + let detected; if (hasDocTitle) { - detected = 'article'; + detected = "article"; } else if (hasSections) { - detected = 'scattered-notes'; + detected = "scattered-notes"; } else { - detected = 'none'; + detected = "none"; } - + console.log(` ${name}: detected ${detected}`); test.expect(detected).toBe(expected); }); }); -test.test('Parse level should affect event structure correctly', () => { +test.test("Parse level should affect event structure correctly", () => { // Understanding Knowledge has structure: = > == (6 sections) > === (many subsections) > ==== // Based on actual content analysis const levelEventCounts = [ - { level: 1, description: 'Only document index', events: 1 }, - { level: 2, description: 'Document index + level 2 sections (==)', events: 7 }, // 1 index + 6 sections - { level: 3, description: 'Document index + section indices + level 3 subsections (===)', events: 20 }, // More complex - { level: 4, description: 'Full hierarchy including level 4 (====)', events: 35 } + { level: 1, description: "Only document index", events: 1 }, + { + level: 2, + description: "Document index + level 2 sections (==)", + events: 7, + }, // 1 index + 6 sections + { + level: 3, + description: + "Document index + section indices + level 3 subsections (===)", + events: 20, + }, // More complex + { + level: 4, + description: "Full hierarchy including level 4 (====)", + events: 35, + }, ]; levelEventCounts.forEach(({ level, description, events }) => { @@ -424,27 +496,27 @@ test.test('Parse level should affect event structure correctly', () => { // PHASE 5: Integration Tests (End-to-End Workflow) // ============================================================================= -test.test('Full Understanding Knowledge publishing workflow (Level 2)', async () => { +test.test("Full Understanding Knowledge publishing workflow (Level 2)", async () => { // Mock the complete ITERATIVE workflow const mockWorkflow = { parseLevel2: (content: string) => ({ metadata: { - title: 'Understanding Knowledge', - image: 'https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg', - published: '2025-04-21', - tags: ['knowledge', 'philosophy', 'education'], - type: 'text' + title: "Understanding Knowledge", + image: "https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg", + published: "2025-04-21", + tags: ["knowledge", "philosophy", "education"], + type: "text", }, - title: 'Understanding Knowledge', - content: 'Introduction content before any sections', + title: "Understanding Knowledge", + content: "Introduction content before any sections", sections: [ - { - title: 'Preface', - content: '[NOTE]\nThis essay was written to outline...', - metadata: { title: 'Preface' } + { + title: "Preface", + content: "[NOTE]\nThis essay was written to outline...", + metadata: { title: "Preface" }, }, - { - title: 'Introduction: Knowledge as a Living Ecosystem', + { + title: "Introduction: Knowledge as a Living Ecosystem", // Contains ALL subsections (===, ====) in content content: `image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library] @@ -461,41 +533,46 @@ Traditionally, knowledge has been perceived as a static repository... ===== 1. The Building Blocks (Material Cause) Just as living organisms are made up of cells...`, - metadata: { title: 'Introduction: Knowledge as a Living Ecosystem' } - } + metadata: { title: "Introduction: Knowledge as a Living Ecosystem" }, + }, // ... 4 more sections (Material Cause, Formal Cause, Efficient Cause, Final Cause) - ] + ], }), - + buildLevel2Events: (parsed: any) => ({ - indexEvent: { - kind: 30040, - content: '', + indexEvent: { + kind: 30040, + content: "", tags: [ - ['d', 'understanding-knowledge'], - ['title', parsed.title], - ['image', parsed.metadata.image], - ['t', 'knowledge'], ['t', 'philosophy'], ['t', 'education'], - ['type', 'text'], - ['a', '30041:pubkey:preface'], - ['a', '30041:pubkey:introduction-knowledge-as-a-living-ecosystem'] - ] + ["d", "understanding-knowledge"], + ["title", parsed.title], + ["image", parsed.metadata.image], + ["t", "knowledge"], + ["t", "philosophy"], + ["t", "education"], + ["type", "text"], + ["a", "30041:pubkey:preface"], + ["a", "30041:pubkey:introduction-knowledge-as-a-living-ecosystem"], + ], }, sectionEvents: parsed.sections.map((s: any) => ({ kind: 30041, content: s.content, tags: [ - ['d', s.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')], - ['title', s.title] - ] - })) + ["d", s.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")], + ["title", s.title], + ], + })), }), - + publish: (events: any) => ({ success: true, published: events.sectionEvents.length + 1, - eventIds: ['main-index', ...events.sectionEvents.map((_: any, i: number) => `section-${i}`)] - }) + eventIds: [ + "main-index", + ...events.sectionEvents.map((_: any, i: number) => `section-${i}`), + ], + }), }; // Test the full Level 2 workflow @@ -503,29 +580,38 @@ Just as living organisms are made up of cells...`, const events = mockWorkflow.buildLevel2Events(parsed); const result = mockWorkflow.publish(events); - test.expect(parsed.metadata.title).toBe('Understanding Knowledge'); + test.expect(parsed.metadata.title).toBe("Understanding Knowledge"); test.expect(parsed.sections).toHaveLength(2); test.expect(events.indexEvent.kind).toBe(30040); test.expect(events.sectionEvents).toHaveLength(2); - test.expect(events.sectionEvents[1].content).toContain('=== Why Investigate'); // Contains subsections - test.expect(events.sectionEvents[1].content).toContain('===== 1. The Building Blocks'); // Contains deeper levels + test.expect(events.sectionEvents[1].content).toContain("=== Why Investigate"); // Contains subsections + test.expect(events.sectionEvents[1].content).toContain( + "===== 1. The Building Blocks", + ); // Contains deeper levels test.expect(result.success).toBeTruthy(); test.expect(result.published).toBe(3); // 1 index + 2 sections }); -test.test('Error handling for malformed content', () => { +test.test("Error handling for malformed content", () => { const invalidCases = [ - { content: '== Section\n=== Subsection\n==== Missing content', error: 'Empty content sections' }, - { content: '= Title\n\n== Section\n==== Skipped level', error: 'Invalid header nesting' }, - { content: '', error: 'Empty document' } + { + content: "== Section\n=== Subsection\n==== Missing content", + error: "Empty content sections", + }, + { + content: "= Title\n\n== Section\n==== Skipped level", + error: "Invalid header nesting", + }, + { content: "", error: "Empty document" }, ]; invalidCases.forEach(({ content, error }) => { // Mock error detection - const hasEmptySections = content.includes('Missing content'); - const hasSkippedLevels = content.includes('====') && !content.includes('==='); - const isEmpty = content.trim() === ''; - + const hasEmptySections = content.includes("Missing content"); + const hasSkippedLevels = content.includes("====") && + !content.includes("==="); + const isEmpty = content.trim() === ""; + const shouldError = hasEmptySections || hasSkippedLevels || isEmpty; test.expect(shouldError).toBeTruthy(); }); @@ -535,26 +621,40 @@ test.test('Error handling for malformed content', () => { // Test Execution // ============================================================================= -console.log('🎯 ZettelPublisher Test-Driven Development (ITERATIVE)\n'); -console.log('📋 Test Data Analysis:'); -console.log(`- Understanding Knowledge: ${understandingKnowledge.split('\n').length} lines`); -console.log(`- Desire: ${desire.split('\n').length} lines`); -console.log('- Both files use = document title with metadata directly underneath'); -console.log('- Sections use == with deep nesting (===, ====, =====)'); -console.log('- Custom attributes like :type: podcastArticle need preservation'); -console.log('- CRITICAL: Structure is ITERATIVE not recursive (per docreference.md)\n'); - -test.run().then(success => { +console.log("🎯 ZettelPublisher Test-Driven Development (ITERATIVE)\n"); +console.log("📋 Test Data Analysis:"); +console.log( + `- Understanding Knowledge: ${ + understandingKnowledge.split("\n").length + } lines`, +); +console.log(`- Desire: ${desire.split("\n").length} lines`); +console.log( + "- Both files use = document title with metadata directly underneath", +); +console.log("- Sections use == with deep nesting (===, ====, =====)"); +console.log("- Custom attributes like :type: podcastArticle need preservation"); +console.log( + "- CRITICAL: Structure is ITERATIVE not recursive (per docreference.md)\n", +); + +test.run().then((success) => { if (success) { - console.log('\n🎉 All tests defined! Ready for ITERATIVE implementation.'); - console.log('\n📋 Implementation Plan:'); - console.log('1. ✅ Update ParsedAsciiDoc interface for ITERATIVE parsing'); - console.log('2. ✅ Fix content processing (header separation, custom attributes)'); - console.log('3. ✅ Implement level-based publishing logic (30040/30041 structure)'); - console.log('4. ✅ Add parse-level controlled event generation'); - console.log('5. ✅ Create context-aware UI with level selector'); - console.log('\n🔄 Each level can be developed and tested independently!'); + console.log("\n🎉 All tests defined! Ready for ITERATIVE implementation."); + console.log("\n📋 Implementation Plan:"); + console.log("1. ✅ Update ParsedAsciiDoc interface for ITERATIVE parsing"); + console.log( + "2. ✅ Fix content processing (header separation, custom attributes)", + ); + console.log( + "3. ✅ Implement level-based publishing logic (30040/30041 structure)", + ); + console.log("4. ✅ Add parse-level controlled event generation"); + console.log("5. ✅ Create context-aware UI with level selector"); + console.log("\n🔄 Each level can be developed and tested independently!"); } else { - console.log('\n❌ Tests ready - implement ITERATIVE features to make them pass!'); + console.log( + "\n❌ Tests ready - implement ITERATIVE features to make them pass!", + ); } -}).catch(console.error); \ No newline at end of file +}).catch(console.error);