Browse Source

Merges pull request #80

fix build errors
master
Michael J 3 months ago
parent
commit
8a1f3b2035
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 23
      CLAUDE.md
  2. 162
      TECHNIQUE-create-test-highlights.md
  3. 26
      TEST_SUMMARY.md
  4. 20
      WIKI_TAG_SPEC.md
  5. 45
      check-publication-structure.js
  6. 117
      create-test-comments.js
  7. 104
      create-test-highlights.js
  8. 193
      deno.lock
  9. 60
      doc/compose_tree.md
  10. 6
      import_map.json
  11. 54
      nips/09.md
  12. 124
      package-lock.json
  13. 153
      src/lib/a/cards/AEventPreview.svelte
  14. 124
      src/lib/components/EventDetails.svelte
  15. 146
      src/lib/components/EventKindFilter.svelte
  16. 10
      src/lib/components/Navigation.svelte
  17. 59
      src/lib/components/Preview.svelte
  18. 741
      src/lib/components/ZettelEditor.svelte
  19. 328
      src/lib/components/publications/HighlightLayer.svelte
  20. 83
      src/lib/components/publications/HighlightSelectionHandler.svelte
  21. 329
      src/lib/components/publications/Publication.svelte
  22. 72
      src/lib/components/publications/PublicationSection.svelte
  23. 85
      src/lib/components/util/CardActions.svelte
  24. 20
      src/lib/components/util/Interactions.svelte
  25. 14
      src/lib/services/deletion.ts
  26. 29
      src/lib/services/publisher.ts
  27. 136
      src/lib/utils/asciidoc_ast_parser.ts
  28. 13
      src/lib/utils/asciidoc_parser.ts
  29. 84
      src/lib/utils/asciidoc_publication_parser.ts
  30. 4
      src/lib/utils/event_input_utils.ts
  31. 2
      src/lib/utils/fetch_publication_highlights.ts
  32. 61
      src/lib/utils/highlightPositioning.ts
  33. 189
      src/lib/utils/highlightUtils.ts
  34. 18
      src/lib/utils/mockCommentData.ts
  35. 113
      src/lib/utils/mockHighlightData.ts
  36. 15
      src/lib/utils/publication_tree_factory.ts
  37. 74
      src/lib/utils/publication_tree_processor.ts
  38. 41
      src/lib/utils/wiki_links.ts
  39. 39
      src/routes/new/edit/+page.svelte
  40. 581
      tests/unit/commentButton.test.ts
  41. 77
      tests/unit/deletion.test.ts
  42. 52
      tests/unit/fetchPublicationHighlights.test.ts
  43. 445
      tests/unit/highlightLayer.test.ts
  44. 36
      tests/unit/highlightSelection.test.ts
  45. 121
      tests/unit/publication_tree_processor.test.ts
  46. 534
      tests/zettel-publisher-tdd.test.ts

23
CLAUDE.md

@ -1,14 +1,19 @@
# Alexandria Codebase - Local Instructions # Alexandria Codebase - Local Instructions
This document provides project-specific instructions for working with the Alexandria codebase, based on existing Cursor rules and project conventions. This document provides project-specific instructions for working with the
Alexandria codebase, based on existing Cursor rules and project conventions.
## Developer Context ## 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. 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 ## 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: 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) - **Svelte 5** and **SvelteKit 2** (latest versions)
- **TypeScript** (exclusively, no plain JavaScript) - **TypeScript** (exclusively, no plain JavaScript)
@ -22,19 +27,22 @@ The project follows a Model-View-Controller (MVC) pattern:
- **Model**: Nostr relays (via WebSocket APIs) and browser storage - **Model**: Nostr relays (via WebSocket APIs) and browser storage
- **View**: Reactive UI with SvelteKit pages and Svelte components - **View**: Reactive UI with SvelteKit pages and Svelte components
- **Controller**: TypeScript modules with utilities, services, and data preparation - **Controller**: TypeScript modules with utilities, services, and data
preparation
## Critical Development Guidelines ## Critical Development Guidelines
### Prime Directive ### Prime Directive
**NEVER assume developer intent.** If unsure, ALWAYS ask for clarification before proceeding. **NEVER assume developer intent.** If unsure, ALWAYS ask for clarification
before proceeding.
### AI Anchor Comments System ### AI Anchor Comments System
Before any work, search for `AI-` anchor comments in relevant directories: Before any work, search for `AI-` anchor comments in relevant directories:
- `AI-NOTE:`, `AI-TODO:`, `AI-QUESTION:` - Context sharing between AI and developers - `AI-NOTE:`, `AI-TODO:`, `AI-QUESTION:` - Context sharing between AI and
developers
- `AI-<MM/DD/YYYY>:` - Developer-recorded context (read but don't write) - `AI-<MM/DD/YYYY>:` - Developer-recorded context (read but don't write)
- **Always update relevant anchor comments when modifying code** - **Always update relevant anchor comments when modifying code**
- Add new anchors for complex, critical, or confusing code - Add new anchors for complex, critical, or confusing code
@ -101,7 +109,8 @@ Before any work, search for `AI-` anchor comments in relevant directories:
### Core Classes to Use ### Core Classes to Use
- `WebSocketPool` (`src/lib/data_structures/websocket_pool.ts`) - For WebSocket management - `WebSocketPool` (`src/lib/data_structures/websocket_pool.ts`) - For WebSocket
management
- `PublicationTree` - For hierarchical publication structure - `PublicationTree` - For hierarchical publication structure
- `ZettelParser` - For AsciiDoc parsing - `ZettelParser` - For AsciiDoc parsing

162
TECHNIQUE-create-test-highlights.md

@ -2,7 +2,10 @@
## Overview ## 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. 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 ## When to Use This
@ -19,75 +22,77 @@ This technique allows you to create test highlight events (kind 9802) for testin
npm install nostr-tools ws npm install nostr-tools ws
``` ```
2. **Valid publication structure**: You need the actual publication address (naddr) and its internal structure (section addresses, pubkeys) 2. **Valid publication structure**: You need the actual publication address
(naddr) and its internal structure (section addresses, pubkeys)
## Step 1: Decode the Publication Address ## Step 1: Decode the Publication Address
If you have an `naddr` (Nostr address), decode it to find the publication structure: If you have an `naddr` (Nostr address), decode it to find the publication
structure:
**Script**: `check-publication-structure.js` **Script**: `check-publication-structure.js`
```javascript ```javascript
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
import WebSocket from 'ws'; import WebSocket from "ws";
const naddr = 'naddr1qvzqqqr4t...'; // Your publication naddr const naddr = "naddr1qvzqqqr4t..."; // Your publication naddr
console.log('Decoding naddr...\n'); console.log("Decoding naddr...\n");
const decoded = nip19.decode(naddr); const decoded = nip19.decode(naddr);
console.log('Decoded:', JSON.stringify(decoded, null, 2)); console.log("Decoded:", JSON.stringify(decoded, null, 2));
const { data } = decoded; const { data } = decoded;
const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`; const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`;
console.log('\nRoot Address:', rootAddress); console.log("\nRoot Address:", rootAddress);
// Fetch the index event to see what sections it references // Fetch the index event to see what sections it references
const relay = 'wss://relay.nostr.band'; const relay = "wss://relay.nostr.band";
async function fetchPublication() { async function fetchPublication() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ws = new WebSocket(relay); const ws = new WebSocket(relay);
const events = []; const events = [];
ws.on('open', () => { ws.on("open", () => {
console.log(`\nConnected to ${relay}`); console.log(`\nConnected to ${relay}`);
console.log('Fetching index event...\n'); console.log("Fetching index event...\n");
const filter = { const filter = {
kinds: [data.kind], kinds: [data.kind],
authors: [data.pubkey], authors: [data.pubkey],
'#d': [data.identifier], "#d": [data.identifier],
}; };
const subscriptionId = `sub-${Date.now()}`; const subscriptionId = `sub-${Date.now()}`;
ws.send(JSON.stringify(['REQ', subscriptionId, filter])); ws.send(JSON.stringify(["REQ", subscriptionId, filter]));
}); });
ws.on('message', (message) => { ws.on("message", (message) => {
const [type, subId, event] = JSON.parse(message.toString()); const [type, subId, event] = JSON.parse(message.toString());
if (type === 'EVENT') { if (type === "EVENT") {
events.push(event); events.push(event);
console.log('Found index event:', event.id); console.log("Found index event:", event.id);
console.log('\nTags:'); console.log("\nTags:");
event.tags.forEach(tag => { event.tags.forEach((tag) => {
if (tag[0] === 'a') { if (tag[0] === "a") {
console.log(` Section address: ${tag[1]}`); console.log(` Section address: ${tag[1]}`);
} }
if (tag[0] === 'd') { if (tag[0] === "d") {
console.log(` D-tag: ${tag[1]}`); console.log(` D-tag: ${tag[1]}`);
} }
if (tag[0] === 'title') { if (tag[0] === "title") {
console.log(` Title: ${tag[1]}`); console.log(` Title: ${tag[1]}`);
} }
}); });
} else if (type === 'EOSE') { } else if (type === "EOSE") {
ws.close(); ws.close();
resolve(events); resolve(events);
} }
}); });
ws.on('error', reject); ws.on("error", reject);
setTimeout(() => { setTimeout(() => {
ws.close(); ws.close();
@ -97,13 +102,14 @@ async function fetchPublication() {
} }
fetchPublication() fetchPublication()
.then(() => console.log('\nDone!')) .then(() => console.log("\nDone!"))
.catch(console.error); .catch(console.error);
``` ```
**Run it**: `node check-publication-structure.js` **Run it**: `node check-publication-structure.js`
**Expected output**: Section addresses like `30041:dc4cd086...:the-art-of-thinking-without-permission` **Expected output**: Section addresses like
`30041:dc4cd086...:the-art-of-thinking-without-permission`
## Step 2: Understand Kind 9802 Event Structure ## Step 2: Understand Kind 9802 Event Structure
@ -128,31 +134,33 @@ A highlight event (kind 9802) has this structure:
### Critical Differences from Comments (kind 1111): ### Critical Differences from Comments (kind 1111):
| Aspect | Comments (1111) | Highlights (9802) | | Aspect | Comments (1111) | Highlights (9802) |
|--------|----------------|-------------------| | ---------------------- | ---------------------------------------------------------------- | -------------------------------------------- |
| **Content field** | User's comment text | The highlighted text itself | | **Content field** | User's comment text | The highlighted text itself |
| **User annotation** | N/A (content is the comment) | Optional `["comment", ...]` tag | | **User annotation** | N/A (content is the comment) | Optional `["comment", ...]` tag |
| **Context** | Not used | `["context", ...]` provides surrounding text | | **Context** | Not used | `["context", ...]` provides surrounding text |
| **Threading** | Uses `["e", ..., "reply"]` tags | No threading (flat structure) | | **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 | | **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 ## Step 3: Create Test Highlight Events
**Script**: `create-test-highlights.js` **Script**: `create-test-highlights.js`
```javascript ```javascript
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'; import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import WebSocket from 'ws'; import WebSocket from "ws";
// Test user keys (generate fresh ones) // Test user keys (generate fresh ones)
const testUserKey = generateSecretKey(); const testUserKey = generateSecretKey();
const testUserPubkey = getPublicKey(testUserKey); const testUserPubkey = getPublicKey(testUserKey);
console.log('Test User pubkey:', testUserPubkey); console.log("Test User pubkey:", testUserPubkey);
// The publication details (from Step 1) // The publication details (from Step 1)
const publicationPubkey = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06'; const publicationPubkey =
const rootAddress = `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`; "dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06";
const rootAddress =
`30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`;
// Section addresses (from Step 1 output) // Section addresses (from Step 1 output)
const sections = [ const sections = [
@ -163,25 +171,29 @@ const sections = [
// Relays to publish to (matching HighlightLayer's relay list) // Relays to publish to (matching HighlightLayer's relay list)
const relays = [ const relays = [
'wss://relay.damus.io', "wss://relay.damus.io",
'wss://relay.nostr.band', "wss://relay.nostr.band",
'wss://nostr.wine', "wss://nostr.wine",
]; ];
// Test highlights to create // Test highlights to create
const testHighlights = [ const testHighlights = [
{ {
highlightedText: 'Knowledge that tries to stay put inevitably becomes ossified', highlightedText:
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', "Knowledge that tries to stay put inevitably becomes ossified",
comment: 'This perfectly captures why traditional academia struggles', // Optional 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], targetAddress: sections[0],
author: testUserKey, author: testUserKey,
authorPubkey: testUserPubkey, authorPubkey: testUserPubkey,
}, },
{ {
highlightedText: 'The attempt to hold knowledge still is like trying to photograph a river', highlightedText:
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.', "The attempt to hold knowledge still is like trying to photograph a river",
comment: null, // No annotation, just highlight 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], targetAddress: sections[0],
author: testUserKey, author: testUserKey,
authorPubkey: testUserPubkey, authorPubkey: testUserPubkey,
@ -193,14 +205,14 @@ async function publishEvent(event, relayUrl) {
const ws = new WebSocket(relayUrl); const ws = new WebSocket(relayUrl);
let published = false; let published = false;
ws.on('open', () => { ws.on("open", () => {
console.log(`Connected to ${relayUrl}`); console.log(`Connected to ${relayUrl}`);
ws.send(JSON.stringify(['EVENT', event])); ws.send(JSON.stringify(["EVENT", event]));
}); });
ws.on('message', (data) => { ws.on("message", (data) => {
const message = JSON.parse(data.toString()); const message = JSON.parse(data.toString());
if (message[0] === 'OK' && message[1] === event.id) { if (message[0] === "OK" && message[1] === event.id) {
if (message[2]) { if (message[2]) {
console.log(`✓ Published ${event.id.substring(0, 8)}`); console.log(`✓ Published ${event.id.substring(0, 8)}`);
published = true; published = true;
@ -214,22 +226,22 @@ async function publishEvent(event, relayUrl) {
} }
}); });
ws.on('error', reject); ws.on("error", reject);
ws.on('close', () => { ws.on("close", () => {
if (!published) reject(new Error('Connection closed')); if (!published) reject(new Error("Connection closed"));
}); });
setTimeout(() => { setTimeout(() => {
if (!published) { if (!published) {
ws.close(); ws.close();
reject(new Error('Timeout')); reject(new Error("Timeout"));
} }
}, 10000); }, 10000);
}); });
} }
async function createAndPublishHighlights() { async function createAndPublishHighlights() {
console.log('\n=== Creating Test Highlights ===\n'); console.log("\n=== Creating Test Highlights ===\n");
for (const highlight of testHighlights) { for (const highlight of testHighlights) {
try { try {
@ -238,23 +250,25 @@ async function createAndPublishHighlights() {
kind: 9802, kind: 9802,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
['a', highlight.targetAddress, relays[0]], ["a", highlight.targetAddress, relays[0]],
['context', highlight.context], ["context", highlight.context],
['p', publicationPubkey, relays[0], 'author'], ["p", publicationPubkey, relays[0], "author"],
], ],
content: highlight.highlightedText, // The highlighted text content: highlight.highlightedText, // The highlighted text
pubkey: highlight.authorPubkey, pubkey: highlight.authorPubkey,
}; };
// Add optional comment/annotation // Add optional comment/annotation
if (highlight.comment) { if (highlight.comment) {
unsignedEvent.tags.push(['comment', highlight.comment]); unsignedEvent.tags.push(["comment", highlight.comment]);
} }
// Sign the event // Sign the event
const signedEvent = finalizeEvent(unsignedEvent, highlight.author); const signedEvent = finalizeEvent(unsignedEvent, highlight.author);
console.log(`\nHighlight: "${highlight.highlightedText.substring(0, 60)}..."`); console.log(
`\nHighlight: "${highlight.highlightedText.substring(0, 60)}..."`,
);
console.log(`Target: ${highlight.targetAddress}`); console.log(`Target: ${highlight.targetAddress}`);
console.log(`Event ID: ${signedEvent.id}`); console.log(`Event ID: ${signedEvent.id}`);
@ -262,14 +276,13 @@ async function createAndPublishHighlights() {
await publishEvent(signedEvent, relays[0]); await publishEvent(signedEvent, relays[0]);
// Delay to avoid rate limiting // Delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) { } catch (error) {
console.error(`Failed: ${error.message}`); console.error(`Failed: ${error.message}`);
} }
} }
console.log('\n=== Done! ==='); console.log("\n=== Done! ===");
console.log('\nRefresh the page and toggle "Show Highlights" to view them.'); console.log('\nRefresh the page and toggle "Show Highlights" to view them.');
} }
@ -313,24 +326,27 @@ createAndPublishHighlights().catch(console.error);
**Cause**: Publishing too many events too quickly **Cause**: Publishing too many events too quickly
**Solution**: Increase delay between publishes **Solution**: Increase delay between publishes
```javascript ```javascript
await new Promise(resolve => setTimeout(resolve, 2000)); // 2 seconds await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 seconds
``` ```
### Issue: Highlights don't appear after publishing ### Issue: Highlights don't appear after publishing
**Possible causes**: **Possible causes**:
1. Wrong section address - verify with `check-publication-structure.js` 1. Wrong section address - verify with `check-publication-structure.js`
2. HighlightLayer not fetching from the relay you published to 2. HighlightLayer not fetching from the relay you published to
3. Browser cache - hard refresh (Ctrl+Shift+R) 3. Browser cache - hard refresh (Ctrl+Shift+R)
**Debug steps**: **Debug steps**:
```javascript ```javascript
// In browser console, check what highlights are being fetched: // In browser console, check what highlights are being fetched:
console.log('All highlights:', allHighlights); console.log("All highlights:", allHighlights);
// Check if your event ID is present // Check if your event ID is present
allHighlights.find(h => h.id === 'your-event-id') allHighlights.find((h) => h.id === "your-event-id");
``` ```
### Issue: Context not matching actual publication text ### Issue: Context not matching actual publication text
@ -338,6 +354,7 @@ allHighlights.find(h => h.id === 'your-event-id')
**Cause**: The publication content changed, or you're using sample text **Cause**: The publication content changed, or you're using sample text
**Solution**: Copy actual text from the publication: **Solution**: Copy actual text from the publication:
1. Open the publication in browser 1. Open the publication in browser
2. Select the text you want to highlight 2. Select the text you want to highlight
3. Copy a larger surrounding context (2-3 sentences) 3. Copy a larger surrounding context (2-3 sentences)
@ -368,6 +385,9 @@ To use this technique on a different publication:
## Further Reading ## Further Reading
- NIP-84 (Highlights): https://github.com/nostr-protocol/nips/blob/master/84.md - 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/HighlightLayer.svelte` - Fetching
- `src/lib/components/publications/HighlightSelectionHandler.svelte` - Event creation implementation
- NIP-19 (Address encoding): https://github.com/nostr-protocol/nips/blob/master/19.md - `src/lib/components/publications/HighlightSelectionHandler.svelte` - Event
creation
- NIP-19 (Address encoding):
https://github.com/nostr-protocol/nips/blob/master/19.md

26
TEST_SUMMARY.md

@ -1,15 +1,19 @@
# Comment Button TDD Tests - Summary # Comment Button TDD Tests - Summary
## Overview ## Overview
Comprehensive test suite for CommentButton component and NIP-22 comment functionality.
**Test File:** `/home/user/gc-alexandria-comments/tests/unit/commentButton.test.ts` 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 **Status:** ✅ All 69 tests passing
## Test Coverage ## Test Coverage
### 1. Address Parsing (5 tests) ### 1. Address Parsing (5 tests)
- ✅ Parses valid event address correctly (kind:pubkey:dtag) - ✅ Parses valid event address correctly (kind:pubkey:dtag)
- ✅ Handles dTag with colons correctly - ✅ Handles dTag with colons correctly
- ✅ Validates invalid address format (too few parts) - ✅ Validates invalid address format (too few parts)
@ -17,6 +21,7 @@ Comprehensive test suite for CommentButton component and NIP-22 comment function
- ✅ Parses different publication kinds (30040, 30041, 30818, 30023) - ✅ Parses different publication kinds (30040, 30041, 30818, 30023)
### 2. NIP-22 Event Creation (8 tests) ### 2. NIP-22 Event Creation (8 tests)
- ✅ Creates kind 1111 comment event - ✅ Creates kind 1111 comment event
- ✅ Includes correct uppercase tags (A, K, P) for root scope - ✅ Includes correct uppercase tags (A, K, P) for root scope
- ✅ Includes correct lowercase tags (a, k, p) for parent scope - ✅ Includes correct lowercase tags (a, k, p) for parent scope
@ -27,12 +32,14 @@ Comprehensive test suite for CommentButton component and NIP-22 comment function
- ✅ Handles empty relay list gracefully - ✅ Handles empty relay list gracefully
### 3. Event Signing and Publishing (4 tests) ### 3. Event Signing and Publishing (4 tests)
- ✅ Signs event with user's signer - ✅ Signs event with user's signer
- ✅ Publishes to outbox relays - ✅ Publishes to outbox relays
- ✅ Handles publishing errors gracefully - ✅ Handles publishing errors gracefully
- ✅ Throws error when publishing fails - ✅ Throws error when publishing fails
### 4. User Authentication (5 tests) ### 4. User Authentication (5 tests)
- ✅ Requires user to be signed in - ✅ Requires user to be signed in
- ✅ Shows error when user is not signed in - ✅ Shows error when user is not signed in
- ✅ Allows commenting when user is signed in - ✅ Allows commenting when user is signed in
@ -40,6 +47,7 @@ Comprehensive test suite for CommentButton component and NIP-22 comment function
- ✅ Handles missing user profile gracefully - ✅ Handles missing user profile gracefully
### 5. User Interactions (7 tests) ### 5. User Interactions (7 tests)
- ✅ Prevents submission of empty comment - ✅ Prevents submission of empty comment
- ✅ Allows submission of non-empty comment - ✅ Allows submission of non-empty comment
- ✅ Handles whitespace-only comments as empty - ✅ Handles whitespace-only comments as empty
@ -49,6 +57,7 @@ Comprehensive test suite for CommentButton component and NIP-22 comment function
- ✅ Does not error when onCommentPosted is not provided - ✅ Does not error when onCommentPosted is not provided
### 6. UI State Management (10 tests) ### 6. UI State Management (10 tests)
- ✅ Button is hidden by default - ✅ Button is hidden by default
- ✅ Button appears on section hover - ✅ Button appears on section hover
- ✅ Button remains visible when comment UI is shown - ✅ Button remains visible when comment UI is shown
@ -61,6 +70,7 @@ Comprehensive test suite for CommentButton component and NIP-22 comment function
- ✅ Enables submit button when comment is valid - ✅ Enables submit button when comment is valid
### 7. Edge Cases (8 tests) ### 7. Edge Cases (8 tests)
- ✅ Handles invalid address format gracefully - ✅ Handles invalid address format gracefully
- ✅ Handles network errors during event fetch - ✅ Handles network errors during event fetch
- ✅ Handles missing relay information - ✅ Handles missing relay information
@ -71,17 +81,20 @@ Comprehensive test suite for CommentButton component and NIP-22 comment function
- ✅ Handles publish failure when no relays accept event - ✅ Handles publish failure when no relays accept event
### 8. Cancel Functionality (4 tests) ### 8. Cancel Functionality (4 tests)
- ✅ Clears comment content when canceling - ✅ Clears comment content when canceling
- ✅ Closes comment UI when canceling - ✅ Closes comment UI when canceling
- ✅ Clears error state when canceling - ✅ Clears error state when canceling
- ✅ Clears success state when canceling - ✅ Clears success state when canceling
### 9. Event Fetching (3 tests) ### 9. Event Fetching (3 tests)
- ✅ Fetches target event to get event ID - ✅ Fetches target event to get event ID
- ✅ Continues without event ID when fetch fails - ✅ Continues without event ID when fetch fails
- ✅ Handles null event from fetch - ✅ Handles null event from fetch
### 10. CSS Classes and Styling (6 tests) ### 10. CSS Classes and Styling (6 tests)
- ✅ Applies visible class when section is hovered - ✅ Applies visible class when section is hovered
- ✅ Removes visible class when not hovered and UI closed - ✅ Removes visible class when not hovered and UI closed
- ✅ Button has correct aria-label - ✅ Button has correct aria-label
@ -90,6 +103,7 @@ Comprehensive test suite for CommentButton component and NIP-22 comment function
- ✅ Submit button shows normal state when not submitting - ✅ Submit button shows normal state when not submitting
### 11. NIP-22 Compliance (5 tests) ### 11. NIP-22 Compliance (5 tests)
- ✅ Uses kind 1111 for comment events - ✅ Uses kind 1111 for comment events
- ✅ Includes all required NIP-22 tags for addressable events - ✅ Includes all required NIP-22 tags for addressable events
- ✅ A tag includes relay hint and author pubkey - ✅ A tag includes relay hint and author pubkey
@ -97,6 +111,7 @@ Comprehensive test suite for CommentButton component and NIP-22 comment function
- ✅ Lowercase tags for parent scope match root tags - ✅ Lowercase tags for parent scope match root tags
### 12. Integration Scenarios (4 tests) ### 12. Integration Scenarios (4 tests)
- ✅ Complete comment flow for signed-in user - ✅ Complete comment flow for signed-in user
- ✅ Prevents comment flow for signed-out user - ✅ Prevents comment flow for signed-out user
- ✅ Handles comment with event ID lookup - ✅ Handles comment with event ID lookup
@ -128,13 +143,18 @@ The tests verify the correct NIP-22 tag structure for addressable events:
``` ```
## Files Changed ## Files Changed
- `tests/unit/commentButton.test.ts` - 911 lines (new file) - `tests/unit/commentButton.test.ts` - 911 lines (new file)
- `package-lock.json` - Updated dependencies - `package-lock.json` - Updated dependencies
## Current Status ## 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.
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 ## To Commit and Push
```bash ```bash
cd /home/user/gc-alexandria-comments cd /home/user/gc-alexandria-comments
git commit -m "Add TDD tests for comment functionality" git commit -m "Add TDD tests for comment functionality"

20
WIKI_TAG_SPEC.md

@ -25,6 +25,7 @@ This syntax automatically generates a 'w' tag during conversion:
``` ```
**Semantics**: **Semantics**:
- The d-tag **IS** the subject/identity of the event - The d-tag **IS** the subject/identity of the event
- Represents an **explicit definition** or primary topic - Represents an **explicit definition** or primary topic
- Forward declaration: "This event defines/is about knowledge-graphs" - Forward declaration: "This event defines/is about knowledge-graphs"
@ -42,10 +43,12 @@ This syntax automatically generates a 'w' tag during conversion:
``` ```
**Semantics**: **Semantics**:
- The w-tag **REFERENCES** a concept within the content - The w-tag **REFERENCES** a concept within the content
- Represents an **implicit mention** or contextual usage - Represents an **implicit mention** or contextual usage
- Backward reference: "This event mentions/relates to knowledge-graphs" - Backward reference: "This event mentions/relates to knowledge-graphs"
- Search query: "Show me ALL events that discuss 'knowledge-graphs' in their text" - Search query: "Show me ALL events that discuss 'knowledge-graphs' in their
text"
- Expectation: Multiple content events that reference the term - Expectation: Multiple content events that reference the term
**Use Case**: Discovering all content that relates to or discusses a concept **Use Case**: Discovering all content that relates to or discusses a concept
@ -53,6 +56,7 @@ This syntax automatically generates a 'w' tag during conversion:
## Structural Opacity Comparison ## Structural Opacity Comparison
### D-Tags: Transparent Structure ### D-Tags: Transparent Structure
``` ```
Event with d-tag "knowledge-graphs" Event with d-tag "knowledge-graphs"
└── Title: "Knowledge Graphs" └── Title: "Knowledge Graphs"
@ -61,6 +65,7 @@ Event with d-tag "knowledge-graphs"
``` ```
### W-Tags: Opaque Structure ### W-Tags: Opaque Structure
``` ```
Event mentioning "knowledge-graphs" Event mentioning "knowledge-graphs"
├── Title: "Semantic Web Technologies" ├── Title: "Semantic Web Technologies"
@ -69,6 +74,7 @@ Event mentioning "knowledge-graphs"
``` ```
**Opacity**: You retrieve content events that regard the topic without knowing: **Opacity**: You retrieve content events that regard the topic without knowing:
- Whether they define it - Whether they define it
- How central it is to the event - How central it is to the event
- What relationship context it appears in - What relationship context it appears in
@ -76,28 +82,34 @@ Event mentioning "knowledge-graphs"
## Query Pattern Examples ## Query Pattern Examples
### Finding Definitions (D-Tag Query) ### Finding Definitions (D-Tag Query)
```bash ```bash
# Find THE definition event for "knowledge-graphs" # Find THE definition event for "knowledge-graphs"
nak req -k 30041 --tag d=knowledge-graphs nak req -k 30041 --tag d=knowledge-graphs
``` ```
**Result**: The specific event with d="knowledge-graphs" (if it exists) **Result**: The specific event with d="knowledge-graphs" (if it exists)
### Finding References (W-Tag Query) ### Finding References (W-Tag Query)
```bash ```bash
# Find ALL events that mention "knowledge-graphs" # Find ALL events that mention "knowledge-graphs"
nak req -k 30041 --tag w=knowledge-graphs nak req -k 30041 --tag w=knowledge-graphs
``` ```
**Result**: Any content event containing `[[Knowledge Graphs]]` wikilinks **Result**: Any content event containing `[[Knowledge Graphs]]` wikilinks
## Analogy ## Analogy
**D-Tag**: Like a book's ISBN - uniquely identifies and locates a specific work **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 **W-Tag**: Like a book's index entries - shows where a term appears across many
works
## Implementation Notes ## Implementation Notes
From your codebase (`nkbip_converter.py:327-329`): From your codebase (`nkbip_converter.py:327-329`):
```python ```python
# Extract wiki links and create 'w' tags # Extract wiki links and create 'w' tags
wiki_links = extract_wiki_links(content) wiki_links = extract_wiki_links(content)
@ -105,4 +117,6 @@ for wiki_term in wiki_links:
tags.append(["w", clean_tag(wiki_term), wiki_term]) 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. 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.

45
check-publication-structure.js

@ -1,63 +1,64 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
import WebSocket from 'ws'; import WebSocket from "ws";
const naddr = 'naddr1qvzqqqr4tqpzphzv6zrv6l89kxpj4h60m5fpz2ycsrfv0c54hjcwdpxqrt8wwlqxqyd8wumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6qgmwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakszyrhwden5te0dehhxarj9ekxzmnyqyg8wumn8ghj7mn0wd68ytnhd9hx2qghwaehxw309ahx7um5wgh8xmmkvf5hgtngdaehgqg3waehxw309ahx7um5wgerztnrdakszxthwden5te0wpex7enfd3jhxtnwdaehgu339e3k7mgpz4mhxue69uhkzem8wghxummnw3ezumrpdejqzxrhwden5te0wfjkccte9ehx7umhdpjhyefwvdhk6qg5waehxw309aex2mrp0yhxgctdw4eju6t0qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpr9mhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwsqrcctwv9exx6rfwd6xjcedddhx7amvv4jxwefdw35x2ttpwf6z6mmx946xs6twdd5kueedwa5hg6r0w46z6ur9wfkkjumnd9hkuwdu5na'; const naddr =
"naddr1qvzqqqr4tqpzphzv6zrv6l89kxpj4h60m5fpz2ycsrfv0c54hjcwdpxqrt8wwlqxqyd8wumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6qgmwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakszyrhwden5te0dehhxarj9ekxzmnyqyg8wumn8ghj7mn0wd68ytnhd9hx2qghwaehxw309ahx7um5wgh8xmmkvf5hgtngdaehgqg3waehxw309ahx7um5wgerztnrdakszxthwden5te0wpex7enfd3jhxtnwdaehgu339e3k7mgpz4mhxue69uhkzem8wghxummnw3ezumrpdejqzxrhwden5te0wfjkccte9ehx7umhdpjhyefwvdhk6qg5waehxw309aex2mrp0yhxgctdw4eju6t0qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpr9mhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwsqrcctwv9exx6rfwd6xjcedddhx7amvv4jxwefdw35x2ttpwf6z6mmx946xs6twdd5kueedwa5hg6r0w46z6ur9wfkkjumnd9hkuwdu5na";
console.log('Decoding naddr...\n'); console.log("Decoding naddr...\n");
const decoded = nip19.decode(naddr); const decoded = nip19.decode(naddr);
console.log('Decoded:', JSON.stringify(decoded, null, 2)); console.log("Decoded:", JSON.stringify(decoded, null, 2));
const { data } = decoded; const { data } = decoded;
const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`; const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`;
console.log('\nRoot Address:', rootAddress); console.log("\nRoot Address:", rootAddress);
// Fetch the index event to see what sections it references // Fetch the index event to see what sections it references
const relay = 'wss://relay.nostr.band'; const relay = "wss://relay.nostr.band";
async function fetchPublication() { async function fetchPublication() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ws = new WebSocket(relay); const ws = new WebSocket(relay);
const events = []; const events = [];
ws.on('open', () => { ws.on("open", () => {
console.log(`\nConnected to ${relay}`); console.log(`\nConnected to ${relay}`);
console.log('Fetching index event...\n'); console.log("Fetching index event...\n");
const filter = { const filter = {
kinds: [data.kind], kinds: [data.kind],
authors: [data.pubkey], authors: [data.pubkey],
'#d': [data.identifier], "#d": [data.identifier],
}; };
const subscriptionId = `sub-${Date.now()}`; const subscriptionId = `sub-${Date.now()}`;
ws.send(JSON.stringify(['REQ', subscriptionId, filter])); ws.send(JSON.stringify(["REQ", subscriptionId, filter]));
}); });
ws.on('message', (message) => { ws.on("message", (message) => {
const [type, subId, event] = JSON.parse(message.toString()); const [type, subId, event] = JSON.parse(message.toString());
if (type === 'EVENT') { if (type === "EVENT") {
events.push(event); events.push(event);
console.log('Found index event:', event.id); console.log("Found index event:", event.id);
console.log('\nTags:'); console.log("\nTags:");
event.tags.forEach(tag => { event.tags.forEach((tag) => {
if (tag[0] === 'a') { if (tag[0] === "a") {
console.log(` Section address: ${tag[1]}`); console.log(` Section address: ${tag[1]}`);
} }
if (tag[0] === 'd') { if (tag[0] === "d") {
console.log(` D-tag: ${tag[1]}`); console.log(` D-tag: ${tag[1]}`);
} }
if (tag[0] === 'title') { if (tag[0] === "title") {
console.log(` Title: ${tag[1]}`); console.log(` Title: ${tag[1]}`);
} }
}); });
} else if (type === 'EOSE') { } else if (type === "EOSE") {
ws.close(); ws.close();
resolve(events); resolve(events);
} }
}); });
ws.on('error', reject); ws.on("error", reject);
setTimeout(() => { setTimeout(() => {
ws.close(); ws.close();
@ -67,5 +68,5 @@ async function fetchPublication() {
} }
fetchPublication() fetchPublication()
.then(() => console.log('\nDone!')) .then(() => console.log("\nDone!"))
.catch(console.error); .catch(console.error);

117
create-test-comments.js

@ -1,5 +1,5 @@
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'; import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import WebSocket from 'ws'; import WebSocket from "ws";
// Test user keys (generate fresh ones) // Test user keys (generate fresh ones)
const testUserKey = generateSecretKey(); const testUserKey = generateSecretKey();
@ -8,12 +8,14 @@ const testUserPubkey = getPublicKey(testUserKey);
const testUser2Key = generateSecretKey(); const testUser2Key = generateSecretKey();
const testUser2Pubkey = getPublicKey(testUser2Key); const testUser2Pubkey = getPublicKey(testUser2Key);
console.log('Test User 1 pubkey:', testUserPubkey); console.log("Test User 1 pubkey:", testUserPubkey);
console.log('Test User 2 pubkey:', testUser2Pubkey); console.log("Test User 2 pubkey:", testUser2Pubkey);
// The publication details from the article (REAL VALUES) // The publication details from the article (REAL VALUES)
const publicationPubkey = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06'; const publicationPubkey =
const rootAddress = `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`; "dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06";
const rootAddress =
`30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`;
// Section addresses (from the actual publication structure) // Section addresses (from the actual publication structure)
const sections = [ const sections = [
@ -25,15 +27,16 @@ const sections = [
// Relays to publish to (matching CommentLayer's relay list) // Relays to publish to (matching CommentLayer's relay list)
const relays = [ const relays = [
'wss://relay.damus.io', "wss://relay.damus.io",
'wss://relay.nostr.band', "wss://relay.nostr.band",
'wss://nostr.wine', "wss://nostr.wine",
]; ];
// Test comments to create // Test comments to create
const testComments = [ const testComments = [
{ {
content: 'This is a fascinating exploration of how knowledge naturally resists institutional capture. The analogy to flowing water is particularly apt.', content:
"This is a fascinating exploration of how knowledge naturally resists institutional capture. The analogy to flowing water is particularly apt.",
targetAddress: sections[0], targetAddress: sections[0],
targetKind: 30041, targetKind: 30041,
author: testUserKey, author: testUserKey,
@ -41,7 +44,8 @@ const testComments = [
isReply: false, isReply: false,
}, },
{ {
content: 'I love this concept! It reminds me of how open source projects naturally organize without top-down control.', content:
"I love this concept! It reminds me of how open source projects naturally organize without top-down control.",
targetAddress: sections[0], targetAddress: sections[0],
targetKind: 30041, targetKind: 30041,
author: testUser2Key, author: testUser2Key,
@ -49,7 +53,8 @@ const testComments = [
isReply: false, isReply: false,
}, },
{ {
content: 'The section on institutional capture really resonates with my experience in academia.', content:
"The section on institutional capture really resonates with my experience in academia.",
targetAddress: sections[1], targetAddress: sections[1],
targetKind: 30041, targetKind: 30041,
author: testUserKey, author: testUserKey,
@ -57,7 +62,8 @@ const testComments = [
isReply: false, isReply: false,
}, },
{ {
content: 'Excellent point about underground networks of understanding. This is exactly how most practical knowledge develops.', content:
"Excellent point about underground networks of understanding. This is exactly how most practical knowledge develops.",
targetAddress: sections[2], targetAddress: sections[2],
targetKind: 30041, targetKind: 30041,
author: testUser2Key, author: testUser2Key,
@ -65,7 +71,8 @@ const testComments = [
isReply: false, isReply: false,
}, },
{ {
content: 'This is a brilliant piece of work! Really captures the tension between institutional knowledge and living understanding.', content:
"This is a brilliant piece of work! Really captures the tension between institutional knowledge and living understanding.",
targetAddress: rootAddress, targetAddress: rootAddress,
targetKind: 30040, targetKind: 30040,
author: testUserKey, author: testUserKey,
@ -79,16 +86,18 @@ async function publishEvent(event, relayUrl) {
const ws = new WebSocket(relayUrl); const ws = new WebSocket(relayUrl);
let published = false; let published = false;
ws.on('open', () => { ws.on("open", () => {
console.log(`Connected to ${relayUrl}`); console.log(`Connected to ${relayUrl}`);
ws.send(JSON.stringify(['EVENT', event])); ws.send(JSON.stringify(["EVENT", event]));
}); });
ws.on('message', (data) => { ws.on("message", (data) => {
const message = JSON.parse(data.toString()); const message = JSON.parse(data.toString());
if (message[0] === 'OK' && message[1] === event.id) { if (message[0] === "OK" && message[1] === event.id) {
if (message[2]) { if (message[2]) {
console.log(`✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`); console.log(
`✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`,
);
published = true; published = true;
ws.close(); ws.close();
resolve(); resolve();
@ -100,14 +109,14 @@ async function publishEvent(event, relayUrl) {
} }
}); });
ws.on('error', (error) => { ws.on("error", (error) => {
console.error(`WebSocket error: ${error.message}`); console.error(`WebSocket error: ${error.message}`);
reject(error); reject(error);
}); });
ws.on('close', () => { ws.on("close", () => {
if (!published) { if (!published) {
reject(new Error('Connection closed before OK received')); reject(new Error("Connection closed before OK received"));
} }
}); });
@ -115,14 +124,14 @@ async function publishEvent(event, relayUrl) {
setTimeout(() => { setTimeout(() => {
if (!published) { if (!published) {
ws.close(); ws.close();
reject(new Error('Timeout')); reject(new Error("Timeout"));
} }
}, 10000); }, 10000);
}); });
} }
async function createAndPublishComments() { async function createAndPublishComments() {
console.log('\n=== Creating Test Comments ===\n'); console.log("\n=== Creating Test Comments ===\n");
const publishedEvents = []; const publishedEvents = [];
@ -134,14 +143,14 @@ async function createAndPublishComments() {
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
// Root scope - uppercase tags // Root scope - uppercase tags
['A', comment.targetAddress, relays[0], publicationPubkey], ["A", comment.targetAddress, relays[0], publicationPubkey],
['K', comment.targetKind.toString()], ["K", comment.targetKind.toString()],
['P', publicationPubkey, relays[0]], ["P", publicationPubkey, relays[0]],
// Parent scope - lowercase tags // Parent scope - lowercase tags
['a', comment.targetAddress, relays[0]], ["a", comment.targetAddress, relays[0]],
['k', comment.targetKind.toString()], ["k", comment.targetKind.toString()],
['p', publicationPubkey, relays[0]], ["p", publicationPubkey, relays[0]],
], ],
content: comment.content, content: comment.content,
pubkey: comment.authorPubkey, pubkey: comment.authorPubkey,
@ -149,14 +158,18 @@ async function createAndPublishComments() {
// If this is a reply, add reply tags // If this is a reply, add reply tags
if (comment.isReply && comment.replyToId) { if (comment.isReply && comment.replyToId) {
unsignedEvent.tags.push(['e', comment.replyToId, relay, 'reply']); unsignedEvent.tags.push(["e", comment.replyToId, relay, "reply"]);
unsignedEvent.tags.push(['p', comment.replyToAuthor, relay]); unsignedEvent.tags.push(["p", comment.replyToAuthor, relay]);
} }
// Sign the event // Sign the event
const signedEvent = finalizeEvent(unsignedEvent, comment.author); const signedEvent = finalizeEvent(unsignedEvent, comment.author);
console.log(`\nCreating comment on ${comment.targetKind === 30040 ? 'collection' : 'section'}:`); console.log(
`\nCreating comment on ${
comment.targetKind === 30040 ? "collection" : "section"
}:`,
);
console.log(` Content: "${comment.content.substring(0, 60)}..."`); console.log(` Content: "${comment.content.substring(0, 60)}..."`);
console.log(` Target: ${comment.targetAddress}`); console.log(` Target: ${comment.targetAddress}`);
console.log(` Event ID: ${signedEvent.id}`); console.log(` Event ID: ${signedEvent.id}`);
@ -169,19 +182,19 @@ async function createAndPublishComments() {
comment.eventId = signedEvent.id; comment.eventId = signedEvent.id;
// Delay between publishes to avoid rate limiting // Delay between publishes to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) { } catch (error) {
console.error(`Failed to publish comment: ${error.message}`); console.error(`Failed to publish comment: ${error.message}`);
} }
} }
// Now create some threaded replies // Now create some threaded replies
console.log('\n=== Creating Threaded Replies ===\n'); console.log("\n=== Creating Threaded Replies ===\n");
const replies = [ const replies = [
{ {
content: 'Absolutely agree! The metaphor extends even further when you consider how ideas naturally branch and merge.', content:
"Absolutely agree! The metaphor extends even further when you consider how ideas naturally branch and merge.",
targetAddress: sections[0], targetAddress: sections[0],
targetKind: 30041, targetKind: 30041,
author: testUser2Key, author: testUser2Key,
@ -191,7 +204,8 @@ async function createAndPublishComments() {
replyToAuthor: testComments[0].authorPubkey, replyToAuthor: testComments[0].authorPubkey,
}, },
{ {
content: 'Great connection! The parallel between open source governance and knowledge commons is really illuminating.', content:
"Great connection! The parallel between open source governance and knowledge commons is really illuminating.",
targetAddress: sections[0], targetAddress: sections[0],
targetKind: 30041, targetKind: 30041,
author: testUserKey, author: testUserKey,
@ -209,17 +223,17 @@ async function createAndPublishComments() {
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
// Root scope // Root scope
['A', reply.targetAddress, relays[0], publicationPubkey], ["A", reply.targetAddress, relays[0], publicationPubkey],
['K', reply.targetKind.toString()], ["K", reply.targetKind.toString()],
['P', publicationPubkey, relays[0]], ["P", publicationPubkey, relays[0]],
// Parent scope (points to the comment we're replying to) // Parent scope (points to the comment we're replying to)
['a', reply.targetAddress, relays[0]], ["a", reply.targetAddress, relays[0]],
['k', reply.targetKind.toString()], ["k", reply.targetKind.toString()],
['p', reply.replyToAuthor, relays[0]], ["p", reply.replyToAuthor, relays[0]],
// Reply markers // Reply markers
['e', reply.replyToId, relays[0], 'reply'], ["e", reply.replyToId, relays[0], "reply"],
], ],
content: reply.content, content: reply.content,
pubkey: reply.authorPubkey, pubkey: reply.authorPubkey,
@ -233,16 +247,19 @@ async function createAndPublishComments() {
console.log(` Event ID: ${signedEvent.id}`); console.log(` Event ID: ${signedEvent.id}`);
await publishEvent(signedEvent, relays[0]); await publishEvent(signedEvent, relays[0]);
await new Promise(resolve => setTimeout(resolve, 1000)); // Longer delay to avoid rate limiting await new Promise((resolve) => setTimeout(resolve, 1000)); // Longer delay to avoid rate limiting
} catch (error) { } catch (error) {
console.error(`Failed to publish reply: ${error.message}`); console.error(`Failed to publish reply: ${error.message}`);
} }
} }
console.log('\n=== Done! ==='); console.log("\n=== Done! ===");
console.log(`\nPublished ${publishedEvents.length + replies.length} total comments/replies`); console.log(
console.log('\nRefresh the page to see the comments in the Comment Panel.'); `\nPublished ${
publishedEvents.length + replies.length
} total comments/replies`,
);
console.log("\nRefresh the page to see the comments in the Comment Panel.");
} }
// Run it // Run it

104
create-test-highlights.js

@ -1,5 +1,5 @@
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'; import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import WebSocket from 'ws'; import WebSocket from "ws";
// Test user keys (generate fresh ones) // Test user keys (generate fresh ones)
const testUserKey = generateSecretKey(); const testUserKey = generateSecretKey();
@ -8,12 +8,14 @@ const testUserPubkey = getPublicKey(testUserKey);
const testUser2Key = generateSecretKey(); const testUser2Key = generateSecretKey();
const testUser2Pubkey = getPublicKey(testUser2Key); const testUser2Pubkey = getPublicKey(testUser2Key);
console.log('Test User 1 pubkey:', testUserPubkey); console.log("Test User 1 pubkey:", testUserPubkey);
console.log('Test User 2 pubkey:', testUser2Pubkey); console.log("Test User 2 pubkey:", testUser2Pubkey);
// The publication details from the article (REAL VALUES) // The publication details from the article (REAL VALUES)
const publicationPubkey = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06'; const publicationPubkey =
const rootAddress = `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`; "dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06";
const rootAddress =
`30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`;
// Section addresses (from the actual publication structure) // Section addresses (from the actual publication structure)
const sections = [ const sections = [
@ -25,9 +27,9 @@ const sections = [
// Relays to publish to (matching HighlightLayer's relay list) // Relays to publish to (matching HighlightLayer's relay list)
const relays = [ const relays = [
'wss://relay.damus.io', "wss://relay.damus.io",
'wss://relay.nostr.band', "wss://relay.nostr.band",
'wss://nostr.wine', "wss://nostr.wine",
]; ];
// Test highlights to create // Test highlights to create
@ -35,40 +37,53 @@ const relays = [
// and optionally a user comment/annotation in the ["comment", ...] tag // and optionally a user comment/annotation in the ["comment", ...] tag
const testHighlights = [ const testHighlights = [
{ {
highlightedText: 'Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice.', highlightedText:
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.', "Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice.",
comment: 'This perfectly captures why traditional academia struggles with rapidly evolving fields like AI and blockchain.', 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], targetAddress: sections[0],
author: testUserKey, author: testUserKey,
authorPubkey: testUserPubkey, 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.', highlightedText:
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.', "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 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], targetAddress: sections[0],
author: testUser2Key, author: testUser2Key,
authorPubkey: testUser2Pubkey, authorPubkey: testUser2Pubkey,
}, },
{ {
highlightedText: 'Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas.', highlightedText:
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.', "Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas.",
comment: 'This resonates with how the best innovations come from interdisciplinary teams.', 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], targetAddress: sections[1],
author: testUserKey, author: testUserKey,
authorPubkey: testUserPubkey, authorPubkey: testUserPubkey,
}, },
{ {
highlightedText: 'The most vibrant intellectual communities have always been those at crossroads and borderlands.', highlightedText:
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.', "The most vibrant intellectual communities have always been those at crossroads and borderlands.",
comment: 'Historical examples: Renaissance Florence, Vienna Circle, Bell Labs', 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], targetAddress: sections[1],
author: testUser2Key, author: testUser2Key,
authorPubkey: testUser2Pubkey, authorPubkey: testUser2Pubkey,
}, },
{ {
highlightedText: 'institutions that try to monopolize understanding inevitably find themselves gatekeeping corpses', highlightedText:
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.', "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, comment: null,
targetAddress: sections[2], targetAddress: sections[2],
author: testUserKey, author: testUserKey,
@ -81,16 +96,18 @@ async function publishEvent(event, relayUrl) {
const ws = new WebSocket(relayUrl); const ws = new WebSocket(relayUrl);
let published = false; let published = false;
ws.on('open', () => { ws.on("open", () => {
console.log(`Connected to ${relayUrl}`); console.log(`Connected to ${relayUrl}`);
ws.send(JSON.stringify(['EVENT', event])); ws.send(JSON.stringify(["EVENT", event]));
}); });
ws.on('message', (data) => { ws.on("message", (data) => {
const message = JSON.parse(data.toString()); const message = JSON.parse(data.toString());
if (message[0] === 'OK' && message[1] === event.id) { if (message[0] === "OK" && message[1] === event.id) {
if (message[2]) { if (message[2]) {
console.log(`✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`); console.log(
`✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`,
);
published = true; published = true;
ws.close(); ws.close();
resolve(); resolve();
@ -102,14 +119,14 @@ async function publishEvent(event, relayUrl) {
} }
}); });
ws.on('error', (error) => { ws.on("error", (error) => {
console.error(`WebSocket error: ${error.message}`); console.error(`WebSocket error: ${error.message}`);
reject(error); reject(error);
}); });
ws.on('close', () => { ws.on("close", () => {
if (!published) { if (!published) {
reject(new Error('Connection closed before OK received')); reject(new Error("Connection closed before OK received"));
} }
}); });
@ -117,14 +134,14 @@ async function publishEvent(event, relayUrl) {
setTimeout(() => { setTimeout(() => {
if (!published) { if (!published) {
ws.close(); ws.close();
reject(new Error('Timeout')); reject(new Error("Timeout"));
} }
}, 10000); }, 10000);
}); });
} }
async function createAndPublishHighlights() { async function createAndPublishHighlights() {
console.log('\n=== Creating Test Highlights ===\n'); console.log("\n=== Creating Test Highlights ===\n");
const publishedEvents = []; const publishedEvents = [];
@ -138,28 +155,30 @@ async function createAndPublishHighlights() {
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
// Target section // Target section
['a', highlight.targetAddress, relays[0]], ["a", highlight.targetAddress, relays[0]],
// Surrounding context (helps locate the highlight) // Surrounding context (helps locate the highlight)
['context', highlight.context], ["context", highlight.context],
// Original publication author // Original publication author
['p', publicationPubkey, relays[0], 'author'], ["p", publicationPubkey, relays[0], "author"],
], ],
content: highlight.highlightedText, // The actual highlighted text content: highlight.highlightedText, // The actual highlighted text
pubkey: highlight.authorPubkey, pubkey: highlight.authorPubkey,
}; };
// Add optional comment/annotation if present // Add optional comment/annotation if present
if (highlight.comment) { if (highlight.comment) {
unsignedEvent.tags.push(['comment', highlight.comment]); unsignedEvent.tags.push(["comment", highlight.comment]);
} }
// Sign the event // Sign the event
const signedEvent = finalizeEvent(unsignedEvent, highlight.author); const signedEvent = finalizeEvent(unsignedEvent, highlight.author);
console.log(`\nCreating highlight on section:`); console.log(`\nCreating highlight on section:`);
console.log(` Highlighted: "${highlight.highlightedText.substring(0, 60)}..."`); console.log(
` Highlighted: "${highlight.highlightedText.substring(0, 60)}..."`,
);
if (highlight.comment) { if (highlight.comment) {
console.log(` Comment: "${highlight.comment.substring(0, 60)}..."`); console.log(` Comment: "${highlight.comment.substring(0, 60)}..."`);
} }
@ -171,16 +190,15 @@ async function createAndPublishHighlights() {
publishedEvents.push(signedEvent); publishedEvents.push(signedEvent);
// Delay between publishes to avoid rate limiting // Delay between publishes to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) { } catch (error) {
console.error(`Failed to publish highlight: ${error.message}`); console.error(`Failed to publish highlight: ${error.message}`);
} }
} }
console.log('\n=== Done! ==='); console.log("\n=== Done! ===");
console.log(`\nPublished ${publishedEvents.length} total highlights`); console.log(`\nPublished ${publishedEvents.length} total highlights`);
console.log('\nRefresh the page to see the highlights.'); console.log("\nRefresh the page to see the highlights.");
console.log('Toggle "Show Highlights" to view them inline.'); console.log('Toggle "Show Highlights" to view them inline.');
} }

193
deno.lock

@ -62,6 +62,7 @@
"npm:typescript@^5.8.3": "5.9.2", "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: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: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" "npm:yaml@^2.5.0": "2.8.1"
}, },
"jsr": { "jsr": {
@ -326,261 +327,131 @@
"tslib" "tslib"
] ]
}, },
"@esbuild/aix-ppc64@0.25.7": {
"integrity": "sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==",
"os": ["aix"],
"cpu": ["ppc64"]
},
"@esbuild/aix-ppc64@0.25.9": { "@esbuild/aix-ppc64@0.25.9": {
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
"os": ["aix"], "os": ["aix"],
"cpu": ["ppc64"] "cpu": ["ppc64"]
}, },
"@esbuild/android-arm64@0.25.7": {
"integrity": "sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==",
"os": ["android"],
"cpu": ["arm64"]
},
"@esbuild/android-arm64@0.25.9": { "@esbuild/android-arm64@0.25.9": {
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
"os": ["android"], "os": ["android"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/android-arm@0.25.7": {
"integrity": "sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==",
"os": ["android"],
"cpu": ["arm"]
},
"@esbuild/android-arm@0.25.9": { "@esbuild/android-arm@0.25.9": {
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
"os": ["android"], "os": ["android"],
"cpu": ["arm"] "cpu": ["arm"]
}, },
"@esbuild/android-x64@0.25.7": {
"integrity": "sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==",
"os": ["android"],
"cpu": ["x64"]
},
"@esbuild/android-x64@0.25.9": { "@esbuild/android-x64@0.25.9": {
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
"os": ["android"], "os": ["android"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/darwin-arm64@0.25.7": {
"integrity": "sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@esbuild/darwin-arm64@0.25.9": { "@esbuild/darwin-arm64@0.25.9": {
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
"os": ["darwin"], "os": ["darwin"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/darwin-x64@0.25.7": {
"integrity": "sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@esbuild/darwin-x64@0.25.9": { "@esbuild/darwin-x64@0.25.9": {
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
"os": ["darwin"], "os": ["darwin"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/freebsd-arm64@0.25.7": {
"integrity": "sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
"@esbuild/freebsd-arm64@0.25.9": { "@esbuild/freebsd-arm64@0.25.9": {
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
"os": ["freebsd"], "os": ["freebsd"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/freebsd-x64@0.25.7": {
"integrity": "sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@esbuild/freebsd-x64@0.25.9": { "@esbuild/freebsd-x64@0.25.9": {
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
"os": ["freebsd"], "os": ["freebsd"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/linux-arm64@0.25.7": {
"integrity": "sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@esbuild/linux-arm64@0.25.9": { "@esbuild/linux-arm64@0.25.9": {
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/linux-arm@0.25.7": {
"integrity": "sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==",
"os": ["linux"],
"cpu": ["arm"]
},
"@esbuild/linux-arm@0.25.9": { "@esbuild/linux-arm@0.25.9": {
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm"] "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": { "@esbuild/linux-ia32@0.25.9": {
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
"os": ["linux"], "os": ["linux"],
"cpu": ["ia32"] "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": { "@esbuild/linux-loong64@0.25.9": {
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
"os": ["linux"], "os": ["linux"],
"cpu": ["loong64"] "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": { "@esbuild/linux-mips64el@0.25.9": {
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
"os": ["linux"], "os": ["linux"],
"cpu": ["mips64el"] "cpu": ["mips64el"]
}, },
"@esbuild/linux-ppc64@0.25.7": {
"integrity": "sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@esbuild/linux-ppc64@0.25.9": { "@esbuild/linux-ppc64@0.25.9": {
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
"os": ["linux"], "os": ["linux"],
"cpu": ["ppc64"] "cpu": ["ppc64"]
}, },
"@esbuild/linux-riscv64@0.25.7": {
"integrity": "sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@esbuild/linux-riscv64@0.25.9": { "@esbuild/linux-riscv64@0.25.9": {
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
"os": ["linux"], "os": ["linux"],
"cpu": ["riscv64"] "cpu": ["riscv64"]
}, },
"@esbuild/linux-s390x@0.25.7": {
"integrity": "sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@esbuild/linux-s390x@0.25.9": { "@esbuild/linux-s390x@0.25.9": {
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
"os": ["linux"], "os": ["linux"],
"cpu": ["s390x"] "cpu": ["s390x"]
}, },
"@esbuild/linux-x64@0.25.7": {
"integrity": "sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==",
"os": ["linux"],
"cpu": ["x64"]
},
"@esbuild/linux-x64@0.25.9": { "@esbuild/linux-x64@0.25.9": {
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
"os": ["linux"], "os": ["linux"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/netbsd-arm64@0.25.7": {
"integrity": "sha512-bwXGEU4ua45+u5Ci/a55B85KWaDSRS8NPOHtxy2e3etDjbz23wlry37Ffzapz69JAGGc4089TBo+dGzydQmydg==",
"os": ["netbsd"],
"cpu": ["arm64"]
},
"@esbuild/netbsd-arm64@0.25.9": { "@esbuild/netbsd-arm64@0.25.9": {
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
"os": ["netbsd"], "os": ["netbsd"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/netbsd-x64@0.25.7": {
"integrity": "sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==",
"os": ["netbsd"],
"cpu": ["x64"]
},
"@esbuild/netbsd-x64@0.25.9": { "@esbuild/netbsd-x64@0.25.9": {
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
"os": ["netbsd"], "os": ["netbsd"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/openbsd-arm64@0.25.7": {
"integrity": "sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==",
"os": ["openbsd"],
"cpu": ["arm64"]
},
"@esbuild/openbsd-arm64@0.25.9": { "@esbuild/openbsd-arm64@0.25.9": {
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
"os": ["openbsd"], "os": ["openbsd"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/openbsd-x64@0.25.7": {
"integrity": "sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==",
"os": ["openbsd"],
"cpu": ["x64"]
},
"@esbuild/openbsd-x64@0.25.9": { "@esbuild/openbsd-x64@0.25.9": {
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
"os": ["openbsd"], "os": ["openbsd"],
"cpu": ["x64"] "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": { "@esbuild/openharmony-arm64@0.25.9": {
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
"os": ["openharmony"], "os": ["openharmony"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/sunos-x64@0.25.7": {
"integrity": "sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==",
"os": ["sunos"],
"cpu": ["x64"]
},
"@esbuild/sunos-x64@0.25.9": { "@esbuild/sunos-x64@0.25.9": {
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
"os": ["sunos"], "os": ["sunos"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@esbuild/win32-arm64@0.25.7": {
"integrity": "sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@esbuild/win32-arm64@0.25.9": { "@esbuild/win32-arm64@0.25.9": {
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
"os": ["win32"], "os": ["win32"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@esbuild/win32-ia32@0.25.7": {
"integrity": "sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@esbuild/win32-ia32@0.25.9": { "@esbuild/win32-ia32@0.25.9": {
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
"os": ["win32"], "os": ["win32"],
"cpu": ["ia32"] "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": { "@esbuild/win32-x64@0.25.9": {
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
"os": ["win32"], "os": ["win32"],
@ -2128,32 +1999,32 @@
"esbuild@0.25.9": { "esbuild@0.25.9": {
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"optionalDependencies": [ "optionalDependencies": [
"@esbuild/aix-ppc64@0.25.9", "@esbuild/aix-ppc64",
"@esbuild/android-arm@0.25.9", "@esbuild/android-arm",
"@esbuild/android-arm64@0.25.9", "@esbuild/android-arm64",
"@esbuild/android-x64@0.25.9", "@esbuild/android-x64",
"@esbuild/darwin-arm64@0.25.9", "@esbuild/darwin-arm64",
"@esbuild/darwin-x64@0.25.9", "@esbuild/darwin-x64",
"@esbuild/freebsd-arm64@0.25.9", "@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64@0.25.9", "@esbuild/freebsd-x64",
"@esbuild/linux-arm@0.25.9", "@esbuild/linux-arm",
"@esbuild/linux-arm64@0.25.9", "@esbuild/linux-arm64",
"@esbuild/linux-ia32@0.25.9", "@esbuild/linux-ia32",
"@esbuild/linux-loong64@0.25.9", "@esbuild/linux-loong64",
"@esbuild/linux-mips64el@0.25.9", "@esbuild/linux-mips64el",
"@esbuild/linux-ppc64@0.25.9", "@esbuild/linux-ppc64",
"@esbuild/linux-riscv64@0.25.9", "@esbuild/linux-riscv64",
"@esbuild/linux-s390x@0.25.9", "@esbuild/linux-s390x",
"@esbuild/linux-x64@0.25.9", "@esbuild/linux-x64",
"@esbuild/netbsd-arm64@0.25.9", "@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64@0.25.9", "@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64@0.25.9", "@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64@0.25.9", "@esbuild/openbsd-x64",
"@esbuild/openharmony-arm64@0.25.9", "@esbuild/openharmony-arm64",
"@esbuild/sunos-x64@0.25.9", "@esbuild/sunos-x64",
"@esbuild/win32-arm64@0.25.9", "@esbuild/win32-arm64",
"@esbuild/win32-ia32@0.25.9", "@esbuild/win32-ia32",
"@esbuild/win32-x64@0.25.9" "@esbuild/win32-x64"
], ],
"scripts": true, "scripts": true,
"bin": true "bin": true
@ -3650,6 +3521,9 @@
"wrappy@1.0.2": { "wrappy@1.0.2": {
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
}, },
"ws@8.18.3": {
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
},
"y18n@4.0.3": { "y18n@4.0.3": {
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
}, },
@ -3835,6 +3709,11 @@
}, },
"workspace": { "workspace": {
"dependencies": [ "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/curves@^1.9.4",
"npm:@noble/hashes@^1.8.0", "npm:@noble/hashes@^1.8.0",
"npm:@nostr-dev-kit/ndk-cache-dexie@2.6", "npm:@nostr-dev-kit/ndk-cache-dexie@2.6",
@ -3845,6 +3724,7 @@
"npm:@tailwindcss/typography@0.5", "npm:@tailwindcss/typography@0.5",
"npm:asciidoctor@3.0", "npm:asciidoctor@3.0",
"npm:bech32@2", "npm:bech32@2",
"npm:codemirror@^6.0.2",
"npm:d3@^7.9.0", "npm:d3@^7.9.0",
"npm:flowbite-svelte-icons@2.1", "npm:flowbite-svelte-icons@2.1",
"npm:flowbite-svelte@0.48", "npm:flowbite-svelte@0.48",
@ -3918,6 +3798,7 @@
"npm:typescript@^5.8.3", "npm:typescript@^5.8.3",
"npm:vite@^6.3.5", "npm:vite@^6.3.5",
"npm:vitest@^3.1.3", "npm:vitest@^3.1.3",
"npm:ws@^8.18.3",
"npm:yaml@^2.5.0" "npm:yaml@^2.5.0"
] ]
} }

60
doc/compose_tree.md

@ -2,33 +2,42 @@
## Overview ## 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 ## Current State Analysis
### Problems Identified ### Problems Identified
1. **Dual Architecture Conflict**: Two competing parsing implementations exist: 1. **Dual Architecture Conflict**: Two competing parsing implementations exist:
- `publication_tree_factory.ts` - AST-first approach (currently used) - `publication_tree_factory.ts` - AST-first approach (currently used)
- `publication_tree_extension.ts` - Extension approach (incomplete) - `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 ## NKBIP-01 Specification Summary
From `test_data/AsciidocFiles/docreference.md`: From `test_data/AsciidocFiles/docreference.md`:
### Event Types ### Event Types
- **30040**: Index events (collections/hierarchical containers) - **30040**: Index events (collections/hierarchical containers)
- **30041**: Content events (actual article sections) - **30041**: Content events (actual article sections)
### Parse Level Behaviors ### 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 - **Level 4+**: Full hierarchy with each level becoming separate events
### Key Rules ### Key Rules
1. If a section has subsections at target level → becomes 30040 index 1. If a section has subsections at target level → becomes 30040 index
2. If no subsections at target level → becomes 30041 content event 2. If no subsections at target level → becomes 30041 content event
3. Content inclusion: 30041 events include all content below parse level 3. Content inclusion: 30041 events include all content below parse level
@ -44,13 +53,13 @@ Following the pattern you provided:
// Extension registration pattern // Extension registration pattern
module.exports = function (registry) { module.exports = function (registry) {
registry.treeProcessor(function () { registry.treeProcessor(function () {
var self = this var self = this;
self.process(function (doc) { self.process(function (doc) {
// Process document and build PublicationTree // Process document and build PublicationTree
return doc return doc;
}) });
}) });
} };
``` ```
### Implementation Components ### Implementation Components
@ -80,11 +89,12 @@ export function registerPublicationTreeProcessor(
registry: Registry, registry: Registry,
ndk: NDK, ndk: NDK,
parseLevel: number, parseLevel: number,
options?: ProcessorOptions options?: ProcessorOptions,
): { getResult: () => ProcessorResult | null } ): { getResult: () => ProcessorResult | null };
``` ```
**Key Features:** **Key Features:**
- Follows Asciidoctor extension pattern exactly - Follows Asciidoctor extension pattern exactly
- Builds events during AST traversal (not after) - Builds events during AST traversal (not after)
- Preserves original AsciiDoc content in events - Preserves original AsciiDoc content in events
@ -97,11 +107,12 @@ export function registerPublicationTreeProcessor(
export async function parseAsciiDocWithTree( export async function parseAsciiDocWithTree(
content: string, content: string,
ndk: NDK, ndk: NDK,
parseLevel: number = 2 parseLevel: number = 2,
): Promise<PublicationTreeResult> ): Promise<PublicationTreeResult>;
``` ```
**Responsibilities:** **Responsibilities:**
- Create Asciidoctor instance - Create Asciidoctor instance
- Register tree processor extension - Register tree processor extension
- Execute parsing with extension - Execute parsing with extension
@ -111,6 +122,7 @@ export async function parseAsciiDocWithTree(
### Phase 3: ZettelEditor Integration ### Phase 3: ZettelEditor Integration
**Changes to `ZettelEditor.svelte`:** **Changes to `ZettelEditor.svelte`:**
- Replace `createPublicationTreeFromContent()` calls - Replace `createPublicationTreeFromContent()` calls
- Use new `parseAsciiDocWithTree()` function - Use new `parseAsciiDocWithTree()` function
- Maintain existing preview/publishing interface - Maintain existing preview/publishing interface
@ -119,6 +131,7 @@ export async function parseAsciiDocWithTree(
### Phase 4: Validation Testing ### Phase 4: Validation Testing
**Test Suite:** **Test Suite:**
1. Parse `deep_hierarchy_test.adoc` at levels 2-7 1. Parse `deep_hierarchy_test.adoc` at levels 2-7
2. Verify event structures match `docreference.md` examples 2. Verify event structures match `docreference.md` examples
3. Validate content preservation and tag inheritance 3. Validate content preservation and tag inheritance
@ -127,23 +140,29 @@ export async function parseAsciiDocWithTree(
## File Organization ## File Organization
### Files to Create ### Files to Create
1. `src/lib/utils/publication_tree_processor.ts` - Core tree processor extension 1. `src/lib/utils/publication_tree_processor.ts` - Core tree processor extension
2. `src/lib/utils/asciidoc_publication_parser.ts` - Unified parser interface 2. `src/lib/utils/asciidoc_publication_parser.ts` - Unified parser interface
3. `tests/unit/publication_tree_processor.test.ts` - Comprehensive test suite 3. `tests/unit/publication_tree_processor.test.ts` - Comprehensive test suite
### Files to Modify ### Files to Modify
1. `src/lib/components/ZettelEditor.svelte` - Update parsing calls 1. `src/lib/components/ZettelEditor.svelte` - Update parsing calls
2. `src/routes/new/compose/+page.svelte` - Verify integration works 2. `src/routes/new/compose/+page.svelte` - Verify integration works
### Files to Remove (After Validation) ### Files to Remove (After Validation)
1. `src/lib/utils/publication_tree_factory.ts` - Replace with processor 1. `src/lib/utils/publication_tree_factory.ts` - Replace with processor
2. `src/lib/utils/publication_tree_extension.ts` - Merge concepts into processor 2. `src/lib/utils/publication_tree_extension.ts` - Merge concepts into processor
## Success Criteria ## Success Criteria
1. **NKBIP-01 Compliance**: All parse levels produce structures exactly matching `docreference.md` 1. **NKBIP-01 Compliance**: All parse levels produce structures exactly matching
2. **Content Preservation**: Original AsciiDoc content preserved in events (not converted to HTML) `docreference.md`
3. **Proper Extension Pattern**: Uses official Asciidoctor tree processor registration 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 4. **Zero Regression**: Current ZettelEditor functionality unchanged
5. **Performance**: No degradation in parsing or preview speed 5. **Performance**: No degradation in parsing or preview speed
6. **Test Coverage**: Comprehensive validation with `deep_hierarchy_test.adoc` 6. **Test Coverage**: Comprehensive validation with `deep_hierarchy_test.adoc`
@ -152,7 +171,7 @@ export async function parseAsciiDocWithTree(
1. **Study & Plan** ✓ (Current phase) 1. **Study & Plan** ✓ (Current phase)
2. **Implement Core Processor** - Create `publication_tree_processor.ts` 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 4. **Integrate with ZettelEditor** - Update parsing calls
5. **Validate with Test Documents** - Verify NKBIP-01 compliance 5. **Validate with Test Documents** - Verify NKBIP-01 compliance
6. **Clean Up Legacy Code** - Remove old implementations 6. **Clean Up Legacy Code** - Remove old implementations
@ -169,5 +188,6 @@ export async function parseAsciiDocWithTree(
- NKBIP-01 Specification: `test_data/AsciidocFiles/docreference.md` - NKBIP-01 Specification: `test_data/AsciidocFiles/docreference.md`
- Test Document: `test_data/AsciidocFiles/deep_hierarchy_test.adoc` - Test Document: `test_data/AsciidocFiles/deep_hierarchy_test.adoc`
- Asciidoctor Extensions: [Official Documentation](https://docs.asciidoctor.org/asciidoctor.js/latest/extend/extensions/) - Asciidoctor Extensions:
- Current Implementation: `src/lib/components/ZettelEditor.svelte:64` [Official Documentation](https://docs.asciidoctor.org/asciidoctor.js/latest/extend/extensions/)
- Current Implementation: `src/lib/components/ZettelEditor.svelte:64`

6
import_map.json

@ -1,5 +1,10 @@
{ {
"imports": { "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", "he": "npm:he@1.2.x",
"@nostr-dev-kit/ndk": "npm:@nostr-dev-kit/ndk@^2.14.32", "@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", "@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/postcss": "npm:@tailwindcss/postcss@^4.1.11",
"@tailwindcss/typography": "npm:@tailwindcss/typography@0.5.x", "@tailwindcss/typography": "npm:@tailwindcss/typography@0.5.x",
"asciidoctor": "npm:asciidoctor@3.0.x", "asciidoctor": "npm:asciidoctor@3.0.x",
"codemirror": "npm:codemirror@^6.0.2",
"d3": "npm:d3@^7.9.0", "d3": "npm:d3@^7.9.0",
"nostr-tools": "npm:nostr-tools@2.15.x", "nostr-tools": "npm:nostr-tools@2.15.x",
"tailwind-merge": "npm:tailwind-merge@^3.3.1", "tailwind-merge": "npm:tailwind-merge@^3.3.1",

54
nips/09.md

@ -1,14 +1,16 @@
NIP-09 # NIP-09
======
Event Deletion Request ## Event Deletion Request
----------------------
`draft` `optional` `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. 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. The event's `content` field MAY contain a text note describing the reason for
the deletion request.
For example: For example:
@ -28,26 +30,48 @@ For example:
} }
``` ```
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 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. 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. 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 ## 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. 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. 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 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. 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 ## 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. 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 ## 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. Publishing a deletion request event against a deletion request has no effect.
Clients and relays are not obliged to support "unrequest deletion"
functionality.

124
package-lock.json generated

@ -107,7 +107,6 @@
"resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-3.0.4.tgz",
"integrity": "sha512-41SDMi7iRRBViPe0L6VWFTe55bv6HEOJeRqMj5+E5wB1YPdUPuTucL4UAESPZM6OWmn4t/5qM5LusXomFUVwVQ==", "integrity": "sha512-41SDMi7iRRBViPe0L6VWFTe55bv6HEOJeRqMj5+E5wB1YPdUPuTucL4UAESPZM6OWmn4t/5qM5LusXomFUVwVQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@asciidoctor/opal-runtime": "3.0.1", "@asciidoctor/opal-runtime": "3.0.1",
"unxhr": "1.2.0" "unxhr": "1.2.0"
@ -1176,6 +1175,7 @@
"integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0" "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
} }
@ -1186,6 +1186,7 @@
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@eslint/object-schema": "^2.1.6", "@eslint/object-schema": "^2.1.6",
"debug": "^4.3.1", "debug": "^4.3.1",
@ -1201,6 +1202,7 @@
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
@ -1211,6 +1213,7 @@
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.15" "@types/json-schema": "^7.0.15"
}, },
@ -1224,6 +1227,7 @@
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ajv": "^6.12.4", "ajv": "^6.12.4",
"debug": "^4.3.2", "debug": "^4.3.2",
@ -1248,6 +1252,7 @@
"integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@ -1261,6 +1266,7 @@
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
@ -1271,6 +1277,7 @@
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@eslint/core": "^0.15.2", "@eslint/core": "^0.15.2",
"levn": "^0.4.1" "levn": "^0.4.1"
@ -1313,6 +1320,7 @@
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=18.18.0" "node": ">=18.18.0"
} }
@ -1323,6 +1331,7 @@
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@humanfs/core": "^0.19.1", "@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0" "@humanwhocodes/retry": "^0.4.0"
@ -1337,6 +1346,7 @@
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=12.22" "node": ">=12.22"
}, },
@ -1351,6 +1361,7 @@
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=18.18" "node": ">=18.18"
}, },
@ -2262,7 +2273,6 @@
"integrity": "sha512-H8eXW5TSziSvt9d5IJ5pPyWGhXQLdmq+17H9j7aofA/TsfSvG8ZIpTjObphFRNagfIyoFGyoB3lOzdsGHKiKpw==", "integrity": "sha512-H8eXW5TSziSvt9d5IJ5pPyWGhXQLdmq+17H9j7aofA/TsfSvG8ZIpTjObphFRNagfIyoFGyoB3lOzdsGHKiKpw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
@ -2302,7 +2312,6 @@
"integrity": "sha512-nJsV36+o7rZUDlrnSduMNl11+RoDE1cKqOI0yUEBCcqFoAZOk47TwD3dPKS2WmRutke9StXnzsPBslY7prDM9w==", "integrity": "sha512-nJsV36+o7rZUDlrnSduMNl11+RoDE1cKqOI0yUEBCcqFoAZOk47TwD3dPKS2WmRutke9StXnzsPBslY7prDM9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"debug": "^4.4.1", "debug": "^4.4.1",
@ -2365,7 +2374,6 @@
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/Fuzzyma" "url": "https://github.com/sponsors/Fuzzyma"
@ -2391,7 +2399,6 @@
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 14.18" "node": ">= 14.18"
}, },
@ -3012,7 +3019,8 @@
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/mathjax": { "node_modules/@types/mathjax": {
"version": "0.0.40", "version": "0.0.40",
@ -3191,7 +3199,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -3215,6 +3222,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@ -3256,6 +3264,7 @@
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"license": "ISC", "license": "ISC",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"picomatch": "^2.0.4" "picomatch": "^2.0.4"
@ -3270,6 +3279,7 @@
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
}, },
@ -3297,7 +3307,8 @@
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0",
"peer": true
}, },
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.2", "version": "5.3.2",
@ -3445,6 +3456,7 @@
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
}, },
@ -3458,6 +3470,7 @@
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@ -3469,6 +3482,7 @@
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
}, },
@ -3496,7 +3510,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@ -3556,6 +3569,7 @@
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -3613,6 +3627,7 @@
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-styles": "^4.1.0", "ansi-styles": "^4.1.0",
"supports-color": "^7.1.0" "supports-color": "^7.1.0"
@ -3658,6 +3673,7 @@
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"anymatch": "~3.1.2", "anymatch": "~3.1.2",
"braces": "~3.0.2", "braces": "~3.0.2",
@ -3683,6 +3699,7 @@
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC", "license": "ISC",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
}, },
@ -3860,7 +3877,8 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/constantinople": { "node_modules/constantinople": {
"version": "4.0.1", "version": "4.0.1",
@ -3894,6 +3912,7 @@
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
"shebang-command": "^2.0.0", "shebang-command": "^2.0.0",
@ -4232,7 +4251,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -4369,7 +4387,8 @@
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
@ -4579,6 +4598,7 @@
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@ -4805,6 +4825,7 @@
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"dependencies": { "dependencies": {
"estraverse": "^5.1.0" "estraverse": "^5.1.0"
}, },
@ -4876,21 +4897,24 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/fast-levenshtein": { "node_modules/fast-levenshtein": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
@ -4916,6 +4940,7 @@
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"flat-cache": "^4.0.0" "flat-cache": "^4.0.0"
}, },
@ -4959,6 +4984,7 @@
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
}, },
@ -4972,6 +4998,7 @@
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"locate-path": "^6.0.0", "locate-path": "^6.0.0",
"path-exists": "^4.0.0" "path-exists": "^4.0.0"
@ -4989,6 +5016,7 @@
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"flatted": "^3.2.9", "flatted": "^3.2.9",
"keyv": "^4.5.4" "keyv": "^4.5.4"
@ -5002,7 +5030,8 @@
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC",
"peer": true
}, },
"node_modules/flowbite": { "node_modules/flowbite": {
"version": "2.5.2", "version": "2.5.2",
@ -5224,6 +5253,7 @@
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"is-glob": "^4.0.3" "is-glob": "^4.0.3"
}, },
@ -5258,6 +5288,7 @@
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -5310,6 +5341,7 @@
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -5389,6 +5421,7 @@
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 4" "node": ">= 4"
} }
@ -5399,6 +5432,7 @@
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"parent-module": "^1.0.0", "parent-module": "^1.0.0",
"resolve-from": "^4.0.0" "resolve-from": "^4.0.0"
@ -5416,6 +5450,7 @@
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.8.19" "node": ">=0.8.19"
} }
@ -5452,6 +5487,7 @@
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"binary-extensions": "^2.0.0" "binary-extensions": "^2.0.0"
}, },
@ -5502,6 +5538,7 @@
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -5521,6 +5558,7 @@
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
}, },
@ -5541,6 +5579,7 @@
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
} }
@ -5584,7 +5623,8 @@
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC",
"peer": true
}, },
"node_modules/jake": { "node_modules/jake": {
"version": "10.9.4", "version": "10.9.4",
@ -5631,6 +5671,7 @@
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
}, },
@ -5643,21 +5684,24 @@
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/jstransformer": { "node_modules/jstransformer": {
"version": "1.0.0", "version": "1.0.0",
@ -5675,6 +5719,7 @@
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
@ -5702,6 +5747,7 @@
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prelude-ls": "^1.2.1", "prelude-ls": "^1.2.1",
"type-check": "~0.4.0" "type-check": "~0.4.0"
@ -6005,6 +6051,7 @@
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"p-locate": "^5.0.0" "p-locate": "^5.0.0"
}, },
@ -6076,6 +6123,7 @@
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@ -6141,7 +6189,8 @@
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/neo-async": { "node_modules/neo-async": {
"version": "2.6.2", "version": "2.6.2",
@ -6177,6 +6226,7 @@
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -6196,7 +6246,6 @@
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.15.2.tgz", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.15.2.tgz",
"integrity": "sha512-utmqVVS4HMDiwhIgI6Cr+KqA4aUhF3Sb755iO/qCiqxc5H9JW/9Z3N1RO/jKWpjP6q/Vx0lru7IYuiPvk+2/ng==", "integrity": "sha512-utmqVVS4HMDiwhIgI6Cr+KqA4aUhF3Sb755iO/qCiqxc5H9JW/9Z3N1RO/jKWpjP6q/Vx0lru7IYuiPvk+2/ng==",
"license": "Unlicense", "license": "Unlicense",
"peer": true,
"dependencies": { "dependencies": {
"@noble/ciphers": "^0.5.1", "@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0", "@noble/curves": "1.2.0",
@ -6327,6 +6376,7 @@
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"deep-is": "^0.1.3", "deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6", "fast-levenshtein": "^2.0.6",
@ -6345,6 +6395,7 @@
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"yocto-queue": "^0.1.0" "yocto-queue": "^0.1.0"
}, },
@ -6361,6 +6412,7 @@
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"p-limit": "^3.0.2" "p-limit": "^3.0.2"
}, },
@ -6386,6 +6438,7 @@
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"callsites": "^3.0.0" "callsites": "^3.0.0"
}, },
@ -6408,6 +6461,7 @@
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -6545,7 +6599,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -6696,6 +6749,7 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
@ -6706,7 +6760,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@ -6867,6 +6920,7 @@
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -7022,6 +7076,7 @@
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"picomatch": "^2.2.1" "picomatch": "^2.2.1"
}, },
@ -7035,6 +7090,7 @@
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
}, },
@ -7083,6 +7139,7 @@
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=4" "node": ">=4"
} }
@ -7099,7 +7156,6 @@
"integrity": "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g==", "integrity": "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@ -7193,6 +7249,7 @@
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
}, },
@ -7206,6 +7263,7 @@
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -7308,6 +7366,7 @@
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
}, },
@ -7340,6 +7399,7 @@
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"has-flag": "^4.0.0" "has-flag": "^4.0.0"
}, },
@ -7364,7 +7424,6 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.4.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.4.tgz",
"integrity": "sha512-VU729KzEau1l6d6d25EnRQhdkwwYdTQxQrF8gdUfjZ3dCjrG7VmRMylMxx92ayO9/z5PKWpDrShJdzc4PGW1uA==", "integrity": "sha512-VU729KzEau1l6d6d25EnRQhdkwwYdTQxQrF8gdUfjZ3dCjrG7VmRMylMxx92ayO9/z5PKWpDrShJdzc4PGW1uA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@ -7497,7 +7556,6 @@
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/dcastil" "url": "https://github.com/sponsors/dcastil"
@ -7527,8 +7585,7 @@
"version": "4.1.16", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
@ -7610,6 +7667,7 @@
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
}, },
@ -7652,6 +7710,7 @@
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prelude-ls": "^1.2.1" "prelude-ls": "^1.2.1"
}, },
@ -7665,7 +7724,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -7755,6 +7813,7 @@
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
@ -7771,7 +7830,6 @@
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@ -7978,6 +8036,7 @@
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
}, },
@ -8032,6 +8091,7 @@
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -8102,7 +8162,6 @@
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },
@ -8143,6 +8202,7 @@
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },

153
src/lib/a/cards/AEventPreview.svelte

@ -1,77 +1,77 @@
<script lang="ts"> <script lang="ts">
/** /**
* @fileoverview AEventPreview Component - Alexandria * @fileoverview AEventPreview Component - Alexandria
* *
* A card component for displaying nostr event previews with configurable display options. * A card component for displaying nostr event previews with configurable display options.
* Shows event metadata, content, author information, and action buttons. * Shows event metadata, content, author information, and action buttons.
* *
* @component * @component
* @category Cards * @category Cards
* *
* @prop {NDKEvent} event - The nostr event to display (required) * @prop {NDKEvent} event - The nostr event to display (required)
* @prop {string} [label=""] - Optional label/category for the event * @prop {string} [label=""] - Optional label/category for the event
* @prop {boolean} [community=false] - Whether this is a community event * @prop {boolean} [community=false] - Whether this is a community event
* @prop {number} [truncateContentAt=200] - Character limit for content truncation * @prop {number} [truncateContentAt=200] - Character limit for content truncation
* @prop {boolean} [showKind=true] - Whether to show event kind * @prop {boolean} [showKind=true] - Whether to show event kind
* @prop {boolean} [showSummary=true] - Whether to show event summary * @prop {boolean} [showSummary=true] - Whether to show event summary
* @prop {boolean} [showDeferralNaddr=true] - Whether to show deferral naddr * @prop {boolean} [showDeferralNaddr=true] - Whether to show deferral naddr
* @prop {boolean} [showPublicationLink=true] - Whether to show publication link * @prop {boolean} [showPublicationLink=true] - Whether to show publication link
* @prop {boolean} [showContent=true] - Whether to show event content * @prop {boolean} [showContent=true] - Whether to show event content
* @prop {Array<{label: string, onClick: (ev: NDKEvent) => void, variant?: string}>} [actions] - Action buttons * @prop {Array<{label: string, onClick: (ev: NDKEvent) => void, variant?: string}>} [actions] - Action buttons
* @prop {(ev: NDKEvent) => void} [onSelect] - Callback when event is selected * @prop {(ev: NDKEvent) => void} [onSelect] - Callback when event is selected
* @prop {(naddr: string, ev: NDKEvent) => void} [onDeferralClick] - Callback for deferral clicks * @prop {(naddr: string, ev: NDKEvent) => void} [onDeferralClick] - Callback for deferral clicks
* *
* @example * @example
* ```svelte * ```svelte
* <AEventPreview * <AEventPreview
* {event} * {event}
* label="Article" * label="Article"
* showContent={true} * showContent={true}
* actions={[{label: "View", onClick: handleView}]} * actions={[{label: "View", onClick: handleView}]}
* /> * />
* ``` * ```
* *
* @example Basic event preview * @example Basic event preview
* ```svelte * ```svelte
* <AEventPreview {event} /> * <AEventPreview {event} />
* ``` * ```
* *
* @example Community event with actions * @example Community event with actions
* ```svelte * ```svelte
* <AEventPreview * <AEventPreview
* {event} * {event}
* community={true} * community={true}
* actions={[ * actions={[
* {label: "Reply", onClick: handleReply}, * {label: "Reply", onClick: handleReply},
* {label: "Share", onClick: handleShare, variant: "light"} * {label: "Share", onClick: handleShare, variant: "light"}
* ]} * ]}
* /> * />
* ``` * ```
* *
* @example Minimal preview without content * @example Minimal preview without content
* ```svelte * ```svelte
* <AEventPreview * <AEventPreview
* {event} * {event}
* showContent={false} * showContent={false}
* showSummary={false} * showSummary={false}
* truncateContentAt={100} * truncateContentAt={100}
* /> * />
* ``` * ```
* *
* @features * @features
* - Responsive card layout with author badges * - Responsive card layout with author badges
* - Content truncation with "show more" functionality * - Content truncation with "show more" functionality
* - Publication links and metadata display * - Publication links and metadata display
* - Configurable action buttons * - Configurable action buttons
* - Community event highlighting * - Community event highlighting
* - Event kind and summary display * - Event kind and summary display
* *
* @accessibility * @accessibility
* - Semantic card structure * - Semantic card structure
* - Keyboard accessible action buttons * - Keyboard accessible action buttons
* - Screen reader friendly metadata * - Screen reader friendly metadata
* - Proper heading hierarchy * - Proper heading hierarchy
*/ */
import { Card, Button } from "flowbite-svelte"; import { Card, Button } from "flowbite-svelte";
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte"; import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
@ -198,7 +198,7 @@
<Card <Card
class="event-preview-card" class="event-preview-card"
role="group" role="group"
tabindex="0" tabindex={0}
aria-label="Event preview" aria-label="Event preview"
onclick={handleSelect} onclick={handleSelect}
onkeydown={handleKeydown} onkeydown={handleKeydown}
@ -219,10 +219,7 @@
</span> </span>
{/if} {/if}
{#if community} {#if community}
<span <span class="community-badge" title="Has posted to the community">
class="community-badge"
title="Has posted to the community"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path <path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"

124
src/lib/components/EventDetails.svelte

@ -14,8 +14,8 @@
import { navigateToEvent } from "$lib/utils/nostrEventService"; import { navigateToEvent } from "$lib/utils/nostrEventService";
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte"; import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
import Notifications from "$lib/components/Notifications.svelte"; import Notifications from "$lib/components/Notifications.svelte";
import { import {
repostContent, repostContent,
quotedContent, quotedContent,
} from "$lib/snippets/EmbeddedSnippets.svelte"; } from "$lib/snippets/EmbeddedSnippets.svelte";
import { repostKinds } from "$lib/consts"; import { repostKinds } from "$lib/consts";
@ -41,7 +41,10 @@
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
let showFullContent = $state(false); let showFullContent = $state(false);
let shouldTruncate = $derived(event.content.length > 250 && !showFullContent); let shouldTruncate = $derived(event.content.length > 250 && !showFullContent);
let isRepost = $derived(repostKinds.includes(event.kind) || (event.kind === 1 && event.getMatchingTags("q").length > 0)); let isRepost = $derived(
repostKinds.includes(event.kind) ||
(event.kind === 1 && event.getMatchingTags("q").length > 0),
);
function getEventTitle(event: NDKEvent): string { function getEventTitle(event: NDKEvent): string {
// First try to get title from title tag // First try to get title from title tag
@ -253,13 +256,15 @@
return; return;
} }
getUserMetadata(toNpub(event.pubkey) as string, undefined).then((profile) => { getUserMetadata(toNpub(event.pubkey) as string, undefined).then(
authorDisplayName = (profile) => {
profile.displayName || authorDisplayName =
(profile as any).display_name || profile.displayName ||
profile.name || (profile as any).display_name ||
event.pubkey; profile.name ||
}); event.pubkey;
},
);
}); });
// --- Identifier helpers --- // --- Identifier helpers ---
@ -300,7 +305,11 @@
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` }); ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {} } catch {}
// hex id - make it a clickable link to search for the event ID // hex id - make it a clickable link to search for the event ID
ids.push({ label: "id", value: event.id, link: `/events?id=${event.id}` }); ids.push({
label: "id",
value: event.id,
link: `/events?id=${event.id}`,
});
} }
return ids; return ids;
} }
@ -333,17 +342,17 @@
<div class="flex items-center space-x-2 min-w-0"> <div class="flex items-center space-x-2 min-w-0">
{#if toNpub(event.pubkey)} {#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400 min-w-0" <span class="text-gray-600 dark:text-gray-400 min-w-0"
>Author: {@render userBadge( >Author: {@render userBadge(
toNpub(event.pubkey) || '', toNpub(event.pubkey) || "",
profile?.display_name || undefined, profile?.display_name || undefined,
ndk, ndk,
)}</span )}</span
> >
{:else} {:else}
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words" <span class="text-gray-600 dark:text-gray-400 min-w-0 break-words"
>Author: {profile?.display_name || event.pubkey}</span >Author: {profile?.display_name || event.pubkey}</span
> >
{/if} {/if}
</div> </div>
@ -351,13 +360,15 @@
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span> <span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span>
<span class="font-mono flex-shrink-0">{event.kind}</span> <span class="font-mono flex-shrink-0">{event.kind}</span>
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0" <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"
>({getEventTypeDisplay(event)})</span >({getEventTypeDisplay(event)})</span
> >
</div> </div>
<div class="flex flex-col space-y-1 min-w-0"> <div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300">Summary:</span> <span class="text-gray-700 dark:text-gray-300">Summary:</span>
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0"> <div
class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0"
>
{@render basicMarkup(getEventSummary(event), ndk)} {@render basicMarkup(getEventSummary(event), ndk)}
</div> </div>
</div> </div>
@ -370,29 +381,41 @@
{#if event.kind !== 0} {#if event.kind !== 0}
{@const kind = event.kind} {@const kind = event.kind}
{@const content = event.content.trim()} {@const content = event.content.trim()}
<div class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border max-w-full overflow-hidden"> <div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border max-w-full overflow-hidden"
>
<div class="flex flex-col space-y-1 min-w-0"> <div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span> <span class="text-gray-700 dark:text-gray-300 font-semibold"
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}> >Content:</span
>
<div class={shouldTruncate ? "max-h-32 overflow-hidden" : ""}>
{#if isRepost} {#if isRepost}
<!-- Repost content handling --> <!-- Repost content handling -->
{#if repostKinds.includes(event.kind)} {#if repostKinds.includes(event.kind)}
<!-- Kind 6 and 16 reposts - stringified JSON content --> <!-- Kind 6 and 16 reposts - stringified JSON content -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2"> <div
class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2"
>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{event.kind === 6 ? 'Reposted content:' : 'Generic reposted content:'} {event.kind === 6
? "Reposted content:"
: "Generic reposted content:"}
</div> </div>
{@render repostContent(event.content)} {@render repostContent(event.content)}
</div> </div>
{:else if event.kind === 1 && event.getMatchingTags("q").length > 0} {:else if event.kind === 1 && event.getMatchingTags("q").length > 0}
<!-- Quote repost - kind 1 with q tag --> <!-- Quote repost - kind 1 with q tag -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2"> <div
class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2"
>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Quote repost: Quote repost:
</div> </div>
{@render quotedContent(event, [], ndk)} {@render quotedContent(event, [], ndk)}
{#if content} {#if content}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"> <div
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Added comment: Added comment:
</div> </div>
@ -407,7 +430,7 @@
{/if} {/if}
{:else} {:else}
<!-- Regular content --> <!-- Regular content -->
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}> <div class={shouldTruncate ? "max-h-32 overflow-hidden" : ""}>
{#if repostKinds.includes(kind)} {#if repostKinds.includes(kind)}
{@html content} {@html content}
{:else} {:else}
@ -428,35 +451,46 @@
<!-- If event is profile --> <!-- If event is profile -->
{#if event.kind === 0} {#if event.kind === 0}
<AProfilePreview event={event} profile={profile} communityStatusMap={communityStatusMap} /> <AProfilePreview {event} {profile} {communityStatusMap} />
{/if} {/if}
<ATechBlock> <ATechBlock>
{#snippet content()} {#snippet content()}
<Heading tag="h3" class="h-leather my-6"> <Heading tag="h3" class="h-leather my-6">Technical details</Heading>
Technical details
</Heading>
<Accordion flush class="w-full"> <Accordion flush class="w-full">
<AccordionItem open={false} > <AccordionItem open={false}>
{#snippet header()}Identifiers{/snippet} {#snippet header()}Identifiers{/snippet}
{#if event} {#if event}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each getIdentifiers(event, profile) as identifier} {#each getIdentifiers(event, profile) as identifier}
<div class="grid grid-cols-[max-content_minmax(0,1fr)_max-content] items-start gap-2 min-w-0"> <div
<span class="min-w-24 text-gray-600 dark:text-gray-400">{identifier.label}:</span> class="grid grid-cols-[max-content_minmax(0,1fr)_max-content] items-start gap-2 min-w-0"
>
<span class="min-w-24 text-gray-600 dark:text-gray-400"
>{identifier.label}:</span
>
<div class="min-w-0"> <div class="min-w-0">
{#if identifier.link} {#if identifier.link}
<button class="font-mono text-sm text-primary-700 dark:text-primary-300 hover:text-primary-900 dark:hover:text-primary-100 break-all cursor-pointer bg-transparent border-none p-0 text-left" <button
onclick={() => navigateToIdentifier(identifier.link)}> class="font-mono text-sm text-primary-700 dark:text-primary-300 hover:text-primary-900 dark:hover:text-primary-100 break-all cursor-pointer bg-transparent border-none p-0 text-left"
onclick={() =>
navigateToIdentifier(identifier.link ?? "")}
>
{identifier.value} {identifier.value}
</button> </button>
{:else} {:else}
<span class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all">{identifier.value}</span> <span
class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all"
>{identifier.value}</span
>
{/if} {/if}
</div> </div>
<div class="justify-self-end"> <div class="justify-self-end">
<CopyToClipboard displayText="" copyText={identifier.value} /> <CopyToClipboard
displayText=""
copyText={identifier.value}
/>
</div> </div>
</div> </div>
{/each} {/each}
@ -494,8 +528,10 @@
/> />
</div> </div>
{#if event} {#if event}
<pre class="p-4 wrap-break-word bg-highlight dark:bg-primary-900"> <pre class="p-4 wrap-break-word bg-highlight dark:bg-primary-900">
<code class="text-wrap">{JSON.stringify(event.rawEvent(), null, 2)}</code> <code class="text-wrap"
>{JSON.stringify(event.rawEvent(), null, 2)}</code
>
</pre> </pre>
{/if} {/if}
</AccordionItem> </AccordionItem>

146
src/lib/components/EventKindFilter.svelte

@ -1,41 +1,41 @@
<script lang="ts"> <script lang="ts">
import { visualizationConfig, enabledEventKinds } from '$lib/stores/visualizationConfig'; import {
import { Button, Badge } from 'flowbite-svelte'; visualizationConfig,
import { CloseCircleOutline } from 'flowbite-svelte-icons'; enabledEventKinds,
} from "$lib/stores/visualizationConfig";
import { Button, Badge } from "flowbite-svelte";
import { CloseCircleOutline } from "flowbite-svelte-icons";
import type { EventCounts } from "$lib/types"; import type { EventCounts } from "$lib/types";
import { NostrKind } from '$lib/types'; import { NostrKind } from "$lib/types";
let { let { onReload = () => {}, eventCounts = {} } = $props<{
onReload = () => {},
eventCounts = {}
} = $props<{
onReload?: () => void; onReload?: () => void;
eventCounts?: EventCounts; eventCounts?: EventCounts;
}>(); }>();
let newKind = $state(''); let newKind = $state("");
let showAddInput = $state(false); let showAddInput = $state(false);
let inputError = $state(''); let inputError = $state("");
function validateKind(value: string): number | null { function validateKind(value: string): number | null {
if (!value || value.trim() === '') { if (!value || value.trim() === "") {
inputError = ''; inputError = "";
return null; return null;
} }
const kind = parseInt(value.trim()); const kind = parseInt(value.trim());
if (isNaN(kind)) { if (isNaN(kind)) {
inputError = 'Must be a number'; inputError = "Must be a number";
return null; return null;
} }
if (kind < 0) { if (kind < 0) {
inputError = 'Must be positive'; inputError = "Must be positive";
return null; return null;
} }
if ($visualizationConfig.eventConfigs.some(ec => ec.kind === kind)) { if ($visualizationConfig.eventConfigs.some((ec) => ec.kind === kind)) {
inputError = 'Already added'; inputError = "Already added";
return null; return null;
} }
inputError = ''; inputError = "";
return kind; return kind;
} }
@ -43,19 +43,19 @@
const kind = validateKind(newKind); const kind = validateKind(newKind);
if (kind != null) { if (kind != null) {
visualizationConfig.addEventKind(kind); visualizationConfig.addEventKind(kind);
newKind = ''; newKind = "";
showAddInput = false; showAddInput = false;
inputError = ''; inputError = "";
} }
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { if (e.key === "Enter") {
handleAddKind(); handleAddKind();
} else if (e.key === 'Escape') { } else if (e.key === "Escape") {
showAddInput = false; showAddInput = false;
newKind = ''; newKind = "";
inputError = ''; inputError = "";
} }
} }
@ -69,12 +69,18 @@
function getKindName(kind: number): string { function getKindName(kind: number): string {
switch (kind) { switch (kind) {
case NostrKind.PublicationIndex: return 'Publication Index'; case NostrKind.PublicationIndex:
case NostrKind.PublicationContent: return 'Publication Content'; return "Publication Index";
case NostrKind.Wiki: return 'Wiki'; case NostrKind.PublicationContent:
case NostrKind.TextNote: return 'Text Note'; return "Publication Content";
case NostrKind.UserMetadata: return 'Metadata'; case NostrKind.Wiki:
default: return `Kind ${kind}`; return "Wiki";
case NostrKind.TextNote:
return "Text Note";
case NostrKind.UserMetadata:
return "Metadata";
default:
return `Kind ${kind}`;
} }
} }
</script> </script>
@ -84,15 +90,21 @@
{#each $visualizationConfig.eventConfigs as ec} {#each $visualizationConfig.eventConfigs as ec}
{@const isEnabled = ec.enabled !== false} {@const isEnabled = ec.enabled !== false}
{@const isLoaded = (eventCounts[ec.kind] || 0) > 0} {@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"}
<button <button
class="badge-container {isEnabled ? '' : 'disabled'} {isLoaded ? 'loaded' : 'not-loaded'}" class="badge-container {isEnabled ? '' : 'disabled'} {isLoaded
? 'loaded'
: 'not-loaded'}"
onclick={() => toggleKind(ec.kind)} onclick={() => toggleKind(ec.kind)}
title={isEnabled ? `Click to disable ${getKindName(ec.kind)}` : `Click to enable ${getKindName(ec.kind)}`} title={isEnabled
? `Click to disable ${getKindName(ec.kind)}`
: `Click to enable ${getKindName(ec.kind)}`}
> >
<Badge <Badge
color="dark" color="primary"
class="flex items-center gap-1 px-2 py-1 {isEnabled ? '' : 'opacity-40'} border-2 {borderColor}" class="flex items-center gap-1 px-2 py-1 {isEnabled
? ''
: 'opacity-40'} border-2 {borderColor}"
> >
<span class="text-xs">{ec.kind}</span> <span class="text-xs">{ec.kind}</span>
{#if isLoaded} {#if isLoaded}
@ -111,33 +123,44 @@
</Badge> </Badge>
</button> </button>
{/each} {/each}
{#if !showAddInput} {#if !showAddInput}
<Button <Button
size="xs" size="xs"
color="light" color="light"
onclick={() => showAddInput = true} onclick={() => (showAddInput = true)}
class="gap-1" class="gap-1"
> >
<span>+</span> <span>+</span>
<span>Add Kind</span> <span>Add Kind</span>
</Button> </Button>
{/if} {/if}
<Button <Button
size="xs" size="xs"
color="blue" color="blue"
onclick={onReload} onclick={onReload}
class="gap-1" class="gap-1"
title="Reload graph with current event type filters" title="Reload graph with current event type filters"
> >
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
></path>
</svg> </svg>
<span>Reload</span> <span>Reload</span>
</Button> </Button>
</div> </div>
{#if showAddInput} {#if showAddInput}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
@ -151,16 +174,14 @@
validateKind(value); validateKind(value);
}} }}
/> />
<Button size="xs" onclick={handleAddKind} disabled={!newKind}> <Button size="xs" onclick={handleAddKind} disabled={!newKind}>Add</Button>
Add <Button
</Button> size="xs"
<Button color="light"
size="xs"
color="light"
onclick={() => { onclick={() => {
showAddInput = false; showAddInput = false;
newKind = ''; newKind = "";
inputError = ''; inputError = "";
}} }}
> >
Cancel Cancel
@ -172,10 +193,11 @@
</p> </p>
{/if} {/if}
{/if} {/if}
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1"> <div class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p class="flex items-center gap-2"> <p class="flex items-center gap-2">
<span class="inline-block w-3 h-3 border-2 border-green-500 rounded"></span> <span class="inline-block w-3 h-3 border-2 border-green-500 rounded"
></span>
<span>Green border = Events loaded</span> <span>Green border = Events loaded</span>
</p> </p>
<p class="flex items-center gap-2"> <p class="flex items-center gap-2">
@ -193,12 +215,12 @@
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
.badge-container:hover:not(.disabled) { .badge-container:hover:not(.disabled) {
transform: scale(1.05); transform: scale(1.05);
} }
.badge-container.disabled { .badge-container.disabled {
cursor: pointer; cursor: pointer;
} }
</style> </style>

10
src/lib/components/Navigation.svelte

@ -11,7 +11,7 @@
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
let { class: className = "" } = $props(); let { class: className = "" } = $props();
let userState = $derived($userStore); let userState = $derived($userStore);
</script> </script>
@ -20,12 +20,14 @@
<NavBrand href="/"> <NavBrand href="/">
<div class="flex flex-col"> <div class="flex flex-col">
<h1 class="text-2xl font-bold">Alexandria</h1> <h1 class="text-2xl font-bold">Alexandria</h1>
<p class="text-xs font-semibold tracking-wide max-sm:max-w-[11rem]">READ THE ORIGINAL. MAKE CONNECTIONS. CULTIVATE KNOWLEDGE.</p> <p class="text-xs font-semibold tracking-wide max-sm:max-w-[11rem]">
READ THE ORIGINAL. MAKE CONNECTIONS. CULTIVATE KNOWLEDGE.
</p>
</div> </div>
</NavBrand> </NavBrand>
</div> </div>
<div class="flex md:order-2"> <div class="flex md:order-2">
<Profile isNav={true} /> <Profile />
<NavHamburger class="btn-leather" /> <NavHamburger class="btn-leather" />
</div> </div>
<NavUl class="ul-leather"> <NavUl class="ul-leather">
@ -40,7 +42,7 @@
<NavLi href="/about">About</NavLi> <NavLi href="/about">About</NavLi>
<NavLi href="/contact">Contact</NavLi> <NavLi href="/contact">Contact</NavLi>
<NavLi> <NavLi>
<DarkMode btnClass="btn-leather p-0" /> <DarkMode class="btn-leather p-0" />
</NavLi> </NavLi>
</NavUl> </NavUl>
</Navbar> </Navbar>

59
src/lib/components/Preview.svelte

@ -253,9 +253,9 @@
{#if hasCoverImage(rootId, index)} {#if hasCoverImage(rootId, index)}
{@const event = blogEntries[index][1]} {@const event = blogEntries[index][1]}
<div class="coverImage depth-{depth}"> <div class="coverImage depth-{depth}">
<LazyImage <LazyImage
src={hasCoverImage(rootId, index)} src={hasCoverImage(rootId, index)}
alt={title || "Cover image"} alt={title || "Cover image"}
eventId={event?.id || rootId} eventId={event?.id || rootId}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@ -274,15 +274,15 @@
{#snippet contentParagraph(content: string, publicationType: string)} {#snippet contentParagraph(content: string, publicationType: string)}
{#if publicationType === "novel"} {#if publicationType === "novel"}
<P class="whitespace-normal" firstupper={isSectionStart}> <P class="whitespace-normal" firstUpper={isSectionStart}>
{@html content} {@html content}
</P> </P>
{:else if publicationType === "blog"} {:else if publicationType === "blog"}
<P class="whitespace-normal" firstupper={false}> <P class="whitespace-normal" firstUpper={false}>
{@html content} {@html content}
</P> </P>
{:else} {:else}
<P class="whitespace-normal" firstupper={false}> <P class="whitespace-normal" firstUpper={false}>
{@html content} {@html content}
</P> </P>
{/if} {/if}
@ -305,25 +305,27 @@
class="textarea-leather w-full whitespace-normal" class="textarea-leather w-full whitespace-normal"
bind:value={currentContent} bind:value={currentContent}
> >
<div slot="footer" class="flex space-x-2 justify-end"> {#snippet footer()}
<Button <div class="flex space-x-2 justify-end">
type="reset" <Button
class="btn-leather min-w-fit" type="reset"
size="sm" class="btn-leather min-w-fit"
outline size="sm"
onclick={() => toggleEditing(rootId, false)} outline
> onclick={() => toggleEditing(rootId, false)}
Cancel >
</Button> Cancel
<Button </Button>
type="submit" <Button
class="btn-leather min-w-fit" type="submit"
size="sm" class="btn-leather min-w-fit"
onclick={() => toggleEditing(rootId, true)} size="sm"
> onclick={() => toggleEditing(rootId, true)}
Save >
</Button> Save
</div> </Button>
</div>
{/snippet}
</Textarea> </Textarea>
</form> </form>
{:else} {:else}
@ -335,10 +337,9 @@
{#if isEditing} {#if isEditing}
<ButtonGroup class="w-full"> <ButtonGroup class="w-full">
<Input type="text" class="input-leather" size="lg" bind:value={title}> <Input type="text" class="input-leather" size="lg" bind:value={title}>
<CloseButton {#snippet right()}
slot="right" <CloseButton onclick={() => toggleEditing(rootId, false)} />
onclick={() => toggleEditing(rootId, false)} {/snippet}
/>
</Input> </Input>
<Button <Button
class="btn-leather" class="btn-leather"

741
src/lib/components/ZettelEditor.svelte

@ -5,10 +5,10 @@
QuestionCircleOutline, QuestionCircleOutline,
ChartPieOutline, ChartPieOutline,
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { EditorView, basicSetup } from "codemirror";
import { EditorState, StateField, StateEffect } from "@codemirror/state"; import { EditorState, StateField, StateEffect } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown"; import { markdown } from "@codemirror/lang-markdown";
import { Decoration, type DecorationSet } from "@codemirror/view"; import { EditorView, Decoration, type DecorationSet } from "@codemirror/view";
import { basicSetup } from "@codemirror/basic-setup";
import { RangeSet } from "@codemirror/state"; import { RangeSet } from "@codemirror/state";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { import {
@ -22,8 +22,11 @@
exportEventsFromTree, exportEventsFromTree,
} from "$lib/utils/asciidoc_publication_parser"; } from "$lib/utils/asciidoc_publication_parser";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
import Asciidoctor from "asciidoctor"; import Asciidoctor, { Document } from "asciidoctor";
import { extractWikiLinks, renderWikiLinksToHtml } from "$lib/utils/wiki_links"; import {
extractWikiLinks,
renderWikiLinksToHtml,
} from "$lib/utils/wiki_links";
// Initialize Asciidoctor processor // Initialize Asciidoctor processor
const asciidoctor = Asciidoctor(); const asciidoctor = Asciidoctor();
@ -159,13 +162,6 @@
keys: Object.keys(publicationResult), keys: Object.keys(publicationResult),
}); });
console.log("Event structure details:", JSON.stringify(publicationResult.metadata.eventStructure, null, 2));
console.log("Content events details:", publicationResult.contentEvents?.map(e => ({
dTag: e.tags?.find(t => t[0] === 'd')?.[1],
title: e.tags?.find(t => t[0] === 'title')?.[1],
content: e.content?.substring(0, 100) + '...'
})));
// Helper to get d-tag from event (works with both NDK events and serialized events) // Helper to get d-tag from event (works with both NDK events and serialized events)
const getEventDTag = (event: any) => { const getEventDTag = (event: any) => {
if (event?.tagValue) { if (event?.tagValue) {
@ -179,11 +175,16 @@
}; };
// Helper to find event by dTag and kind // Helper to find event by dTag and kind
const findEventByDTag = (events: any[], dTag: string, eventKind?: number) => { const findEventByDTag = (
events: any[],
dTag: string,
eventKind?: number,
) => {
return events.find((event) => { return events.find((event) => {
const matchesDTag = getEventDTag(event) === dTag; const matchesDTag = getEventDTag(event) === dTag;
if (eventKind !== undefined) { if (eventKind !== undefined) {
const eventKindValue = event?.kind || (event?.tagValue ? event.tagValue("k") : null); const eventKindValue =
event?.kind || (event?.tagValue ? event.tagValue("k") : null);
return matchesDTag && eventKindValue === eventKind; return matchesDTag && eventKindValue === eventKind;
} }
return matchesDTag; return matchesDTag;
@ -218,7 +219,11 @@
} else { } else {
// contentEvents can contain both 30040 and 30041 events at parse level 3+ // contentEvents can contain both 30040 and 30041 events at parse level 3+
// Use eventKind to find the correct event type // Use eventKind to find the correct event type
event = findEventByDTag(publicationResult.contentEvents, node.dTag, node.eventKind); event = findEventByDTag(
publicationResult.contentEvents,
node.dTag,
node.eventKind,
);
} }
// Extract all tags (t for hashtags, w for wiki links) // Extract all tags (t for hashtags, w for wiki links)
@ -228,7 +233,6 @@
const titleTag = event?.tags.find((t: string[]) => t[0] === "title"); const titleTag = event?.tags.find((t: string[]) => t[0] === "title");
const eventTitle = titleTag ? titleTag[1] : node.title; const eventTitle = titleTag ? titleTag[1] : node.title;
// For content events, remove the first heading from content since we'll use the title tag // For content events, remove the first heading from content since we'll use the title tag
let processedContent = event?.content || ""; let processedContent = event?.content || "";
if (event && node.eventType === "content") { if (event && node.eventType === "content") {
@ -237,8 +241,8 @@
// since the title is displayed separately from the "title" tag // since the title is displayed separately from the "title" tag
const lines = processedContent.split("\n"); const lines = processedContent.split("\n");
const expectedHeading = `${"=".repeat(node.level)} ${node.title}`; const expectedHeading = `${"=".repeat(node.level)} ${node.title}`;
const titleHeadingIndex = lines.findIndex((line: string) => const titleHeadingIndex = lines.findIndex(
line.trim() === expectedHeading.trim(), (line: string) => line.trim() === expectedHeading.trim(),
); );
if (titleHeadingIndex !== -1) { if (titleHeadingIndex !== -1) {
// Remove only the specific title heading line // Remove only the specific title heading line
@ -247,7 +251,6 @@
} }
} }
return { return {
title: eventTitle, title: eventTitle,
content: processedContent, content: processedContent,
@ -378,11 +381,11 @@
for (const link of wikiLinks) { for (const link of wikiLinks) {
const className = const className =
link.type === 'auto' link.type === "auto"
? 'cm-wiki-link-auto' ? "cm-wiki-link-auto"
: link.type === 'w' : link.type === "w"
? 'cm-wiki-link-ref' ? "cm-wiki-link-ref"
: 'cm-wiki-link-def'; : "cm-wiki-link-def";
ranges.push({ ranges.push({
from: link.startIndex, from: link.startIndex,
@ -730,14 +733,16 @@
".cm-wiki-link-auto": { ".cm-wiki-link-auto": {
color: "var(--color-primary-700)", // [[term]] (auto) - medium leather color: "var(--color-primary-700)", // [[term]] (auto) - medium leather
fontWeight: "500", fontWeight: "500",
backgroundColor: "color-mix(in srgb, var(--color-primary-700) 10%, transparent)", backgroundColor:
"color-mix(in srgb, var(--color-primary-700) 10%, transparent)",
padding: "2px 4px", padding: "2px 4px",
borderRadius: "3px", borderRadius: "3px",
}, },
".cm-wiki-link-ref": { ".cm-wiki-link-ref": {
color: "var(--color-primary-800)", // [[w:term]] (reference) - darker leather color: "var(--color-primary-800)", // [[w:term]] (reference) - darker leather
fontWeight: "500", fontWeight: "500",
backgroundColor: "color-mix(in srgb, var(--color-primary-800) 10%, transparent)", backgroundColor:
"color-mix(in srgb, var(--color-primary-800) 10%, transparent)",
padding: "2px 4px", padding: "2px 4px",
borderRadius: "3px", borderRadius: "3px",
}, },
@ -790,35 +795,43 @@
}, },
}), }),
// Override background and text to match preview (gray-800 bg, gray-100 text) // Override background and text to match preview (gray-800 bg, gray-100 text)
...(isDarkMode ? [EditorView.theme({ ...(isDarkMode
"&": { ? [
backgroundColor: "#1f2937", EditorView.theme(
color: "#f3f4f6", {
}, "&": {
".cm-content": { backgroundColor: "#1f2937",
color: "#f3f4f6", color: "#f3f4f6",
}, },
".cm-line": { ".cm-content": {
color: "#f3f4f6", color: "#f3f4f6",
}, },
".cm-gutters": { ".cm-line": {
backgroundColor: "#1f2937", color: "#f3f4f6",
borderColor: "#374151", },
color: "#9ca3af", ".cm-gutters": {
}, backgroundColor: "#1f2937",
".cm-activeLineGutter": { borderColor: "#374151",
backgroundColor: "#374151", color: "#9ca3af",
}, },
".cm-cursor": { ".cm-activeLineGutter": {
borderLeftColor: "#f3f4f6", backgroundColor: "#374151",
}, },
".cm-selectionBackground, ::selection": { ".cm-cursor": {
backgroundColor: "#374151 !important", borderLeftColor: "#f3f4f6",
}, },
"&.cm-focused .cm-selectionBackground, &.cm-focused ::selection": { ".cm-selectionBackground, ::selection": {
backgroundColor: "#4b5563 !important", backgroundColor: "#374151 !important",
}, },
}, { dark: true })] : []), "&.cm-focused .cm-selectionBackground, &.cm-focused ::selection":
{
backgroundColor: "#4b5563 !important",
},
},
{ dark: true },
),
]
: []),
], ],
}); });
@ -847,12 +860,12 @@
// Mount CodeMirror when component mounts // Mount CodeMirror when component mounts
onMount(() => { onMount(() => {
// Initialize dark mode state // Initialize dark mode state
isDarkMode = document.documentElement.classList.contains('dark'); isDarkMode = document.documentElement.classList.contains("dark");
createEditor(); createEditor();
// Watch for dark mode changes // Watch for dark mode changes
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
const newDarkMode = document.documentElement.classList.contains('dark'); const newDarkMode = document.documentElement.classList.contains("dark");
if (newDarkMode !== isDarkMode) { if (newDarkMode !== isDarkMode) {
isDarkMode = newDarkMode; isDarkMode = newDarkMode;
// Recreate editor with new theme // Recreate editor with new theme
@ -876,7 +889,7 @@
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
attributes: true, attributes: true,
attributeFilter: ['class'], attributeFilter: ["class"],
}); });
return () => { return () => {
@ -1042,298 +1055,343 @@
</h3> </h3>
</div> </div>
<div class="flex-1 overflow-y-auto p-6 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"> <div
class="flex-1 overflow-y-auto p-6 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
{#if !content.trim()} {#if !content.trim()}
<div <div
class="text-gray-500 dark:text-gray-400 text-sm text-center py-8" class="text-gray-500 dark:text-gray-400 text-sm text-center py-8"
> >
Start typing to see the preview... Start typing to see the preview...
</div> </div>
{:else} {:else}
<div class="prose prose-sm dark:prose-invert max-w-none"> <div class="prose prose-sm dark:prose-invert max-w-none">
<!-- Render full document with title if it's an article --> <!-- Render full document with title if it's an article -->
{#if contentType === "article" && publicationResult?.metadata.title} {#if contentType === "article" && publicationResult?.metadata.title}
{@const documentHeader = content.split(/\n==\s+/)[0]} {@const documentHeader = content.split(/\n==\s+/)[0]}
<div <div
class="mb-6 border-b border-gray-200 dark:border-gray-700 pb-4" class="mb-6 border-b border-gray-200 dark:border-gray-700 pb-4"
> >
<div class="asciidoc-content"> <div class="asciidoc-content">
{@html asciidoctor.convert(documentHeader, { {@html asciidoctor.convert(documentHeader, {
standalone: false, standalone: false,
attributes: { attributes: {
showtitle: true, showtitle: true,
sectids: false, sectids: false,
}, },
})} })}
</div>
</div> </div>
</div> {/if}
{/if}
{#each parsedSections as section, index}
<div
class="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700 last:border-0"
>
{#if section.isIndex}
<!-- Index event: show title and tags -->
<div class="space-y-3">
<!-- Event type indicator -->
<div
class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider"
>
Index Event (30040)
</div>
<!-- Title --> {#each parsedSections as section, index}
<h2 <div
class="text-lg font-bold text-gray-900 dark:text-gray-100" class="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700 last:border-0"
> >
{section.title} {#if section.isIndex}
</h2> <!-- Index event: show title and tags -->
<div class="space-y-3">
<!-- Tags and wiki links --> <!-- Event type indicator -->
{#if section.tags && section.tags.length > 0} <div
{@const tTags = section.tags.filter((tag) => tag[0] === 't')} class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider"
{@const wTags = section.tags.filter((tag) => tag[0] === 'w')} >
Index Event (30040)
{#if tTags.length > 0 || wTags.length > 0} </div>
<div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tTags as tag}
<span
class="bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) --> <!-- Title -->
{#if wTags.length > 0} <h2
<div class="flex flex-wrap gap-2"> class="text-lg font-bold text-gray-900 dark:text-gray-100"
{#each wTags as tag} >
<span {section.title}
class="bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200 px-2 py-1 rounded-full text-xs font-medium" </h2>
title="Wiki reference: {tag[1]}"
> <!-- Tags and wiki links -->
🔗 {tag[2] || tag[1]} {#if section.tags && section.tags.length > 0}
</span> {@const tTags = section.tags.filter(
{/each} (tag: any) => tag[0] === "t",
)}
{@const wTags = section.tags.filter(
(tag: any) => tag[0] === "w",
)}
{#if tTags.length > 0 || wTags.length > 0}
<div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tTags as tag}
<span
class="bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) -->
{#if wTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each wTags as tag}
<span
class="bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200 px-2 py-1 rounded-full text-xs font-medium"
title="Wiki reference: {tag[1]}"
>
🔗 {tag[2] || tag[1]}
</span>
{/each}
</div>
{/if}
</div> </div>
{/if} {/if}
</div>
{/if} {/if}
{/if}
</div>
{:else}
<!-- Content event: show title, tags, then content -->
<div class="space-y-3">
<!-- Event type indicator -->
<div
class="text-xs font-semibold text-green-600 dark:text-green-400 uppercase tracking-wider"
>
Content Event (30041)
</div> </div>
{:else}
<!-- Content event: show title, tags, then content -->
<div class="space-y-3">
<!-- Event type indicator -->
<div
class="text-xs font-semibold text-green-600 dark:text-green-400 uppercase tracking-wider"
>
Content Event (30041)
</div>
<!-- Title at correct heading level --> <!-- Title at correct heading level -->
<div <div
class="prose prose-sm dark:prose-invert max-w-none" class="prose prose-sm dark:prose-invert max-w-none"
> >
{@html asciidoctor.convert( {@html asciidoctor.convert(
`${"=".repeat(section.level)} ${section.title}`, `${"=".repeat(section.level)} ${section.title}`,
{ {
standalone: false, standalone: false,
attributes: { attributes: {
showtitle: false, showtitle: false,
sectids: false, sectids: false,
},
}, },
}, )}
)} </div>
</div>
<!-- Tags and wiki links (green for content events) -->
{#if section.tags && section.tags.length > 0}
{@const tTags = section.tags.filter((tag) => tag[0] === 't')}
{@const wTags = section.tags.filter((tag) => tag[0] === 'w')}
{#if tTags.length > 0 || wTags.length > 0}
<div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tTags as tag}
<span
class="bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) --> <!-- Tags and wiki links (green for content events) -->
{#if wTags.length > 0} {#if section.tags && section.tags.length > 0}
<div class="flex flex-wrap gap-2"> {@const tTags = section.tags.filter(
{#each wTags as tag} (tag: any) => tag[0] === "t",
<span )}
class="bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200 px-2 py-1 rounded-full text-xs font-medium" {@const wTags = section.tags.filter(
title="Wiki reference: {tag[1]}" (tag: any) => tag[0] === "w",
> )}
🔗 {tag[2] || tag[1]}
</span> {#if tTags.length > 0 || wTags.length > 0}
{/each} <div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tTags as tag}
<span
class="bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) -->
{#if wTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each wTags as tag}
<span
class="bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200 px-2 py-1 rounded-full text-xs font-medium"
title="Wiki reference: {tag[1]}"
>
🔗 {tag[2] || tag[1]}
</span>
{/each}
</div>
{/if}
</div> </div>
{/if} {/if}
</div>
{/if} {/if}
{/if}
<!-- Content rendered as AsciiDoc --> <!-- Content rendered as AsciiDoc -->
{#if section.content} {#if section.content}
<div <div
class="prose prose-sm dark:prose-invert max-w-none mt-4" class="prose prose-sm dark:prose-invert max-w-none mt-4"
> >
{@html (() => { {@html (() => {
// Extract wiki links and replace with placeholders BEFORE Asciidoctor // Extract wiki links and replace with placeholders BEFORE Asciidoctor
const wikiLinks = extractWikiLinks(section.content); const wikiLinks = extractWikiLinks(
let contentWithPlaceholders = section.content; section.content,
const placeholders = new Map(); );
let contentWithPlaceholders = section.content;
wikiLinks.forEach((link, index) => { const placeholders = new Map();
// Use a placeholder inside a passthrough macro - Asciidoctor will strip pass:[...] and leave the inner text
const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`; wikiLinks.forEach((link, index) => {
const placeholder = `pass:[${innerPlaceholder}]`; // Use a placeholder inside a passthrough macro - Asciidoctor will strip pass:[...] and leave the inner text
placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor) const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`;
contentWithPlaceholders = contentWithPlaceholders.replace(link.fullMatch, placeholder); const placeholder = `pass:[${innerPlaceholder}]`;
}); placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor)
contentWithPlaceholders =
// Check if content contains nested headers contentWithPlaceholders.replace(
const hasNestedHeaders = contentWithPlaceholders.includes('\n===') || contentWithPlaceholders.includes('\n===='); link.fullMatch,
placeholder,
let rendered; );
if (hasNestedHeaders) {
// For proper nested header parsing, we need full document context
// Create a complete AsciiDoc document structure
// Important: Ensure proper level sequence for nested headers
const fullDoc = `= Temporary Document\n\n${"=".repeat(section.level)} ${section.title}\n\n${contentWithPlaceholders}`;
rendered = asciidoctor.convert(fullDoc, {
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
}); });
// Extract just the content we want (remove the temporary structure) // Check if content contains nested headers
// Find the section we care about const hasNestedHeaders =
const sectionStart = rendered.indexOf(`<h${section.level}`); contentWithPlaceholders.includes("\n===") ||
if (sectionStart !== -1) { contentWithPlaceholders.includes("\n====");
const nextSectionStart = rendered.indexOf(`</h${section.level}>`, sectionStart);
if (nextSectionStart !== -1) { let rendered: string | Document;
// Get everything after our section header if (hasNestedHeaders) {
const afterHeader = rendered.substring(nextSectionStart + `</h${section.level}>`.length); // For proper nested header parsing, we need full document context
// Find where the section ends (at the closing div) // Create a complete AsciiDoc document structure
const sectionEnd = afterHeader.lastIndexOf('</div>'); // Important: Ensure proper level sequence for nested headers
if (sectionEnd !== -1) { const fullDoc = `= Temporary Document\n\n${"=".repeat(section.level)} ${section.title}\n\n${contentWithPlaceholders}`;
rendered = afterHeader.substring(0, sectionEnd);
rendered = asciidoctor.convert(fullDoc, {
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
});
// Extract just the content we want (remove the temporary structure)
// Find the section we care about
const sectionStart = rendered
.toString()
.indexOf(`<h${section.level}`);
if (sectionStart !== -1) {
const nextSectionStart = rendered
.toString()
.indexOf(
`</h${section.level}>`,
sectionStart,
);
if (nextSectionStart !== -1) {
// Get everything after our section header
const afterHeader = rendered
.toString()
.substring(
nextSectionStart +
`</h${section.level}>`.length,
);
// Find where the section ends (at the closing div)
const sectionEnd =
afterHeader.lastIndexOf("</div>");
if (sectionEnd !== -1) {
rendered = afterHeader.substring(
0,
sectionEnd,
);
}
} }
} }
} else {
// Simple content without nested headers
rendered = asciidoctor.convert(
contentWithPlaceholders,
{
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
},
);
} }
} else {
// Simple content without nested headers // Replace placeholders with actual wiki link HTML
rendered = asciidoctor.convert(contentWithPlaceholders, { // Use a global regex to catch all occurrences (Asciidoctor might have duplicated them)
standalone: false, placeholders.forEach((link, placeholder) => {
attributes: { const className =
showtitle: false, link.type === "auto"
sectids: false, ? "wiki-link wiki-link-auto"
}, : link.type === "w"
? "wiki-link wiki-link-ref"
: "wiki-link wiki-link-def";
const title =
link.type === "w"
? "Wiki reference (mentions this concept)"
: link.type === "d"
? "Wiki definition (defines this concept)"
: "Wiki link (searches both references and definitions)";
const html = `<a class="${className}" href="#wiki/${link.type}/${encodeURIComponent(link.term)}" title="${title}" data-wiki-type="${link.type}" data-wiki-term="${link.term}">${link.displayText}</a>`;
// Use global replace to handle all occurrences
const regex = new RegExp(
placeholder.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
),
"g",
);
rendered = rendered
.toString()
.replace(regex, html);
}); });
}
// Replace placeholders with actual wiki link HTML
// Use a global regex to catch all occurrences (Asciidoctor might have duplicated them)
placeholders.forEach((link, placeholder) => {
const className =
link.type === 'auto'
? 'wiki-link wiki-link-auto'
: link.type === 'w'
? 'wiki-link wiki-link-ref'
: 'wiki-link wiki-link-def';
const title =
link.type === 'w'
? 'Wiki reference (mentions this concept)'
: link.type === 'd'
? 'Wiki definition (defines this concept)'
: 'Wiki link (searches both references and definitions)';
const html = `<a class="${className}" href="#wiki/${link.type}/${encodeURIComponent(link.term)}" title="${title}" data-wiki-type="${link.type}" data-wiki-term="${link.term}">${link.displayText}</a>`;
// Use global replace to handle all occurrences
const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
rendered = rendered.replace(regex, html);
});
return rendered;
})()}
</div>
{/if}
</div>
{/if}
<!-- Event boundary indicator --> return rendered;
{#if index < parsedSections.length - 1} })()}
<div class="mt-6 relative"> </div>
<div class="absolute inset-0 flex items-center"> {/if}
<div
class="w-full border-t-2 border-dashed border-gray-300 dark:border-gray-600"
></div>
</div> </div>
<div class="relative flex justify-center"> {/if}
<span
class="bg-white dark:bg-gray-800 px-3 text-xs text-gray-500 dark:text-gray-400" <!-- Event boundary indicator -->
> {#if index < parsedSections.length - 1}
Event Boundary <div class="mt-6 relative">
</span> <div class="absolute inset-0 flex items-center">
<div
class="w-full border-t-2 border-dashed border-gray-300 dark:border-gray-600"
></div>
</div>
<div class="relative flex justify-center">
<span
class="bg-white dark:bg-gray-800 px-3 text-xs text-gray-500 dark:text-gray-400"
>
Event Boundary
</span>
</div>
</div> </div>
</div> {/if}
{/if} </div>
</div> {/each}
{/each} </div>
</div>
<div <div
class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 p-2 rounded border" class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 p-2 rounded border"
> >
<strong>Event Count:</strong> <strong>Event Count:</strong>
{#if generatedEvents} {#if generatedEvents}
{@const indexEvents = generatedEvents.contentEvents.filter( {@const indexEvents = generatedEvents.contentEvents.filter(
(e: any) => e.kind === 30040, (e: any) => e.kind === 30040,
)}
{@const contentOnlyEvents =
generatedEvents.contentEvents.filter(
(e: any) => e.kind === 30041,
)} )}
{@const totalIndexEvents = {@const contentOnlyEvents =
indexEvents.length + (generatedEvents.indexEvent ? 1 : 0)} generatedEvents.contentEvents.filter(
{@const totalEvents = (e: any) => e.kind === 30041,
totalIndexEvents + contentOnlyEvents.length} )}
{totalEvents} event{totalEvents !== 1 ? "s" : ""} {@const totalIndexEvents =
({totalIndexEvents} index{totalIndexEvents !== 1 indexEvents.length + (generatedEvents.indexEvent ? 1 : 0)}
? " events" {@const totalEvents =
: ""} + {contentOnlyEvents.length} content{contentOnlyEvents.length !== totalIndexEvents + contentOnlyEvents.length}
1 {totalEvents} event{totalEvents !== 1 ? "s" : ""}
? " events" ({totalIndexEvents} index{totalIndexEvents !== 1
: ""}) ? " events"
{:else} : ""} + {contentOnlyEvents.length} content{contentOnlyEvents.length !==
0 events 1
{/if} ? " events"
</div> : ""})
{/if} {:else}
0 events
{/if}
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -1497,23 +1555,42 @@ Understanding the nature of knowledge...
</p> </p>
<ul class="space-y-2 text-xs"> <ul class="space-y-2 text-xs">
<li> <li>
<code class="bg-violet-100 dark:bg-violet-900/30 px-1 py-0.5 rounded">[[term]]</code> <code
<span class="text-gray-600 dark:text-gray-400">- Auto link (queries both w and d tags)</span> class="bg-violet-100 dark:bg-violet-900/30 px-1 py-0.5 rounded"
>[[term]]</code
>
<span class="text-gray-600 dark:text-gray-400"
>- Auto link (queries both w and d tags)</span
>
</li> </li>
<li> <li>
<code class="bg-cyan-100 dark:bg-cyan-900/30 px-1 py-0.5 rounded">[[w:term]]</code> <code
<span class="text-gray-600 dark:text-gray-400">- Reference/mention (backward link)</span> class="bg-cyan-100 dark:bg-cyan-900/30 px-1 py-0.5 rounded"
>[[w:term]]</code
>
<span class="text-gray-600 dark:text-gray-400"
>- Reference/mention (backward link)</span
>
</li> </li>
<li> <li>
<code class="bg-amber-100 dark:bg-amber-900/30 px-1 py-0.5 rounded">[[d:term]]</code> <code
<span class="text-gray-600 dark:text-gray-400">- Definition link (forward link)</span> class="bg-amber-100 dark:bg-amber-900/30 px-1 py-0.5 rounded"
>[[d:term]]</code
>
<span class="text-gray-600 dark:text-gray-400"
>- Definition link (forward link)</span
>
</li> </li>
<li class="mt-2"> <li class="mt-2">
<strong>Custom text:</strong> <code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">[[term|display text]]</code> <strong>Custom text:</strong>
<code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded"
>[[term|display text]]</code
>
</li> </li>
</ul> </ul>
<p class="text-xs mt-2 text-gray-600 dark:text-gray-400"> <p class="text-xs mt-2 text-gray-600 dark:text-gray-400">
Example: "The concept of [[Knowledge Graphs]] enables..." creates a w-tag automatically. Example: "The concept of [[Knowledge Graphs]] enables..."
creates a w-tag automatically.
</p> </p>
</div> </div>
</div> </div>
@ -1591,7 +1668,7 @@ Understanding the nature of knowledge...
<!-- Hierarchical structure --> <!-- Hierarchical structure -->
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-3"> <div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-3">
<div class="font-mono text-xs space-y-1"> <div class="font-mono text-xs space-y-1">
{#snippet renderEventNode(node, depth = 0)} {#snippet renderEventNode(node: any, depth = 0)}
<div class="py-0.5" style="margin-left: {depth * 1}rem;"> <div class="py-0.5" style="margin-left: {depth * 1}rem;">
{node.eventKind === 30040 ? "📁" : "📄"} {node.eventKind === 30040 ? "📁" : "📄"}
[{node.eventKind}] {node.title || "Untitled"} [{node.eventKind}] {node.title || "Untitled"}

328
src/lib/components/publications/HighlightLayer.svelte

@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
import { getNdkContext, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import {
getNdkContext,
activeInboxRelays,
activeOutboxRelays,
} from "$lib/ndk";
import { pubkeyToHue } from "$lib/utils/nostrUtils"; import { pubkeyToHue } from "$lib/utils/nostrUtils";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
@ -12,11 +16,14 @@
encodeHighlightNaddr, encodeHighlightNaddr,
getRelaysFromHighlight, getRelaysFromHighlight,
getAuthorDisplayName, getAuthorDisplayName,
sortHighlightsByTime sortHighlightsByTime,
} from "$lib/utils/highlightUtils"; } from "$lib/utils/highlightUtils";
import { unifiedProfileCache } from "$lib/utils/npubCache"; import { unifiedProfileCache } from "$lib/utils/npubCache";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { highlightByOffset, getPlainText } from "$lib/utils/highlightPositioning"; import {
highlightByOffset,
getPlainText,
} from "$lib/utils/highlightPositioning";
let { let {
eventId, eventId,
@ -47,7 +54,7 @@
// Derived state for color mapping // Derived state for color mapping
let colorMap = $derived.by(() => { let colorMap = $derived.by(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
highlights.forEach(highlight => { highlights.forEach((highlight) => {
if (!map.has(highlight.pubkey)) { if (!map.has(highlight.pubkey)) {
const hue = pubkeyToHue(highlight.pubkey); const hue = pubkeyToHue(highlight.pubkey);
map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
@ -73,8 +80,13 @@
} }
// Collect all event IDs and addresses // Collect all event IDs and addresses
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean); const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(
const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean); Boolean,
);
const allAddresses = [
...(eventAddress ? [eventAddress] : []),
...eventAddresses,
].filter(Boolean);
if (allEventIds.length === 0 && allAddresses.length === 0) { if (allEventIds.length === 0 && allAddresses.length === 0) {
console.warn("[HighlightLayer] No event IDs or addresses provided"); console.warn("[HighlightLayer] No event IDs or addresses provided");
@ -87,20 +99,29 @@
// AI-NOTE: Mock mode allows testing highlight UI without publishing to relays // AI-NOTE: Mock mode allows testing highlight UI without publishing to relays
// This is useful for development and demonstrating the highlight system // This is useful for development and demonstrating the highlight system
if (useMockHighlights) { if (useMockHighlights) {
console.log(`[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`); console.log(
`[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`,
);
try { try {
// Generate mock highlight data // Generate mock highlight data
const mockHighlights = generateMockHighlightsForSections(allAddresses); const mockHighlights = generateMockHighlightsForSections(allAddresses);
// Convert to NDKEvent instances (same as real events) // Convert to NDKEvent instances (same as real events)
highlights = mockHighlights.map(rawEvent => new NDKEventClass(ndk, rawEvent)); highlights = mockHighlights.map(
(rawEvent) => new NDKEventClass(ndk, rawEvent),
);
console.log(`[HighlightLayer] Generated ${highlights.length} mock highlights`); console.log(
`[HighlightLayer] Generated ${highlights.length} mock highlights`,
);
loading = false; loading = false;
return; return;
} catch (err) { } catch (err) {
console.error(`[HighlightLayer] Error generating mock highlights:`, err); console.error(
`[HighlightLayer] Error generating mock highlights:`,
err,
);
loading = false; loading = false;
return; return;
} }
@ -108,7 +129,7 @@
console.log(`[HighlightLayer] Fetching highlights for:`, { console.log(`[HighlightLayer] Fetching highlights for:`, {
eventIds: allEventIds, eventIds: allEventIds,
addresses: allAddresses addresses: allAddresses,
}); });
try { try {
@ -128,7 +149,10 @@
filter["#e"] = allEventIds; filter["#e"] = allEventIds;
} }
console.log(`[HighlightLayer] Fetching with filter:`, JSON.stringify(filter, null, 2)); console.log(
`[HighlightLayer] Fetching with filter:`,
JSON.stringify(filter, null, 2),
);
// Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton) // Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton)
const relays = [ const relays = [
@ -137,7 +161,10 @@
...$activeInboxRelays, ...$activeInboxRelays,
]; ];
const uniqueRelays = Array.from(new Set(relays)); const uniqueRelays = Array.from(new Set(relays));
console.log(`[HighlightLayer] Fetching from ${uniqueRelays.length} relays:`, uniqueRelays); console.log(
`[HighlightLayer] Fetching from ${uniqueRelays.length} relays:`,
uniqueRelays,
);
/** /**
* Use WebSocketPool with nostr-tools protocol instead of NDK * Use WebSocketPool with nostr-tools protocol instead of NDK
@ -168,8 +195,11 @@
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
// Log ALL messages from relay.nostr.band for debugging // Log ALL messages from relay.nostr.band for debugging
if (relayUrl.includes('relay.nostr.band')) { if (relayUrl.includes("relay.nostr.band")) {
console.log(`[HighlightLayer] RAW message from ${relayUrl}:`, message); console.log(
`[HighlightLayer] RAW message from ${relayUrl}:`,
message,
);
} }
if (message[0] === "EVENT" && message[1] === subscriptionId) { if (message[0] === "EVENT" && message[1] === subscriptionId) {
@ -178,7 +208,7 @@
id: rawEvent.id, id: rawEvent.id,
kind: rawEvent.kind, kind: rawEvent.kind,
content: rawEvent.content.substring(0, 50), content: rawEvent.content.substring(0, 50),
tags: rawEvent.tags tags: rawEvent.tags,
}); });
// Avoid duplicates // Avoid duplicates
@ -188,11 +218,18 @@
// Convert to NDKEvent // Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent); const ndkEvent = new NDKEventClass(ndk, rawEvent);
highlights = [...highlights, ndkEvent]; highlights = [...highlights, ndkEvent];
console.log(`[HighlightLayer] Added highlight, total now: ${highlights.length}`); console.log(
`[HighlightLayer] Added highlight, total now: ${highlights.length}`,
);
} }
} else if (message[0] === "EOSE" && message[1] === subscriptionId) { } else if (
message[0] === "EOSE" &&
message[1] === subscriptionId
) {
eoseCount++; eoseCount++;
console.log(`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`); console.log(
`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`,
);
// Close subscription // Close subscription
ws.send(JSON.stringify(["CLOSE", subscriptionId])); ws.send(JSON.stringify(["CLOSE", subscriptionId]));
@ -200,10 +237,16 @@
WebSocketPool.instance.release(ws); WebSocketPool.instance.release(ws);
resolve(); resolve();
} else if (message[0] === "NOTICE") { } else if (message[0] === "NOTICE") {
console.warn(`[HighlightLayer] NOTICE from ${relayUrl}:`, message[1]); console.warn(
`[HighlightLayer] NOTICE from ${relayUrl}:`,
message[1],
);
} }
} catch (err) { } catch (err) {
console.error(`[HighlightLayer] Error processing message from ${relayUrl}:`, err); console.error(
`[HighlightLayer] Error processing message from ${relayUrl}:`,
err,
);
} }
}; };
@ -211,8 +254,11 @@
// Send REQ // Send REQ
const req = ["REQ", subscriptionId, filter]; const req = ["REQ", subscriptionId, filter];
if (relayUrl.includes('relay.nostr.band')) { if (relayUrl.includes("relay.nostr.band")) {
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req)); console.log(
`[HighlightLayer] Sending REQ to ${relayUrl}:`,
JSON.stringify(req),
);
} else { } else {
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`); console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`);
} }
@ -229,7 +275,10 @@
}, 5000); }, 5000);
}); });
} catch (err) { } catch (err) {
console.error(`[HighlightLayer] Error connecting to ${relayUrl}:`, err); console.error(
`[HighlightLayer] Error connecting to ${relayUrl}:`,
err,
);
} }
}); });
@ -239,17 +288,19 @@
console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`); console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`);
if (highlights.length > 0) { if (highlights.length > 0) {
console.log(`[HighlightLayer] Highlights summary:`, highlights.map(h => ({ console.log(
content: h.content.substring(0, 30) + "...", `[HighlightLayer] Highlights summary:`,
address: h.tags.find(t => t[0] === "a")?.[1], highlights.map((h) => ({
author: h.pubkey.substring(0, 8) content: h.content.substring(0, 30) + "...",
}))); address: h.tags.find((t) => t[0] === "a")?.[1],
author: h.pubkey.substring(0, 8),
})),
);
} }
loading = false; loading = false;
// Rendering is handled by the visibility/highlights effect // Rendering is handled by the visibility/highlights effect
} catch (err) { } catch (err) {
console.error(`[HighlightLayer] Error fetching highlights:`, err); console.error(`[HighlightLayer] Error fetching highlights:`, err);
loading = false; loading = false;
@ -267,10 +318,12 @@
offsetStart: number, offsetStart: number,
offsetEnd: number, offsetEnd: number,
color: string, color: string,
targetAddress?: string targetAddress?: string,
): boolean { ): boolean {
if (!containerRef) { if (!containerRef) {
console.log(`[HighlightLayer] Cannot highlight by position - no containerRef`); console.log(
`[HighlightLayer] Cannot highlight by position - no containerRef`,
);
return false; return false;
} }
@ -280,17 +333,25 @@
const sectionElement = document.getElementById(targetAddress); const sectionElement = document.getElementById(targetAddress);
if (sectionElement) { if (sectionElement) {
searchRoot = sectionElement; searchRoot = sectionElement;
console.log(`[HighlightLayer] Highlighting in specific section: ${targetAddress}`); console.log(
`[HighlightLayer] Highlighting in specific section: ${targetAddress}`,
);
} else { } else {
console.log(`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`); console.log(
`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`,
);
} }
} }
console.log(`[HighlightLayer] Applying position-based highlight ${offsetStart}-${offsetEnd}`); console.log(
`[HighlightLayer] Applying position-based highlight ${offsetStart}-${offsetEnd}`,
);
const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color); const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
if (result) { if (result) {
console.log(`[HighlightLayer] Successfully applied position-based highlight`); console.log(
`[HighlightLayer] Successfully applied position-based highlight`,
);
} else { } else {
console.log(`[HighlightLayer] Failed to apply position-based highlight`); console.log(`[HighlightLayer] Failed to apply position-based highlight`);
} }
@ -304,9 +365,15 @@
* @param color - The color to use for highlighting * @param color - The color to use for highlighting
* @param targetAddress - Optional address to limit search to specific section * @param targetAddress - Optional address to limit search to specific section
*/ */
function findAndHighlightText(text: string, color: string, targetAddress?: string): void { function findAndHighlightText(
text: string,
color: string,
targetAddress?: string,
): void {
if (!containerRef || !text || text.trim().length === 0) { if (!containerRef || !text || text.trim().length === 0) {
console.log(`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`); console.log(
`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`,
);
return; return;
} }
@ -316,19 +383,26 @@
const sectionElement = document.getElementById(targetAddress); const sectionElement = document.getElementById(targetAddress);
if (sectionElement) { if (sectionElement) {
searchRoot = sectionElement; searchRoot = sectionElement;
console.log(`[HighlightLayer] Searching in specific section: ${targetAddress}`); console.log(
`[HighlightLayer] Searching in specific section: ${targetAddress}`,
);
} else { } else {
console.log(`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`); console.log(
`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`,
);
} }
} }
console.log(`[HighlightLayer] Searching for text: "${text}" in`, searchRoot); console.log(
`[HighlightLayer] Searching for text: "${text}" in`,
searchRoot,
);
// Use TreeWalker to find all text nodes // Use TreeWalker to find all text nodes
const walker = document.createTreeWalker( const walker = document.createTreeWalker(
searchRoot, searchRoot,
NodeFilter.SHOW_TEXT, NodeFilter.SHOW_TEXT,
null null,
); );
const textNodes: Node[] = []; const textNodes: Node[] = [];
@ -338,19 +412,30 @@
} }
// Search for the highlight text in text nodes // Search for the highlight text in text nodes
console.log(`[HighlightLayer] Searching through ${textNodes.length} text nodes`); console.log(
`[HighlightLayer] Searching through ${textNodes.length} text nodes`,
);
for (const textNode of textNodes) { for (const textNode of textNodes) {
const nodeText = textNode.textContent || ""; const nodeText = textNode.textContent || "";
const index = nodeText.toLowerCase().indexOf(text.toLowerCase()); const index = nodeText.toLowerCase().indexOf(text.toLowerCase());
if (index !== -1) { if (index !== -1) {
console.log(`[HighlightLayer] Found match in text node:`, nodeText.substring(Math.max(0, index - 20), Math.min(nodeText.length, index + text.length + 20))); console.log(
`[HighlightLayer] Found match in text node:`,
nodeText.substring(
Math.max(0, index - 20),
Math.min(nodeText.length, index + text.length + 20),
),
);
const parent = textNode.parentNode; const parent = textNode.parentNode;
if (!parent) continue; if (!parent) continue;
// Skip if already highlighted // Skip if already highlighted
if (parent.nodeName === "MARK" || (parent instanceof Element && parent.classList?.contains("highlight"))) { if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
continue; continue;
} }
@ -386,10 +471,14 @@
* Render all highlights on the page * Render all highlights on the page
*/ */
function renderHighlights() { function renderHighlights() {
console.log(`[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`); console.log(
`[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`,
);
if (!visible || !containerRef) { if (!visible || !containerRef) {
console.log(`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`); console.log(
`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`,
);
return; return;
} }
@ -403,7 +492,10 @@
console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`); console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`);
console.log(`[HighlightLayer] Container element:`, containerRef); console.log(`[HighlightLayer] Container element:`, containerRef);
console.log(`[HighlightLayer] Container has children:`, containerRef.children.length); console.log(
`[HighlightLayer] Container has children:`,
containerRef.children.length,
);
// Apply each highlight // Apply each highlight
for (const highlight of highlights) { for (const highlight of highlights) {
@ -411,12 +503,13 @@
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)"; const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)";
// Extract the target address from the highlight's "a" tag // Extract the target address from the highlight's "a" tag
const aTag = highlight.tags.find(tag => tag[0] === "a"); const aTag = highlight.tags.find((tag) => tag[0] === "a");
const targetAddress = aTag ? aTag[1] : undefined; const targetAddress = aTag ? aTag[1] : undefined;
// Check for offset tags (position-based highlighting) // Check for offset tags (position-based highlighting)
const offsetTag = highlight.tags.find(tag => tag[0] === "offset"); const offsetTag = highlight.tags.find((tag) => tag[0] === "offset");
const hasOffset = offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined; const hasOffset =
offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined;
console.log(`[HighlightLayer] Rendering highlight:`, { console.log(`[HighlightLayer] Rendering highlight:`, {
hasOffset, hasOffset,
@ -425,7 +518,7 @@
contentLength: content.length, contentLength: content.length,
targetAddress, targetAddress,
color, color,
allTags: highlight.tags allTags: highlight.tags,
}); });
if (hasOffset) { if (hasOffset) {
@ -434,10 +527,14 @@
const offsetEnd = parseInt(offsetTag[2], 10); const offsetEnd = parseInt(offsetTag[2], 10);
if (!isNaN(offsetStart) && !isNaN(offsetEnd)) { if (!isNaN(offsetStart) && !isNaN(offsetEnd)) {
console.log(`[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`); console.log(
`[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`,
);
highlightByPosition(offsetStart, offsetEnd, color, targetAddress); highlightByPosition(offsetStart, offsetEnd, color, targetAddress);
} else { } else {
console.log(`[HighlightLayer] Invalid offset values, falling back to text search`); console.log(
`[HighlightLayer] Invalid offset values, falling back to text search`,
);
if (content && content.trim().length > 0) { if (content && content.trim().length > 0) {
findAndHighlightText(content, color, targetAddress); findAndHighlightText(content, color, targetAddress);
} }
@ -455,7 +552,9 @@
// Check if any highlights were actually rendered // Check if any highlights were actually rendered
const renderedHighlights = containerRef.querySelectorAll("mark.highlight"); const renderedHighlights = containerRef.querySelectorAll("mark.highlight");
console.log(`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`); console.log(
`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`,
);
} }
/** /**
@ -465,7 +564,7 @@
if (!containerRef) return; if (!containerRef) return;
const highlightElements = containerRef.querySelectorAll("mark.highlight"); const highlightElements = containerRef.querySelectorAll("mark.highlight");
highlightElements.forEach(el => { highlightElements.forEach((el) => {
const parent = el.parentNode; const parent = el.parentNode;
if (parent) { if (parent) {
// Replace highlight with plain text // Replace highlight with plain text
@ -477,7 +576,9 @@
} }
}); });
console.log(`[HighlightLayer] Cleared ${highlightElements.length} highlights`); console.log(
`[HighlightLayer] Cleared ${highlightElements.length} highlights`,
);
} }
// Track the last fetched event count to know when to refetch // Track the last fetched event count to know when to refetch
@ -489,7 +590,9 @@
const currentCount = eventIds.length + eventAddresses.length; const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0; const hasEventData = currentCount > 0;
console.log(`[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`); console.log(
`[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`,
);
// Only fetch if: // Only fetch if:
// 1. We have event data // 1. We have event data
@ -503,7 +606,9 @@
// Debounce: wait 500ms for more events to arrive before fetching // Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => { fetchTimeout = setTimeout(() => {
console.log(`[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`); console.log(
`[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`,
);
lastFetchedCount = currentCount; lastFetchedCount = currentCount;
fetchHighlights(); fetchHighlights();
}, 500); }, 500);
@ -521,10 +626,14 @@
$effect(() => { $effect(() => {
// This effect runs when either visible or highlights.length changes // This effect runs when either visible or highlights.length changes
const highlightCount = highlights.length; const highlightCount = highlights.length;
console.log(`[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`); console.log(
`[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`,
);
if (visible && highlightCount > 0) { if (visible && highlightCount > 0) {
console.log(`[HighlightLayer] Both visible and highlights ready, rendering...`); console.log(
`[HighlightLayer] Both visible and highlights ready, rendering...`,
);
renderHighlights(); renderHighlights();
} else if (!visible) { } else if (!visible) {
clearHighlights(); clearHighlights();
@ -544,7 +653,9 @@
*/ */
async function fetchAuthorProfiles() { async function fetchAuthorProfiles() {
const uniquePubkeys = Array.from(groupedHighlights.keys()); const uniquePubkeys = Array.from(groupedHighlights.keys());
console.log(`[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`); console.log(
`[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`,
);
for (const pubkey of uniquePubkeys) { for (const pubkey of uniquePubkeys) {
try { try {
@ -557,7 +668,10 @@
authorProfiles = new Map(authorProfiles); authorProfiles = new Map(authorProfiles);
} }
} catch (err) { } catch (err) {
console.error(`[HighlightLayer] Error fetching profile for ${pubkey}:`, err); console.error(
`[HighlightLayer] Error fetching profile for ${pubkey}:`,
err,
);
} }
} }
} }
@ -579,7 +693,10 @@
* Scroll to a specific highlight in the document * Scroll to a specific highlight in the document
*/ */
function scrollToHighlight(highlight: NDKEvent) { function scrollToHighlight(highlight: NDKEvent) {
console.log(`[HighlightLayer] scrollToHighlight called for:`, highlight.content.substring(0, 50)); console.log(
`[HighlightLayer] scrollToHighlight called for:`,
highlight.content.substring(0, 50),
);
if (!containerRef) { if (!containerRef) {
console.warn(`[HighlightLayer] No containerRef available`); console.warn(`[HighlightLayer] No containerRef available`);
@ -594,7 +711,9 @@
// Find the highlight mark element // Find the highlight mark element
const highlightMarks = containerRef.querySelectorAll("mark.highlight"); const highlightMarks = containerRef.querySelectorAll("mark.highlight");
console.log(`[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`); console.log(
`[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`,
);
// Try exact match first // Try exact match first
for (const mark of highlightMarks) { for (const mark of highlightMarks) {
@ -602,7 +721,9 @@
const searchText = content.toLowerCase(); const searchText = content.toLowerCase();
if (markText === searchText) { if (markText === searchText) {
console.log(`[HighlightLayer] Found exact match, scrolling and flashing`); console.log(
`[HighlightLayer] Found exact match, scrolling and flashing`,
);
// Scroll to this element // Scroll to this element
mark.scrollIntoView({ behavior: "smooth", block: "center" }); mark.scrollIntoView({ behavior: "smooth", block: "center" });
@ -621,7 +742,9 @@
const searchText = content.toLowerCase(); const searchText = content.toLowerCase();
if (markText.includes(searchText) || searchText.includes(markText)) { if (markText.includes(searchText) || searchText.includes(markText)) {
console.log(`[HighlightLayer] Found partial match, scrolling and flashing`); console.log(
`[HighlightLayer] Found partial match, scrolling and flashing`,
);
mark.scrollIntoView({ behavior: "smooth", block: "center" }); mark.scrollIntoView({ behavior: "smooth", block: "center" });
mark.classList.add("highlight-flash"); mark.classList.add("highlight-flash");
setTimeout(() => { setTimeout(() => {
@ -631,7 +754,10 @@
} }
} }
console.warn(`[HighlightLayer] Could not find highlight mark for:`, content.substring(0, 50)); console.warn(
`[HighlightLayer] Could not find highlight mark for:`,
content.substring(0, 50),
);
} }
/** /**
@ -679,13 +805,19 @@
</script> </script>
{#if loading && visible} {#if loading && visible}
<div class="fixed top-40 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-3"> <div
<p class="text-sm text-gray-600 dark:text-gray-300">Loading highlights...</p> class="fixed top-40 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-3"
>
<p class="text-sm text-gray-600 dark:text-gray-300">
Loading highlights...
</p>
</div> </div>
{/if} {/if}
{#if visible && highlights.length > 0} {#if visible && highlights.length > 0}
<div class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-sm w-80"> <div
class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-sm w-80"
>
<h4 class="text-sm font-semibold mb-3 text-gray-900 dark:text-gray-100"> <h4 class="text-sm font-semibold mb-3 text-gray-900 dark:text-gray-100">
Highlights Highlights
</h4> </h4>
@ -707,19 +839,28 @@
class="w-3 h-3 rounded flex-shrink-0" class="w-3 h-3 rounded flex-shrink-0"
style="background-color: {color};" style="background-color: {color};"
></div> ></div>
<span class="font-medium text-gray-900 dark:text-gray-100 flex-1 text-left truncate"> <span
class="font-medium text-gray-900 dark:text-gray-100 flex-1 text-left truncate"
>
{displayName} {displayName}
</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
({authorHighlights.length}) ({authorHighlights.length})
</span> </span>
<svg <svg
class="w-4 h-4 text-gray-500 transition-transform {isExpanded ? 'rotate-90' : ''}" class="w-4 h-4 text-gray-500 transition-transform {isExpanded
? 'rotate-90'
: ''}"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg> </svg>
</button> </button>
@ -727,14 +868,18 @@
{#if isExpanded} {#if isExpanded}
<div class="mt-2 ml-5 space-y-2"> <div class="mt-2 ml-5 space-y-2">
{#each sortedHighlights as highlight} {#each sortedHighlights as highlight}
{@const truncated = useMockHighlights ? "test data" : truncateHighlight(highlight.content)} {@const truncated = useMockHighlights
? "test data"
: truncateHighlight(highlight.content)}
{@const showCopied = copyFeedback === highlight.id} {@const showCopied = copyFeedback === highlight.id}
<div class="flex items-start gap-2 group"> <div class="flex items-start gap-2 group">
<button <button
class="flex-1 text-left text-xs text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors" class="flex-1 text-left text-xs text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
onclick={() => scrollToHighlight(highlight)} onclick={() => scrollToHighlight(highlight)}
title={useMockHighlights ? "Mock highlight" : highlight.content} title={useMockHighlights
? "Mock highlight"
: highlight.content}
> >
{truncated} {truncated}
</button> </button>
@ -744,12 +889,30 @@
title="Copy naddr" title="Copy naddr"
> >
{#if showCopied} {#if showCopied}
<svg class="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /> class="w-3 h-3 text-green-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg> </svg>
{:else} {:else}
<svg class="w-3 h-3 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> class="w-3 h-3 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg> </svg>
{/if} {/if}
</button> </button>
@ -776,8 +939,9 @@
animation: flash 1.5s ease-in-out; animation: flash 1.5s ease-in-out;
} }
@keyframes :global(flash) { @keyframes -global-flash {
0%, 100% { 0%,
100% {
filter: brightness(1); filter: brightness(1);
} }
50% { 50% {

83
src/lib/components/publications/HighlightSelectionHandler.svelte

@ -73,7 +73,7 @@
tags: tags, tags: tags,
content: selectedText, content: selectedText,
id: "<calculated-on-signing>", id: "<calculated-on-signing>",
sig: "<calculated-on-signing>" sig: "<calculated-on-signing>",
}; };
}); });
@ -110,7 +110,7 @@
address: sectionAddress, address: sectionAddress,
eventId: sectionEventId, eventId: sectionEventId,
allDataAttrs: publicationSection.dataset, allDataAttrs: publicationSection.dataset,
sectionId: publicationSection.id sectionId: publicationSection.id,
}); });
currentSelection = selection; currentSelection = selection;
@ -151,13 +151,14 @@
event.pubkey = $userStore.pubkey; // Set pubkey from user store event.pubkey = $userStore.pubkey; // Set pubkey from user store
// Use the specific section's address/ID if available, otherwise fall back to publication event // Use the specific section's address/ID if available, otherwise fall back to publication event
const useAddress = selectedSectionAddress || publicationEvent.tagAddress(); const useAddress =
selectedSectionAddress || publicationEvent.tagAddress();
const useEventId = selectedSectionEventId || publicationEvent.id; const useEventId = selectedSectionEventId || publicationEvent.id;
console.log("[HighlightSelectionHandler] Creating highlight with:", { console.log("[HighlightSelectionHandler] Creating highlight with:", {
address: useAddress, address: useAddress,
eventId: useEventId, eventId: useEventId,
fallbackUsed: !selectedSectionAddress fallbackUsed: !selectedSectionAddress,
}); });
const tags: string[][] = []; const tags: string[][] = [];
@ -202,7 +203,11 @@
content: String(event.content), content: String(event.content),
}; };
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) { if (
typeof window !== "undefined" &&
window.nostr &&
window.nostr.signEvent
) {
const signed = await window.nostr.signEvent(plainEvent); const signed = await window.nostr.signEvent(plainEvent);
event.sig = signed.sig; event.sig = signed.sig;
if ("id" in signed) { if ("id" in signed) {
@ -222,7 +227,10 @@
// Remove duplicates // Remove duplicates
const uniqueRelays = Array.from(new Set(relays)); const uniqueRelays = Array.from(new Set(relays));
console.log("[HighlightSelectionHandler] Publishing to relays:", uniqueRelays); console.log(
"[HighlightSelectionHandler] Publishing to relays:",
uniqueRelays,
);
const signedEvent = { const signedEvent = {
...plainEvent, ...plainEvent,
@ -248,11 +256,15 @@
clearTimeout(timeout); clearTimeout(timeout);
if (ok) { if (ok) {
publishedCount++; publishedCount++;
console.log(`[HighlightSelectionHandler] Published to ${relayUrl}`); console.log(
`[HighlightSelectionHandler] Published to ${relayUrl}`,
);
WebSocketPool.instance.release(ws); WebSocketPool.instance.release(ws);
resolve(); resolve();
} else { } else {
console.warn(`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`); console.warn(
`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`,
);
WebSocketPool.instance.release(ws); WebSocketPool.instance.release(ws);
reject(new Error(message)); reject(new Error(message));
} }
@ -263,7 +275,10 @@
ws.send(JSON.stringify(["EVENT", signedEvent])); ws.send(JSON.stringify(["EVENT", signedEvent]));
}); });
} catch (e) { } catch (e) {
console.error(`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`, e); console.error(
`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`,
e,
);
} }
} }
@ -271,7 +286,10 @@
throw new Error("Failed to publish to any relays"); throw new Error("Failed to publish to any relays");
} }
showFeedbackMessage(`Highlight created and published to ${publishedCount} relay(s)!`, "success"); showFeedbackMessage(
`Highlight created and published to ${publishedCount} relay(s)!`,
"success",
);
// Clear the selection // Clear the selection
if (currentSelection) { if (currentSelection) {
@ -294,7 +312,10 @@
} }
} catch (error) { } catch (error) {
console.error("Failed to create highlight:", error); console.error("Failed to create highlight:", error);
showFeedbackMessage("Failed to create highlight. Please try again.", "error"); showFeedbackMessage(
"Failed to create highlight. Please try again.",
"error",
);
} finally { } finally {
isSubmitting = false; isSubmitting = false;
} }
@ -349,11 +370,18 @@
</script> </script>
{#if showConfirmModal} {#if showConfirmModal}
<Modal title="Create Highlight" bind:open={showConfirmModal} autoclose={false} size="md"> <Modal
title="Create Highlight"
bind:open={showConfirmModal}
autoclose={false}
size="md"
>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<P class="text-sm font-semibold mb-2">Selected Text:</P> <P class="text-sm font-semibold mb-2">Selected Text:</P>
<div class="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg max-h-32 overflow-y-auto"> <div
class="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg max-h-32 overflow-y-auto"
>
<P class="text-sm italic">"{selectedText}"</P> <P class="text-sm italic">"{selectedText}"</P>
</div> </div>
</div> </div>
@ -366,16 +394,21 @@
id="comment" id="comment"
bind:value={comment} bind:value={comment}
placeholder="Share your thoughts about this highlight..." placeholder="Share your thoughts about this highlight..."
rows="3" rows={3}
class="w-full" class="w-full"
/> />
</div> </div>
<!-- JSON Preview Section --> <!-- JSON Preview Section -->
{#if showJsonPreview && previewJson} {#if showJsonPreview && previewJson}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900"> <div
class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900"
>
<P class="text-sm font-semibold mb-2">Event JSON Preview:</P> <P class="text-sm font-semibold mb-2">Event JSON Preview:</P>
<pre class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code>{JSON.stringify(previewJson, null, 2)}</code></pre> <pre
class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code
>{JSON.stringify(previewJson, null, 2)}</code
></pre>
</div> </div>
{/if} {/if}
@ -383,7 +416,7 @@
<Button <Button
color="light" color="light"
size="sm" size="sm"
onclick={() => showJsonPreview = !showJsonPreview} onclick={() => (showJsonPreview = !showJsonPreview)}
class="flex items-center gap-1" class="flex items-center gap-1"
> >
{#if showJsonPreview} {#if showJsonPreview}
@ -395,10 +428,18 @@
</Button> </Button>
<div class="flex space-x-2"> <div class="flex space-x-2">
<Button color="alternative" onclick={cancelHighlight} disabled={isSubmitting}> <Button
color="alternative"
onclick={cancelHighlight}
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
<Button color="primary" onclick={createHighlight} disabled={isSubmitting}> <Button
color="primary"
onclick={createHighlight}
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Create Highlight"} {isSubmitting ? "Creating..." : "Create Highlight"}
</Button> </Button>
</div> </div>
@ -409,7 +450,9 @@
{#if showFeedback} {#if showFeedback}
<div <div
class="fixed bottom-4 right-4 z-50 p-4 rounded-lg shadow-lg {feedbackMessage.includes('success') class="fixed bottom-4 right-4 z-50 p-4 rounded-lg shadow-lg {feedbackMessage.includes(
'success',
)
? 'bg-green-500 text-white' ? 'bg-green-500 text-white'
: 'bg-red-500 text-white'}" : 'bg-red-500 text-white'}"
> >

329
src/lib/components/publications/Publication.svelte

@ -7,7 +7,8 @@
SidebarGroup, SidebarGroup,
SidebarWrapper, SidebarWrapper,
Heading, Heading,
CloseButton, uiHelpers CloseButton,
uiHelpers,
} from "flowbite-svelte"; } from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
import { import {
@ -37,13 +38,14 @@
import { Textarea, P } from "flowbite-svelte"; import { Textarea, P } from "flowbite-svelte";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{ let { rootAddress, publicationType, indexEvent, publicationTree, toc } =
rootAddress: string; $props<{
publicationType: string; rootAddress: string;
indexEvent: NDKEvent; publicationType: string;
publicationTree: SveltePublicationTree; indexEvent: NDKEvent;
toc: TocType; publicationTree: SveltePublicationTree;
}>(); toc: TocType;
}>();
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -64,23 +66,25 @@
// Toggle between mock and real data for testing (DEBUG MODE) // Toggle between mock and real data for testing (DEBUG MODE)
// Can be controlled via VITE_USE_MOCK_COMMENTS and VITE_USE_MOCK_HIGHLIGHTS environment variables // Can be controlled via VITE_USE_MOCK_COMMENTS and VITE_USE_MOCK_HIGHLIGHTS environment variables
let useMockComments = $state(import.meta.env.VITE_USE_MOCK_COMMENTS === "true"); let useMockComments = $state(
let useMockHighlights = $state(import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true"); import.meta.env.VITE_USE_MOCK_COMMENTS === "true",
);
let useMockHighlights = $state(
import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true",
);
// Log initial state for debugging // Log initial state for debugging
console.log('[Publication] Mock data initialized:', { console.log("[Publication] Mock data initialized:", {
useMockComments,
useMockHighlights,
envVars: { envVars: {
VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS, VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS,
VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS, VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS,
} },
}); });
// Derive all event IDs and addresses for highlight fetching // Derive all event IDs and addresses for highlight fetching
let allEventIds = $derived.by(() => { let allEventIds = $derived.by(() => {
const ids = [indexEvent.id]; const ids = [indexEvent.id];
leaves.forEach(leaf => { leaves.forEach((leaf) => {
if (leaf?.id) ids.push(leaf.id); if (leaf?.id) ids.push(leaf.id);
}); });
return ids; return ids;
@ -88,7 +92,7 @@
let allEventAddresses = $derived.by(() => { let allEventAddresses = $derived.by(() => {
const addresses = [rootAddress]; const addresses = [rootAddress];
leaves.forEach(leaf => { leaves.forEach((leaf) => {
if (leaf) { if (leaf) {
const addr = leaf.tagAddress(); const addr = leaf.tagAddress();
if (addr) addresses.push(addr); if (addr) addresses.push(addr);
@ -99,11 +103,11 @@
// Filter comments for the root publication (kind 30040) // Filter comments for the root publication (kind 30040)
let articleComments = $derived( let articleComments = $derived(
comments.filter(comment => { comments.filter((comment) => {
// Check if comment targets the root publication via #a tag // Check if comment targets the root publication via #a tag
const aTag = comment.tags.find(t => t[0] === 'a'); const aTag = comment.tags.find((t) => t[0] === "a");
return aTag && aTag[1] === rootAddress; return aTag && aTag[1] === rootAddress;
}) }),
); );
// #region Loading // #region Loading
@ -124,9 +128,11 @@
console.warn("[Publication] publicationTree is not available"); console.warn("[Publication] publicationTree is not available");
return; return;
} }
console.log(`[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`); console.log(
`[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`,
);
isLoading = true; isLoading = true;
try { try {
@ -159,7 +165,9 @@
console.error("[Publication] Error loading more content:", error); console.error("[Publication] Error loading more content:", error);
} finally { } finally {
isLoading = false; isLoading = false;
console.log(`[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`); console.log(
`[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`,
);
} }
} }
@ -196,12 +204,12 @@
lastElementRef = null; lastElementRef = null;
loadedAddresses = new Set(); loadedAddresses = new Set();
hasInitialized = false; hasInitialized = false;
// Reset the publication tree iterator to prevent duplicate events // Reset the publication tree iterator to prevent duplicate events
if (typeof publicationTree.resetIterator === 'function') { if (typeof publicationTree.resetIterator === "function") {
publicationTree.resetIterator(); publicationTree.resetIterator();
} }
// AI-NOTE: Use setTimeout to ensure iterator reset completes before loading // AI-NOTE: Use setTimeout to ensure iterator reset completes before loading
// This prevents race conditions where loadMore is called before the iterator is fully reset // This prevents race conditions where loadMore is called before the iterator is fully reset
setTimeout(() => { setTimeout(() => {
@ -298,7 +306,9 @@
const kind = parseInt(kindStr); const kind = parseInt(kindStr);
// Create comment event (kind 1111) // Create comment event (kind 1111)
const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent(ndk); const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent(
ndk,
);
commentEvent.kind = 1111; commentEvent.kind = 1111;
commentEvent.content = articleCommentContent; commentEvent.content = articleCommentContent;
@ -330,10 +340,10 @@
articleCommentSuccess = false; articleCommentSuccess = false;
handleCommentPosted(); handleCommentPosted();
}, 1500); }, 1500);
} catch (err) { } catch (err) {
console.error("[Publication] Error posting article comment:", err); console.error("[Publication] Error posting article comment:", err);
articleCommentError = err instanceof Error ? err.message : "Failed to post comment"; articleCommentError =
err instanceof Error ? err.message : "Failed to post comment";
} finally { } finally {
isSubmittingArticleComment = false; isSubmittingArticleComment = false;
} }
@ -344,30 +354,36 @@
*/ */
async function handleDeletePublication() { async function handleDeletePublication() {
const confirmed = confirm( const confirmed = confirm(
"Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays." "Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays.",
); );
if (!confirmed) return; if (!confirmed) return;
try { try {
await deleteEvent({ await deleteEvent(
eventAddress: indexEvent.tagAddress(), {
eventKind: indexEvent.kind, eventAddress: indexEvent.tagAddress(),
reason: "User deleted publication", eventKind: indexEvent.kind,
onSuccess: (deletionEventId) => { reason: "User deleted publication",
console.log("[Publication] Deletion event published:", deletionEventId); onSuccess: (deletionEventId) => {
publicationDeleted = true; console.log(
"[Publication] Deletion event published:",
// Redirect after 2 seconds deletionEventId,
setTimeout(() => { );
goto("/publications"); publicationDeleted = true;
}, 2000);
// Redirect after 2 seconds
setTimeout(() => {
goto("/publications");
}, 2000);
},
onError: (error) => {
console.error("[Publication] Failed to delete publication:", error);
alert(`Failed to delete publication: ${error}`);
},
}, },
onError: (error) => { ndk,
console.error("[Publication] Failed to delete publication:", error); );
alert(`Failed to delete publication: ${error}`);
},
});
} catch (error) { } catch (error) {
console.error("[Publication] Error deleting publication:", error); console.error("[Publication] Error deleting publication:", error);
alert(`Error: ${error}`); alert(`Error: ${error}`);
@ -422,14 +438,19 @@
observer = new IntersectionObserver( observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading && !isDone && publicationTree) { if (
entry.isIntersecting &&
!isLoading &&
!isDone &&
publicationTree
) {
loadMore(1); loadMore(1);
} }
}); });
}, },
{ threshold: 0.5 }, { threshold: 0.5 },
); );
// AI-NOTE: Removed duplicate loadMore call // AI-NOTE: Removed duplicate loadMore call
// Initial content loading is handled by the $effect that watches publicationTree // Initial content loading is handled by the $effect that watches publicationTree
// This prevents duplicate loading when both onMount and $effect trigger // This prevents duplicate loading when both onMount and $effect trigger
@ -450,14 +471,11 @@
</script> </script>
<!-- Add gap & items-start so sticky sidebars size correctly --> <!-- Add gap & items-start so sticky sidebars size correctly -->
<div class="relative grid gap-4 items-start grid-cols-[1fr_3fr_1fr] grid-rows-[auto_1fr]"> <div
class="relative grid gap-4 items-start grid-cols-[1fr_3fr_1fr] grid-rows-[auto_1fr]"
>
<!-- Full-width ArticleNav row --> <!-- Full-width ArticleNav row -->
<ArticleNav <ArticleNav {publicationType} rootId={indexEvent.id} {indexEvent} />
publicationType={publicationType}
rootId={indexEvent.id}
indexEvent={indexEvent}
/>
<!-- Highlight selection handler --> <!-- Highlight selection handler -->
<HighlightSelectionHandler <HighlightSelectionHandler
@ -477,43 +495,53 @@
<!-- Three-column row --> <!-- Three-column row -->
<div class="contents"> <div class="contents">
<!-- Table of contents --> <!-- Table of contents -->
<div class="mt-[70px] relative {$publicationColumnVisibility.toc ? 'w-64' : 'w-auto'}"> <div
class="mt-[70px] relative {$publicationColumnVisibility.toc
? 'w-64'
: 'w-auto'}"
>
{#if publicationType !== "blog" && !isLeaf} {#if publicationType !== "blog" && !isLeaf}
{#if $publicationColumnVisibility.toc} {#if $publicationColumnVisibility.toc}
<Sidebar <Sidebar
class="z-10 ml-2 fixed top-[162px] max-h-[calc(100vh-165px)] overflow-y-auto dark:bg-primary-900 bg-primary-50 rounded" class="z-10 ml-2 fixed top-[162px] max-h-[calc(100vh-165px)] overflow-y-auto dark:bg-primary-900 bg-primary-50 rounded"
activeUrl={`#${activeAddress ?? ""}`} activeUrl={`#${activeAddress ?? ""}`}
classes={{ classes={{
div: 'dark:bg-primary-900 bg-primary-50', div: "dark:bg-primary-900 bg-primary-50",
active: 'bg-primary-100 dark:bg-primary-800 p-2 rounded-lg', active: "bg-primary-100 dark:bg-primary-800 p-2 rounded-lg",
nonactive: 'bg-primary-50 dark:bg-primary-900', nonactive: "bg-primary-50 dark:bg-primary-900",
}} }}
> >
<SidebarWrapper> <SidebarWrapper>
<CloseButton color="secondary" class="m-2 dark:text-primary-100" onclick={closeToc} ></CloseButton> <CloseButton
color="secondary"
class="m-2 dark:text-primary-100"
onclick={closeToc}
></CloseButton>
<TableOfContents <TableOfContents
{rootAddress} {rootAddress}
{toc} {toc}
depth={2} depth={2}
onSectionFocused={(address: string) => publicationTree.setBookmark(address)} onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)}
onLoadMore={() => { onLoadMore={() => {
if (!isLoading && !isDone && publicationTree) { if (!isLoading && !isDone && publicationTree) {
loadMore(4); loadMore(4);
} }
}} }}
/> />
</SidebarWrapper> </SidebarWrapper>
</Sidebar> </Sidebar>
{/if} {/if}
{/if} {/if}
</div> </div>
<div class="mt-[70px]"> <div class="mt-[70px]">
<!-- Default publications --> <!-- Default publications -->
{#if $publicationColumnVisibility.main} {#if $publicationColumnVisibility.main}
<!-- Remove overflow-auto so page scroll drives it --> <!-- Remove overflow-auto so page scroll drives it -->
<div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto" bind:this={publicationContentRef}> <div
class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto"
bind:this={publicationContentRef}
>
<!-- Publication header with comments (similar to section layout) --> <!-- Publication header with comments (similar to section layout) -->
<div class="relative"> <div class="relative">
<!-- Main header content - centered --> <!-- Main header content - centered -->
@ -521,7 +549,10 @@
<div <div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
> >
<Details event={indexEvent} onDelete={handleDeletePublication} /> <Details
event={indexEvent}
onDelete={handleDeletePublication}
/>
</div> </div>
{#if publicationDeleted} {#if publicationDeleted}
@ -542,7 +573,9 @@
</div> </div>
<!-- Desktop article comments - positioned on right side on XL+ screens --> <!-- Desktop article comments - positioned on right side on XL+ screens -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-0 w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"> <div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-0 w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"
>
<SectionComments <SectionComments
sectionAddress={rootAddress} sectionAddress={rootAddress}
comments={articleComments} comments={articleComments}
@ -557,18 +590,14 @@
<Button <Button
color="light" color="light"
size="sm" size="sm"
onclick={() => showArticleCommentUI = !showArticleCommentUI} onclick={() => (showArticleCommentUI = !showArticleCommentUI)}
> >
{showArticleCommentUI ? 'Close Comment' : 'Comment On Article'} {showArticleCommentUI ? "Close Comment" : "Comment On Article"}
</Button> </Button>
<HighlightButton bind:isActive={highlightModeActive} /> <HighlightButton bind:isActive={highlightModeActive} />
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button color="light" size="sm" onclick={toggleComments}>
color="light"
size="sm"
onclick={toggleComments}
>
{#if commentsVisible} {#if commentsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" /> <EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Comments Hide Comments
@ -577,11 +606,7 @@
Show Comments Show Comments
{/if} {/if}
</Button> </Button>
<Button <Button color="light" size="sm" onclick={toggleHighlights}>
color="light"
size="sm"
onclick={toggleHighlights}
>
{#if highlightsVisible} {#if highlightsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" /> <EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Highlights Hide Highlights
@ -595,9 +620,13 @@
<!-- Article Comment UI --> <!-- Article Comment UI -->
{#if showArticleCommentUI} {#if showArticleCommentUI}
<div class="mb-4 border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-800"> <div
class="mb-4 border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-800"
>
<div class="space-y-3"> <div class="space-y-3">
<h4 class="font-semibold text-gray-900 dark:text-white">Comment on Article</h4> <h4 class="font-semibold text-gray-900 dark:text-white">
Comment on Article
</h4>
<Textarea <Textarea
bind:value={articleCommentContent} bind:value={articleCommentContent}
@ -607,18 +636,28 @@
/> />
{#if articleCommentError} {#if articleCommentError}
<P class="text-red-600 dark:text-red-400 text-sm">{articleCommentError}</P> <P class="text-red-600 dark:text-red-400 text-sm"
>{articleCommentError}</P
>
{/if} {/if}
{#if articleCommentSuccess} {#if articleCommentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm">Comment posted successfully!</P> <P class="text-green-600 dark:text-green-400 text-sm"
>Comment posted successfully!</P
>
{/if} {/if}
<div class="flex gap-2"> <div class="flex gap-2">
<Button onclick={submitArticleComment} disabled={isSubmittingArticleComment}> <Button
{isSubmittingArticleComment ? 'Posting...' : 'Post Comment'} onclick={submitArticleComment}
disabled={isSubmittingArticleComment}
>
{isSubmittingArticleComment ? "Posting..." : "Post Comment"}
</Button> </Button>
<Button color="light" onclick={() => showArticleCommentUI = false}> <Button
color="light"
onclick={() => (showArticleCommentUI = false)}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@ -651,7 +690,9 @@
{#if isLoading} {#if isLoading}
<Button disabled color="primary">Loading...</Button> <Button disabled color="primary">Loading...</Button>
{:else if !isDone} {:else if !isDone}
<Button color="primary" onclick={() => loadMore(1)}>Show More</Button> <Button color="primary" onclick={() => loadMore(1)}
>Show More</Button
>
{:else} {:else}
<p class="text-gray-500 dark:text-gray-400"> <p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication. You've reached the end of the publication.
@ -696,57 +737,64 @@
{/if} {/if}
</div> </div>
<div class="mt-[70px] relative {$publicationColumnVisibility.discussion ? 'w-64' : 'w-auto'}"> <div
class="mt-[70px] relative {$publicationColumnVisibility.discussion
? 'w-64'
: 'w-auto'}"
>
<!-- Discussion sidebar --> <!-- Discussion sidebar -->
{#if $publicationColumnVisibility.discussion} {#if $publicationColumnVisibility.discussion}
<Sidebar <Sidebar
class="z-10 ml-4 fixed top-[162px] h-[calc(100vh-165px)] overflow-y-auto" class="z-10 ml-4 fixed top-[162px] h-[calc(100vh-165px)] overflow-y-auto"
classes={{ classes={{
div: 'bg-transparent' div: "bg-transparent",
}} }}
> >
<SidebarWrapper> <SidebarWrapper>
<SidebarGroup> <SidebarGroup>
<div class="flex justify-between items-baseline"> <div class="flex justify-between items-baseline">
<Heading tag="h1" class="h-leather !text-lg">Discussion</Heading> <Heading tag="h1" class="h-leather !text-lg">Discussion</Heading
<Button >
class="btn-leather hidden sm:flex z-30 !p-1 bg-primary-50 dark:bg-gray-800" <Button
outline class="btn-leather hidden sm:flex z-30 !p-1 bg-primary-50 dark:bg-gray-800"
onclick={closeDiscussion} outline
> onclick={closeDiscussion}
<CloseOutline /> >
</Button> <CloseOutline />
</div> </Button>
<div class="flex flex-col space-y-4"> </div>
<!-- TODO <div class="flex flex-col space-y-4">
<!-- TODO
alternative for other publications and alternative for other publications and
when blog is not opened, but discussion is opened from the list when blog is not opened, but discussion is opened from the list
--> -->
{#if showBlogHeader() && currentBlog && currentBlogEvent} {#if showBlogHeader() && currentBlog && currentBlogEvent}
<BlogHeader <BlogHeader
rootId={currentBlog} rootId={currentBlog}
event={currentBlogEvent} event={currentBlogEvent}
onBlogUpdate={loadBlog} onBlogUpdate={loadBlog}
active={true} active={true}
/> />
{/if}
<div class="flex flex-col w-full space-y-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
{#if articleComments.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
No comments yet. Be the first to comment!
</p>
{/if} {/if}
<div class="flex flex-col w-full space-y-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
{#if articleComments.length === 0}
<p
class="text-sm text-gray-500 dark:text-gray-400 text-center py-4"
>
No comments yet. Be the first to comment!
</p>
{/if}
</div>
</div> </div>
</div> </SidebarGroup>
</SidebarGroup> </SidebarWrapper>
</SidebarWrapper> </Sidebar>
</Sidebar> {/if}
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -757,7 +805,7 @@
eventIds={allEventIds} eventIds={allEventIds}
eventAddresses={allEventAddresses} eventAddresses={allEventAddresses}
bind:visible={highlightsVisible} bind:visible={highlightsVisible}
useMockHighlights={useMockHighlights} {useMockHighlights}
/> />
<!-- Comment Layer Component --> <!-- Comment Layer Component -->
@ -765,7 +813,6 @@
bind:this={commentLayerRef} bind:this={commentLayerRef}
eventIds={allEventIds} eventIds={allEventIds}
eventAddresses={allEventAddresses} eventAddresses={allEventAddresses}
bind:comments={comments} bind:comments
useMockComments={useMockComments} {useMockComments}
/> />

72
src/lib/components/publications/PublicationSection.svelte

@ -42,11 +42,11 @@
// Filter comments for this section // Filter comments for this section
let sectionComments = $derived( let sectionComments = $derived(
allComments.filter(comment => { allComments.filter((comment) => {
// Check if comment targets this section via #a tag // Check if comment targets this section via #a tag
const aTag = comment.tags.find(t => t[0] === 'a'); const aTag = comment.tags.find((t) => t[0] === "a");
return aTag && aTag[1] === address; return aTag && aTag[1] === address;
}) }),
); );
let leafEvent: Promise<NDKEvent | null> = $derived.by( let leafEvent: Promise<NDKEvent | null> = $derived.by(
@ -56,10 +56,13 @@
let leafEventId = $state<string>(""); let leafEventId = $state<string>("");
$effect(() => { $effect(() => {
leafEvent.then(e => { leafEvent.then((e) => {
if (e?.id) { if (e?.id) {
leafEventId = e.id; leafEventId = e.id;
console.log(`[PublicationSection] Set leafEventId for ${address}:`, e.id); console.log(
`[PublicationSection] Set leafEventId for ${address}:`,
e.id,
);
} }
}); });
}); });
@ -83,7 +86,7 @@
let leafContent: Promise<string | Document> = $derived.by(async () => { let leafContent: Promise<string | Document> = $derived.by(async () => {
const event = await leafEvent; const event = await leafEvent;
const content = event?.content ?? ""; const content = event?.content ?? "";
// AI-NOTE: Kind 30023 events contain Markdown content, not AsciiDoc // AI-NOTE: Kind 30023 events contain Markdown content, not AsciiDoc
// Use parseAdvancedmarkup for 30023 events, Asciidoctor for 30041/30818 events // Use parseAdvancedmarkup for 30023 events, Asciidoctor for 30041/30818 events
if (event?.kind === 30023) { if (event?.kind === 30023) {
@ -91,7 +94,10 @@
} else { } else {
// For 30041 and 30818 events, use Asciidoctor (AsciiDoc) // For 30041 and 30818 events, use Asciidoctor (AsciiDoc)
const converted = asciidoctor.convert(content); const converted = asciidoctor.convert(content);
const processed = await postProcessAdvancedAsciidoctorHtml(converted.toString(), ndk); const processed = await postProcessAdvancedAsciidoctorHtml(
converted.toString(),
ndk,
);
return processed; return processed;
} }
}); });
@ -169,26 +175,32 @@
if (!event) return; if (!event) return;
const confirmed = confirm( const confirmed = confirm(
"Are you sure you want to delete this section? This action will publish a deletion request to all relays." "Are you sure you want to delete this section? This action will publish a deletion request to all relays.",
); );
if (!confirmed) return; if (!confirmed) return;
try { try {
await deleteEvent({ await deleteEvent(
eventAddress: address, {
eventKind: event.kind, eventAddress: address,
reason: "User deleted section", eventKind: event.kind,
onSuccess: (deletionEventId) => { reason: "User deleted section",
console.log("[PublicationSection] Deletion event published:", deletionEventId); onSuccess: (deletionEventId) => {
// Refresh the page to reflect the deletion console.log(
window.location.reload(); "[PublicationSection] Deletion event published:",
}, deletionEventId,
onError: (error) => { );
console.error("[PublicationSection] Deletion failed:", error); // Refresh the page to reflect the deletion
alert(`Failed to delete section: ${error}`); window.location.reload();
},
onError: (error) => {
console.error("[PublicationSection] Deletion failed:", error);
alert(`Failed to delete section: ${error}`);
},
}, },
}, ndk); ndk,
);
} catch (error) { } catch (error) {
console.error("[PublicationSection] Deletion error:", error); console.error("[PublicationSection] Deletion error:", error);
} }
@ -206,7 +218,7 @@
address, address,
leafEventId, leafEventId,
dataAddress: sectionRef.dataset.eventAddress, dataAddress: sectionRef.dataset.eventAddress,
dataEventId: sectionRef.dataset.eventId dataEventId: sectionRef.dataset.eventId,
}); });
}); });
</script> </script>
@ -221,7 +233,7 @@
data-event-id={leafEventId} data-event-id={leafEventId}
> >
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )} {#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
<TextPlaceholder size="xxl" /> <TextPlaceholder size="2xl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
<!-- Main content area - centered --> <!-- Main content area - centered -->
<div class="section-content relative max-w-4xl mx-auto px-4"> <div class="section-content relative max-w-4xl mx-auto px-4">
@ -229,7 +241,11 @@
<div class="xl:hidden absolute top-2 right-2 z-10"> <div class="xl:hidden absolute top-2 right-2 z-10">
{#await leafEvent then event} {#await leafEvent then event}
{#if event} {#if event}
<CardActions {event} sectionAddress={address} onDelete={handleDelete} /> <CardActions
{event}
sectionAddress={address}
onDelete={handleDelete}
/>
{/if} {/if}
{/await} {/await}
</div> </div>
@ -265,14 +281,18 @@
{#await leafEvent then event} {#await leafEvent then event}
{#if event} {#if event}
<!-- Three-dot menu - positioned at top-center on XL+ screens --> <!-- Three-dot menu - positioned at top-center on XL+ screens -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-[20%] z-10"> <div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-[20%] z-10"
>
<CardActions {event} sectionAddress={address} onDelete={handleDelete} /> <CardActions {event} sectionAddress={address} onDelete={handleDelete} />
</div> </div>
{/if} {/if}
{/await} {/await}
<!-- Comments area: positioned below menu, top-center of section --> <!-- Comments area: positioned below menu, top-center of section -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-[calc(20%+3rem)] w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"> <div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-[calc(20%+3rem)] w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"
>
<SectionComments <SectionComments
sectionAddress={address} sectionAddress={address}
comments={sectionComments} comments={sectionComments}

85
src/lib/components/util/CardActions.svelte

@ -12,7 +12,11 @@
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk"; import {
activeInboxRelays,
activeOutboxRelays,
getNdkContext,
} from "$lib/ndk";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
@ -104,19 +108,20 @@
], ],
content: commentContent, content: commentContent,
id: "<calculated-on-signing>", id: "<calculated-on-signing>",
sig: "<calculated-on-signing>" sig: "<calculated-on-signing>",
}; };
}); });
// Check if user can delete this event (must be the author) // Check if user can delete this event (must be the author)
let canDelete = $derived.by(() => { let canDelete = $derived.by(() => {
const result = user.signedIn && user.pubkey === event.pubkey && onDelete !== undefined; const result =
console.log('[CardActions] canDelete check:', { user.signedIn && user.pubkey === event.pubkey && onDelete !== undefined;
console.log("[CardActions] canDelete check:", {
userSignedIn: user.signedIn, userSignedIn: user.signedIn,
userPubkey: user.pubkey, userPubkey: user.pubkey,
eventPubkey: event.pubkey, eventPubkey: event.pubkey,
onDeleteProvided: onDelete !== undefined, onDeleteProvided: onDelete !== undefined,
canDelete: result canDelete: result,
}); });
return result; return result;
}); });
@ -221,7 +226,9 @@
/** /**
* Parse address to get event details * Parse address to get event details
*/ */
function parseAddress(address: string): { kind: number; pubkey: string; dTag: string } | null { function parseAddress(
address: string,
): { kind: number; pubkey: string; dTag: string } | null {
const parts = address.split(":"); const parts = address.split(":");
if (parts.length !== 3) { if (parts.length !== 3) {
console.error("[CardActions] Invalid address format:", address); console.error("[CardActions] Invalid address format:", address);
@ -301,12 +308,18 @@
const plainEvent = { const plainEvent = {
kind: Number(commentEvent.kind), kind: Number(commentEvent.kind),
pubkey: String(commentEvent.pubkey), pubkey: String(commentEvent.pubkey),
created_at: Number(commentEvent.created_at ?? Math.floor(Date.now() / 1000)), created_at: Number(
commentEvent.created_at ?? Math.floor(Date.now() / 1000),
),
tags: commentEvent.tags.map((tag) => tag.map(String)), tags: commentEvent.tags.map((tag) => tag.map(String)),
content: String(commentEvent.content), content: String(commentEvent.content),
}; };
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) { if (
typeof window !== "undefined" &&
window.nostr &&
window.nostr.signEvent
) {
const signed = await window.nostr.signEvent(plainEvent); const signed = await window.nostr.signEvent(plainEvent);
commentEvent.sig = signed.sig; commentEvent.sig = signed.sig;
if ("id" in signed) { if ("id" in signed) {
@ -373,10 +386,10 @@
commentContent = ""; commentContent = "";
showJsonPreview = false; showJsonPreview = false;
}, 2000); }, 2000);
} catch (err) { } catch (err) {
console.error("[CardActions] Error submitting comment:", err); console.error("[CardActions] Error submitting comment:", err);
commentError = err instanceof Error ? err.message : "Failed to post comment"; commentError =
err instanceof Error ? err.message : "Failed to post comment";
} finally { } finally {
isSubmittingComment = false; isSubmittingComment = false;
} }
@ -404,7 +417,7 @@
type="button" type="button"
id="dots-{event.id}" id="dots-{event.id}"
class=" hover:bg-primary-50 dark:text-highlight dark:hover:bg-primary-800 p-1 dots" class=" hover:bg-primary-50 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
color="none" color="primary"
data-popover-target="popover-actions" data-popover-target="popover-actions"
> >
<DotsVerticalOutline class="h-6 w-6" /> <DotsVerticalOutline class="h-6 w-6" />
@ -463,7 +476,8 @@
onDelete?.(); onDelete?.();
}} }}
> >
<TrashBinOutline class="inline mr-2" /> {deleteButtonText} <TrashBinOutline class="inline mr-2" />
{deleteButtonText}
</button> </button>
</li> </li>
{/if} {/if}
@ -570,7 +584,9 @@
> >
<div class="space-y-4"> <div class="space-y-4">
{#if user.profile} {#if user.profile}
<div class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700"> <div
class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700"
>
{#if user.profile.picture} {#if user.profile.picture}
<img <img
src={user.profile.picture} src={user.profile.picture}
@ -597,14 +613,21 @@
{/if} {/if}
{#if commentSuccess} {#if commentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm">Comment posted successfully!</P> <P class="text-green-600 dark:text-green-400 text-sm"
>Comment posted successfully!</P
>
{/if} {/if}
<!-- JSON Preview Section --> <!-- JSON Preview Section -->
{#if showJsonPreview && previewJson} {#if showJsonPreview && previewJson}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900"> <div
class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900"
>
<P class="text-sm font-semibold mb-2">Event JSON Preview:</P> <P class="text-sm font-semibold mb-2">Event JSON Preview:</P>
<pre class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code>{JSON.stringify(previewJson, null, 2)}</code></pre> <pre
class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code
>{JSON.stringify(previewJson, null, 2)}</code
></pre>
</div> </div>
{/if} {/if}
@ -612,7 +635,7 @@
<Button <Button
color="light" color="light"
size="sm" size="sm"
onclick={() => showJsonPreview = !showJsonPreview} onclick={() => (showJsonPreview = !showJsonPreview)}
class="flex items-center gap-1" class="flex items-center gap-1"
> >
{#if showJsonPreview} {#if showJsonPreview}
@ -624,20 +647,20 @@
</Button> </Button>
<div class="flex gap-3"> <div class="flex gap-3">
<Button <Button
color="alternative" color="alternative"
onclick={cancelComment} onclick={cancelComment}
disabled={isSubmittingComment} disabled={isSubmittingComment}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
color="primary" color="primary"
onclick={submitComment} onclick={submitComment}
disabled={isSubmittingComment || !commentContent.trim()} disabled={isSubmittingComment || !commentContent.trim()}
> >
{isSubmittingComment ? "Posting..." : "Post Comment"} {isSubmittingComment ? "Posting..." : "Post Comment"}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>

20
src/lib/components/util/Interactions.svelte

@ -11,10 +11,11 @@
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
const { const { rootId, direction = "row" } = $props<{
rootId, rootId: string;
direction = "row", event?: NDKEvent;
} = $props<{ rootId: string; event?: NDKEvent; direction?: string }>(); direction?: string;
}>();
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -90,26 +91,27 @@
class="InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-300" class="InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-300"
> >
<Button <Button
color="none" color="secondary"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0" class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doLike} onclick={doLike}
><HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span></Button
> >
<HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span>
</Button>
<Button <Button
color="none" color="secondary"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0" class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doZap} onclick={doZap}
><ZapOutline className="mx-2" /><span>{zapCount}</span></Button ><ZapOutline className="mx-2" /><span>{zapCount}</span></Button
> >
<Button <Button
color="none" color="secondary"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0" class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doHighlight} onclick={doHighlight}
><FilePenOutline class="mx-2" size="lg" /><span>{highlightCount}</span ><FilePenOutline class="mx-2" size="lg" /><span>{highlightCount}</span
></Button ></Button
> >
<Button <Button
color="none" color="secondary"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0" class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={showDiscussion} onclick={showDiscussion}
><AnnotationOutline class="mx-2" size="lg" /><span>{commentCount}</span ><AnnotationOutline class="mx-2" size="lg" /><span>{commentCount}</span

14
src/lib/services/deletion.ts

@ -25,7 +25,8 @@ export async function deleteEvent(
options: DeletionOptions, options: DeletionOptions,
ndk: NDK, ndk: NDK,
): Promise<DeletionResult> { ): Promise<DeletionResult> {
const { eventId, eventAddress, eventKind, reason = "", onSuccess, onError } = options; const { eventId, eventAddress, eventKind, reason = "", onSuccess, onError } =
options;
if (!eventId && !eventAddress) { if (!eventId && !eventAddress) {
const error = "Either eventId or eventAddress must be provided"; const error = "Either eventId or eventAddress must be provided";
@ -52,17 +53,17 @@ export async function deleteEvent(
if (eventId) { if (eventId) {
// Add 'e' tag for event ID // Add 'e' tag for event ID
tags.push(['e', eventId]); tags.push(["e", eventId]);
} }
if (eventAddress) { if (eventAddress) {
// Add 'a' tag for replaceable event address // Add 'a' tag for replaceable event address
tags.push(['a', eventAddress]); tags.push(["a", eventAddress]);
} }
if (eventKind) { if (eventKind) {
// Add 'k' tag for event kind (recommended by NIP-09) // Add 'k' tag for event kind (recommended by NIP-09)
tags.push(['k', eventKind.toString()]); tags.push(["k", eventKind.toString()]);
} }
deletionEvent.tags = tags; deletionEvent.tags = tags;
@ -93,8 +94,9 @@ export async function deleteEvent(
throw new Error("Failed to publish deletion request to any relays"); throw new Error("Failed to publish deletion request to any relays");
} }
} catch (error) { } catch (error) {
const errorMessage = const errorMessage = error instanceof Error
error instanceof Error ? error.message : "Unknown error"; ? error.message
: "Unknown error";
console.error(`[deletion.ts] Error deleting event: ${errorMessage}`); console.error(`[deletion.ts] Error deleting event: ${errorMessage}`);
onError?.(errorMessage); onError?.(errorMessage);
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };

29
src/lib/services/publisher.ts

@ -102,8 +102,9 @@ export async function publishZettel(
throw new Error("Failed to publish to any relays"); throw new Error("Failed to publish to any relays");
} }
} catch (error) { } catch (error) {
const errorMessage = const errorMessage = error instanceof Error
error instanceof Error ? error.message : "Unknown error"; ? error.message
: "Unknown error";
onError?.(errorMessage); onError?.(errorMessage);
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
} }
@ -165,8 +166,7 @@ export async function publishSingleEvent(
if (!hasAuthorTag && ndk.activeUser) { if (!hasAuthorTag && ndk.activeUser) {
// Add display name as author // Add display name as author
const displayName = const displayName = ndk.activeUser.profile?.displayName ||
ndk.activeUser.profile?.displayName ||
ndk.activeUser.profile?.name || ndk.activeUser.profile?.name ||
"Anonymous"; "Anonymous";
finalTags.push(["author", displayName]); finalTags.push(["author", displayName]);
@ -196,8 +196,9 @@ export async function publishSingleEvent(
throw new Error("Failed to publish to any relays"); throw new Error("Failed to publish to any relays");
} }
} catch (error) { } catch (error) {
const errorMessage = const errorMessage = error instanceof Error
error instanceof Error ? error.message : "Unknown error"; ? error.message
: "Unknown error";
console.error(`Error publishing event: ${errorMessage}`); console.error(`Error publishing event: ${errorMessage}`);
onError?.(errorMessage); onError?.(errorMessage);
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
@ -272,15 +273,17 @@ export async function publishMultipleZettels(
}); });
} }
} catch (err) { } catch (err) {
const errorMessage = const errorMessage = err instanceof Error
err instanceof Error ? err.message : "Unknown error"; ? err.message
: "Unknown error";
results.push({ success: false, error: errorMessage }); results.push({ success: false, error: errorMessage });
} }
} }
return results; return results;
} catch (error) { } catch (error) {
const errorMessage = const errorMessage = error instanceof Error
error instanceof Error ? error.message : "Unknown error"; ? error.message
: "Unknown error";
onError?.(errorMessage); onError?.(errorMessage);
return [{ success: false, error: errorMessage }]; return [{ success: false, error: errorMessage }];
} }
@ -314,8 +317,7 @@ export function processPublishResults(
} else { } else {
const contentIndex = hasIndexEvent ? index - 1 : index; const contentIndex = hasIndexEvent ? index - 1 : index;
const contentEvent = events.contentEvents[contentIndex]; const contentEvent = events.contentEvents[contentIndex];
title = title = contentEvent?.title ||
contentEvent?.title ||
contentEvent?.tags?.find((t: any) => t[0] === "title")?.[1] || contentEvent?.tags?.find((t: any) => t[0] === "title")?.[1] ||
`Note ${contentIndex + 1}`; `Note ${contentIndex + 1}`;
} }
@ -338,8 +340,7 @@ export function processPublishResults(
} else { } else {
const contentIndex = hasIndexEvent ? index - 1 : index; const contentIndex = hasIndexEvent ? index - 1 : index;
const contentEvent = events.contentEvents[contentIndex]; const contentEvent = events.contentEvents[contentIndex];
title = title = contentEvent?.title ||
contentEvent?.title ||
contentEvent?.tags?.find((t: any) => t[0] === "title")?.[1] || contentEvent?.tags?.find((t: any) => t[0] === "title")?.[1] ||
`Note ${contentIndex + 1}`; `Note ${contentIndex + 1}`;
} }

136
src/lib/utils/asciidoc_ast_parser.ts

@ -1,6 +1,6 @@
/** /**
* AST-based AsciiDoc parsing using Asciidoctor's native document structure * AST-based AsciiDoc parsing using Asciidoctor's native document structure
* *
* This replaces the manual regex parsing in asciidoc_metadata.ts with proper * This replaces the manual regex parsing in asciidoc_metadata.ts with proper
* AST traversal, leveraging Asciidoctor's built-in parsing capabilities. * AST traversal, leveraging Asciidoctor's built-in parsing capabilities.
*/ */
@ -30,27 +30,33 @@ export interface ASTParsedDocument {
/** /**
* Parse AsciiDoc content using Asciidoctor's AST instead of manual regex * Parse AsciiDoc content using Asciidoctor's AST instead of manual regex
*/ */
export function parseAsciiDocAST(content: string, parseLevel: number = 2): ASTParsedDocument { export function parseAsciiDocAST(
content: string,
parseLevel: number = 2,
): ASTParsedDocument {
const asciidoctor = Processor(); const asciidoctor = Processor();
const document = asciidoctor.load(content, { standalone: false }) as Document; const document = asciidoctor.load(content, { standalone: false }) as Document;
return { return {
title: document.getTitle() || '', title: document.getTitle() || "",
content: document.getContent() || '', content: document.getContent() || "",
attributes: document.getAttributes(), attributes: document.getAttributes(),
sections: extractSectionsFromAST(document, parseLevel) sections: extractSectionsFromAST(document, parseLevel),
}; };
} }
/** /**
* Extract sections from Asciidoctor AST based on parse level * Extract sections from Asciidoctor AST based on parse level
*/ */
function extractSectionsFromAST(document: Document, parseLevel: number): ASTSection[] { function extractSectionsFromAST(
document: Document,
parseLevel: number,
): ASTSection[] {
const directSections = document.getSections(); const directSections = document.getSections();
// Collect all sections at all levels up to parseLevel // Collect all sections at all levels up to parseLevel
const allSections: ASTSection[] = []; const allSections: ASTSection[] = [];
function collectSections(sections: any[]) { function collectSections(sections: any[]) {
for (const section of sections) { for (const section of sections) {
const asciidoctorLevel = section.getLevel(); const asciidoctorLevel = section.getLevel();
@ -58,17 +64,17 @@ function extractSectionsFromAST(document: Document, parseLevel: number): ASTSect
// Asciidoctor: == is level 1, === is level 2, etc. // Asciidoctor: == is level 1, === is level 2, etc.
// Our app: == is level 2, === is level 3, etc. // Our app: == is level 2, === is level 3, etc.
const appLevel = asciidoctorLevel + 1; const appLevel = asciidoctorLevel + 1;
if (appLevel <= parseLevel) { if (appLevel <= parseLevel) {
allSections.push({ allSections.push({
title: section.getTitle() || '', title: section.getTitle() || "",
content: section.getContent() || '', content: section.getContent() || "",
level: appLevel, level: appLevel,
attributes: section.getAttributes() || {}, attributes: section.getAttributes() || {},
subsections: [] subsections: [],
}); });
} }
// Recursively collect subsections // Recursively collect subsections
const subsections = section.getSections?.() || []; const subsections = section.getSections?.() || [];
if (subsections.length > 0) { if (subsections.length > 0) {
@ -76,9 +82,9 @@ function extractSectionsFromAST(document: Document, parseLevel: number): ASTSect
} }
} }
} }
collectSections(directSections); collectSections(directSections);
return allSections; return allSections;
} }
@ -87,15 +93,15 @@ function extractSectionsFromAST(document: Document, parseLevel: number): ASTSect
*/ */
function extractSubsections(section: any, parseLevel: number): ASTSection[] { function extractSubsections(section: any, parseLevel: number): ASTSection[] {
const subsections = section.getSections?.() || []; const subsections = section.getSections?.() || [];
return subsections return subsections
.filter((sub: any) => (sub.getLevel() + 1) <= parseLevel) .filter((sub: any) => (sub.getLevel() + 1) <= parseLevel)
.map((sub: any) => ({ .map((sub: any) => ({
title: sub.getTitle() || '', title: sub.getTitle() || "",
content: sub.getContent() || '', content: sub.getContent() || "",
level: sub.getLevel() + 1, // Convert to app level level: sub.getLevel() + 1, // Convert to app level
attributes: sub.getAttributes() || {}, attributes: sub.getAttributes() || {},
subsections: extractSubsections(sub, parseLevel) subsections: extractSubsections(sub, parseLevel),
})); }));
} }
@ -130,7 +136,10 @@ export async function createPublicationTreeFromAST(
/** /**
* Create a 30040 index event from AST document metadata * Create a 30040 index event from AST document metadata
*/ */
function createIndexEventFromAST(parsed: ASTParsedDocument, ndk: NDK): NDKEvent { function createIndexEventFromAST(
parsed: ASTParsedDocument,
ndk: NDK,
): NDKEvent {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.kind = 30040; event.kind = 30040;
event.created_at = Math.floor(Date.now() / 1000); event.created_at = Math.floor(Date.now() / 1000);
@ -251,29 +260,63 @@ function generateTitleAbbreviation(title: string): string {
/** /**
* Add AsciiDoc attributes as Nostr event tags, filtering out system attributes * Add AsciiDoc attributes as Nostr event tags, filtering out system attributes
*/ */
function addAttributesAsTags(tags: string[][], attributes: Record<string, string>) { function addAttributesAsTags(
tags: string[][],
attributes: Record<string, string>,
) {
const systemAttributes = [ const systemAttributes = [
'attribute-undefined', 'attribute-missing', 'appendix-caption', 'appendix-refsig', "attribute-undefined",
'caution-caption', 'chapter-refsig', 'example-caption', 'figure-caption', "attribute-missing",
'important-caption', 'last-update-label', 'manname-title', 'note-caption', "appendix-caption",
'part-refsig', 'preface-title', 'section-refsig', 'table-caption', "appendix-refsig",
'tip-caption', 'toc-title', 'untitled-label', 'version-label', 'warning-caption', "caution-caption",
'asciidoctor', 'asciidoctor-version', 'safe-mode-name', 'backend', 'doctype', "chapter-refsig",
'basebackend', 'filetype', 'outfilesuffix', 'stylesdir', 'iconsdir', "example-caption",
'localdate', 'localyear', 'localtime', 'localdatetime', 'docdate', "figure-caption",
'docyear', 'doctime', 'docdatetime', 'doctitle', 'embedded', 'notitle' "important-caption",
"last-update-label",
"manname-title",
"note-caption",
"part-refsig",
"preface-title",
"section-refsig",
"table-caption",
"tip-caption",
"toc-title",
"untitled-label",
"version-label",
"warning-caption",
"asciidoctor",
"asciidoctor-version",
"safe-mode-name",
"backend",
"doctype",
"basebackend",
"filetype",
"outfilesuffix",
"stylesdir",
"iconsdir",
"localdate",
"localyear",
"localtime",
"localdatetime",
"docdate",
"docyear",
"doctime",
"docdatetime",
"doctitle",
"embedded",
"notitle",
]; ];
// Add standard metadata tags // Add standard metadata tags
if (attributes.author) tags.push(["author", attributes.author]); if (attributes.author) tags.push(["author", attributes.author]);
if (attributes.version) tags.push(["version", attributes.version]); if (attributes.version) tags.push(["version", attributes.version]);
if (attributes.description) tags.push(["summary", attributes.description]); if (attributes.description) tags.push(["summary", attributes.description]);
if (attributes.tags) { if (attributes.tags) {
attributes.tags.split(',').forEach(tag => attributes.tags.split(",").forEach((tag) => tags.push(["t", tag.trim()]));
tags.push(["t", tag.trim()])
);
} }
// Add custom attributes (non-system) // Add custom attributes (non-system)
Object.entries(attributes).forEach(([key, value]) => { Object.entries(attributes).forEach(([key, value]) => {
if (!systemAttributes.includes(key) && value) { if (!systemAttributes.includes(key) && value) {
@ -286,14 +329,21 @@ function addAttributesAsTags(tags: string[][], attributes: Record<string, string
* Tree processor extension for Asciidoctor * Tree processor extension for Asciidoctor
* This can be registered to automatically populate PublicationTree during parsing * This can be registered to automatically populate PublicationTree during parsing
*/ */
export function createPublicationTreeProcessor(ndk: NDK, parseLevel: number = 2) { export function createPublicationTreeProcessor(
return function(extensions: any) { ndk: NDK,
extensions.treeProcessor(function(this: any) { parseLevel: number = 2,
) {
return function (extensions: any) {
extensions.treeProcessor(function (this: any) {
const dsl = this; const dsl = this;
dsl.process(function(this: any, document: Document) { dsl.process(function (this: any, document: Document) {
// Create PublicationTree and store on document for later retrieval // Create PublicationTree and store on document for later retrieval
const publicationTree = createPublicationTreeFromDocument(document, ndk, parseLevel); const publicationTree = createPublicationTreeFromDocument(
document.setAttribute('publicationTree', publicationTree); document,
ndk,
parseLevel,
);
document.setAttribute("publicationTree", publicationTree);
}); });
}); });
}; };
@ -327,4 +377,4 @@ async function createPublicationTreeFromDocument(
} }
return tree; return tree;
} }

13
src/lib/utils/asciidoc_parser.ts

@ -9,9 +9,9 @@
import Processor from "asciidoctor"; import Processor from "asciidoctor";
import type { Document } from "asciidoctor"; import type { Document } from "asciidoctor";
import { import {
parseSimpleAttributes,
extractDocumentMetadata, extractDocumentMetadata,
extractSectionMetadata, extractSectionMetadata,
parseSimpleAttributes,
} from "./asciidoc_metadata.ts"; } from "./asciidoc_metadata.ts";
export interface ParsedAsciiDoc { export interface ParsedAsciiDoc {
@ -418,8 +418,7 @@ export function generateNostrEvents(
const hasChildrenAtTargetLevel = children.some( const hasChildrenAtTargetLevel = children.some(
(child) => child.level === parseLevel, (child) => child.level === parseLevel,
); );
const shouldBeIndex = const shouldBeIndex = level < parseLevel &&
level < parseLevel &&
(hasChildrenAtTargetLevel || (hasChildrenAtTargetLevel ||
children.some((child) => child.level <= parseLevel)); children.some((child) => child.level <= parseLevel));
@ -461,8 +460,8 @@ export function generateNostrEvents(
const childHasSubChildren = child.children.some( const childHasSubChildren = child.children.some(
(grandchild) => grandchild.level <= parseLevel, (grandchild) => grandchild.level <= parseLevel,
); );
const childShouldBeIndex = const childShouldBeIndex = child.level < parseLevel &&
child.level < parseLevel && childHasSubChildren; childHasSubChildren;
const childKind = childShouldBeIndex ? 30040 : 30041; const childKind = childShouldBeIndex ? 30040 : 30041;
childATags.push([ childATags.push([
"a", "a",
@ -563,8 +562,8 @@ export function generateNostrEvents(
export function detectContentType( export function detectContentType(
content: string, content: string,
): "article" | "scattered-notes" | "none" { ): "article" | "scattered-notes" | "none" {
const hasDocTitle = const hasDocTitle = content.trim().startsWith("=") &&
content.trim().startsWith("=") && !content.trim().startsWith("=="); !content.trim().startsWith("==");
const hasSections = content.includes("=="); const hasSections = content.includes("==");
if (hasDocTitle) { if (hasDocTitle) {

84
src/lib/utils/asciidoc_publication_parser.ts

@ -1,15 +1,18 @@
/** /**
* Unified AsciiDoc Publication Parser * Unified AsciiDoc Publication Parser
* *
* Single entry point for parsing AsciiDoc content into NKBIP-01 compliant * Single entry point for parsing AsciiDoc content into NKBIP-01 compliant
* publication trees using proper Asciidoctor tree processor extensions. * publication trees using proper Asciidoctor tree processor extensions.
* *
* This implements Michael's vision of using PublicationTree as the primary * This implements Michael's vision of using PublicationTree as the primary
* data structure for organizing hierarchical Nostr events. * data structure for organizing hierarchical Nostr events.
*/ */
import Asciidoctor from "asciidoctor"; import Asciidoctor from "asciidoctor";
import { registerPublicationTreeProcessor, type ProcessorResult } from "./publication_tree_processor"; import {
type ProcessorResult,
registerPublicationTreeProcessor,
} from "./publication_tree_processor";
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
export type PublicationTreeResult = ProcessorResult; export type PublicationTreeResult = ProcessorResult;
@ -21,51 +24,54 @@ export type PublicationTreeResult = ProcessorResult;
export async function parseAsciiDocWithTree( export async function parseAsciiDocWithTree(
content: string, content: string,
ndk: NDK, ndk: NDK,
parseLevel: number = 2 parseLevel: number = 2,
): Promise<PublicationTreeResult> { ): Promise<PublicationTreeResult> {
console.log(`[Parser] Starting parse at level ${parseLevel}`); console.log(`[Parser] Starting parse at level ${parseLevel}`);
// Create fresh Asciidoctor instance // Create fresh Asciidoctor instance
const asciidoctor = Asciidoctor(); const asciidoctor = Asciidoctor();
const registry = asciidoctor.Extensions.create(); const registry = asciidoctor.Extensions.create();
// Register our tree processor extension // Register our tree processor extension
const processorAccessor = registerPublicationTreeProcessor( const processorAccessor = registerPublicationTreeProcessor(
registry, registry,
ndk, ndk,
parseLevel, parseLevel,
content content,
); );
try { try {
// Parse the document with our extension // Parse the document with our extension
const doc = asciidoctor.load(content, { const doc = asciidoctor.load(content, {
extension_registry: registry, extension_registry: registry,
standalone: false, standalone: false,
attributes: { attributes: {
sectids: false sectids: false,
} },
}); });
console.log(`[Parser] Document converted successfully`); console.log(`[Parser] Document converted successfully`);
// Get the result from our processor // Get the result from our processor
const result = processorAccessor.getResult(); const result = processorAccessor.getResult();
if (!result) { if (!result) {
throw new Error("Tree processor failed to generate result"); throw new Error("Tree processor failed to generate result");
} }
// Build async relationships in the PublicationTree // Build async relationships in the PublicationTree
await buildTreeRelationships(result); await buildTreeRelationships(result);
console.log(`[Parser] Tree relationships built successfully`); console.log(`[Parser] Tree relationships built successfully`);
return result; return result;
} catch (error) { } catch (error) {
console.error('[Parser] Error during parsing:', error); console.error("[Parser] Error during parsing:", error);
throw new Error(`Failed to parse AsciiDoc content: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Failed to parse AsciiDoc content: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
} }
} }
@ -75,11 +81,11 @@ export async function parseAsciiDocWithTree(
*/ */
async function buildTreeRelationships(result: ProcessorResult): Promise<void> { async function buildTreeRelationships(result: ProcessorResult): Promise<void> {
const { tree, indexEvent, contentEvents } = result; const { tree, indexEvent, contentEvents } = result;
if (!tree) { if (!tree) {
throw new Error("No tree available to build relationships"); throw new Error("No tree available to build relationships");
} }
try { try {
// Add content events to the tree // Add content events to the tree
if (indexEvent && contentEvents.length > 0) { if (indexEvent && contentEvents.length > 0) {
@ -94,11 +100,10 @@ async function buildTreeRelationships(result: ProcessorResult): Promise<void> {
await tree.addEvent(contentEvents[i], rootEvent); await tree.addEvent(contentEvents[i], rootEvent);
} }
} }
console.log(`[Parser] Added ${contentEvents.length} events to tree`); console.log(`[Parser] Added ${contentEvents.length} events to tree`);
} catch (error) { } catch (error) {
console.error('[Parser] Error building tree relationships:', error); console.error("[Parser] Error building tree relationships:", error);
throw error; throw error;
} }
} }
@ -108,8 +113,10 @@ async function buildTreeRelationships(result: ProcessorResult): Promise<void> {
*/ */
export function exportEventsFromTree(result: PublicationTreeResult) { export function exportEventsFromTree(result: PublicationTreeResult) {
return { return {
indexEvent: result.indexEvent ? eventToPublishableObject(result.indexEvent) : undefined, indexEvent: result.indexEvent
contentEvents: result.contentEvents.map(eventToPublishableObject) ? eventToPublishableObject(result.indexEvent)
: undefined,
contentEvents: result.contentEvents.map(eventToPublishableObject),
// Note: Deliberately omitting 'tree' to ensure the object is serializable for postMessage // Note: Deliberately omitting 'tree' to ensure the object is serializable for postMessage
}; };
} }
@ -122,14 +129,17 @@ function eventToPublishableObject(event: any) {
// Extract only primitive values to ensure serializability // Extract only primitive values to ensure serializability
return { return {
kind: Number(event.kind), kind: Number(event.kind),
content: String(event.content || ''), content: String(event.content || ""),
tags: Array.isArray(event.tags) ? event.tags.map((tag: any) => tags: Array.isArray(event.tags)
Array.isArray(tag) ? tag.map(t => String(t)) : [] ? event.tags.map((tag: any) =>
) : [], Array.isArray(tag) ? tag.map((t) => String(t)) : []
)
: [],
created_at: Number(event.created_at || Math.floor(Date.now() / 1000)), created_at: Number(event.created_at || Math.floor(Date.now() / 1000)),
pubkey: String(event.pubkey || ''), pubkey: String(event.pubkey || ""),
id: String(event.id || ''), id: String(event.id || ""),
title: event.tags?.find?.((t: string[]) => t[0] === "title")?.[1] || "Untitled" title: event.tags?.find?.((t: string[]) => t[0] === "title")?.[1] ||
"Untitled",
}; };
} }
@ -145,4 +155,4 @@ export function validateParseLevel(level: number): boolean {
*/ */
export function getSupportedParseLevels(): number[] { export function getSupportedParseLevels(): number[] {
return [2, 3, 4, 5]; return [2, 3, 4, 5];
} }

4
src/lib/utils/event_input_utils.ts

@ -5,9 +5,7 @@ import {
extractDocumentMetadata, extractDocumentMetadata,
metadataToTags, metadataToTags,
} from "./asciidoc_metadata.ts"; } from "./asciidoc_metadata.ts";
import { import { parseAsciiDocWithMetadata } from "./asciidoc_parser.ts";
parseAsciiDocWithMetadata,
} from "./asciidoc_parser.ts";
// ========================= // =========================
// Validation // Validation

2
src/lib/utils/fetch_publication_highlights.ts

@ -19,7 +19,7 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
*/ */
export async function fetchHighlightsForPublication( export async function fetchHighlightsForPublication(
publicationEvent: NDKEvent, publicationEvent: NDKEvent,
ndk: NDK ndk: NDK,
): Promise<Map<string, NDKEvent[]>> { ): Promise<Map<string, NDKEvent[]>> {
// Extract all "a" tags from the publication event // Extract all "a" tags from the publication event
const aTags = publicationEvent.getMatchingTags("a"); const aTags = publicationEvent.getMatchingTags("a");

61
src/lib/utils/highlightPositioning.ts

@ -17,7 +17,9 @@ function getTextNodes(element: HTMLElement): Text[] {
acceptNode: (node) => { acceptNode: (node) => {
// Skip text in script/style tags // Skip text in script/style tags
const parent = node.parentElement; const parent = node.parentElement;
if (parent && (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE')) { if (
parent && (parent.tagName === "SCRIPT" || parent.tagName === "STYLE")
) {
return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_REJECT;
} }
// Skip empty text nodes // Skip empty text nodes
@ -25,8 +27,8 @@ function getTextNodes(element: HTMLElement): Text[] {
return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_REJECT;
} }
return NodeFilter.FILTER_ACCEPT; return NodeFilter.FILTER_ACCEPT;
} },
} },
); );
let node: Node | null; let node: Node | null;
@ -41,7 +43,10 @@ function getTextNodes(element: HTMLElement): Text[] {
* Calculate the total text length from text nodes * Calculate the total text length from text nodes
*/ */
function getTotalTextLength(textNodes: Text[]): number { function getTotalTextLength(textNodes: Text[]): number {
return textNodes.reduce((total, node) => total + (node.textContent?.length || 0), 0); return textNodes.reduce(
(total, node) => total + (node.textContent?.length || 0),
0,
);
} }
/** /**
@ -49,7 +54,7 @@ function getTotalTextLength(textNodes: Text[]): number {
*/ */
function findNodeAtOffset( function findNodeAtOffset(
textNodes: Text[], textNodes: Text[],
globalOffset: number globalOffset: number,
): { node: Text; localOffset: number } | null { ): { node: Text; localOffset: number } | null {
let currentOffset = 0; let currentOffset = 0;
@ -59,7 +64,7 @@ function findNodeAtOffset(
if (globalOffset < currentOffset + nodeLength) { if (globalOffset < currentOffset + nodeLength) {
return { return {
node, node,
localOffset: globalOffset - currentOffset localOffset: globalOffset - currentOffset,
}; };
} }
@ -82,13 +87,17 @@ export function highlightByOffset(
container: HTMLElement, container: HTMLElement,
startOffset: number, startOffset: number,
endOffset: number, endOffset: number,
color: string color: string,
): boolean { ): boolean {
console.log(`[highlightByOffset] Attempting to highlight chars ${startOffset}-${endOffset}`); console.log(
`[highlightByOffset] Attempting to highlight chars ${startOffset}-${endOffset}`,
);
// Validate inputs // Validate inputs
if (startOffset < 0 || endOffset <= startOffset) { if (startOffset < 0 || endOffset <= startOffset) {
console.warn(`[highlightByOffset] Invalid offsets: ${startOffset}-${endOffset}`); console.warn(
`[highlightByOffset] Invalid offsets: ${startOffset}-${endOffset}`,
);
return false; return false;
} }
@ -100,11 +109,15 @@ export function highlightByOffset(
} }
const totalLength = getTotalTextLength(textNodes); const totalLength = getTotalTextLength(textNodes);
console.log(`[highlightByOffset] Total text length: ${totalLength}, nodes: ${textNodes.length}`); console.log(
`[highlightByOffset] Total text length: ${totalLength}, nodes: ${textNodes.length}`,
);
// Validate offsets are within bounds // Validate offsets are within bounds
if (startOffset >= totalLength) { if (startOffset >= totalLength) {
console.warn(`[highlightByOffset] Start offset ${startOffset} exceeds total length ${totalLength}`); console.warn(
`[highlightByOffset] Start offset ${startOffset} exceeds total length ${totalLength}`,
);
return false; return false;
} }
@ -124,16 +137,16 @@ export function highlightByOffset(
startNode: startPos.node.textContent?.substring(0, 20), startNode: startPos.node.textContent?.substring(0, 20),
startLocal: startPos.localOffset, startLocal: startPos.localOffset,
endNode: endPos.node.textContent?.substring(0, 20), endNode: endPos.node.textContent?.substring(0, 20),
endLocal: endPos.localOffset endLocal: endPos.localOffset,
}); });
// Create the highlight mark element // Create the highlight mark element
const createHighlightMark = (text: string): HTMLElement => { const createHighlightMark = (text: string): HTMLElement => {
const mark = document.createElement('mark'); const mark = document.createElement("mark");
mark.className = 'highlight'; mark.className = "highlight";
mark.style.backgroundColor = color; mark.style.backgroundColor = color;
mark.style.borderRadius = '2px'; mark.style.borderRadius = "2px";
mark.style.padding = '2px 0'; mark.style.padding = "2px 0";
mark.textContent = text; mark.textContent = text;
return mark; return mark;
}; };
@ -141,9 +154,12 @@ export function highlightByOffset(
try { try {
// Case 1: Highlight is within a single text node // Case 1: Highlight is within a single text node
if (startPos.node === endPos.node) { if (startPos.node === endPos.node) {
const text = startPos.node.textContent || ''; const text = startPos.node.textContent || "";
const before = text.substring(0, startPos.localOffset); const before = text.substring(0, startPos.localOffset);
const highlighted = text.substring(startPos.localOffset, endPos.localOffset); const highlighted = text.substring(
startPos.localOffset,
endPos.localOffset,
);
const after = text.substring(endPos.localOffset); const after = text.substring(endPos.localOffset);
const parent = startPos.node.parentNode; const parent = startPos.node.parentNode;
@ -156,7 +172,9 @@ export function highlightByOffset(
if (after) fragment.appendChild(document.createTextNode(after)); if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, startPos.node); parent.replaceChild(fragment, startPos.node);
console.log(`[highlightByOffset] Applied single-node highlight: "${highlighted}"`); console.log(
`[highlightByOffset] Applied single-node highlight: "${highlighted}"`,
);
return true; return true;
} }
@ -169,7 +187,7 @@ export function highlightByOffset(
const parent = currentNode.parentNode; const parent = currentNode.parentNode;
if (!parent) break; if (!parent) break;
const text = currentNode.textContent || ''; const text = currentNode.textContent || "";
let fragment = document.createDocumentFragment(); let fragment = document.createDocumentFragment();
if (isFirstNode) { if (isFirstNode) {
@ -200,7 +218,6 @@ export function highlightByOffset(
console.log(`[highlightByOffset] Applied multi-node highlight`); console.log(`[highlightByOffset] Applied multi-node highlight`);
return true; return true;
} catch (err) { } catch (err) {
console.error(`[highlightByOffset] Error applying highlight:`, err); console.error(`[highlightByOffset] Error applying highlight:`, err);
return false; return false;
@ -213,7 +230,7 @@ export function highlightByOffset(
*/ */
export function getPlainText(element: HTMLElement): string { export function getPlainText(element: HTMLElement): string {
const textNodes = getTextNodes(element); const textNodes = getTextNodes(element);
return textNodes.map(node => node.textContent).join(''); return textNodes.map((node) => node.textContent).join("");
} }
/** /**

189
src/lib/utils/highlightUtils.ts

@ -6,26 +6,28 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
export interface GroupedHighlight { export interface GroupedHighlight {
pubkey: string; pubkey: string;
highlights: NDKEvent[]; highlights: NDKEvent[];
count: number; count: number;
} }
/** /**
* Groups highlights by author pubkey * Groups highlights by author pubkey
* Returns a Map with pubkey as key and array of highlights as value * Returns a Map with pubkey as key and array of highlights as value
*/ */
export function groupHighlightsByAuthor(highlights: NDKEvent[]): Map<string, NDKEvent[]> { export function groupHighlightsByAuthor(
const grouped = new Map<string, NDKEvent[]>(); highlights: NDKEvent[],
): Map<string, NDKEvent[]> {
for (const highlight of highlights) { const grouped = new Map<string, NDKEvent[]>();
const pubkey = highlight.pubkey;
const existing = grouped.get(pubkey) || []; for (const highlight of highlights) {
existing.push(highlight); const pubkey = highlight.pubkey;
grouped.set(pubkey, existing); const existing = grouped.get(pubkey) || [];
} existing.push(highlight);
grouped.set(pubkey, existing);
return grouped; }
return grouped;
} }
/** /**
@ -34,21 +36,24 @@ export function groupHighlightsByAuthor(highlights: NDKEvent[]): Map<string, NDK
* @param maxLength - Maximum length (default: 50) * @param maxLength - Maximum length (default: 50)
* @returns Truncated text with ellipsis if needed * @returns Truncated text with ellipsis if needed
*/ */
export function truncateHighlight(text: string, maxLength: number = 50): string { export function truncateHighlight(
if (!text || text.length <= maxLength) { text: string,
return text; maxLength: number = 50,
} ): string {
if (!text || text.length <= maxLength) {
return text;
}
// Find the last space before maxLength // Find the last space before maxLength
const truncated = text.slice(0, maxLength); const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(" "); const lastSpace = truncated.lastIndexOf(" ");
// If there's a space, break there; otherwise use the full maxLength // If there's a space, break there; otherwise use the full maxLength
if (lastSpace > 0) { if (lastSpace > 0) {
return truncated.slice(0, lastSpace) + "..."; return truncated.slice(0, lastSpace) + "...";
} }
return truncated + "..."; return truncated + "...";
} }
/** /**
@ -57,26 +62,29 @@ export function truncateHighlight(text: string, maxLength: number = 50): string
* @param relays - Array of relay URLs to include as hints * @param relays - Array of relay URLs to include as hints
* @returns naddr string * @returns naddr string
*/ */
export function encodeHighlightNaddr(event: NDKEvent, relays: string[] = []): string { export function encodeHighlightNaddr(
try { event: NDKEvent,
// For kind 9802 highlights, we need the event's unique identifier relays: string[] = [],
// Since highlights don't have a d-tag, we'll use the event id as nevent instead ): string {
// But per NIP-19, naddr is for addressable events (with d-tag) try {
// For non-addressable events like kind 9802, we should use nevent // For kind 9802 highlights, we need the event's unique identifier
// Since highlights don't have a d-tag, we'll use the event id as nevent instead
const nevent = nip19.neventEncode({ // But per NIP-19, naddr is for addressable events (with d-tag)
id: event.id, // For non-addressable events like kind 9802, we should use nevent
relays: relays.length > 0 ? relays : undefined,
author: event.pubkey, const nevent = nip19.neventEncode({
kind: event.kind, id: event.id,
}); relays: relays.length > 0 ? relays : undefined,
author: event.pubkey,
return nevent; kind: event.kind,
} catch (error) { });
console.error("Error encoding highlight naddr:", error);
// Fallback to just the event id return nevent;
return event.id; } catch (error) {
} console.error("Error encoding highlight naddr:", error);
// Fallback to just the event id
return event.id;
}
} }
/** /**
@ -86,22 +94,22 @@ export function encodeHighlightNaddr(event: NDKEvent, relays: string[] = []): st
* @returns Shortened npub like "npub1abc...xyz" * @returns Shortened npub like "npub1abc...xyz"
*/ */
export function shortenNpub(pubkey: string, length: number = 8): string { export function shortenNpub(pubkey: string, length: number = 8): string {
try { try {
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
// npub format: "npub1" + bech32 encoded data // npub format: "npub1" + bech32 encoded data
// Show first part and last part // Show first part and last part
if (npub.length <= length + 10) { if (npub.length <= length + 10) {
return npub; return npub;
} }
const start = npub.slice(0, length + 5); // "npub1" + first chars const start = npub.slice(0, length + 5); // "npub1" + first chars
const end = npub.slice(-4); // last chars const end = npub.slice(-4); // last chars
return `${start}...${end}`; return `${start}...${end}`;
} catch (error) { } catch (error) {
console.error("Error creating shortened npub:", error); console.error("Error creating shortened npub:", error);
// Fallback to shortened hex // Fallback to shortened hex
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
} }
} }
/** /**
@ -110,22 +118,22 @@ export function shortenNpub(pubkey: string, length: number = 8): string {
* @returns Array of relay URLs * @returns Array of relay URLs
*/ */
export function getRelaysFromHighlight(event: NDKEvent): string[] { export function getRelaysFromHighlight(event: NDKEvent): string[] {
const relays: string[] = []; const relays: string[] = [];
// Check for relay hints in tags (e.g., ["a", "30041:pubkey:id", "relay-url"]) // Check for relay hints in tags (e.g., ["a", "30041:pubkey:id", "relay-url"])
for (const tag of event.tags) { for (const tag of event.tags) {
if ((tag[0] === "a" || tag[0] === "e" || tag[0] === "p") && tag[2]) { if ((tag[0] === "a" || tag[0] === "e" || tag[0] === "p") && tag[2]) {
relays.push(tag[2]); relays.push(tag[2]);
} }
} }
// Also include relay from the event if available // Also include relay from the event if available
if (event.relay?.url) { if (event.relay?.url) {
relays.push(event.relay.url); relays.push(event.relay.url);
} }
// Deduplicate // Deduplicate
return [...new Set(relays)]; return [...new Set(relays)];
} }
/** /**
@ -134,11 +142,11 @@ export function getRelaysFromHighlight(event: NDKEvent): string[] {
* @returns Sorted array * @returns Sorted array
*/ */
export function sortHighlightsByTime(highlights: NDKEvent[]): NDKEvent[] { export function sortHighlightsByTime(highlights: NDKEvent[]): NDKEvent[] {
return [...highlights].sort((a, b) => { return [...highlights].sort((a, b) => {
const timeA = a.created_at || 0; const timeA = a.created_at || 0;
const timeB = b.created_at || 0; const timeB = b.created_at || 0;
return timeB - timeA; // Newest first return timeB - timeA; // Newest first
}); });
} }
/** /**
@ -146,11 +154,14 @@ export function sortHighlightsByTime(highlights: NDKEvent[]): NDKEvent[] {
* Priority: displayName > name > shortened npub * Priority: displayName > name > shortened npub
*/ */
export function getAuthorDisplayName( export function getAuthorDisplayName(
profile: { name?: string; displayName?: string; display_name?: string } | null, profile:
pubkey: string, | { name?: string; displayName?: string; display_name?: string }
| null,
pubkey: string,
): string { ): string {
if (profile) { if (profile) {
return profile.displayName || profile.display_name || profile.name || shortenNpub(pubkey); return profile.displayName || profile.display_name || profile.name ||
} shortenNpub(pubkey);
return shortenNpub(pubkey); }
return shortenNpub(pubkey);
} }

18
src/lib/utils/mockCommentData.ts

@ -47,7 +47,7 @@ function createMockComment(
targetAddress: string, targetAddress: string,
createdAt: number, createdAt: number,
replyToId?: string, replyToId?: string,
replyToAuthor?: string replyToAuthor?: string,
): any { ): any {
const tags: string[][] = [ const tags: string[][] = [
["A", targetAddress, "wss://relay.damus.io", pubkey], ["A", targetAddress, "wss://relay.damus.io", pubkey],
@ -85,7 +85,7 @@ function createMockComment(
export function generateMockComments( export function generateMockComments(
sectionAddress: string, sectionAddress: string,
numRootComments: number = 3, numRootComments: number = 3,
numRepliesPerThread: number = 2 numRepliesPerThread: number = 2,
): any[] { ): any[] {
const comments: any[] = []; const comments: any[] = [];
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@ -103,7 +103,7 @@ export function generateMockComments(
rootContent, rootContent,
rootPubkey, rootPubkey,
sectionAddress, sectionAddress,
rootCreatedAt rootCreatedAt,
); );
comments.push(rootComment); comments.push(rootComment);
@ -112,7 +112,8 @@ export function generateMockComments(
for (let j = 0; j < numRepliesPerThread; j++) { for (let j = 0; j < numRepliesPerThread; j++) {
const replyId = `mock-reply-${i}-${j}-${Date.now()}`; const replyId = `mock-reply-${i}-${j}-${Date.now()}`;
const replyPubkey = mockPubkeys[(i + j + 1) % mockPubkeys.length]; const replyPubkey = mockPubkeys[(i + j + 1) % mockPubkeys.length];
const replyContent = loremIpsumReplies[commentIndex % loremIpsumReplies.length]; const replyContent =
loremIpsumReplies[commentIndex % loremIpsumReplies.length];
const replyCreatedAt = rootCreatedAt + (j + 1) * 1800; // 30 min after each const replyCreatedAt = rootCreatedAt + (j + 1) * 1800; // 30 min after each
const reply = createMockComment( const reply = createMockComment(
@ -122,7 +123,7 @@ export function generateMockComments(
sectionAddress, sectionAddress,
replyCreatedAt, replyCreatedAt,
rootId, rootId,
rootPubkey rootPubkey,
); );
comments.push(reply); comments.push(reply);
@ -131,7 +132,8 @@ export function generateMockComments(
if (j === 0 && i < 2) { if (j === 0 && i < 2) {
const nestedId = `mock-nested-${i}-${j}-${Date.now()}`; const nestedId = `mock-nested-${i}-${j}-${Date.now()}`;
const nestedPubkey = mockPubkeys[(i + j + 2) % mockPubkeys.length]; const nestedPubkey = mockPubkeys[(i + j + 2) % mockPubkeys.length];
const nestedContent = loremIpsumReplies[(commentIndex + 1) % loremIpsumReplies.length]; const nestedContent =
loremIpsumReplies[(commentIndex + 1) % loremIpsumReplies.length];
const nestedCreatedAt = replyCreatedAt + 900; // 15 min after reply const nestedCreatedAt = replyCreatedAt + 900; // 15 min after reply
const nested = createMockComment( const nested = createMockComment(
@ -141,7 +143,7 @@ export function generateMockComments(
sectionAddress, sectionAddress,
nestedCreatedAt, nestedCreatedAt,
replyId, replyId,
replyPubkey replyPubkey,
); );
comments.push(nested); comments.push(nested);
@ -160,7 +162,7 @@ export function generateMockComments(
* @returns Array of all mock comments across all sections * @returns Array of all mock comments across all sections
*/ */
export function generateMockCommentsForSections( export function generateMockCommentsForSections(
sectionAddresses: string[] sectionAddresses: string[],
): any[] { ): any[] {
const allComments: any[] = []; const allComments: any[] = [];

113
src/lib/utils/mockHighlightData.ts

@ -5,53 +5,53 @@
// Sample highlighted text snippets (things users might actually highlight) // Sample highlighted text snippets (things users might actually highlight)
const highlightedTexts = [ const highlightedTexts = [
'Knowledge that tries to stay put inevitably becomes ossified', "Knowledge that tries to stay put inevitably becomes ossified",
'The attempt to hold knowledge still is like trying to photograph a river', "The attempt to hold knowledge still is like trying to photograph a river",
'Understanding emerges not from rigid frameworks but from fluid engagement', "Understanding emerges not from rigid frameworks but from fluid engagement",
'Traditional institutions struggle with the natural promiscuity of ideas', "Traditional institutions struggle with the natural promiscuity of ideas",
'Thinking without permission means refusing predetermined categories', "Thinking without permission means refusing predetermined categories",
'The most valuable insights often come from unexpected juxtapositions', "The most valuable insights often come from unexpected juxtapositions",
'Anarchistic knowledge rejects the notion of authorized interpreters', "Anarchistic knowledge rejects the notion of authorized interpreters",
'Every act of reading is an act of creative interpretation', "Every act of reading is an act of creative interpretation",
'Hierarchy in knowledge systems serves power, not understanding', "Hierarchy in knowledge systems serves power, not understanding",
'The boundary between creator and consumer is an artificial construction', "The boundary between creator and consumer is an artificial construction",
]; ];
// Context strings (surrounding text to help locate the highlight) // Context strings (surrounding text to help locate the highlight)
const contexts = [ const contexts = [
'This is the fundamental paradox of institutionalized knowledge. Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice.', "This is the fundamental paradox of institutionalized knowledge. 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. What remains is a static representation, not the dynamic reality.', "The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow. What remains is a static representation, not the dynamic reality.",
'Understanding emerges not from rigid frameworks but from fluid engagement with ideas, people, and contexts. This fluidity is precisely what traditional systems attempt to eliminate.', "Understanding emerges not from rigid frameworks but from fluid engagement with ideas, people, and contexts. This fluidity is precisely what traditional systems attempt to eliminate.",
'Traditional institutions struggle with the natural promiscuity of ideas—the way concepts naturally migrate, mutate, and merge across boundaries that were meant to contain them.', "Traditional institutions struggle with the natural promiscuity of ideas—the way concepts naturally migrate, mutate, and merge across boundaries that were meant to contain them.",
'Thinking without permission means refusing predetermined categories and challenging the gatekeepers who claim authority over legitimate thought.', "Thinking without permission means refusing predetermined categories and challenging the gatekeepers who claim authority over legitimate thought.",
'The most valuable insights often come from unexpected juxtapositions, from bringing together ideas that were never meant to meet.', "The most valuable insights often come from unexpected juxtapositions, from bringing together ideas that were never meant to meet.",
'Anarchistic knowledge rejects the notion of authorized interpreters, asserting instead that meaning-making is a fundamentally distributed and democratic process.', "Anarchistic knowledge rejects the notion of authorized interpreters, asserting instead that meaning-making is a fundamentally distributed and democratic process.",
'Every act of reading is an act of creative interpretation, a collaboration between text and reader that produces something new each time.', "Every act of reading is an act of creative interpretation, a collaboration between text and reader that produces something new each time.",
'Hierarchy in knowledge systems serves power, not understanding. It determines who gets to speak, who must listen, and what counts as legitimate knowledge.', "Hierarchy in knowledge systems serves power, not understanding. It determines who gets to speak, who must listen, and what counts as legitimate knowledge.",
'The boundary between creator and consumer is an artificial construction, one that digital networks make increasingly untenable and obsolete.', "The boundary between creator and consumer is an artificial construction, one that digital networks make increasingly untenable and obsolete.",
]; ];
// Optional annotations (user comments on their highlights) // Optional annotations (user comments on their highlights)
const annotations = [ const annotations = [
'This perfectly captures the institutional problem', "This perfectly captures the institutional problem",
'Key insight - worth revisiting', "Key insight - worth revisiting",
'Reminds me of Deleuze on rhizomatic structures', "Reminds me of Deleuze on rhizomatic structures",
'Fundamental critique of academic gatekeeping', "Fundamental critique of academic gatekeeping",
'The core argument in one sentence', "The core argument in one sentence",
null, // Some highlights have no annotation null, // Some highlights have no annotation
'Important for understanding the broader thesis', "Important for understanding the broader thesis",
null, null,
'Connects to earlier discussion on page 12', "Connects to earlier discussion on page 12",
null, null,
]; ];
// Mock pubkeys - MUST be exactly 64 hex characters // Mock pubkeys - MUST be exactly 64 hex characters
const mockPubkeys = [ const mockPubkeys = [
'a1b2c3d4e5f67890123456789012345678901234567890123456789012345678', "a1b2c3d4e5f67890123456789012345678901234567890123456789012345678",
'b2c3d4e5f67890123456789012345678901234567890123456789012345678ab', "b2c3d4e5f67890123456789012345678901234567890123456789012345678ab",
'c3d4e5f67890123456789012345678901234567890123456789012345678abcd', "c3d4e5f67890123456789012345678901234567890123456789012345678abcd",
'd4e5f67890123456789012345678901234567890123456789012345678abcdef', "d4e5f67890123456789012345678901234567890123456789012345678abcdef",
'e5f6789012345678901234567890123456789012345678901234567890abcdef', "e5f6789012345678901234567890123456789012345678901234567890abcdef",
]; ];
/** /**
@ -74,22 +74,22 @@ function createMockHighlight(
authorPubkey: string, authorPubkey: string,
annotation?: string | null, annotation?: string | null,
offsetStart?: number, offsetStart?: number,
offsetEnd?: number offsetEnd?: number,
): any { ): any {
const tags: string[][] = [ const tags: string[][] = [
['a', targetAddress, 'wss://relay.damus.io'], ["a", targetAddress, "wss://relay.damus.io"],
['context', context], ["context", context],
['p', authorPubkey, 'wss://relay.damus.io', 'author'], ["p", authorPubkey, "wss://relay.damus.io", "author"],
]; ];
// Add optional annotation // Add optional annotation
if (annotation) { if (annotation) {
tags.push(['comment', annotation]); tags.push(["comment", annotation]);
} }
// Add optional offset for position-based highlighting // Add optional offset for position-based highlighting
if (offsetStart !== undefined && offsetEnd !== undefined) { if (offsetStart !== undefined && offsetEnd !== undefined) {
tags.push(['offset', offsetStart.toString(), offsetEnd.toString()]); tags.push(["offset", offsetStart.toString(), offsetEnd.toString()]);
} }
return { return {
@ -99,7 +99,7 @@ function createMockHighlight(
created_at: createdAt, created_at: createdAt,
content: highlightedText, // The highlighted text itself content: highlightedText, // The highlighted text itself
tags, tags,
sig: 'mock-signature-' + id, sig: "mock-signature-" + id,
}; };
} }
@ -113,7 +113,7 @@ function createMockHighlight(
export function generateMockHighlights( export function generateMockHighlights(
sectionAddress: string, sectionAddress: string,
authorPubkey: string, authorPubkey: string,
numHighlights: number = Math.floor(Math.random() * 2) + 2 // 2-3 highlights numHighlights: number = Math.floor(Math.random() * 2) + 2, // 2-3 highlights
): any[] { ): any[] {
const highlights: any[] = []; const highlights: any[] = [];
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@ -123,7 +123,9 @@ export function generateMockHighlights(
// The offset tags will highlight the ACTUAL text at those positions in the section // The offset tags will highlight the ACTUAL text at those positions in the section
for (let i = 0; i < numHighlights; i++) { for (let i = 0; i < numHighlights; i++) {
const id = `mock-highlight-${i}-${Date.now()}-${Math.random().toString(36).substring(7)}`; const id = `mock-highlight-${i}-${Date.now()}-${
Math.random().toString(36).substring(7)
}`;
const highlighterPubkey = mockPubkeys[i % mockPubkeys.length]; const highlighterPubkey = mockPubkeys[i % mockPubkeys.length];
const annotation = annotations[i % annotations.length]; const annotation = annotations[i % annotations.length];
const createdAt = now - (numHighlights - i) * 7200; // Stagger by 2 hours const createdAt = now - (numHighlights - i) * 7200; // Stagger by 2 hours
@ -136,7 +138,9 @@ export function generateMockHighlights(
// Use placeholder text - the actual highlighted text will be determined by the offsets // Use placeholder text - the actual highlighted text will be determined by the offsets
const placeholderText = `Test highlight ${i + 1}`; const placeholderText = `Test highlight ${i + 1}`;
const placeholderContext = `This is test highlight ${i + 1} at position ${offsetStart}-${offsetEnd}`; const placeholderContext = `This is test highlight ${
i + 1
} at position ${offsetStart}-${offsetEnd}`;
const highlight = createMockHighlight( const highlight = createMockHighlight(
id, id,
@ -148,7 +152,7 @@ export function generateMockHighlights(
authorPubkey, authorPubkey,
annotation, annotation,
offsetStart, offsetStart,
offsetEnd offsetEnd,
); );
highlights.push(highlight); highlights.push(highlight);
@ -165,19 +169,32 @@ export function generateMockHighlights(
*/ */
export function generateMockHighlightsForSections( export function generateMockHighlightsForSections(
sectionAddresses: string[], sectionAddresses: string[],
authorPubkey: string = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06' authorPubkey: string =
"dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06",
): any[] { ): any[] {
const allHighlights: any[] = []; const allHighlights: any[] = [];
sectionAddresses.forEach((address, index) => { sectionAddresses.forEach((address, index) => {
// Each section gets 2 highlights at the very beginning (positions 0-100 and 120-220) // Each section gets 2 highlights at the very beginning (positions 0-100 and 120-220)
const numHighlights = 2; const numHighlights = 2;
const sectionHighlights = generateMockHighlights(address, authorPubkey, numHighlights); const sectionHighlights = generateMockHighlights(
console.log(`[MockHighlightData] Generated ${numHighlights} highlights for section ${address.split(':')[2]?.substring(0, 20)}... at positions 0-100, 120-220`); address,
authorPubkey,
numHighlights,
);
console.log(
`[MockHighlightData] Generated ${numHighlights} highlights for section ${
address.split(":")[2]?.substring(0, 20)
}... at positions 0-100, 120-220`,
);
allHighlights.push(...sectionHighlights); allHighlights.push(...sectionHighlights);
}); });
console.log(`[MockHighlightData] Total: ${allHighlights.length} highlights across ${sectionAddresses.length} sections`); console.log(
console.log(`[MockHighlightData] Each highlight is anchored to its section via "a" tag and uses offset tags for position`); `[MockHighlightData] Total: ${allHighlights.length} highlights across ${sectionAddresses.length} sections`,
);
console.log(
`[MockHighlightData] Each highlight is anchored to its section via "a" tag and uses offset tags for position`,
);
return allHighlights; return allHighlights;
} }

15
src/lib/utils/publication_tree_factory.ts

@ -189,8 +189,8 @@ function detectContentType(
// Check if the "title" is actually just the first section title // Check if the "title" is actually just the first section title
// This happens when AsciiDoc starts with == instead of = // This happens when AsciiDoc starts with == instead of =
const titleMatchesFirstSection = const titleMatchesFirstSection = parsed.sections.length > 0 &&
parsed.sections.length > 0 && parsed.title === parsed.sections[0].title; parsed.title === parsed.sections[0].title;
if (hasDocTitle && hasSections && !titleMatchesFirstSection) { if (hasDocTitle && hasSections && !titleMatchesFirstSection) {
return "article"; return "article";
@ -286,8 +286,9 @@ function inheritDocumentAttributes(
documentAttributes: Record<string, string>, documentAttributes: Record<string, string>,
) { ) {
// Inherit selected document attributes // Inherit selected document attributes
if (documentAttributes.language) if (documentAttributes.language) {
tags.push(["language", documentAttributes.language]); tags.push(["language", documentAttributes.language]);
}
if (documentAttributes.type) tags.push(["type", documentAttributes.type]); if (documentAttributes.type) tags.push(["type", documentAttributes.type]);
} }
@ -368,9 +369,11 @@ function generateIndexContent(parsed: any): string {
${parsed.sections.length} sections available: ${parsed.sections.length} sections available:
${parsed.sections ${
.map((section: any, i: number) => `${i + 1}. ${section.title}`) parsed.sections
.join("\n")}`; .map((section: any, i: number) => `${i + 1}. ${section.title}`)
.join("\n")
}`;
} }
/** /**

74
src/lib/utils/publication_tree_processor.ts

@ -127,7 +127,9 @@ export function registerPublicationTreeProcessor(
}; };
console.log( console.log(
`[TreeProcessor] Built tree with ${contentEvents.length} content events and ${indexEvent ? "1" : "0"} index events`, `[TreeProcessor] Built tree with ${contentEvents.length} content events and ${
indexEvent ? "1" : "0"
} index events`,
); );
} catch (error) { } catch (error) {
console.error("[TreeProcessor] Error processing document:", error); console.error("[TreeProcessor] Error processing document:", error);
@ -333,11 +335,11 @@ function parseSegmentContent(
// Extract content (everything after attributes, but stop at child sections) // Extract content (everything after attributes, but stop at child sections)
const contentLines = sectionLines.slice(contentStartIdx); const contentLines = sectionLines.slice(contentStartIdx);
// Find where to stop content extraction based on parse level // Find where to stop content extraction based on parse level
let contentEndIdx = contentLines.length; let contentEndIdx = contentLines.length;
const currentSectionLevel = sectionLines[0].match(/^(=+)/)?.[1].length || 2; const currentSectionLevel = sectionLines[0].match(/^(=+)/)?.[1].length || 2;
for (let i = 0; i < contentLines.length; i++) { for (let i = 0; i < contentLines.length; i++) {
const line = contentLines[i]; const line = contentLines[i];
const headerMatch = line.match(/^(=+)\s+/); const headerMatch = line.match(/^(=+)\s+/);
@ -350,7 +352,7 @@ function parseSegmentContent(
} }
} }
} }
const content = contentLines.slice(0, contentEndIdx).join("\n").trim(); const content = contentLines.slice(0, contentEndIdx).join("\n").trim();
// Debug logging for Level 3+ content extraction // Debug logging for Level 3+ content extraction
@ -363,7 +365,6 @@ function parseSegmentContent(
console.log(` extracted content:`, JSON.stringify(content)); console.log(` extracted content:`, JSON.stringify(content));
} }
return { attributes, content }; return { attributes, content };
} }
@ -378,8 +379,8 @@ function detectContentType(
const hasSections = segments.length > 0; const hasSections = segments.length > 0;
// Check if the title matches the first section title // Check if the title matches the first section title
const titleMatchesFirstSection = const titleMatchesFirstSection = segments.length > 0 &&
segments.length > 0 && title === segments[0].title; title === segments[0].title;
if (hasDocTitle && hasSections && !titleMatchesFirstSection) { if (hasDocTitle && hasSections && !titleMatchesFirstSection) {
return "article"; return "article";
@ -530,7 +531,11 @@ function buildLevel2Structure(
// Group segments by level 2 sections // Group segments by level 2 sections
const level2Groups = groupSegmentsByLevel2(segments); const level2Groups = groupSegmentsByLevel2(segments);
console.log(`[TreeProcessor] Level 2 groups:`, level2Groups.length, level2Groups.map(g => g.title)); console.log(
`[TreeProcessor] Level 2 groups:`,
level2Groups.length,
level2Groups.map((g) => g.title),
);
// Generate publication abbreviation for namespacing // Generate publication abbreviation for namespacing
const pubAbbrev = generateTitleAbbreviation(title); const pubAbbrev = generateTitleAbbreviation(title);
@ -550,7 +555,7 @@ function buildLevel2Structure(
dTag: namespacedDTag, dTag: namespacedDTag,
children: [], children: [],
}; };
console.log(`[TreeProcessor] Adding child node:`, childNode.title); console.log(`[TreeProcessor] Adding child node:`, childNode.title);
eventStructure[0].children.push(childNode); eventStructure[0].children.push(childNode);
} }
@ -599,7 +604,7 @@ function buildHierarchicalStructure(
contentEvents, contentEvents,
ndk, ndk,
parseLevel, parseLevel,
title title,
); );
return { tree, indexEvent, contentEvents, eventStructure }; return { tree, indexEvent, contentEvents, eventStructure };
@ -680,7 +685,10 @@ function createContentEvent(
if (wikiLinks.length > 0) { if (wikiLinks.length > 0) {
const wikiTags = wikiLinksToTags(wikiLinks); const wikiTags = wikiLinksToTags(wikiLinks);
tags.push(...wikiTags); tags.push(...wikiTags);
console.log(`[TreeProcessor] Added ${wikiTags.length} wiki link tags:`, wikiTags); console.log(
`[TreeProcessor] Added ${wikiTags.length} wiki link tags:`,
wikiTags,
);
} }
event.tags = tags; event.tags = tags;
@ -879,17 +887,18 @@ function groupSegmentsByLevel2(segments: ContentSegment[]): ContentSegment[] {
s.level > 2 && s.level > 2 &&
s.startLine > segment.startLine && s.startLine > segment.startLine &&
(segments.find( (segments.find(
(next) => next.level <= 2 && next.startLine > segment.startLine, (next) => next.level <= 2 && next.startLine > segment.startLine,
)?.startLine || Infinity) > s.startLine, )?.startLine || Infinity) > s.startLine,
); );
// Combine the level 2 content with all nested content // Combine the level 2 content with all nested content
let combinedContent = segment.content; let combinedContent = segment.content;
for (const nested of nestedSegments) { for (const nested of nestedSegments) {
combinedContent += `\n\n${"=".repeat(nested.level)} ${nested.title}\n${nested.content}`; combinedContent += `\n\n${
"=".repeat(nested.level)
} ${nested.title}\n${nested.content}`;
} }
level2Groups.push({ level2Groups.push({
...segment, ...segment,
content: combinedContent, content: combinedContent,
@ -906,22 +915,22 @@ function groupSegmentsByLevel2(segments: ContentSegment[]): ContentSegment[] {
*/ */
function buildHierarchicalGroups( function buildHierarchicalGroups(
segments: ContentSegment[], segments: ContentSegment[],
parseLevel: number parseLevel: number,
): HierarchicalNode[] { ): HierarchicalNode[] {
const groups: HierarchicalNode[] = []; const groups: HierarchicalNode[] = [];
// Group segments by their parent-child relationships // Group segments by their parent-child relationships
const segmentsByLevel: Map<number, ContentSegment[]> = new Map(); const segmentsByLevel: Map<number, ContentSegment[]> = new Map();
for (let level = 2; level <= parseLevel; level++) { for (let level = 2; level <= parseLevel; level++) {
segmentsByLevel.set(level, segments.filter(s => s.level === level)); segmentsByLevel.set(level, segments.filter((s) => s.level === level));
} }
// Build the hierarchy from level 2 down to parseLevel // Build the hierarchy from level 2 down to parseLevel
for (const segment of segmentsByLevel.get(2) || []) { for (const segment of segmentsByLevel.get(2) || []) {
const node = buildNodeHierarchy(segment, segments, parseLevel); const node = buildNodeHierarchy(segment, segments, parseLevel);
groups.push(node); groups.push(node);
} }
return groups; return groups;
} }
@ -931,22 +940,23 @@ function buildHierarchicalGroups(
function buildNodeHierarchy( function buildNodeHierarchy(
segment: ContentSegment, segment: ContentSegment,
allSegments: ContentSegment[], allSegments: ContentSegment[],
parseLevel: number parseLevel: number,
): HierarchicalNode { ): HierarchicalNode {
// Find direct children (one level deeper) // Find direct children (one level deeper)
const directChildren = allSegments.filter(s => { const directChildren = allSegments.filter((s) => {
if (s.level !== segment.level + 1) return false; if (s.level !== segment.level + 1) return false;
if (s.startLine <= segment.startLine) return false; if (s.startLine <= segment.startLine) return false;
// Check if this segment is within our section's bounds // Check if this segment is within our section's bounds
const nextSibling = allSegments.find( const nextSibling = allSegments.find(
next => next.level <= segment.level && next.startLine > segment.startLine (next) =>
next.level <= segment.level && next.startLine > segment.startLine,
); );
const endLine = nextSibling?.startLine || Infinity; const endLine = nextSibling?.startLine || Infinity;
return s.startLine < endLine; return s.startLine < endLine;
}); });
// Recursively build child nodes // Recursively build child nodes
const childNodes: HierarchicalNode[] = []; const childNodes: HierarchicalNode[] = [];
for (const child of directChildren) { for (const child of directChildren) {
@ -958,15 +968,15 @@ function buildNodeHierarchy(
childNodes.push({ childNodes.push({
segment: child, segment: child,
children: [], children: [],
hasChildren: false hasChildren: false,
}); });
} }
} }
return { return {
segment, segment,
children: childNodes, children: childNodes,
hasChildren: childNodes.length > 0 hasChildren: childNodes.length > 0,
}; };
} }
@ -1119,7 +1129,6 @@ function createIndexEventForHierarchicalNode(
return event; return event;
} }
/** /**
* Build hierarchical segment structure for Level 3+ parsing * Build hierarchical segment structure for Level 3+ parsing
*/ */
@ -1135,8 +1144,9 @@ function buildSegmentHierarchy(
s.level > 2 && s.level > 2 &&
s.startLine > level2Segment.startLine && s.startLine > level2Segment.startLine &&
(segments.find( (segments.find(
(next) => next.level <= 2 && next.startLine > level2Segment.startLine, (next) =>
)?.startLine || Infinity) > s.startLine, next.level <= 2 && next.startLine > level2Segment.startLine,
)?.startLine || Infinity) > s.startLine,
); );
hierarchy.push({ hierarchy.push({

41
src/lib/utils/wiki_links.ts

@ -5,7 +5,7 @@
export interface WikiLink { export interface WikiLink {
fullMatch: string; fullMatch: string;
type: 'w' | 'd' | 'auto'; // auto means [[term]] without explicit prefix type: "w" | "d" | "auto"; // auto means [[term]] without explicit prefix
term: string; term: string;
displayText: string; displayText: string;
startIndex: number; startIndex: number;
@ -34,7 +34,7 @@ export function extractWikiLinks(content: string): WikiLink[] {
wikiLinks.push({ wikiLinks.push({
fullMatch: match[0], fullMatch: match[0],
type: prefix ? (prefix as 'w' | 'd') : 'auto', type: prefix ? (prefix as "w" | "d") : "auto",
term, term,
displayText: customDisplay || term, displayText: customDisplay || term,
startIndex: match.index, startIndex: match.index,
@ -53,8 +53,8 @@ export function termToTag(term: string): string {
return term return term
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/\s+/g, '-') .replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, ''); .replace(/[^a-z0-9-]/g, "");
} }
/** /**
@ -67,14 +67,14 @@ export function wikiLinksToTags(wikiLinks: WikiLink[]): string[][] {
for (const link of wikiLinks) { for (const link of wikiLinks) {
const tagSlug = termToTag(link.term); const tagSlug = termToTag(link.term);
if (link.type === 'w' || link.type === 'auto') { if (link.type === "w" || link.type === "auto") {
// Reference tag includes display text // Reference tag includes display text
tags.push(['w', tagSlug, link.displayText]); tags.push(["w", tagSlug, link.displayText]);
} }
if (link.type === 'd') { if (link.type === "d") {
// Definition tag (no display text, it IS the thing) // Definition tag (no display text, it IS the thing)
tags.push(['d', tagSlug]); tags.push(["d", tagSlug]);
} }
} }
@ -91,13 +91,13 @@ export function renderWikiLinksToHtml(
linkClass?: string; linkClass?: string;
wLinkClass?: string; wLinkClass?: string;
dLinkClass?: string; dLinkClass?: string;
onClickHandler?: (type: 'w' | 'd' | 'auto', term: string) => string; onClickHandler?: (type: "w" | "d" | "auto", term: string) => string;
} = {}, } = {},
): string { ): string {
const { const {
linkClass = 'wiki-link', linkClass = "wiki-link",
wLinkClass = 'wiki-link-reference', wLinkClass = "wiki-link-reference",
dLinkClass = 'wiki-link-definition', dLinkClass = "wiki-link-definition",
onClickHandler, onClickHandler,
} = options; } = options;
@ -105,13 +105,13 @@ export function renderWikiLinksToHtml(
/\[\[(?:(w|d):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g, /\[\[(?:(w|d):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, prefix, term, customDisplay) => { (match, prefix, term, customDisplay) => {
const displayText = customDisplay?.trim() || term.trim(); const displayText = customDisplay?.trim() || term.trim();
const type = prefix ? prefix : 'auto'; const type = prefix ? prefix : "auto";
const tagSlug = termToTag(term); const tagSlug = termToTag(term);
// Determine CSS classes // Determine CSS classes
let classes = linkClass; let classes = linkClass;
if (type === 'w') classes += ` ${wLinkClass}`; if (type === "w") classes += ` ${wLinkClass}`;
else if (type === 'd') classes += ` ${dLinkClass}`; else if (type === "d") classes += ` ${dLinkClass}`;
// Generate href or onclick // Generate href or onclick
const action = onClickHandler const action = onClickHandler
@ -119,12 +119,11 @@ export function renderWikiLinksToHtml(
: `href="#wiki/${type}/${encodeURIComponent(tagSlug)}"`; : `href="#wiki/${type}/${encodeURIComponent(tagSlug)}"`;
// Add title attribute showing the type // Add title attribute showing the type
const title = const title = type === "w"
type === 'w' ? "Wiki reference (mentions this concept)"
? 'Wiki reference (mentions this concept)' : type === "d"
: type === 'd' ? "Wiki definition (defines this concept)"
? 'Wiki definition (defines this concept)' : "Wiki link (searches both references and definitions)";
: 'Wiki link (searches both references and definitions)';
return `<a class="${classes}" ${action} title="${title}" data-wiki-type="${type}" data-wiki-term="${tagSlug}">${displayText}</a>`; return `<a class="${classes}" ${action} title="${title}" data-wiki-type="${type}" data-wiki-term="${tagSlug}">${displayText}</a>`;
}, },

39
src/routes/new/edit/+page.svelte

@ -1,10 +1,5 @@
<script lang="ts"> <script lang="ts">
import { import { Heading, Textarea, Toolbar, ToolbarButton } from "flowbite-svelte";
Heading,
Textarea,
Toolbar,
ToolbarButton,
} from "flowbite-svelte";
import { import {
CodeOutline, CodeOutline,
EyeSolid, EyeSolid,
@ -14,7 +9,7 @@
import Pharos, { pharosInstance } from "$lib/parser"; import Pharos, { pharosInstance } from "$lib/parser";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
const ndk = getNdkContext(); const ndk = getNdkContext();
let someIndexValue = 0; let someIndexValue = 0;
@ -72,29 +67,37 @@
placeholder="Write AsciiDoc content" placeholder="Write AsciiDoc content"
bind:value={editorText} bind:value={editorText}
> >
<Toolbar slot="header" embedded> <!-- MichaelJ 12-04-2025 - This `Toolbar` construct may be invalid with the current version of Flowbite Svelte -->
<ToolbarButton name="Preview" onclick={showPreview}> {#snippet header()}
<EyeSolid class="w-6 h-6" /> <Toolbar embedded>
</ToolbarButton> <ToolbarButton name="Preview" onclick={showPreview}>
<ToolbarButton name="Review" slot="end" onclick={prepareReview}> <EyeSolid class="w-6 h-6" />
<PaperPlaneOutline class="w=6 h-6 rotate-90" /> </ToolbarButton>
</ToolbarButton> {#snippet end()}
</Toolbar> <ToolbarButton name="Review" onclick={prepareReview}>
<PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton>
{/snippet}
</Toolbar>
{/snippet}
</Textarea> </Textarea>
</form> </form>
{:else} {:else}
<form <form
class="border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit" class="border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit"
> >
<!-- MichaelJ 12-04-2025 - This `Toolbar` construct may be invalid with the current version of Flowbite Svelte -->
<Toolbar <Toolbar
class="toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800" class="toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800"
> >
<ToolbarButton name="Edit" onclick={hidePreview}> <ToolbarButton name="Edit" onclick={hidePreview}>
<CodeOutline class="w-6 h-6" /> <CodeOutline class="w-6 h-6" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton name="Review" slot="end" onclick={prepareReview}> {#snippet end()}
<PaperPlaneOutline class="w=6 h-6 rotate-90" /> <ToolbarButton name="Review" onclick={prepareReview}>
</ToolbarButton> <PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton>
{/snippet}
</Toolbar> </Toolbar>
{#if rootIndexId} {#if rootIndexId}
<Preview <Preview

581
tests/unit/commentButton.test.ts

File diff suppressed because it is too large Load Diff

77
tests/unit/deletion.test.ts

@ -1,8 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteEvent, canDeleteEvent } from '$lib/services/deletion'; import {
import NDK, { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; canDeleteEvent,
deleteEvent,
describe('Deletion Service', () => { } from "../../src/lib/services/deletion.ts";
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
describe("Deletion Service", () => {
let mockNdk: NDK; let mockNdk: NDK;
let mockEvent: NDKEvent; let mockEvent: NDKEvent;
@ -10,47 +13,47 @@ describe('Deletion Service', () => {
// Create mock NDK instance // Create mock NDK instance
mockNdk = { mockNdk = {
activeUser: { activeUser: {
pubkey: 'test-pubkey-123', pubkey: "test-pubkey-123",
}, },
pool: { pool: {
relays: new Map([ relays: new Map([
['wss://relay1.example.com', { url: 'wss://relay1.example.com' }], ["wss://relay1.example.com", { url: "wss://relay1.example.com" }],
['wss://relay2.example.com', { url: 'wss://relay2.example.com' }], ["wss://relay2.example.com", { url: "wss://relay2.example.com" }],
]), ]),
}, },
} as unknown as NDK; } as unknown as NDK;
// Create mock event // Create mock event
mockEvent = { mockEvent = {
id: 'event-id-123', id: "event-id-123",
kind: 30041, kind: 30041,
pubkey: 'test-pubkey-123', pubkey: "test-pubkey-123",
tagAddress: () => '30041:test-pubkey-123:test-identifier', tagAddress: () => "30041:test-pubkey-123:test-identifier",
} as unknown as NDKEvent; } as unknown as NDKEvent;
}); });
describe('canDeleteEvent', () => { describe("canDeleteEvent", () => {
it('should return true when user is the event author', () => { it("should return true when user is the event author", () => {
const result = canDeleteEvent(mockEvent, mockNdk); const result = canDeleteEvent(mockEvent, mockNdk);
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false when user is not the event author', () => { it("should return false when user is not the event author", () => {
const differentUserEvent = { const differentUserEvent = {
...mockEvent, ...mockEvent,
pubkey: 'different-pubkey-456', pubkey: "different-pubkey-456",
} as unknown as NDKEvent; } as unknown as NDKEvent;
const result = canDeleteEvent(differentUserEvent, mockNdk); const result = canDeleteEvent(differentUserEvent, mockNdk);
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false when event is null', () => { it("should return false when event is null", () => {
const result = canDeleteEvent(null, mockNdk); const result = canDeleteEvent(null, mockNdk);
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false when ndk has no active user', () => { it("should return false when ndk has no active user", () => {
const ndkWithoutUser = { const ndkWithoutUser = {
...mockNdk, ...mockNdk,
activeUser: undefined, activeUser: undefined,
@ -61,40 +64,44 @@ describe('Deletion Service', () => {
}); });
}); });
describe('deleteEvent', () => { describe("deleteEvent", () => {
it('should return error when no eventId or eventAddress provided', async () => { it("should return error when no eventId or eventAddress provided", async () => {
const result = await deleteEvent({}, mockNdk); const result = await deleteEvent({}, mockNdk);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Either eventId or eventAddress must be provided'); expect(result.error).toBe(
"Either eventId or eventAddress must be provided",
);
}); });
it('should return error when user is not logged in', async () => { it("should return error when user is not logged in", async () => {
const ndkWithoutUser = { const ndkWithoutUser = {
...mockNdk, ...mockNdk,
activeUser: undefined, activeUser: undefined,
} as unknown as NDK; } as unknown as NDK;
const result = await deleteEvent( const result = await deleteEvent(
{ eventId: 'test-id' }, { eventId: "test-id" },
ndkWithoutUser ndkWithoutUser,
); );
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Please log in first'); expect(result.error).toBe("Please log in first");
}); });
it('should create deletion event with correct tags', async () => { it("should create deletion event with correct tags", async () => {
const mockSign = vi.fn(); const mockSign = vi.fn();
const mockPublish = vi.fn().mockResolvedValue(new Set(['wss://relay1.example.com'])); const mockPublish = vi.fn().mockResolvedValue(
new Set(["wss://relay1.example.com"]),
);
// Mock NDKEvent constructor // Mock NDKEvent constructor
const MockNDKEvent = vi.fn().mockImplementation(function(this: any) { const MockNDKEvent = vi.fn().mockImplementation(function (this: any) {
this.kind = 0; this.kind = 0;
this.created_at = 0; this.created_at = 0;
this.tags = []; this.tags = [];
this.content = ''; this.content = "";
this.pubkey = ''; this.pubkey = "";
this.sign = mockSign; this.sign = mockSign;
this.publish = mockPublish; this.publish = mockPublish;
return this; return this;
@ -102,20 +109,20 @@ describe('Deletion Service', () => {
// Mock NDKRelaySet // Mock NDKRelaySet
const mockRelaySet = {} as NDKRelaySet; const mockRelaySet = {} as NDKRelaySet;
vi.spyOn(NDKRelaySet, 'fromRelayUrls').mockReturnValue(mockRelaySet); vi.spyOn(NDKRelaySet, "fromRelayUrls").mockReturnValue(mockRelaySet);
// Replace global NDKEvent temporarily // Replace global NDKEvent temporarily
const originalNDKEvent = global.NDKEvent; const originalNDKEvent = (globalThis as any).NDKEvent;
(global as any).NDKEvent = MockNDKEvent; (global as any).NDKEvent = MockNDKEvent;
const result = await deleteEvent( const result = await deleteEvent(
{ {
eventId: 'event-123', eventId: "event-123",
eventAddress: '30041:pubkey:identifier', eventAddress: "30041:pubkey:identifier",
eventKind: 30041, eventKind: 30041,
reason: 'Test deletion', reason: "Test deletion",
}, },
mockNdk mockNdk,
); );
// Restore original // Restore original

52
tests/unit/fetchPublicationHighlights.test.ts

@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NDK, NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchHighlightsForPublication } from "../../src/lib/utils/fetch_publication_highlights"; import type NDK from "@nostr-dev-kit/ndk";
import { fetchHighlightsForPublication } from "../../src/lib/utils/fetch_publication_highlights.ts";
// Mock NDKEvent class // Mock NDKEvent class
class MockNDKEvent { class MockNDKEvent {
@ -83,7 +84,8 @@ describe("fetchHighlightsForPublication", () => {
], ],
created_at: 1744910311, created_at: 1744910311,
id: "4585ed74a0be37655aa887340d239f0bbb9df5476165d912f098c55a71196fef", id: "4585ed74a0be37655aa887340d239f0bbb9df5476165d912f098c55a71196fef",
sig: "e6a832dcfc919c913acee62cb598211544bc8e03a3f61c016eb3bf6c8cb4fb333eff8fecc601517604c7a8029dfa73591f3218465071a532f4abfe8c0bf3662d", sig:
"e6a832dcfc919c913acee62cb598211544bc8e03a3f61c016eb3bf6c8cb4fb333eff8fecc601517604c7a8029dfa73591f3218465071a532f4abfe8c0bf3662d",
}) as unknown as NDKEvent; }) as unknown as NDKEvent;
// Create mock highlight events for different sections // Create mock highlight events for different sections
@ -156,7 +158,7 @@ describe("fetchHighlightsForPublication", () => {
return new Set( return new Set(
mockHighlights.filter((highlight) => mockHighlights.filter((highlight) =>
aTagFilter.includes(highlight.tagValue("a") || "") aTagFilter.includes(highlight.tagValue("a") || "")
) ),
); );
} }
return new Set(); return new Set();
@ -167,33 +169,33 @@ describe("fetchHighlightsForPublication", () => {
it("should extract section references from 30040 publication event", async () => { it("should extract section references from 30040 publication event", async () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
publicationEvent, publicationEvent,
mockNDK mockNDK,
); );
// Should have results for the sections that have highlights // Should have results for the sections that have highlights
expect(result.size).toBeGreaterThan(0); expect(result.size).toBeGreaterThan(0);
expect( expect(
result.has( result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
) ),
).toBe(true); ).toBe(true);
}); });
it("should fetch highlights for each section reference", async () => { it("should fetch highlights for each section reference", async () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
publicationEvent, publicationEvent,
mockNDK mockNDK,
); );
// First section should have 2 highlights // First section should have 2 highlights
const firstSectionHighlights = result.get( const firstSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
); );
expect(firstSectionHighlights?.length).toBe(2); expect(firstSectionHighlights?.length).toBe(2);
// Second section should have 1 highlight // Second section should have 1 highlight
const secondSectionHighlights = result.get( const secondSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading",
); );
expect(secondSectionHighlights?.length).toBe(1); expect(secondSectionHighlights?.length).toBe(1);
}); });
@ -201,38 +203,38 @@ describe("fetchHighlightsForPublication", () => {
it("should group highlights by section address", async () => { it("should group highlights by section address", async () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
publicationEvent, publicationEvent,
mockNDK mockNDK,
); );
const firstSectionHighlights = result.get( const firstSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
); );
// Verify the highlights are correctly grouped // Verify the highlights are correctly grouped
expect(firstSectionHighlights?.[0].content).toBe( expect(firstSectionHighlights?.[0].content).toBe(
"This is an interesting point" "This is an interesting point",
); );
expect(firstSectionHighlights?.[1].content).toBe( expect(firstSectionHighlights?.[1].content).toBe(
"Another highlight on same section" "Another highlight on same section",
); );
}); });
it("should not include sections without highlights", async () => { it("should not include sections without highlights", async () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
publicationEvent, publicationEvent,
mockNDK mockNDK,
); );
// Sections without highlights should not be in the result // Sections without highlights should not be in the result
expect( expect(
result.has( result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading",
) ),
).toBe(false); ).toBe(false);
expect( expect(
result.has( result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document" "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document",
) ),
).toBe(false); ).toBe(false);
}); });
@ -249,7 +251,7 @@ describe("fetchHighlightsForPublication", () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
emptyPublication, emptyPublication,
mockNDK mockNDK,
); );
expect(result.size).toBe(0); expect(result.size).toBe(0);
@ -273,7 +275,7 @@ describe("fetchHighlightsForPublication", () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
mixedPublication, mixedPublication,
mockNDK mockNDK,
); );
// Should call fetchEvents with only the 30041 reference // Should call fetchEvents with only the 30041 reference
@ -283,7 +285,7 @@ describe("fetchHighlightsForPublication", () => {
"#a": [ "#a": [
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
], ],
}) }),
); );
}); });
@ -303,7 +305,7 @@ describe("fetchHighlightsForPublication", () => {
const result = await fetchHighlightsForPublication( const result = await fetchHighlightsForPublication(
colonPublication, colonPublication,
mockNDK mockNDK,
); );
// Should correctly parse the section address with colons // Should correctly parse the section address with colons
@ -312,7 +314,7 @@ describe("fetchHighlightsForPublication", () => {
"#a": [ "#a": [
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons", "30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons",
], ],
}) }),
); );
}); });
}); });

445
tests/unit/highlightLayer.test.ts

@ -1,62 +1,62 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pubkeyToHue } from '../../src/lib/utils/nostrUtils'; import { pubkeyToHue } from "../../src/lib/utils/nostrUtils";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
describe('pubkeyToHue', () => { describe("pubkeyToHue", () => {
describe('Consistency', () => { describe("Consistency", () => {
it('returns consistent hue for same pubkey', () => { it("returns consistent hue for same pubkey", () => {
const pubkey = 'a'.repeat(64); const pubkey = "a".repeat(64);
const hue1 = pubkeyToHue(pubkey); const hue1 = pubkeyToHue(pubkey);
const hue2 = pubkeyToHue(pubkey); const hue2 = pubkeyToHue(pubkey);
expect(hue1).toBe(hue2); expect(hue1).toBe(hue2);
}); });
it('returns same hue for same pubkey called multiple times', () => { it("returns same hue for same pubkey called multiple times", () => {
const pubkey = 'abc123def456'.repeat(5) + 'abcd'; const pubkey = "abc123def456".repeat(5) + "abcd";
const hues = Array.from({ length: 10 }, () => pubkeyToHue(pubkey)); const hues = Array.from({ length: 10 }, () => pubkeyToHue(pubkey));
expect(new Set(hues).size).toBe(1); // All hues should be the same expect(new Set(hues).size).toBe(1); // All hues should be the same
}); });
}); });
describe('Range Validation', () => { describe("Range Validation", () => {
it('returns hue in valid range (0-360)', () => { it("returns hue in valid range (0-360)", () => {
const pubkeys = [ const pubkeys = [
'a'.repeat(64), "a".repeat(64),
'f'.repeat(64), "f".repeat(64),
'0'.repeat(64), "0".repeat(64),
'9'.repeat(64), "9".repeat(64),
'abc123def456'.repeat(5) + 'abcd', "abc123def456".repeat(5) + "abcd",
'123456789abc'.repeat(5) + 'def0', "123456789abc".repeat(5) + "def0",
]; ];
pubkeys.forEach(pubkey => { pubkeys.forEach((pubkey) => {
const hue = pubkeyToHue(pubkey); const hue = pubkeyToHue(pubkey);
expect(hue).toBeGreaterThanOrEqual(0); expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360); expect(hue).toBeLessThan(360);
}); });
}); });
it('returns integer hue value', () => { it("returns integer hue value", () => {
const pubkey = 'a'.repeat(64); const pubkey = "a".repeat(64);
const hue = pubkeyToHue(pubkey); const hue = pubkeyToHue(pubkey);
expect(Number.isInteger(hue)).toBe(true); expect(Number.isInteger(hue)).toBe(true);
}); });
}); });
describe('Format Handling', () => { describe("Format Handling", () => {
it('handles hex format pubkeys', () => { it("handles hex format pubkeys", () => {
const hexPubkey = 'abcdef123456789'.repeat(4) + '0123'; const hexPubkey = "abcdef123456789".repeat(4) + "0123";
const hue = pubkeyToHue(hexPubkey); const hue = pubkeyToHue(hexPubkey);
expect(hue).toBeGreaterThanOrEqual(0); expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360); expect(hue).toBeLessThan(360);
}); });
it('handles npub format pubkeys', () => { it("handles npub format pubkeys", () => {
const hexPubkey = 'a'.repeat(64); const hexPubkey = "a".repeat(64);
const npub = nip19.npubEncode(hexPubkey); const npub = nip19.npubEncode(hexPubkey);
const hue = pubkeyToHue(npub); const hue = pubkeyToHue(npub);
@ -64,8 +64,8 @@ describe('pubkeyToHue', () => {
expect(hue).toBeLessThan(360); expect(hue).toBeLessThan(360);
}); });
it('returns same hue for hex and npub format of same pubkey', () => { it("returns same hue for hex and npub format of same pubkey", () => {
const hexPubkey = 'abc123def456'.repeat(5) + 'abcd'; const hexPubkey = "abc123def456".repeat(5) + "abcd";
const npub = nip19.npubEncode(hexPubkey); const npub = nip19.npubEncode(hexPubkey);
const hueFromHex = pubkeyToHue(hexPubkey); const hueFromHex = pubkeyToHue(hexPubkey);
@ -75,11 +75,11 @@ describe('pubkeyToHue', () => {
}); });
}); });
describe('Uniqueness', () => { describe("Uniqueness", () => {
it('different pubkeys generate different hues', () => { it("different pubkeys generate different hues", () => {
const pubkey1 = 'a'.repeat(64); const pubkey1 = "a".repeat(64);
const pubkey2 = 'b'.repeat(64); const pubkey2 = "b".repeat(64);
const pubkey3 = 'c'.repeat(64); const pubkey3 = "c".repeat(64);
const hue1 = pubkeyToHue(pubkey1); const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2); const hue2 = pubkeyToHue(pubkey2);
@ -90,12 +90,13 @@ describe('pubkeyToHue', () => {
expect(hue1).not.toBe(hue3); expect(hue1).not.toBe(hue3);
}); });
it('generates diverse hues for multiple pubkeys', () => { it("generates diverse hues for multiple pubkeys", () => {
const pubkeys = Array.from({ length: 10 }, (_, i) => const pubkeys = Array.from(
String.fromCharCode(97 + i).repeat(64) { length: 10 },
(_, i) => String.fromCharCode(97 + i).repeat(64),
); );
const hues = pubkeys.map(pk => pubkeyToHue(pk)); const hues = pubkeys.map((pk) => pubkeyToHue(pk));
const uniqueHues = new Set(hues); const uniqueHues = new Set(hues);
// Most pubkeys should generate unique hues (allowing for some collisions) // Most pubkeys should generate unique hues (allowing for some collisions)
@ -103,16 +104,16 @@ describe('pubkeyToHue', () => {
}); });
}); });
describe('Edge Cases', () => { describe("Edge Cases", () => {
it('handles empty string input', () => { it("handles empty string input", () => {
const hue = pubkeyToHue(''); const hue = pubkeyToHue("");
expect(hue).toBeGreaterThanOrEqual(0); expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360); expect(hue).toBeLessThan(360);
}); });
it('handles invalid npub format gracefully', () => { it("handles invalid npub format gracefully", () => {
const invalidNpub = 'npub1invalid'; const invalidNpub = "npub1invalid";
const hue = pubkeyToHue(invalidNpub); const hue = pubkeyToHue(invalidNpub);
// Should still return a valid hue even if decode fails // Should still return a valid hue even if decode fails
@ -120,16 +121,16 @@ describe('pubkeyToHue', () => {
expect(hue).toBeLessThan(360); expect(hue).toBeLessThan(360);
}); });
it('handles short input strings', () => { it("handles short input strings", () => {
const shortInput = 'abc'; const shortInput = "abc";
const hue = pubkeyToHue(shortInput); const hue = pubkeyToHue(shortInput);
expect(hue).toBeGreaterThanOrEqual(0); expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360); expect(hue).toBeLessThan(360);
}); });
it('handles special characters', () => { it("handles special characters", () => {
const specialInput = '!@#$%^&*()'; const specialInput = "!@#$%^&*()";
const hue = pubkeyToHue(specialInput); const hue = pubkeyToHue(specialInput);
expect(hue).toBeGreaterThanOrEqual(0); expect(hue).toBeGreaterThanOrEqual(0);
@ -137,19 +138,20 @@ describe('pubkeyToHue', () => {
}); });
}); });
describe('Color Distribution', () => { describe("Color Distribution", () => {
it('distributes colors across the spectrum', () => { it("distributes colors across the spectrum", () => {
// Generate hues for many different pubkeys // Generate hues for many different pubkeys
const pubkeys = Array.from({ length: 50 }, (_, i) => const pubkeys = Array.from(
i.toString().repeat(16) { length: 50 },
(_, i) => i.toString().repeat(16),
); );
const hues = pubkeys.map(pk => pubkeyToHue(pk)); const hues = pubkeys.map((pk) => pubkeyToHue(pk));
// Check that we have hues in different ranges of the spectrum // Check that we have hues in different ranges of the spectrum
const hasLowHues = hues.some(h => h < 120); const hasLowHues = hues.some((h) => h < 120);
const hasMidHues = hues.some(h => h >= 120 && h < 240); const hasMidHues = hues.some((h) => h >= 120 && h < 240);
const hasHighHues = hues.some(h => h >= 240); const hasHighHues = hues.some((h) => h >= 240);
expect(hasLowHues).toBe(true); expect(hasLowHues).toBe(true);
expect(hasMidHues).toBe(true); expect(hasMidHues).toBe(true);
@ -158,7 +160,7 @@ describe('pubkeyToHue', () => {
}); });
}); });
describe('HighlightLayer Component', () => { describe("HighlightLayer Component", () => {
let mockNdk: any; let mockNdk: any;
let mockSubscription: any; let mockSubscription: any;
let eventHandlers: Map<string, Function>; let eventHandlers: Map<string, Function>;
@ -190,9 +192,9 @@ describe('HighlightLayer Component', () => {
textContent: text, textContent: text,
})), })),
createElement: vi.fn((tag: string) => ({ createElement: vi.fn((tag: string) => ({
className: '', className: "",
style: {}, style: {},
textContent: '', textContent: "",
})), })),
} as any; } as any;
}); });
@ -201,60 +203,60 @@ describe('HighlightLayer Component', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('NDK Subscription', () => { describe("NDK Subscription", () => {
it('fetches kind 9802 events with correct filter when eventId provided', () => { it("fetches kind 9802 events with correct filter when eventId provided", () => {
const eventId = 'a'.repeat(64); const eventId = "a".repeat(64);
// Simulate calling fetchHighlights // Simulate calling fetchHighlights
mockNdk.subscribe({ kinds: [9802], '#e': [eventId], limit: 100 }); mockNdk.subscribe({ kinds: [9802], "#e": [eventId], limit: 100 });
expect(mockNdk.subscribe).toHaveBeenCalledWith( expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
kinds: [9802], kinds: [9802],
'#e': [eventId], "#e": [eventId],
limit: 100, limit: 100,
}) }),
); );
}); });
it('fetches kind 9802 events with correct filter when eventAddress provided', () => { it("fetches kind 9802 events with correct filter when eventAddress provided", () => {
const eventAddress = '30040:' + 'a'.repeat(64) + ':chapter-1'; const eventAddress = "30040:" + "a".repeat(64) + ":chapter-1";
// Simulate calling fetchHighlights // Simulate calling fetchHighlights
mockNdk.subscribe({ kinds: [9802], '#a': [eventAddress], limit: 100 }); mockNdk.subscribe({ kinds: [9802], "#a": [eventAddress], limit: 100 });
expect(mockNdk.subscribe).toHaveBeenCalledWith( expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
kinds: [9802], kinds: [9802],
'#a': [eventAddress], "#a": [eventAddress],
limit: 100, limit: 100,
}) }),
); );
}); });
it('fetches with both eventId and eventAddress filters when both provided', () => { it("fetches with both eventId and eventAddress filters when both provided", () => {
const eventId = 'a'.repeat(64); const eventId = "a".repeat(64);
const eventAddress = '30040:' + 'b'.repeat(64) + ':chapter-1'; const eventAddress = "30040:" + "b".repeat(64) + ":chapter-1";
// Simulate calling fetchHighlights // Simulate calling fetchHighlights
mockNdk.subscribe({ mockNdk.subscribe({
kinds: [9802], kinds: [9802],
'#e': [eventId], "#e": [eventId],
'#a': [eventAddress], "#a": [eventAddress],
limit: 100, limit: 100,
}); });
expect(mockNdk.subscribe).toHaveBeenCalledWith( expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
kinds: [9802], kinds: [9802],
'#e': [eventId], "#e": [eventId],
'#a': [eventAddress], "#a": [eventAddress],
limit: 100, limit: 100,
}) }),
); );
}); });
it('cleans up subscription on unmount', () => { it("cleans up subscription on unmount", () => {
mockNdk.subscribe({ kinds: [9802], limit: 100 }); mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Simulate unmount by calling stop // Simulate unmount by calling stop
@ -264,10 +266,10 @@ describe('HighlightLayer Component', () => {
}); });
}); });
describe('Color Mapping', () => { describe("Color Mapping", () => {
it('maps highlights to colors correctly', () => { it("maps highlights to colors correctly", () => {
const pubkey1 = 'a'.repeat(64); const pubkey1 = "a".repeat(64);
const pubkey2 = 'b'.repeat(64); const pubkey2 = "b".repeat(64);
const hue1 = pubkeyToHue(pubkey1); const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2); const hue2 = pubkeyToHue(pubkey2);
@ -280,8 +282,8 @@ describe('HighlightLayer Component', () => {
expect(expectedColor1).not.toBe(expectedColor2); expect(expectedColor1).not.toBe(expectedColor2);
}); });
it('uses consistent color for same pubkey', () => { it("uses consistent color for same pubkey", () => {
const pubkey = 'abc123def456'.repeat(5) + 'abcd'; const pubkey = "abc123def456".repeat(5) + "abcd";
const hue = pubkeyToHue(pubkey); const hue = pubkeyToHue(pubkey);
const color1 = `hsla(${hue}, 70%, 60%, 0.3)`; const color1 = `hsla(${hue}, 70%, 60%, 0.3)`;
@ -290,16 +292,16 @@ describe('HighlightLayer Component', () => {
expect(color1).toBe(color2); expect(color1).toBe(color2);
}); });
it('generates semi-transparent colors with 0.3 opacity', () => { it("generates semi-transparent colors with 0.3 opacity", () => {
const pubkey = 'a'.repeat(64); const pubkey = "a".repeat(64);
const hue = pubkeyToHue(pubkey); const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`; const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain('0.3'); expect(color).toContain("0.3");
}); });
it('uses HSL color format with correct values', () => { it("uses HSL color format with correct values", () => {
const pubkey = 'a'.repeat(64); const pubkey = "a".repeat(64);
const hue = pubkeyToHue(pubkey); const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`; const color = `hsla(${hue}, 70%, 60%, 0.3)`;
@ -308,20 +310,20 @@ describe('HighlightLayer Component', () => {
}); });
}); });
describe('Highlight Events', () => { describe("Highlight Events", () => {
it('handles no highlights gracefully', () => { it("handles no highlights gracefully", () => {
const highlights: any[] = []; const highlights: any[] = [];
expect(highlights.length).toBe(0); expect(highlights.length).toBe(0);
// Component should render without errors // Component should render without errors
}); });
it('handles single highlight from one user', () => { it("handles single highlight from one user", () => {
const mockHighlight = { const mockHighlight = {
id: 'highlight1', id: "highlight1",
kind: 9802, kind: 9802,
pubkey: 'a'.repeat(64), pubkey: "a".repeat(64),
content: 'highlighted text', content: "highlighted text",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}; };
@ -329,25 +331,25 @@ describe('HighlightLayer Component', () => {
const highlights = [mockHighlight]; const highlights = [mockHighlight];
expect(highlights.length).toBe(1); expect(highlights.length).toBe(1);
expect(highlights[0].pubkey).toBe('a'.repeat(64)); expect(highlights[0].pubkey).toBe("a".repeat(64));
}); });
it('handles multiple highlights from same user', () => { it("handles multiple highlights from same user", () => {
const pubkey = 'a'.repeat(64); const pubkey = "a".repeat(64);
const mockHighlights = [ const mockHighlights = [
{ {
id: 'highlight1', id: "highlight1",
kind: 9802, kind: 9802,
pubkey: pubkey, pubkey: pubkey,
content: 'first highlight', content: "first highlight",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}, },
{ {
id: 'highlight2', id: "highlight2",
kind: 9802, kind: 9802,
pubkey: pubkey, pubkey: pubkey,
content: 'second highlight', content: "second highlight",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}, },
@ -363,33 +365,33 @@ describe('HighlightLayer Component', () => {
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
}); });
it('handles multiple highlights from different users', () => { it("handles multiple highlights from different users", () => {
const pubkey1 = 'a'.repeat(64); const pubkey1 = "a".repeat(64);
const pubkey2 = 'b'.repeat(64); const pubkey2 = "b".repeat(64);
const pubkey3 = 'c'.repeat(64); const pubkey3 = "c".repeat(64);
const mockHighlights = [ const mockHighlights = [
{ {
id: 'highlight1', id: "highlight1",
kind: 9802, kind: 9802,
pubkey: pubkey1, pubkey: pubkey1,
content: 'highlight from user 1', content: "highlight from user 1",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}, },
{ {
id: 'highlight2', id: "highlight2",
kind: 9802, kind: 9802,
pubkey: pubkey2, pubkey: pubkey2,
content: 'highlight from user 2', content: "highlight from user 2",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}, },
{ {
id: 'highlight3', id: "highlight3",
kind: 9802, kind: 9802,
pubkey: pubkey3, pubkey: pubkey3,
content: 'highlight from user 3', content: "highlight from user 3",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}, },
@ -407,12 +409,12 @@ describe('HighlightLayer Component', () => {
expect(hue1).not.toBe(hue3); expect(hue1).not.toBe(hue3);
}); });
it('prevents duplicate highlights', () => { it("prevents duplicate highlights", () => {
const mockHighlight = { const mockHighlight = {
id: 'highlight1', id: "highlight1",
kind: 9802, kind: 9802,
pubkey: 'a'.repeat(64), pubkey: "a".repeat(64),
content: 'highlighted text', content: "highlighted text",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}; };
@ -420,32 +422,32 @@ describe('HighlightLayer Component', () => {
const highlights = [mockHighlight]; const highlights = [mockHighlight];
// Try to add duplicate // Try to add duplicate
const isDuplicate = highlights.some(h => h.id === mockHighlight.id); const isDuplicate = highlights.some((h) => h.id === mockHighlight.id);
expect(isDuplicate).toBe(true); expect(isDuplicate).toBe(true);
// Should not add duplicate // Should not add duplicate
}); });
it('handles empty content gracefully', () => { it("handles empty content gracefully", () => {
const mockHighlight = { const mockHighlight = {
id: 'highlight1', id: "highlight1",
kind: 9802, kind: 9802,
pubkey: 'a'.repeat(64), pubkey: "a".repeat(64),
content: '', content: "",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}; };
// Should not crash // Should not crash
expect(mockHighlight.content).toBe(''); expect(mockHighlight.content).toBe("");
}); });
it('handles whitespace-only content', () => { it("handles whitespace-only content", () => {
const mockHighlight = { const mockHighlight = {
id: 'highlight1', id: "highlight1",
kind: 9802, kind: 9802,
pubkey: 'a'.repeat(64), pubkey: "a".repeat(64),
content: ' \n\t ', content: " \n\t ",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}; };
@ -455,9 +457,9 @@ describe('HighlightLayer Component', () => {
}); });
}); });
describe('Highlighter Legend', () => { describe("Highlighter Legend", () => {
it('displays legend with correct color for single highlighter', () => { it("displays legend with correct color for single highlighter", () => {
const pubkey = 'abc123def456'.repeat(5) + 'abcd'; const pubkey = "abc123def456".repeat(5) + "abcd";
const hue = pubkeyToHue(pubkey); const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`; const color = `hsla(${hue}, 70%, 60%, 0.3)`;
@ -471,14 +473,14 @@ describe('HighlightLayer Component', () => {
expect(legend.shortPubkey).toBe(`${pubkey.slice(0, 8)}...`); expect(legend.shortPubkey).toBe(`${pubkey.slice(0, 8)}...`);
}); });
it('displays legend with colors for multiple highlighters', () => { it("displays legend with colors for multiple highlighters", () => {
const pubkeys = [ const pubkeys = [
'a'.repeat(64), "a".repeat(64),
'b'.repeat(64), "b".repeat(64),
'c'.repeat(64), "c".repeat(64),
]; ];
const legendEntries = pubkeys.map(pubkey => ({ const legendEntries = pubkeys.map((pubkey) => ({
pubkey, pubkey,
color: `hsla(${pubkeyToHue(pubkey)}, 70%, 60%, 0.3)`, color: `hsla(${pubkeyToHue(pubkey)}, 70%, 60%, 0.3)`,
shortPubkey: `${pubkey.slice(0, 8)}...`, shortPubkey: `${pubkey.slice(0, 8)}...`,
@ -487,45 +489,45 @@ describe('HighlightLayer Component', () => {
expect(legendEntries.length).toBe(3); expect(legendEntries.length).toBe(3);
// Each should have unique color // Each should have unique color
const colors = legendEntries.map(e => e.color); const colors = legendEntries.map((e) => e.color);
const uniqueColors = new Set(colors); const uniqueColors = new Set(colors);
expect(uniqueColors.size).toBe(3); expect(uniqueColors.size).toBe(3);
}); });
it('shows truncated pubkey in legend', () => { it("shows truncated pubkey in legend", () => {
const pubkey = 'abcdefghijklmnop'.repeat(4); const pubkey = "abcdefghijklmnop".repeat(4);
const shortPubkey = `${pubkey.slice(0, 8)}...`; const shortPubkey = `${pubkey.slice(0, 8)}...`;
expect(shortPubkey).toBe('abcdefgh...'); expect(shortPubkey).toBe("abcdefgh...");
expect(shortPubkey.length).toBeLessThan(pubkey.length); expect(shortPubkey.length).toBeLessThan(pubkey.length);
}); });
it('displays highlight count', () => { it("displays highlight count", () => {
const highlights = [ const highlights = [
{ id: '1', pubkey: 'a'.repeat(64), content: 'text1' }, { id: "1", pubkey: "a".repeat(64), content: "text1" },
{ id: '2', pubkey: 'b'.repeat(64), content: 'text2' }, { id: "2", pubkey: "b".repeat(64), content: "text2" },
{ id: '3', pubkey: 'a'.repeat(64), content: 'text3' }, { id: "3", pubkey: "a".repeat(64), content: "text3" },
]; ];
expect(highlights.length).toBe(3); expect(highlights.length).toBe(3);
// Count unique highlighters // Count unique highlighters
const uniqueHighlighters = new Set(highlights.map(h => h.pubkey)); const uniqueHighlighters = new Set(highlights.map((h) => h.pubkey));
expect(uniqueHighlighters.size).toBe(2); expect(uniqueHighlighters.size).toBe(2);
}); });
}); });
describe('Text Matching', () => { describe("Text Matching", () => {
it('matches text case-insensitively', () => { it("matches text case-insensitively", () => {
const searchText = 'Hello World'; const searchText = "Hello World";
const contentText = 'hello world'; const contentText = "hello world";
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase()); const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0); expect(index).toBeGreaterThanOrEqual(0);
}); });
it('handles special characters in search text', () => { it("handles special characters in search text", () => {
const searchText = 'text with "quotes" and symbols!'; const searchText = 'text with "quotes" and symbols!';
const contentText = 'This is text with "quotes" and symbols! in it.'; const contentText = 'This is text with "quotes" and symbols! in it.';
@ -534,67 +536,75 @@ describe('HighlightLayer Component', () => {
expect(index).toBeGreaterThanOrEqual(0); expect(index).toBeGreaterThanOrEqual(0);
}); });
it('handles Unicode characters', () => { it("handles Unicode characters", () => {
const searchText = 'café résumé'; const searchText = "café résumé";
const contentText = 'The café résumé was excellent.'; const contentText = "The café résumé was excellent.";
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase()); const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0); expect(index).toBeGreaterThanOrEqual(0);
}); });
it('handles multi-line text', () => { it("handles multi-line text", () => {
const searchText = 'line one\nline two'; const searchText = "line one\nline two";
const contentText = 'This is line one\nline two in the document.'; const contentText = "This is line one\nline two in the document.";
const index = contentText.indexOf(searchText); const index = contentText.indexOf(searchText);
expect(index).toBeGreaterThanOrEqual(0); expect(index).toBeGreaterThanOrEqual(0);
}); });
it('does not match partial words when searching for whole words', () => { it("does not match partial words when searching for whole words", () => {
const searchText = 'cat'; const searchText = "cat";
const contentText = 'The category is important.'; const contentText = "The category is important.";
// Simple word boundary check // Simple word boundary check
const wordBoundaryMatch = new RegExp(`\\b${searchText}\\b`, 'i').test(contentText); const wordBoundaryMatch = new RegExp(`\\b${searchText}\\b`, "i").test(
contentText,
);
expect(wordBoundaryMatch).toBe(false); expect(wordBoundaryMatch).toBe(false);
}); });
}); });
describe('Subscription Lifecycle', () => { describe("Subscription Lifecycle", () => {
it('registers EOSE event handler', () => { it("registers EOSE event handler", () => {
const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 }); const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Verify that 'on' method is available for registering handlers // Verify that 'on' method is available for registering handlers
expect(subscription.on).toBeDefined(); expect(subscription.on).toBeDefined();
// Register EOSE handler // Register EOSE handler
subscription.on('eose', () => { subscription.on("eose", () => {
subscription.stop(); subscription.stop();
}); });
// Verify on was called // Verify on was called
expect(subscription.on).toHaveBeenCalledWith('eose', expect.any(Function)); expect(subscription.on).toHaveBeenCalledWith(
"eose",
expect.any(Function),
);
}); });
it('registers error event handler', () => { it("registers error event handler", () => {
const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 }); const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Verify that 'on' method is available for registering handlers // Verify that 'on' method is available for registering handlers
expect(subscription.on).toBeDefined(); expect(subscription.on).toBeDefined();
// Register error handler // Register error handler
subscription.on('error', () => { subscription.on("error", () => {
subscription.stop(); subscription.stop();
}); });
// Verify on was called // Verify on was called
expect(subscription.on).toHaveBeenCalledWith('error', expect.any(Function)); expect(subscription.on).toHaveBeenCalledWith(
"error",
expect.any(Function),
);
}); });
it('stops subscription on timeout', async () => { it("stops subscription on timeout", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
mockNdk.subscribe({ kinds: [9802], limit: 100 }); mockNdk.subscribe({ kinds: [9802], limit: 100 });
@ -608,7 +618,7 @@ describe('HighlightLayer Component', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('handles multiple subscription cleanup calls safely', () => { it("handles multiple subscription cleanup calls safely", () => {
mockNdk.subscribe({ kinds: [9802], limit: 100 }); mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Call stop multiple times // Call stop multiple times
@ -621,8 +631,8 @@ describe('HighlightLayer Component', () => {
}); });
}); });
describe('Performance', () => { describe("Performance", () => {
it('handles large number of highlights efficiently', () => { it("handles large number of highlights efficiently", () => {
const startTime = Date.now(); const startTime = Date.now();
const highlights = Array.from({ length: 1000 }, (_, i) => ({ const highlights = Array.from({ length: 1000 }, (_, i) => ({
@ -636,7 +646,7 @@ describe('HighlightLayer Component', () => {
// Generate colors for all highlights // Generate colors for all highlights
const colorMap = new Map<string, string>(); const colorMap = new Map<string, string>();
highlights.forEach(h => { highlights.forEach((h) => {
if (!colorMap.has(h.pubkey)) { if (!colorMap.has(h.pubkey)) {
const hue = pubkeyToHue(h.pubkey); const hue = pubkeyToHue(h.pubkey);
colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
@ -653,9 +663,9 @@ describe('HighlightLayer Component', () => {
}); });
}); });
describe('Integration Tests', () => { describe("Integration Tests", () => {
describe('Toggle Functionality', () => { describe("Toggle Functionality", () => {
it('toggle button shows highlights when clicked', () => { it("toggle button shows highlights when clicked", () => {
let highlightsVisible = false; let highlightsVisible = false;
// Simulate toggle // Simulate toggle
@ -664,7 +674,7 @@ describe('Integration Tests', () => {
expect(highlightsVisible).toBe(true); expect(highlightsVisible).toBe(true);
}); });
it('toggle button hides highlights when clicked again', () => { it("toggle button hides highlights when clicked again", () => {
let highlightsVisible = true; let highlightsVisible = true;
// Simulate toggle // Simulate toggle
@ -673,7 +683,7 @@ describe('Integration Tests', () => {
expect(highlightsVisible).toBe(false); expect(highlightsVisible).toBe(false);
}); });
it('toggle state persists between interactions', () => { it("toggle state persists between interactions", () => {
let highlightsVisible = false; let highlightsVisible = false;
highlightsVisible = !highlightsVisible; highlightsVisible = !highlightsVisible;
@ -687,37 +697,38 @@ describe('Integration Tests', () => {
}); });
}); });
describe('Color Format Validation', () => { describe("Color Format Validation", () => {
it('generates semi-transparent colors with 0.3 opacity', () => { it("generates semi-transparent colors with 0.3 opacity", () => {
const pubkeys = [ const pubkeys = [
'a'.repeat(64), "a".repeat(64),
'b'.repeat(64), "b".repeat(64),
'c'.repeat(64), "c".repeat(64),
]; ];
pubkeys.forEach(pubkey => { pubkeys.forEach((pubkey) => {
const hue = pubkeyToHue(pubkey); const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`; const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain('0.3'); expect(color).toContain("0.3");
}); });
}); });
it('uses HSL color format with correct saturation and lightness', () => { it("uses HSL color format with correct saturation and lightness", () => {
const pubkey = 'a'.repeat(64); const pubkey = "a".repeat(64);
const hue = pubkeyToHue(pubkey); const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`; const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain('70%'); expect(color).toContain("70%");
expect(color).toContain('60%'); expect(color).toContain("60%");
}); });
it('generates valid CSS color strings', () => { it("generates valid CSS color strings", () => {
const pubkeys = Array.from({ length: 20 }, (_, i) => const pubkeys = Array.from(
String.fromCharCode(97 + i).repeat(64) { length: 20 },
(_, i) => String.fromCharCode(97 + i).repeat(64),
); );
pubkeys.forEach(pubkey => { pubkeys.forEach((pubkey) => {
const hue = pubkeyToHue(pubkey); const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`; const color = `hsla(${hue}, 70%, 60%, 0.3)`;
@ -727,8 +738,8 @@ describe('Integration Tests', () => {
}); });
}); });
describe('End-to-End Flow', () => { describe("End-to-End Flow", () => {
it('complete highlight workflow', () => { it("complete highlight workflow", () => {
// 1. Start with no highlights visible // 1. Start with no highlights visible
let highlightsVisible = false; let highlightsVisible = false;
let highlights: any[] = []; let highlights: any[] = [];
@ -739,18 +750,18 @@ describe('Integration Tests', () => {
// 2. Fetch highlights // 2. Fetch highlights
const mockHighlights = [ const mockHighlights = [
{ {
id: 'h1', id: "h1",
kind: 9802, kind: 9802,
pubkey: 'a'.repeat(64), pubkey: "a".repeat(64),
content: 'first highlight', content: "first highlight",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}, },
{ {
id: 'h2', id: "h2",
kind: 9802, kind: 9802,
pubkey: 'b'.repeat(64), pubkey: "b".repeat(64),
content: 'second highlight', content: "second highlight",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}, },
@ -761,7 +772,7 @@ describe('Integration Tests', () => {
// 3. Generate color map // 3. Generate color map
const colorMap = new Map<string, string>(); const colorMap = new Map<string, string>();
highlights.forEach(h => { highlights.forEach((h) => {
if (!colorMap.has(h.pubkey)) { if (!colorMap.has(h.pubkey)) {
const hue = pubkeyToHue(h.pubkey); const hue = pubkeyToHue(h.pubkey);
colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
@ -783,17 +794,17 @@ describe('Integration Tests', () => {
expect(highlightsVisible).toBe(false); expect(highlightsVisible).toBe(false);
}); });
it('handles event updates correctly', () => { it("handles event updates correctly", () => {
let eventId = 'event1'; let eventId = "event1";
let highlights: any[] = []; let highlights: any[] = [];
// Initial load // Initial load
highlights = [ highlights = [
{ {
id: 'h1', id: "h1",
kind: 9802, kind: 9802,
pubkey: 'a'.repeat(64), pubkey: "a".repeat(64),
content: 'highlight 1', content: "highlight 1",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}, },
@ -802,7 +813,7 @@ describe('Integration Tests', () => {
expect(highlights.length).toBe(1); expect(highlights.length).toBe(1);
// Event changes // Event changes
eventId = 'event2'; eventId = "event2";
highlights = []; highlights = [];
expect(highlights.length).toBe(0); expect(highlights.length).toBe(0);
@ -810,22 +821,22 @@ describe('Integration Tests', () => {
// New highlights loaded // New highlights loaded
highlights = [ highlights = [
{ {
id: 'h2', id: "h2",
kind: 9802, kind: 9802,
pubkey: 'b'.repeat(64), pubkey: "b".repeat(64),
content: 'highlight 2', content: "highlight 2",
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}, },
]; ];
expect(highlights.length).toBe(1); expect(highlights.length).toBe(1);
expect(highlights[0].id).toBe('h2'); expect(highlights[0].id).toBe("h2");
}); });
}); });
describe('Error Handling', () => { describe("Error Handling", () => {
it('handles missing event ID and address gracefully', () => { it("handles missing event ID and address gracefully", () => {
const eventId = undefined; const eventId = undefined;
const eventAddress = undefined; const eventAddress = undefined;
@ -834,25 +845,25 @@ describe('Integration Tests', () => {
expect(eventAddress).toBeUndefined(); expect(eventAddress).toBeUndefined();
}); });
it('handles subscription errors gracefully', () => { it("handles subscription errors gracefully", () => {
const error = new Error('Subscription failed'); const error = new Error("Subscription failed");
// Should log error but not crash // Should log error but not crash
expect(error.message).toBe('Subscription failed'); expect(error.message).toBe("Subscription failed");
}); });
it('handles malformed highlight events', () => { it("handles malformed highlight events", () => {
const malformedHighlight = { const malformedHighlight = {
id: 'h1', id: "h1",
kind: 9802, kind: 9802,
pubkey: '', // Empty pubkey pubkey: "", // Empty pubkey
content: undefined, // Missing content content: undefined, // Missing content
created_at: Date.now(), created_at: Date.now(),
tags: [], tags: [],
}; };
// Should handle gracefully // Should handle gracefully
expect(malformedHighlight.pubkey).toBe(''); expect(malformedHighlight.pubkey).toBe("");
expect(malformedHighlight.content).toBeUndefined(); expect(malformedHighlight.content).toBeUndefined();
}); });
}); });

36
tests/unit/highlightSelection.test.ts

@ -1,11 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
// Mock flowbite-svelte components // Mock flowbite-svelte components
vi.mock("flowbite-svelte", () => ({ vi.mock("flowbite-svelte", () => ({
Button: vi.fn().mockImplementation((props) => ({ Button: vi.fn().mockImplementation((props) => ({
$$render: () => `<button data-testid="button">${props.children || ""}</button>`, $$render: () =>
`<button data-testid="button">${props.children || ""}</button>`,
})), })),
Modal: vi.fn().mockImplementation(() => ({ Modal: vi.fn().mockImplementation(() => ({
$$render: () => `<div data-testid="modal"></div>`, $$render: () => `<div data-testid="modal"></div>`,
@ -277,11 +278,14 @@ describe("HighlightSelectionHandler Component Logic", () => {
describe("Context Extraction", () => { describe("Context Extraction", () => {
it("should extract context from parent paragraph", () => { it("should extract context from parent paragraph", () => {
const paragraph = { const paragraph = {
textContent: "This is the full paragraph context with selected text inside.", textContent:
"This is the full paragraph context with selected text inside.",
}; };
const context = paragraph.textContent?.trim() || ""; const context = paragraph.textContent?.trim() || "";
expect(context).toBe("This is the full paragraph context with selected text inside."); expect(context).toBe(
"This is the full paragraph context with selected text inside.",
);
}); });
it("should extract context from parent section", () => { it("should extract context from parent section", () => {
@ -654,7 +658,10 @@ describe("HighlightSelectionHandler Component Logic", () => {
document.addEventListener = mockAddEventListener; document.addEventListener = mockAddEventListener;
document.addEventListener("mouseup", () => {}); document.addEventListener("mouseup", () => {});
expect(mockAddEventListener).toHaveBeenCalledWith("mouseup", expect.any(Function)); expect(mockAddEventListener).toHaveBeenCalledWith(
"mouseup",
expect.any(Function),
);
}); });
it("should remove mouseup listener on unmount", () => { it("should remove mouseup listener on unmount", () => {
@ -689,7 +696,9 @@ describe("HighlightSelectionHandler Component Logic", () => {
// Simulate inactive mode // Simulate inactive mode
document.body.classList.remove("highlight-mode-active"); document.body.classList.remove("highlight-mode-active");
expect(mockClassList.remove).toHaveBeenCalledWith("highlight-mode-active"); expect(mockClassList.remove).toHaveBeenCalledWith(
"highlight-mode-active",
);
}); });
it("should clean up class on unmount", () => { it("should clean up class on unmount", () => {
@ -701,7 +710,9 @@ describe("HighlightSelectionHandler Component Logic", () => {
// Simulate cleanup // Simulate cleanup
document.body.classList.remove("highlight-mode-active"); document.body.classList.remove("highlight-mode-active");
expect(mockClassList.remove).toHaveBeenCalledWith("highlight-mode-active"); expect(mockClassList.remove).toHaveBeenCalledWith(
"highlight-mode-active",
);
}); });
}); });
@ -767,17 +778,6 @@ describe("HighlightSelectionHandler Component Logic", () => {
// Simulate failed creation - callback not called // Simulate failed creation - callback not called
expect(mockCallback).not.toHaveBeenCalled(); expect(mockCallback).not.toHaveBeenCalled();
}); });
it("should handle missing callback gracefully", () => {
const callback = undefined;
// Should not throw error
expect(() => {
if (callback) {
callback();
}
}).not.toThrow();
});
}); });
describe("Integration Scenarios", () => { describe("Integration Scenarios", () => {

121
tests/unit/publication_tree_processor.test.ts

@ -1,19 +1,23 @@
/** /**
* TDD Tests for NKBIP-01 Publication Tree Processor * TDD Tests for NKBIP-01 Publication Tree Processor
* *
* Tests the iterative parsing function at different hierarchy levels * Tests the iterative parsing function at different hierarchy levels
* using deep_hierarchy_test.adoc to verify NKBIP-01 compliance. * using deep_hierarchy_test.adoc to verify NKBIP-01 compliance.
*/ */
import { describe, it, expect, beforeAll } from 'vitest'; import { beforeAll, describe, expect, it } from "vitest";
import { readFileSync } from 'fs'; import { readFileSync } from "fs";
import { parseAsciiDocWithTree, validateParseLevel, getSupportedParseLevels } from '../../src/lib/utils/asciidoc_publication_parser.js'; import {
getSupportedParseLevels,
parseAsciiDocWithTree,
validateParseLevel,
} from "../../src/lib/utils/asciidoc_publication_parser.js";
// Mock NDK for testing // Mock NDK for testing
const mockNDK = { const mockNDK = {
activeUser: { activeUser: {
pubkey: "test-pubkey-12345" pubkey: "test-pubkey-12345",
} },
} as any; } as any;
// Read the test document // Read the test document
@ -21,7 +25,7 @@ const testDocumentPath = "./test_data/AsciidocFiles/deep_hierarchy_test.adoc";
let testContent: string; let testContent: string;
try { try {
testContent = readFileSync(testDocumentPath, 'utf-8'); testContent = readFileSync(testDocumentPath, "utf-8");
} catch (error) { } catch (error) {
console.error("Failed to read test document:", error); console.error("Failed to read test document:", error);
testContent = `= Deep Hierarchical Document Test 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", () => { describe("NKBIP-01 Publication Tree Processor", () => {
it("should validate parse levels correctly", () => { it("should validate parse levels correctly", () => {
// Test valid parse levels // Test valid parse levels
expect(validateParseLevel(2)).toBe(true); expect(validateParseLevel(2)).toBe(true);
expect(validateParseLevel(3)).toBe(true); expect(validateParseLevel(3)).toBe(true);
expect(validateParseLevel(5)).toBe(true); expect(validateParseLevel(5)).toBe(true);
// Test invalid parse levels // Test invalid parse levels
expect(validateParseLevel(1)).toBe(false); expect(validateParseLevel(1)).toBe(false);
expect(validateParseLevel(6)).toBe(false); expect(validateParseLevel(6)).toBe(false);
expect(validateParseLevel(7)).toBe(false); expect(validateParseLevel(7)).toBe(false);
expect(validateParseLevel(2.5)).toBe(false); expect(validateParseLevel(2.5)).toBe(false);
expect(validateParseLevel(-1)).toBe(false); expect(validateParseLevel(-1)).toBe(false);
// Test supported levels array // Test supported levels array
const supportedLevels = getSupportedParseLevels(); const supportedLevels = getSupportedParseLevels();
expect(supportedLevels).toEqual([2, 3, 4, 5]); 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 () => { it("should parse Level 2 with NKBIP-01 minimal structure", async () => {
const result = await parseAsciiDocWithTree(testContent, mockNDK, 2); const result = await parseAsciiDocWithTree(testContent, mockNDK, 2);
// Should be detected as article (has title and sections) // Should be detected as article (has title and sections)
expect(result.metadata.contentType).toBe("article"); expect(result.metadata.contentType).toBe("article");
expect(result.metadata.parseLevel).toBe(2); expect(result.metadata.parseLevel).toBe(2);
expect(result.metadata.title).toBe("Deep Hierarchical Document Test"); expect(result.metadata.title).toBe("Deep Hierarchical Document Test");
// Should have 1 index event (30040) + 2 content events (30041) for level 2 sections // Should have 1 index event (30040) + 2 content events (30041) for level 2 sections
expect(result.indexEvent).toBeDefined(); expect(result.indexEvent).toBeDefined();
expect(result.indexEvent?.kind).toBe(30040); expect(result.indexEvent?.kind).toBe(30040);
expect(result.contentEvents.length).toBe(2); expect(result.contentEvents.length).toBe(2);
// All content events should be kind 30041 // All content events should be kind 30041
result.contentEvents.forEach(event => { result.contentEvents.forEach((event) => {
expect(event.kind).toBe(30041); expect(event.kind).toBe(30041);
}); });
// Check titles of level 2 sections // 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] e.tags.find((t: string[]) => t[0] === "title")?.[1]
); );
expect(contentTitles).toContain("Level 2: Main Sections"); expect(contentTitles).toContain("Level 2: Main Sections");
expect(contentTitles).toContain("Level 2: Second Main Section"); expect(contentTitles).toContain("Level 2: Second Main Section");
// Content should include all nested subsections as AsciiDoc // Content should include all nested subsections as AsciiDoc
const firstSectionContent = result.contentEvents[0].content; const firstSectionContent = result.contentEvents[0].content;
expect(firstSectionContent).toBeDefined(); expect(firstSectionContent).toBeDefined();
// Should contain level 3, 4, 5 content as nested AsciiDoc markup // Should contain level 3, 4, 5 content as nested AsciiDoc markup
expect(firstSectionContent.includes("=== Level 3: Subsections")).toBe(true); expect(firstSectionContent.includes("=== Level 3: Subsections")).toBe(true);
expect(firstSectionContent.includes("==== Level 4: Sub-subsections")).toBe(true); expect(firstSectionContent.includes("==== Level 4: Sub-subsections")).toBe(
expect(firstSectionContent.includes("===== Level 5: Deep Subsections")).toBe(true); true,
);
expect(firstSectionContent.includes("===== Level 5: Deep Subsections"))
.toBe(true);
}); });
it("should parse Level 3 with NKBIP-01 intermediate structure", async () => { it("should parse Level 3 with NKBIP-01 intermediate structure", async () => {
const result = await parseAsciiDocWithTree(testContent, mockNDK, 3); const result = await parseAsciiDocWithTree(testContent, mockNDK, 3);
expect(result.metadata.contentType).toBe("article"); expect(result.metadata.contentType).toBe("article");
expect(result.metadata.parseLevel).toBe(3); expect(result.metadata.parseLevel).toBe(3);
// Should have hierarchical structure // Should have hierarchical structure
expect(result.indexEvent).toBeDefined(); expect(result.indexEvent).toBeDefined();
expect(result.indexEvent?.kind).toBe(30040); expect(result.indexEvent?.kind).toBe(30040);
// Should have mix of 30040 (for level 2 sections with children) and 30041 (for content) // 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(30040); // Level 2 sections with children
expect(kinds).toContain(30041); // Level 3 content sections expect(kinds).toContain(30041); // Level 3 content sections
// Level 2 sections with children should be 30040 index events // Level 2 sections with children should be 30040 index events
const level2WithChildrenEvents = result.contentEvents.filter(e => const level2WithChildrenEvents = result.contentEvents.filter((e) =>
e.kind === 30040 && e.kind === 30040 &&
e.tags.find((t: string[]) => t[0] === "title")?.[1]?.includes("Level 2:") e.tags.find((t: string[]) => t[0] === "title")?.[1]?.includes("Level 2:")
); );
expect(level2WithChildrenEvents.length).toBe(2); // Both level 2 sections have children expect(level2WithChildrenEvents.length).toBe(2); // Both level 2 sections have children
// Should have 30041 events for level 3 content // Should have 30041 events for level 3 content
const level3ContentEvents = result.contentEvents.filter(e => const level3ContentEvents = result.contentEvents.filter((e) =>
e.kind === 30041 && e.kind === 30041 &&
e.tags.find((t: string[]) => t[0] === "title")?.[1]?.includes("Level 3:") e.tags.find((t: string[]) => t[0] === "title")?.[1]?.includes("Level 3:")
); );
expect(level3ContentEvents.length).toBeGreaterThan(0); 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 () => { it("should parse Level 4 with NKBIP-01 detailed structure", async () => {
const result = await parseAsciiDocWithTree(testContent, mockNDK, 4); const result = await parseAsciiDocWithTree(testContent, mockNDK, 4);
expect(result.metadata.contentType).toBe("article"); expect(result.metadata.contentType).toBe("article");
expect(result.metadata.parseLevel).toBe(4); expect(result.metadata.parseLevel).toBe(4);
// Should have hierarchical structure with mix of 30040 and 30041 events // Should have hierarchical structure with mix of 30040 and 30041 events
expect(result.indexEvent).toBeDefined(); expect(result.indexEvent).toBeDefined();
expect(result.indexEvent?.kind).toBe(30040); 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(30040); // Level 2 sections with children
expect(kinds).toContain(30041); // Content sections expect(kinds).toContain(30041); // Content sections
// Check that we have level 4 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] e.tags.find((t: string[]) => t[0] === "title")?.[1]
); );
expect(contentTitles).toContain("Level 4: Sub-subsections"); 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 () => { it("should parse Level 5 with NKBIP-01 maximum depth", async () => {
const result = await parseAsciiDocWithTree(testContent, mockNDK, 5); const result = await parseAsciiDocWithTree(testContent, mockNDK, 5);
expect(result.metadata.contentType).toBe("article"); expect(result.metadata.contentType).toBe("article");
expect(result.metadata.parseLevel).toBe(5); expect(result.metadata.parseLevel).toBe(5);
// Should have hierarchical structure // Should have hierarchical structure
expect(result.indexEvent).toBeDefined(); expect(result.indexEvent).toBeDefined();
expect(result.indexEvent?.kind).toBe(30040); expect(result.indexEvent?.kind).toBe(30040);
// Should include level 5 sections as content events // 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] e.tags.find((t: string[]) => t[0] === "title")?.[1]
); );
expect(contentTitles).toContain("Level 5: Deep Subsections"); expect(contentTitles).toContain("Level 5: Deep Subsections");
@ -188,27 +194,27 @@ describe("NKBIP-01 Publication Tree Processor", () => {
it("should validate event structure correctly", async () => { it("should validate event structure correctly", async () => {
const result = await parseAsciiDocWithTree(testContent, mockNDK, 3); const result = await parseAsciiDocWithTree(testContent, mockNDK, 3);
// Test index event structure // Test index event structure
expect(result.indexEvent).toBeDefined(); expect(result.indexEvent).toBeDefined();
expect(result.indexEvent?.kind).toBe(30040); expect(result.indexEvent?.kind).toBe(30040);
expect(result.indexEvent?.tags).toBeDefined(); expect(result.indexEvent?.tags).toBeDefined();
// Check required tags // Check required tags
const indexTags = result.indexEvent!.tags; const indexTags = result.indexEvent!.tags;
const dTag = indexTags.find((t: string[]) => t[0] === "d"); const dTag = indexTags.find((t: string[]) => t[0] === "d");
const titleTag = indexTags.find((t: string[]) => t[0] === "title"); const titleTag = indexTags.find((t: string[]) => t[0] === "title");
expect(dTag).toBeDefined(); expect(dTag).toBeDefined();
expect(titleTag).toBeDefined(); expect(titleTag).toBeDefined();
expect(titleTag![1]).toBe("Deep Hierarchical Document Test"); expect(titleTag![1]).toBe("Deep Hierarchical Document Test");
// Test content events structure - mix of 30040 and 30041 // Test content events structure - mix of 30040 and 30041
result.contentEvents.forEach(event => { result.contentEvents.forEach((event) => {
expect([30040, 30041]).toContain(event.kind); expect([30040, 30041]).toContain(event.kind);
expect(event.tags).toBeDefined(); expect(event.tags).toBeDefined();
expect(event.content).toBeDefined(); expect(event.content).toBeDefined();
const eventTitleTag = event.tags.find((t: string[]) => t[0] === "title"); const eventTitleTag = event.tags.find((t: string[]) => t[0] === "title");
expect(eventTitleTag).toBeDefined(); expect(eventTitleTag).toBeDefined();
}); });
@ -216,11 +222,11 @@ describe("NKBIP-01 Publication Tree Processor", () => {
it("should preserve content as AsciiDoc", async () => { it("should preserve content as AsciiDoc", async () => {
const result = await parseAsciiDocWithTree(testContent, mockNDK, 2); const result = await parseAsciiDocWithTree(testContent, mockNDK, 2);
// Content should be preserved as original AsciiDoc, not converted to HTML // Content should be preserved as original AsciiDoc, not converted to HTML
const firstEvent = result.contentEvents[0]; const firstEvent = result.contentEvents[0];
expect(firstEvent.content).toBeDefined(); expect(firstEvent.content).toBeDefined();
// Should contain AsciiDoc markup, not HTML // Should contain AsciiDoc markup, not HTML
expect(firstEvent.content.includes("<")).toBe(false); expect(firstEvent.content.includes("<")).toBe(false);
expect(firstEvent.content.includes("===")).toBe(true); expect(firstEvent.content.includes("===")).toBe(true);
@ -228,16 +234,16 @@ describe("NKBIP-01 Publication Tree Processor", () => {
it("should handle attributes correctly", async () => { it("should handle attributes correctly", async () => {
const result = await parseAsciiDocWithTree(testContent, mockNDK, 2); const result = await parseAsciiDocWithTree(testContent, mockNDK, 2);
// Document-level attributes should be in index event // Document-level attributes should be in index event
expect(result.indexEvent).toBeDefined(); expect(result.indexEvent).toBeDefined();
const indexTags = result.indexEvent!.tags; const indexTags = result.indexEvent!.tags;
// Check for document attributes // Check for document attributes
const authorTag = indexTags.find((t: string[]) => t[0] === "author"); const authorTag = indexTags.find((t: string[]) => t[0] === "author");
const typeTag = indexTags.find((t: string[]) => t[0] === "type"); const typeTag = indexTags.find((t: string[]) => t[0] === "type");
const tagsTag = indexTags.find((t: string[]) => t[0] === "t"); const tagsTag = indexTags.find((t: string[]) => t[0] === "t");
expect(authorTag?.[1]).toBe("Test Author"); expect(authorTag?.[1]).toBe("Test Author");
expect(typeTag?.[1]).toBe("technical"); expect(typeTag?.[1]).toBe("technical");
expect(tagsTag).toBeDefined(); // Should have at least one t-tag expect(tagsTag).toBeDefined(); // Should have at least one t-tag
@ -256,29 +262,28 @@ Content of first note.
Content of second note.`; Content of second note.`;
const result = await parseAsciiDocWithTree(scatteredContent, mockNDK, 2); const result = await parseAsciiDocWithTree(scatteredContent, mockNDK, 2);
expect(result.metadata.contentType).toBe("scattered-notes"); expect(result.metadata.contentType).toBe("scattered-notes");
expect(result.indexEvent).toBeNull(); // No index event for scattered notes expect(result.indexEvent).toBeNull(); // No index event for scattered notes
expect(result.contentEvents.length).toBe(2); expect(result.contentEvents.length).toBe(2);
// All events should be 30041 content events // All events should be 30041 content events
result.contentEvents.forEach(event => { result.contentEvents.forEach((event) => {
expect(event.kind).toBe(30041); expect(event.kind).toBe(30041);
}); });
}); });
it("should integrate with PublicationTree structure", async () => { it("should integrate with PublicationTree structure", async () => {
const result = await parseAsciiDocWithTree(testContent, mockNDK, 2); const result = await parseAsciiDocWithTree(testContent, mockNDK, 2);
// Should have a PublicationTree instance // Should have a PublicationTree instance
expect(result.tree).toBeDefined(); expect(result.tree).toBeDefined();
// Tree should have methods for event management // Tree should have methods for event management
expect(typeof result.tree.addEvent).toBe("function"); expect(typeof result.tree.addEvent).toBe("function");
// Event structure should be populated // Event structure should be populated
expect(result.metadata.eventStructure).toBeDefined(); expect(result.metadata.eventStructure).toBeDefined();
expect(Array.isArray(result.metadata.eventStructure)).toBe(true); expect(Array.isArray(result.metadata.eventStructure)).toBe(true);
}); });
});
});

534
tests/zettel-publisher-tdd.test.ts

@ -3,7 +3,7 @@
/** /**
* Test-Driven Development for ZettelPublisher Enhancement * Test-Driven Development for ZettelPublisher Enhancement
* Based on understanding_knowledge.adoc, desire.adoc, and docreference.md * Based on understanding_knowledge.adoc, desire.adoc, and docreference.md
* *
* Key Requirements Discovered: * Key Requirements Discovered:
* 1. ITERATIVE parsing (not recursive): sections at target level become events * 1. ITERATIVE parsing (not recursive): sections at target level become events
* 2. Level 2: == sections become 30041 events containing ALL subsections (===, ====, etc.) * 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 * 7. Custom attributes: all :key: value pairs preserved as event tags
*/ */
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
// Test framework // Test framework
interface TestCase { interface TestCase {
@ -40,7 +40,9 @@ class TestFramework {
}, },
toEqual: (expected: any) => { toEqual: (expected: any) => {
if (JSON.stringify(actual) === JSON.stringify(expected)) return true; 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) => { toContain: (expected: any) => {
if (actual && actual.includes && actual.includes(expected)) return true; if (actual && actual.includes && actual.includes(expected)) return true;
@ -48,9 +50,11 @@ class TestFramework {
}, },
not: { not: {
toContain: (expected: any) => { 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}"`); throw new Error(`Expected "${actual}" NOT to contain "${expected}"`);
} },
}, },
toBeTruthy: () => { toBeTruthy: () => {
if (actual) return true; if (actual) return true;
@ -58,14 +62,18 @@ class TestFramework {
}, },
toHaveLength: (expected: number) => { toHaveLength: (expected: number) => {
if (actual && actual.length === expected) return true; 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() { async run() {
console.log(`🧪 Running ${this.tests.length} tests...\n`); console.log(`🧪 Running ${this.tests.length} tests...\n`);
for (const { name, fn } of this.tests) { for (const { name, fn } of this.tests) {
try { try {
await fn(); await fn();
@ -87,57 +95,68 @@ class TestFramework {
const test = new TestFramework(); const test = new TestFramework();
// Load test data files // Load test data files
const testDataPath = path.join(process.cwd(), 'test_data', 'AsciidocFiles'); const testDataPath = path.join(process.cwd(), "test_data", "AsciidocFiles");
const understandingKnowledge = fs.readFileSync(path.join(testDataPath, 'understanding_knowledge.adoc'), 'utf-8'); const understandingKnowledge = fs.readFileSync(
const desire = fs.readFileSync(path.join(testDataPath, 'desire.adoc'), 'utf-8'); 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) // 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 // Expected 30040 metadata from understanding_knowledge.adoc
const expectedDocMetadata = { const expectedDocMetadata = {
title: 'Understanding Knowledge', title: "Understanding Knowledge",
image: 'https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg', image: "https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg",
published: '2025-04-21', published: "2025-04-21",
language: 'en, ISO-639-1', language: "en, ISO-639-1",
tags: ['knowledge', 'philosophy', 'education'], tags: ["knowledge", "philosophy", "education"],
type: 'text' type: "text",
}; };
// Test will pass when document parsing extracts these correctly // 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.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 // Expected 30040 metadata from desire.adoc
const expectedDocMetadata = { const expectedDocMetadata = {
title: 'Desire Part 1: Mimesis', title: "Desire Part 1: Mimesis",
image: 'https://i.nostr.build/hGzyi4c3YhTwoCCe.png', image: "https://i.nostr.build/hGzyi4c3YhTwoCCe.png",
published: '2025-07-02', published: "2025-07-02",
language: 'en, ISO-639-1', language: "en, ISO-639-1",
tags: ['memetics', 'philosophy', 'desire'], tags: ["memetics", "philosophy", "desire"],
type: 'podcastArticle' type: "podcastArticle",
}; };
test.expect(expectedDocMetadata.type).toBe('podcastArticle'); test.expect(expectedDocMetadata.type).toBe("podcastArticle");
test.expect(expectedDocMetadata.tags).toContain('memetics'); 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) // Test the ITERATIVE interface structure (not recursive)
// Based on docreference.md - Level 2 parsing example // Based on docreference.md - Level 2 parsing example
const mockLevel2Structure = { const mockLevel2Structure = {
metadata: { title: 'Programming Fundamentals Guide', tags: ['programming', 'fundamentals'] }, metadata: {
content: 'This is the main introduction to the programming guide.', title: "Programming Fundamentals Guide",
title: 'Programming Fundamentals Guide', tags: ["programming", "fundamentals"],
},
content: "This is the main introduction to the programming guide.",
title: "Programming Fundamentals Guide",
sections: [ sections: [
{ {
metadata: { title: 'Data Structures', tags: ['arrays', 'lists', 'trees'], difficulty: 'intermediate' }, metadata: {
content: `Understanding fundamental data structures is crucial for effective programming. title: "Data Structures",
tags: ["arrays", "lists", "trees"],
difficulty: "intermediate",
},
content:
`Understanding fundamental data structures is crucial for effective programming.
=== Arrays and Lists === Arrays and Lists
@ -155,11 +174,16 @@ Linked lists use pointers to connect elements.
=== Trees and Graphs === Trees and Graphs
Tree and graph structures enable hierarchical and networked data representation.`, 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' }, metadata: {
content: `Algorithmic thinking forms the foundation of efficient problem-solving. title: "Algorithms",
tags: ["sorting", "searching", "optimization"],
difficulty: "advanced",
},
content:
`Algorithmic thinking forms the foundation of efficient problem-solving.
=== Sorting Algorithms === Sorting Algorithms
@ -172,54 +196,64 @@ Bubble sort repeatedly steps through the list, compares adjacent elements.
==== Quick Sort ==== Quick Sort
Quick sort uses divide-and-conquer approach with pivot selection.`, Quick sort uses divide-and-conquer approach with pivot selection.`,
title: 'Algorithms' title: "Algorithms",
} },
] ],
}; };
// Verify ITERATIVE structure: only level 2 sections, containing ALL subsections // Verify ITERATIVE structure: only level 2 sections, containing ALL subsections
test.expect(mockLevel2Structure.sections).toHaveLength(2); test.expect(mockLevel2Structure.sections).toHaveLength(2);
test.expect(mockLevel2Structure.sections[0].title).toBe('Data Structures'); 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(
test.expect(mockLevel2Structure.sections[0].content).toContain('==== Dynamic Arrays'); "=== Arrays and Lists",
test.expect(mockLevel2Structure.sections[1].content).toContain('==== Quick Sort'); );
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) // 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 // From understanding_knowledge.adoc: "== Preface" section
const expectedPrefaceContent = `[NOTE] 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. 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`; -- liminal`;
// Should NOT contain "== Preface" // Should NOT contain "== Preface"
test.expect(expectedPrefaceContent).not.toContain('== Preface'); test.expect(expectedPrefaceContent).not.toContain("== Preface");
test.expect(expectedPrefaceContent).toContain('[NOTE]'); 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 // 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 // Should NOT contain subsection content or headers
test.expect(expectedIntroContent).not.toContain('=== Why Investigate'); test.expect(expectedIntroContent).not.toContain("=== Why Investigate");
test.expect(expectedIntroContent).not.toContain('Understanding the nature of knowledge'); test.expect(expectedIntroContent).not.toContain(
test.expect(expectedIntroContent).toContain('image:https://i.nostr.build'); "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 // "=== 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 // Should NOT contain its own header
test.expect(expectedSubsectionContent).not.toContain('=== Why Investigate'); test.expect(expectedSubsectionContent).not.toContain("=== Why Investigate");
test.expect(expectedSubsectionContent).toContain('Understanding the nature'); 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 // From "=== The Four Perspectives" section with ==== subsections
const expectedFormatted = ` const expectedFormatted = `
==== 1. The Building Blocks (Material Cause) ==== 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...`; 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(
test.expect(expectedFormatted).toContain('\n==== 2. The Pattern of Organization (Formal Cause)\n'); "\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) // 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 // Expected 30040 index event structure
const expectedIndexEvent = { const expectedIndexEvent = {
kind: 30040, kind: 30040,
content: '', // Index events have empty content content: "", // Index events have empty content
tags: [ tags: [
['d', 'understanding-knowledge'], ["d", "understanding-knowledge"],
['title', 'Understanding Knowledge'], ["title", "Understanding Knowledge"],
['image', 'https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg'], ["image", "https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg"],
['published', '2025-04-21'], ["published", "2025-04-21"],
['language', 'en, ISO-639-1'], ["language", "en, ISO-639-1"],
['t', 'knowledge'], ["t", "knowledge"],
['t', 'philosophy'], ["t", "philosophy"],
['t', 'education'], ["t", "education"],
['type', 'text'], ["type", "text"],
// a-tags referencing sections // a-tags referencing sections
['a', '30041:pubkey:understanding-knowledge-preface'], ["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-introduction-knowledge-as-a-living-ecosystem",
],
[
"a",
"30041:pubkey:understanding-knowledge-i-material-cause-the-substance-of-knowledge",
],
// ... more a-tags for each section // ... more a-tags for each section
] ],
}; };
test.expect(expectedIndexEvent.kind).toBe(30040); test.expect(expectedIndexEvent.kind).toBe(30040);
test.expect(expectedIndexEvent.content).toBe(''); test.expect(expectedIndexEvent.content).toBe("");
test.expect(expectedIndexEvent.tags.filter(([k]) => k === 't')).toHaveLength(3); test.expect(expectedIndexEvent.tags.filter(([k]) => k === "t")).toHaveLength(
test.expect(expectedIndexEvent.tags.find(([k, v]) => k === 'type' && v === 'text')).toBeTruthy(); 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 // Expected 30041 events for main sections
const expectedSectionEvents = [ const expectedSectionEvents = [
{ {
kind: 30041, 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: [ tags: [
['d', 'understanding-knowledge-preface'], ["d", "understanding-knowledge-preface"],
['title', 'Preface'] ["title", "Preface"],
] ],
}, },
{ {
kind: 30041, kind: 30041,
content: `image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library]`, content: `image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library]`,
tags: [ 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.kind).toBe(30041);
test.expect(event.content).toBeTruthy(); test.expect(event.content).toBeTruthy();
test.expect(event.tags.find(([k]) => k === 'd')).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 === "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 // Based on docreference.md examples
// Level 2 parsing: only == sections become events, containing all subsections // Level 2 parsing: only == sections become events, containing all subsections
const expectedLevel2Events = { const expectedLevel2Events = {
mainIndex: { mainIndex: {
kind: 30040, kind: 30040,
content: '', content: "",
tags: [ tags: [
['d', 'programming-fundamentals-guide'], ["d", "programming-fundamentals-guide"],
['title', 'Programming Fundamentals Guide'], ["title", "Programming Fundamentals Guide"],
['a', '30041:author_pubkey:data-structures'], ["a", "30041:author_pubkey:data-structures"],
['a', '30041:author_pubkey:algorithms'] ["a", "30041:author_pubkey:algorithms"],
] ],
}, },
dataStructuresSection: { dataStructuresSection: {
kind: 30041, 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: [ tags: [
['d', 'data-structures'], ["d", "data-structures"],
['title', 'Data Structures'], ["title", "Data Structures"],
['difficulty', 'intermediate'] ["difficulty", "intermediate"],
] ],
} },
}; };
// Level 3 parsing: == sections become 30040 indices, === sections become 30041 events // Level 3 parsing: == sections become 30040 indices, === sections become 30041 events
const expectedLevel3Events = { const expectedLevel3Events = {
mainIndex: { mainIndex: {
kind: 30040, kind: 30040,
content: '', content: "",
tags: [ tags: [
['d', 'programming-fundamentals-guide'], ["d", "programming-fundamentals-guide"],
['title', 'Programming Fundamentals Guide'], ["title", "Programming Fundamentals Guide"],
['a', '30040:author_pubkey:data-structures'], // Now references sub-index ["a", "30040:author_pubkey:data-structures"], // Now references sub-index
['a', '30040:author_pubkey:algorithms'] ["a", "30040:author_pubkey:algorithms"],
] ],
}, },
dataStructuresIndex: { dataStructuresIndex: {
kind: 30040, kind: 30040,
content: '', content: "",
tags: [ tags: [
['d', 'data-structures'], ["d", "data-structures"],
['title', 'Data Structures'], ["title", "Data Structures"],
['a', '30041:author_pubkey:data-structures-content'], ["a", "30041:author_pubkey:data-structures-content"],
['a', '30041:author_pubkey:arrays-and-lists'], ["a", "30041:author_pubkey:arrays-and-lists"],
['a', '30041:author_pubkey:trees-and-graphs'] ["a", "30041:author_pubkey:trees-and-graphs"],
] ],
}, },
arraysAndListsSection: { arraysAndListsSection: {
kind: 30041, 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: [ tags: [
['d', 'arrays-and-lists'], ["d", "arrays-and-lists"],
['title', 'Arrays and Lists'] ["title", "Arrays and Lists"],
] ],
} },
}; };
test.expect(expectedLevel2Events.mainIndex.kind).toBe(30040); test.expect(expectedLevel2Events.mainIndex.kind).toBe(30040);
test.expect(expectedLevel2Events.dataStructuresSection.kind).toBe(30041); 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.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 // 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 = [ const testCases = [
{ {
name: 'Understanding Knowledge (article)', name: "Understanding Knowledge (article)",
content: understandingKnowledge, content: understandingKnowledge,
expected: 'article' expected: "article",
}, },
{ {
name: 'Desire (article)', name: "Desire (article)",
content: desire, content: desire,
expected: 'article' expected: "article",
}, },
{ {
name: 'Scattered notes format', name: "Scattered notes format",
content: '== Note 1\nContent\n\n== Note 2\nMore content', content: "== Note 1\nContent\n\n== Note 2\nMore content",
expected: 'scattered-notes' expected: "scattered-notes",
} },
]; ];
testCases.forEach(({ name, content, expected }) => { testCases.forEach(({ name, content, expected }) => {
const hasDocTitle = content.trim().startsWith('=') && !content.trim().startsWith('=='); const hasDocTitle = content.trim().startsWith("=") &&
const hasSections = content.includes('=='); !content.trim().startsWith("==");
const hasSections = content.includes("==");
let detected; let detected;
if (hasDocTitle) { if (hasDocTitle) {
detected = 'article'; detected = "article";
} else if (hasSections) { } else if (hasSections) {
detected = 'scattered-notes'; detected = "scattered-notes";
} else { } else {
detected = 'none'; detected = "none";
} }
console.log(` ${name}: detected ${detected}`); console.log(` ${name}: detected ${detected}`);
test.expect(detected).toBe(expected); 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) > ==== // Understanding Knowledge has structure: = > == (6 sections) > === (many subsections) > ====
// Based on actual content analysis // Based on actual content analysis
const levelEventCounts = [ const levelEventCounts = [
{ level: 1, description: 'Only document index', events: 1 }, { 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: 2,
{ level: 4, description: 'Full hierarchy including level 4 (====)', events: 35 } 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 }) => { 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) // 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 // Mock the complete ITERATIVE workflow
const mockWorkflow = { const mockWorkflow = {
parseLevel2: (content: string) => ({ parseLevel2: (content: string) => ({
metadata: { metadata: {
title: 'Understanding Knowledge', title: "Understanding Knowledge",
image: 'https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg', image: "https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg",
published: '2025-04-21', published: "2025-04-21",
tags: ['knowledge', 'philosophy', 'education'], tags: ["knowledge", "philosophy", "education"],
type: 'text' type: "text",
}, },
title: 'Understanding Knowledge', title: "Understanding Knowledge",
content: 'Introduction content before any sections', content: "Introduction content before any sections",
sections: [ sections: [
{ {
title: 'Preface', title: "Preface",
content: '[NOTE]\nThis essay was written to outline...', content: "[NOTE]\nThis essay was written to outline...",
metadata: { title: 'Preface' } metadata: { title: "Preface" },
}, },
{ {
title: 'Introduction: Knowledge as a Living Ecosystem', title: "Introduction: Knowledge as a Living Ecosystem",
// Contains ALL subsections (===, ====) in content // Contains ALL subsections (===, ====) in content
content: `image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library] 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) ===== 1. The Building Blocks (Material Cause)
Just as living organisms are made up of cells...`, 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) // ... 4 more sections (Material Cause, Formal Cause, Efficient Cause, Final Cause)
] ],
}), }),
buildLevel2Events: (parsed: any) => ({ buildLevel2Events: (parsed: any) => ({
indexEvent: { indexEvent: {
kind: 30040, kind: 30040,
content: '', content: "",
tags: [ tags: [
['d', 'understanding-knowledge'], ["d", "understanding-knowledge"],
['title', parsed.title], ["title", parsed.title],
['image', parsed.metadata.image], ["image", parsed.metadata.image],
['t', 'knowledge'], ['t', 'philosophy'], ['t', 'education'], ["t", "knowledge"],
['type', 'text'], ["t", "philosophy"],
['a', '30041:pubkey:preface'], ["t", "education"],
['a', '30041:pubkey:introduction-knowledge-as-a-living-ecosystem'] ["type", "text"],
] ["a", "30041:pubkey:preface"],
["a", "30041:pubkey:introduction-knowledge-as-a-living-ecosystem"],
],
}, },
sectionEvents: parsed.sections.map((s: any) => ({ sectionEvents: parsed.sections.map((s: any) => ({
kind: 30041, kind: 30041,
content: s.content, content: s.content,
tags: [ tags: [
['d', s.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')], ["d", s.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")],
['title', s.title] ["title", s.title],
] ],
})) })),
}), }),
publish: (events: any) => ({ publish: (events: any) => ({
success: true, success: true,
published: events.sectionEvents.length + 1, 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 // 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 events = mockWorkflow.buildLevel2Events(parsed);
const result = mockWorkflow.publish(events); 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(parsed.sections).toHaveLength(2);
test.expect(events.indexEvent.kind).toBe(30040); test.expect(events.indexEvent.kind).toBe(30040);
test.expect(events.sectionEvents).toHaveLength(2); test.expect(events.sectionEvents).toHaveLength(2);
test.expect(events.sectionEvents[1].content).toContain('=== Why Investigate'); // Contains subsections 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(
"===== 1. The Building Blocks",
); // Contains deeper levels
test.expect(result.success).toBeTruthy(); test.expect(result.success).toBeTruthy();
test.expect(result.published).toBe(3); // 1 index + 2 sections 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 = [ 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: "== Section\n=== Subsection\n==== Missing content",
{ content: '', error: 'Empty document' } error: "Empty content sections",
},
{
content: "= Title\n\n== Section\n==== Skipped level",
error: "Invalid header nesting",
},
{ content: "", error: "Empty document" },
]; ];
invalidCases.forEach(({ content, error }) => { invalidCases.forEach(({ content, error }) => {
// Mock error detection // Mock error detection
const hasEmptySections = content.includes('Missing content'); const hasEmptySections = content.includes("Missing content");
const hasSkippedLevels = content.includes('====') && !content.includes('==='); const hasSkippedLevels = content.includes("====") &&
const isEmpty = content.trim() === ''; !content.includes("===");
const isEmpty = content.trim() === "";
const shouldError = hasEmptySections || hasSkippedLevels || isEmpty; const shouldError = hasEmptySections || hasSkippedLevels || isEmpty;
test.expect(shouldError).toBeTruthy(); test.expect(shouldError).toBeTruthy();
}); });
@ -535,26 +621,40 @@ test.test('Error handling for malformed content', () => {
// Test Execution // Test Execution
// ============================================================================= // =============================================================================
console.log('🎯 ZettelPublisher Test-Driven Development (ITERATIVE)\n'); console.log("🎯 ZettelPublisher Test-Driven Development (ITERATIVE)\n");
console.log('📋 Test Data Analysis:'); console.log("📋 Test Data Analysis:");
console.log(`- Understanding Knowledge: ${understandingKnowledge.split('\n').length} lines`); console.log(
console.log(`- Desire: ${desire.split('\n').length} lines`); `- Understanding Knowledge: ${
console.log('- Both files use = document title with metadata directly underneath'); understandingKnowledge.split("\n").length
console.log('- Sections use == with deep nesting (===, ====, =====)'); } lines`,
console.log('- Custom attributes like :type: podcastArticle need preservation'); );
console.log('- CRITICAL: Structure is ITERATIVE not recursive (per docreference.md)\n'); console.log(`- Desire: ${desire.split("\n").length} lines`);
console.log(
test.run().then(success => { "- 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) { if (success) {
console.log('\n🎉 All tests defined! Ready for ITERATIVE implementation.'); console.log("\n🎉 All tests defined! Ready for ITERATIVE implementation.");
console.log('\n📋 Implementation Plan:'); console.log("\n📋 Implementation Plan:");
console.log('1. ✅ Update ParsedAsciiDoc interface for ITERATIVE parsing'); console.log("1. ✅ Update ParsedAsciiDoc interface for ITERATIVE parsing");
console.log('2. ✅ Fix content processing (header separation, custom attributes)'); console.log(
console.log('3. ✅ Implement level-based publishing logic (30040/30041 structure)'); "2. ✅ Fix content processing (header separation, custom attributes)",
console.log('4. ✅ Add parse-level controlled event generation'); );
console.log('5. ✅ Create context-aware UI with level selector'); console.log(
console.log('\n🔄 Each level can be developed and tested independently!'); "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 { } 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); }).catch(console.error);

Loading…
Cancel
Save