Browse Source

Merge remote-tracking branch 'origin/master' into hotfix/publish-progress-bar

# Conflicts:
#	.env.example
#	package-lock.json
master
silberengel 3 months ago
parent
commit
3346537778
  1. 3
      .env.example
  2. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-complete.png
  3. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-editor-fix.png
  4. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-final.png
  5. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-preview-update.png
  6. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-text-fix.png
  7. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-white-text.png
  8. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-with-preview.png
  9. BIN
      .playwright-mcp/500-error.png
  10. BIN
      .playwright-mcp/after-delete-integration.png
  11. BIN
      .playwright-mcp/after-fix.png
  12. BIN
      .playwright-mcp/after-mock-comments-click.png
  13. BIN
      .playwright-mcp/compose-darkmode-preview.png
  14. BIN
      .playwright-mcp/dark-mode-full-preview.png
  15. BIN
      .playwright-mcp/dark-mode-preview-after.png
  16. BIN
      .playwright-mcp/debug-controls-fixed.png
  17. BIN
      .playwright-mcp/highlights-visible.png
  18. BIN
      .playwright-mcp/homepage-loaded.png
  19. BIN
      .playwright-mcp/homepage-state.png
  20. BIN
      .playwright-mcp/offset-highlights-rendered.png
  21. BIN
      .playwright-mcp/offset-highlights-scrolled.png
  22. BIN
      .playwright-mcp/offset-highlights-test-page.png
  23. BIN
      .playwright-mcp/preview-area.png
  24. BIN
      .playwright-mcp/publication-with-debug-controls.png
  25. BIN
      .playwright-mcp/scrolled-with-comments.png
  26. BIN
      .playwright-mcp/with-mock-highlights.png
  27. 193
      CLAUDE.md
  28. 393
      TECHNIQUE-create-test-highlights.md
  29. 162
      TEST_SUMMARY.md
  30. 122
      WIKI_TAG_SPEC.md
  31. 72
      check-publication-structure.js
  32. 266
      create-test-comments.js
  33. 206
      create-test-highlights.js
  34. 193
      deno.lock
  35. 60
      doc/compose_tree.md
  36. 6
      import_map.json
  37. 77
      nips/09.md
  38. 217
      package-lock.json
  39. 1
      package.json
  40. 153
      src/lib/a/cards/AEventPreview.svelte
  41. 124
      src/lib/components/EventDetails.svelte
  42. 146
      src/lib/components/EventKindFilter.svelte
  43. 10
      src/lib/components/Navigation.svelte
  44. 59
      src/lib/components/Preview.svelte
  45. 775
      src/lib/components/ZettelEditor.svelte
  46. 37
      src/lib/components/cards/BlogHeader.svelte
  47. 520
      src/lib/components/publications/CommentButton.svelte
  48. 282
      src/lib/components/publications/CommentLayer.svelte
  49. 280
      src/lib/components/publications/CommentPanel.svelte
  50. 126
      src/lib/components/publications/DeleteButton.svelte
  51. 21
      src/lib/components/publications/HighlightButton.svelte
  52. 952
      src/lib/components/publications/HighlightLayer.svelte
  53. 472
      src/lib/components/publications/HighlightSelectionHandler.svelte
  54. 567
      src/lib/components/publications/Publication.svelte
  55. 33
      src/lib/components/publications/PublicationHeader.svelte
  56. 192
      src/lib/components/publications/PublicationSection.svelte
  57. 928
      src/lib/components/publications/SectionComments.svelte
  58. 2
      src/lib/components/util/ArticleNav.svelte
  59. 415
      src/lib/components/util/CardActions.svelte
  60. 11
      src/lib/components/util/Details.svelte
  61. 20
      src/lib/components/util/Interactions.svelte
  62. 119
      src/lib/services/deletion.ts
  63. 29
      src/lib/services/publisher.ts
  64. 263
      src/lib/utils/asciidoc_ast_parser.ts
  65. 13
      src/lib/utils/asciidoc_parser.ts
  66. 84
      src/lib/utils/asciidoc_publication_parser.ts
  67. 4
      src/lib/utils/event_input_utils.ts
  68. 70
      src/lib/utils/fetch_publication_highlights.ts
  69. 241
      src/lib/utils/highlightPositioning.ts
  70. 167
      src/lib/utils/highlightUtils.ts
  71. 179
      src/lib/utils/mockCommentData.ts
  72. 200
      src/lib/utils/mockHighlightData.ts
  73. 31
      src/lib/utils/nostrUtils.ts
  74. 61
      src/lib/utils/publication_tree_factory.ts
  75. 232
      src/lib/utils/publication_tree_processor.ts
  76. 144
      src/lib/utils/wiki_links.ts
  77. 39
      src/routes/new/edit/+page.svelte
  78. 906
      tests/unit/commentButton.test.ts
  79. 136
      tests/unit/deletion.test.ts
  80. 320
      tests/unit/fetchPublicationHighlights.test.ts
  81. 870
      tests/unit/highlightLayer.test.ts
  82. 875
      tests/unit/highlightSelection.test.ts
  83. 121
      tests/unit/publication_tree_processor.test.ts
  84. 534
      tests/zettel-publisher-tdd.test.ts

3
.env.example

@ -7,8 +7,5 @@ VITE_USE_MOCK_COMMENTS=true @@ -7,8 +7,5 @@ VITE_USE_MOCK_COMMENTS=true
# Set to "true" to use position-based test highlights instead of fetching from relays
VITE_USE_MOCK_HIGHLIGHTS=true
# Set to "true" to mock event publishing (no actual relay publishing, useful for testing UI)
VITE_MOCK_PUBLISH=true
# Enable debug logging for relay connections
DEBUG_RELAYS=false

BIN
.playwright-mcp/.playwright-mcp/dark-mode-complete.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-editor-fix.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-final.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-preview-update.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-text-fix.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-white-text.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-with-preview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
.playwright-mcp/500-error.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
.playwright-mcp/after-delete-integration.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

BIN
.playwright-mcp/after-fix.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

BIN
.playwright-mcp/after-mock-comments-click.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
.playwright-mcp/compose-darkmode-preview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
.playwright-mcp/dark-mode-full-preview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
.playwright-mcp/dark-mode-preview-after.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
.playwright-mcp/debug-controls-fixed.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
.playwright-mcp/highlights-visible.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

BIN
.playwright-mcp/homepage-loaded.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

BIN
.playwright-mcp/homepage-state.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
.playwright-mcp/offset-highlights-rendered.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

BIN
.playwright-mcp/offset-highlights-scrolled.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

BIN
.playwright-mcp/offset-highlights-test-page.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
.playwright-mcp/preview-area.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
.playwright-mcp/publication-with-debug-controls.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
.playwright-mcp/scrolled-with-comments.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

BIN
.playwright-mcp/with-mock-highlights.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

193
CLAUDE.md

@ -0,0 +1,193 @@ @@ -0,0 +1,193 @@
# Alexandria Codebase - Local Instructions
This document provides project-specific instructions for working with the
Alexandria codebase, based on existing Cursor rules and project conventions.
## Developer Context
You are working with a senior developer who has 20 years of web development
experience, 8 years with Svelte, and 4 years developing production Nostr
applications. Assume high technical proficiency.
## Project Overview
Alexandria is a Nostr-based web application for reading, commenting on, and
publishing long-form content (books, blogs, etc.) stored on Nostr relays. Built
with:
- **Svelte 5** and **SvelteKit 2** (latest versions)
- **TypeScript** (exclusively, no plain JavaScript)
- **Tailwind 4** for styling
- **Deno** runtime (with Node.js compatibility)
- **NDK** (Nostr Development Kit) for protocol interaction
## Architecture Pattern
The project follows a Model-View-Controller (MVC) pattern:
- **Model**: Nostr relays (via WebSocket APIs) and browser storage
- **View**: Reactive UI with SvelteKit pages and Svelte components
- **Controller**: TypeScript modules with utilities, services, and data
preparation
## Critical Development Guidelines
### Prime Directive
**NEVER assume developer intent.** If unsure, ALWAYS ask for clarification
before proceeding.
### AI Anchor Comments System
Before any work, search for `AI-` anchor comments in relevant directories:
- `AI-NOTE:`, `AI-TODO:`, `AI-QUESTION:` - Context sharing between AI and
developers
- `AI-<MM/DD/YYYY>:` - Developer-recorded context (read but don't write)
- **Always update relevant anchor comments when modifying code**
- Add new anchors for complex, critical, or confusing code
- Never remove AI comments without explicit instruction
### Communication Style
- Be direct and concise - avoid apologies or verbose explanations
- Include file names and line numbers (e.g., `src/lib/utils/parser.ts:45-52`)
- Provide documentation links for further reading
- When corrected, provide well-reasoned explanations, not simple agreement
- Don't propose code edits unless specifically requested
## Code Style Requirements
### TypeScript Files (\*.ts)
- **File naming**: `snake_case.ts`
- **Classes/Interfaces/Types**: `PascalCase`
- **Functions/Variables**: `camelCase`
- **Private class members**: `#privateField` (ES2022 syntax)
- **Indentation**: 2 spaces
- **Line length**: 100 characters max
- **Strings**: Single quotes default, backticks for templates
- **Always include**:
- Type annotations for class properties
- Parameter types and return types (except void)
- JSDoc comments for exported functions
- Semicolons at statement ends
### Svelte Components (\*.svelte)
- **Component naming**: `PascalCase.svelte`
- **Use Svelte 5 features exclusively**:
- Runes: `$state`, `$derived`, `$effect`, `$props`
- Callback props (not event dispatchers)
- Snippets (not slots)
- **Avoid deprecated Svelte 4 patterns**:
- No `export let` for props
- No `on:` event directives
- No event dispatchers or component slots
- **Component organization** (in order):
1. Imports
2. Props definition (strongly typed)
3. Context imports (`getContext`)
4. State declarations (`$state`, then `$derived`)
5. Non-reactive variables
6. Component logic (functions, `$effect`)
7. Lifecycle hooks (`onMount`)
8. Snippets (before markup)
9. Component markup
10. Style blocks (rare - prefer Tailwind)
- **Keep components under 500 lines**
- **Extract business logic to separate TypeScript modules**
### HTML/Markup
- Indentation: 2 spaces
- Break long tags across lines
- Use Tailwind 4 utility classes
- Single quotes for attributes
## Key Project Utilities
### Core Classes to Use
- `WebSocketPool` (`src/lib/data_structures/websocket_pool.ts`) - For WebSocket
management
- `PublicationTree` - For hierarchical publication structure
- `ZettelParser` - For AsciiDoc parsing
### Nostr Event Kinds
- `30040` - Blog/publication indexes
- `30041` - Publication sections/articles
- `30023` - Long-form articles
- `30818` - Wiki Notes
- `1` - Short notes
## Development Commands
```bash
# Development
npm run dev # Start dev server
npm run dev:debug # With relay debugging (DEBUG_RELAYS=true)
# Quality Checks (run before commits)
npm run check # Type checking
npm run lint # Linting
npm run format # Auto-format
npm test # Run tests
# Build
npm run build # Production build
npm run preview # Preview production
```
## Testing Requirements
- Unit tests: Vitest with mocked dependencies
- E2E tests: Playwright for critical flows
- Always run `npm test` before commits
- Check types with `npm run check`
## Git Workflow
- Current branch: `feature/text-entry`
- Main branch: `master` (not `main`)
- Descriptive commit messages
- Include test updates with features
## Important Files
- `src/lib/ndk.ts` - NDK configuration
- `src/lib/utils/ZettelParser.ts` - AsciiDoc parsing
- `src/lib/services/publisher.ts` - Event publishing
- `src/lib/components/ZettelEditor.svelte` - Main editor
- `src/routes/new/compose/+page.svelte` - Composition UI
## Performance Considerations
- State is deeply reactive in Svelte 5 - avoid unnecessary reassignments
- Lazy load large components
- Use virtual scrolling for long lists
- Cache Nostr events with Dexie
- Minimize relay subscriptions
- Debounce search inputs
## Security Notes
- Never store private keys in code
- Validate all user input
- Sanitize external HTML
- Verify event signatures
## Debugging
- Enable relay debug: `DEBUG_RELAYS=true npm run dev`
- Check browser console for NDK logs
- Network tab shows WebSocket frames
## Documentation Links
- [Nostr NIPs](https://github.com/nostr-protocol/nips)
- [NDK Docs](https://github.com/nostr-dev-kit/ndk)
- [SvelteKit Docs](https://kit.svelte.dev/docs)
- [Svelte 5 Docs](https://svelte.dev/docs/svelte/overview)
- [Flowbite Svelte](https://flowbite-svelte.com/)

393
TECHNIQUE-create-test-highlights.md

@ -0,0 +1,393 @@ @@ -0,0 +1,393 @@
# Technique: Creating Test Highlight Events for Nostr Publications
## Overview
This technique allows you to create test highlight events (kind 9802) for
testing the highlight rendering system in Alexandria. Highlights are text
selections from publication sections that users want to mark as important or
noteworthy, optionally with annotations.
## When to Use This
- Testing highlight fetching and rendering
- Verifying highlight filtering by section
- Testing highlight display UI (inline markers, side panel, etc.)
- Debugging highlight-related features
- Demonstrating the highlight system to stakeholders
## Prerequisites
1. **Node.js packages**: `nostr-tools` and `ws`
```bash
npm install nostr-tools ws
```
2. **Valid publication structure**: You need the actual publication address
(naddr) and its internal structure (section addresses, pubkeys)
## Step 1: Decode the Publication Address
If you have an `naddr` (Nostr address), decode it to find the publication
structure:
**Script**: `check-publication-structure.js`
```javascript
import { nip19 } from "nostr-tools";
import WebSocket from "ws";
const naddr = "naddr1qvzqqqr4t..."; // Your publication naddr
console.log("Decoding naddr...\n");
const decoded = nip19.decode(naddr);
console.log("Decoded:", JSON.stringify(decoded, null, 2));
const { data } = decoded;
const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`;
console.log("\nRoot Address:", rootAddress);
// Fetch the index event to see what sections it references
const relay = "wss://relay.nostr.band";
async function fetchPublication() {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relay);
const events = [];
ws.on("open", () => {
console.log(`\nConnected to ${relay}`);
console.log("Fetching index event...\n");
const filter = {
kinds: [data.kind],
authors: [data.pubkey],
"#d": [data.identifier],
};
const subscriptionId = `sub-${Date.now()}`;
ws.send(JSON.stringify(["REQ", subscriptionId, filter]));
});
ws.on("message", (message) => {
const [type, subId, event] = JSON.parse(message.toString());
if (type === "EVENT") {
events.push(event);
console.log("Found index event:", event.id);
console.log("\nTags:");
event.tags.forEach((tag) => {
if (tag[0] === "a") {
console.log(` Section address: ${tag[1]}`);
}
if (tag[0] === "d") {
console.log(` D-tag: ${tag[1]}`);
}
if (tag[0] === "title") {
console.log(` Title: ${tag[1]}`);
}
});
} else if (type === "EOSE") {
ws.close();
resolve(events);
}
});
ws.on("error", reject);
setTimeout(() => {
ws.close();
resolve(events);
}, 5000);
});
}
fetchPublication()
.then(() => console.log("\nDone!"))
.catch(console.error);
```
**Run it**: `node check-publication-structure.js`
**Expected output**: Section addresses like
`30041:dc4cd086...:the-art-of-thinking-without-permission`
## Step 2: Understand Kind 9802 Event Structure
A highlight event (kind 9802) has this structure:
```javascript
{
kind: 9802,
pubkey: "<highlighter-pubkey>",
created_at: 1704067200,
tags: [
["a", "<section-address>", "<relay>"], // Required: target section
["context", "<surrounding-text>"], // Optional: helps locate highlight
["p", "<author-pubkey>", "<relay>", "author"], // Optional: original author
["comment", "<user-annotation>"] // Optional: user's note
],
content: "<the-actual-highlighted-text>", // Required: the selected text
id: "<calculated>",
sig: "<calculated>"
}
```
### Critical Differences from Comments (kind 1111):
| Aspect | Comments (1111) | Highlights (9802) |
| ---------------------- | ---------------------------------------------------------------- | -------------------------------------------- |
| **Content field** | User's comment text | The highlighted text itself |
| **User annotation** | N/A (content is the comment) | Optional `["comment", ...]` tag |
| **Context** | Not used | `["context", ...]` provides surrounding text |
| **Threading** | Uses `["e", ..., "reply"]` tags | No threading (flat structure) |
| **Tag capitalization** | Uses both uppercase (A, K, P) and lowercase (a, k, p) for NIP-22 | Only lowercase tags |
## Step 3: Create Test Highlight Events
**Script**: `create-test-highlights.js`
```javascript
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import WebSocket from "ws";
// Test user keys (generate fresh ones)
const testUserKey = generateSecretKey();
const testUserPubkey = getPublicKey(testUserKey);
console.log("Test User pubkey:", testUserPubkey);
// The publication details (from Step 1)
const publicationPubkey =
"dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06";
const rootAddress =
`30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`;
// Section addresses (from Step 1 output)
const sections = [
`30041:${publicationPubkey}:the-art-of-thinking-without-permission`,
`30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`,
// ... more sections
];
// Relays to publish to (matching HighlightLayer's relay list)
const relays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nostr.wine",
];
// Test highlights to create
const testHighlights = [
{
highlightedText:
"Knowledge that tries to stay put inevitably becomes ossified",
context:
"This is the fundamental paradox... Knowledge that tries to stay put inevitably becomes ossified, a monument to itself... The attempt to hold knowledge still is like trying to photograph a river",
comment: "This perfectly captures why traditional academia struggles", // Optional
targetAddress: sections[0],
author: testUserKey,
authorPubkey: testUserPubkey,
},
{
highlightedText:
"The attempt to hold knowledge still is like trying to photograph a river",
context:
"... a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.",
comment: null, // No annotation, just highlight
targetAddress: sections[0],
author: testUserKey,
authorPubkey: testUserPubkey,
},
];
async function publishEvent(event, relayUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relayUrl);
let published = false;
ws.on("open", () => {
console.log(`Connected to ${relayUrl}`);
ws.send(JSON.stringify(["EVENT", event]));
});
ws.on("message", (data) => {
const message = JSON.parse(data.toString());
if (message[0] === "OK" && message[1] === event.id) {
if (message[2]) {
console.log(`✓ Published ${event.id.substring(0, 8)}`);
published = true;
ws.close();
resolve();
} else {
console.error(`✗ Rejected: ${message[3]}`);
ws.close();
reject(new Error(message[3]));
}
}
});
ws.on("error", reject);
ws.on("close", () => {
if (!published) reject(new Error("Connection closed"));
});
setTimeout(() => {
if (!published) {
ws.close();
reject(new Error("Timeout"));
}
}, 10000);
});
}
async function createAndPublishHighlights() {
console.log("\n=== Creating Test Highlights ===\n");
for (const highlight of testHighlights) {
try {
// Create unsigned event
const unsignedEvent = {
kind: 9802,
created_at: Math.floor(Date.now() / 1000),
tags: [
["a", highlight.targetAddress, relays[0]],
["context", highlight.context],
["p", publicationPubkey, relays[0], "author"],
],
content: highlight.highlightedText, // The highlighted text
pubkey: highlight.authorPubkey,
};
// Add optional comment/annotation
if (highlight.comment) {
unsignedEvent.tags.push(["comment", highlight.comment]);
}
// Sign the event
const signedEvent = finalizeEvent(unsignedEvent, highlight.author);
console.log(
`\nHighlight: "${highlight.highlightedText.substring(0, 60)}..."`,
);
console.log(`Target: ${highlight.targetAddress}`);
console.log(`Event ID: ${signedEvent.id}`);
// Publish
await publishEvent(signedEvent, relays[0]);
// Delay to avoid rate limiting
await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) {
console.error(`Failed: ${error.message}`);
}
}
console.log("\n=== Done! ===");
console.log('\nRefresh the page and toggle "Show Highlights" to view them.');
}
createAndPublishHighlights().catch(console.error);
```
## Step 4: Run and Verify
1. **Run the script**:
```bash
node create-test-highlights.js
```
2. **Expected output**:
```
Test User pubkey: a1b2c3d4...
=== Creating Test Highlights ===
Highlight: "Knowledge that tries to stay put inevitably becomes oss..."
Target: 30041:dc4cd086...:the-art-of-thinking-without-permission
Event ID: e5f6g7h8...
Connected to wss://relay.damus.io
✓ Published e5f6g7h8
...
=== Done! ===
```
3. **Verify in the app**:
- Refresh the publication page
- Click "Show Highlights" button
- Highlighted text should appear with yellow background
- Hover to see annotation (if provided)
## Common Issues and Solutions
### Issue: "Relay rejected: rate-limited"
**Cause**: Publishing too many events too quickly
**Solution**: Increase delay between publishes
```javascript
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 seconds
```
### Issue: Highlights don't appear after publishing
**Possible causes**:
1. Wrong section address - verify with `check-publication-structure.js`
2. HighlightLayer not fetching from the relay you published to
3. Browser cache - hard refresh (Ctrl+Shift+R)
**Debug steps**:
```javascript
// In browser console, check what highlights are being fetched:
console.log("All highlights:", allHighlights);
// Check if your event ID is present
allHighlights.find((h) => h.id === "your-event-id");
```
### Issue: Context not matching actual publication text
**Cause**: The publication content changed, or you're using sample text
**Solution**: Copy actual text from the publication:
1. Open the publication in browser
2. Select the text you want to highlight
3. Copy a larger surrounding context (2-3 sentences)
4. Use that as the `context` value
## Key Patterns to Remember
1. **Content field = highlighted text** (NOT a comment)
2. **Context tag helps locate** the highlight in the source document
3. **Comment tag is optional** user annotation
4. **No threading** - highlights are flat, not threaded like comments
5. **Single lowercase 'a' tag** - not uppercase/lowercase pairs like comments
6. **Always verify addresses** with `check-publication-structure.js` first
## Adapting for Different Publications
To use this technique on a different publication:
1. Get the publication's naddr from the URL
2. Run `check-publication-structure.js` with that naddr
3. Update these values in `create-test-highlights.js`:
- `publicationPubkey`
- `rootAddress`
- `sections` array
4. Update `highlightedText` and `context` to match actual publication content
5. Run the script
## Further Reading
- NIP-84 (Highlights): https://github.com/nostr-protocol/nips/blob/master/84.md
- `src/lib/components/publications/HighlightLayer.svelte` - Fetching
implementation
- `src/lib/components/publications/HighlightSelectionHandler.svelte` - Event
creation
- NIP-19 (Address encoding):
https://github.com/nostr-protocol/nips/blob/master/19.md

162
TEST_SUMMARY.md

@ -0,0 +1,162 @@ @@ -0,0 +1,162 @@
# Comment Button TDD Tests - Summary
## Overview
Comprehensive test suite for CommentButton component and NIP-22 comment
functionality.
**Test File:**
`/home/user/gc-alexandria-comments/tests/unit/commentButton.test.ts`
**Status:** ✅ All 69 tests passing
## Test Coverage
### 1. Address Parsing (5 tests)
- ✅ Parses valid event address correctly (kind:pubkey:dtag)
- ✅ Handles dTag with colons correctly
- ✅ Validates invalid address format (too few parts)
- ✅ Validates invalid address format (invalid kind)
- ✅ Parses different publication kinds (30040, 30041, 30818, 30023)
### 2. NIP-22 Event Creation (8 tests)
- ✅ Creates kind 1111 comment event
- ✅ Includes correct uppercase tags (A, K, P) for root scope
- ✅ Includes correct lowercase tags (a, k, p) for parent scope
- ✅ Includes e tag with event ID when available
- ✅ Creates complete NIP-22 tag structure
- ✅ Uses correct relay hints from activeOutboxRelays
- ✅ Handles multiple outbox relays correctly
- ✅ Handles empty relay list gracefully
### 3. Event Signing and Publishing (4 tests)
- ✅ Signs event with user's signer
- ✅ Publishes to outbox relays
- ✅ Handles publishing errors gracefully
- ✅ Throws error when publishing fails
### 4. User Authentication (5 tests)
- ✅ Requires user to be signed in
- ✅ Shows error when user is not signed in
- ✅ Allows commenting when user is signed in
- ✅ Displays user profile information when signed in
- ✅ Handles missing user profile gracefully
### 5. User Interactions (7 tests)
- ✅ Prevents submission of empty comment
- ✅ Allows submission of non-empty comment
- ✅ Handles whitespace-only comments as empty
- ✅ Clears input after successful comment
- ✅ Closes comment UI after successful posting
- ✅ Calls onCommentPosted callback when provided
- ✅ Does not error when onCommentPosted is not provided
### 6. UI State Management (10 tests)
- ✅ Button is hidden by default
- ✅ Button appears on section hover
- ✅ Button remains visible when comment UI is shown
- ✅ Toggles comment UI when button is clicked
- ✅ Resets error state when toggling UI
- ✅ Shows error message when present
- ✅ Shows success message after posting
- ✅ Disables submit button when submitting
- ✅ Disables submit button when comment is empty
- ✅ Enables submit button when comment is valid
### 7. Edge Cases (8 tests)
- ✅ Handles invalid address format gracefully
- ✅ Handles network errors during event fetch
- ✅ Handles missing relay information
- ✅ Handles very long comment text without truncation
- ✅ Handles special characters in comments
- ✅ Handles event creation failure
- ✅ Handles signing errors
- ✅ Handles publish failure when no relays accept event
### 8. Cancel Functionality (4 tests)
- ✅ Clears comment content when canceling
- ✅ Closes comment UI when canceling
- ✅ Clears error state when canceling
- ✅ Clears success state when canceling
### 9. Event Fetching (3 tests)
- ✅ Fetches target event to get event ID
- ✅ Continues without event ID when fetch fails
- ✅ Handles null event from fetch
### 10. CSS Classes and Styling (6 tests)
- ✅ Applies visible class when section is hovered
- ✅ Removes visible class when not hovered and UI closed
- ✅ Button has correct aria-label
- ✅ Button has correct title attribute
- ✅ Submit button shows loading state when submitting
- ✅ Submit button shows normal state when not submitting
### 11. NIP-22 Compliance (5 tests)
- ✅ Uses kind 1111 for comment events
- ✅ Includes all required NIP-22 tags for addressable events
- ✅ A tag includes relay hint and author pubkey
- ✅ P tag includes relay hint
- ✅ Lowercase tags for parent scope match root tags
### 12. Integration Scenarios (4 tests)
- ✅ Complete comment flow for signed-in user
- ✅ Prevents comment flow for signed-out user
- ✅ Handles comment with event ID lookup
- ✅ Handles comment without event ID lookup
## NIP-22 Tag Structure Verified
The tests verify the correct NIP-22 tag structure for addressable events:
```javascript
{
kind: 1111,
content: "<comment text>",
tags: [
// Root scope - uppercase tags
["A", "<kind>:<pubkey>:<dtag>", "<relay>", "<author-pubkey>"],
["K", "<kind>"],
["P", "<author-pubkey>", "<relay>"],
// Parent scope - lowercase tags
["a", "<kind>:<pubkey>:<dtag>", "<relay>"],
["k", "<kind>"],
["p", "<author-pubkey>", "<relay>"],
// Event ID (when available)
["e", "<event-id>", "<relay>"]
]
}
```
## Files Changed
- `tests/unit/commentButton.test.ts` - 911 lines (new file)
- `package-lock.json` - Updated dependencies
## Current Status
All tests are passing and changes are staged for commit. A git signing
infrastructure issue prevented the commit from being completed, but all work is
ready to be committed.
## To Commit and Push
```bash
cd /home/user/gc-alexandria-comments
git commit -m "Add TDD tests for comment functionality"
git push origin claude/comments-011CUqFi4cCVXP2bvFmZ3481
```

122
WIKI_TAG_SPEC.md

@ -0,0 +1,122 @@ @@ -0,0 +1,122 @@
# Wiki Tags ('w') vs D-Tags: Conceptual Distinction
## AsciiDoc Wiki Link Syntax
In AsciiDoc content, wiki links are created using double-bracket notation:
```asciidoc
The concept of [[Knowledge Graphs]] enables semantic relationships...
```
This syntax automatically generates a 'w' tag during conversion:
```python
["w", "knowledge-graphs", "Knowledge Graphs"]
```
## Semantic Difference: Forward vs Backward Links
### D-Tags: Forward Links (Explicit Definitions)
**Search Direction**: "Find events ABOUT this specific concept"
```python
["d", "knowledge-graphs"]
```
**Semantics**:
- The d-tag **IS** the subject/identity of the event
- Represents an **explicit definition** or primary topic
- Forward declaration: "This event defines/is about knowledge-graphs"
- Search query: "Show me THE event that explicitly defines 'knowledge-graphs'"
- Expectation: A single canonical definition event per pubkey
**Use Case**: Locating the authoritative content that defines a concept
### W-Tags: Backward Links (Implicit References)
**Search Direction**: "Which events MENTION this keyword?"
```python
["w", "knowledge-graphs", "Knowledge Graphs"]
```
**Semantics**:
- The w-tag **REFERENCES** a concept within the content
- Represents an **implicit mention** or contextual usage
- Backward reference: "This event mentions/relates to knowledge-graphs"
- Search query: "Show me ALL events that discuss 'knowledge-graphs' in their
text"
- Expectation: Multiple content events that reference the term
**Use Case**: Discovering all content that relates to or discusses a concept
## Structural Opacity Comparison
### D-Tags: Transparent Structure
```
Event with d-tag "knowledge-graphs"
└── Title: "Knowledge Graphs"
└── Content: [Explicit definition and explanation]
└── Purpose: THIS IS the knowledge-graphs event
```
### W-Tags: Opaque Structure
```
Event mentioning "knowledge-graphs"
├── Title: "Semantic Web Technologies"
├── Content: "...uses [[Knowledge Graphs]] for..."
└── Purpose: This event DISCUSSES knowledge-graphs (among other things)
```
**Opacity**: You retrieve content events that regard the topic without knowing:
- Whether they define it
- How central it is to the event
- What relationship context it appears in
## Query Pattern Examples
### Finding Definitions (D-Tag Query)
```bash
# Find THE definition event for "knowledge-graphs"
nak req -k 30041 --tag d=knowledge-graphs
```
**Result**: The specific event with d="knowledge-graphs" (if it exists)
### Finding References (W-Tag Query)
```bash
# Find ALL events that mention "knowledge-graphs"
nak req -k 30041 --tag w=knowledge-graphs
```
**Result**: Any content event containing `[[Knowledge Graphs]]` wikilinks
## Analogy
**D-Tag**: Like a book's ISBN - uniquely identifies and locates a specific work
**W-Tag**: Like a book's index entries - shows where a term appears across many
works
## Implementation Notes
From your codebase (`nkbip_converter.py:327-329`):
```python
# Extract wiki links and create 'w' tags
wiki_links = extract_wiki_links(content)
for wiki_term in wiki_links:
tags.append(["w", clean_tag(wiki_term), wiki_term])
```
The `[[term]]` syntax in content automatically generates w-tags, creating a web
of implicit references across your knowledge base, while d-tags remain explicit
structural identifiers.

72
check-publication-structure.js

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

266
create-test-comments.js

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

206
create-test-highlights.js

@ -0,0 +1,206 @@ @@ -0,0 +1,206 @@
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import WebSocket from "ws";
// Test user keys (generate fresh ones)
const testUserKey = generateSecretKey();
const testUserPubkey = getPublicKey(testUserKey);
const testUser2Key = generateSecretKey();
const testUser2Pubkey = getPublicKey(testUser2Key);
console.log("Test User 1 pubkey:", testUserPubkey);
console.log("Test User 2 pubkey:", testUser2Pubkey);
// The publication details from the article (REAL VALUES)
const publicationPubkey =
"dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06";
const rootAddress =
`30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`;
// Section addresses (from the actual publication structure)
const sections = [
`30041:${publicationPubkey}:the-art-of-thinking-without-permission`,
`30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`,
`30041:${publicationPubkey}:institutional-capture-and-knowledge-enclosure`,
`30041:${publicationPubkey}:the-persistent-escape-of-knowledge`,
];
// Relays to publish to (matching HighlightLayer's relay list)
const relays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nostr.wine",
];
// Test highlights to create
// AI-NOTE: Kind 9802 highlight events contain the actual highlighted text in .content
// and optionally a user comment/annotation in the ["comment", ...] tag
const testHighlights = [
{
highlightedText:
"Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice.",
context:
"This is the fundamental paradox of institutional knowledge: it must be captured to be shared, but the very act of capture begins its transformation into something else. Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.",
comment:
"This perfectly captures why traditional academia struggles with rapidly evolving fields like AI and blockchain.",
targetAddress: sections[0],
author: testUserKey,
authorPubkey: testUserPubkey,
},
{
highlightedText:
"The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.",
context:
"Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.",
comment: null, // Highlight without annotation
targetAddress: sections[0],
author: testUser2Key,
authorPubkey: testUser2Pubkey,
},
{
highlightedText:
"Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas.",
context:
"The natural state of knowledge is not purity but promiscuity. Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas. It crosses boundaries not despite them but because of them. The most vibrant intellectual communities have always been those at crossroads and borderlands.",
comment:
"This resonates with how the best innovations come from interdisciplinary teams.",
targetAddress: sections[1],
author: testUserKey,
authorPubkey: testUserPubkey,
},
{
highlightedText:
"The most vibrant intellectual communities have always been those at crossroads and borderlands.",
context:
"Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas. It crosses boundaries not despite them but because of them. The most vibrant intellectual communities have always been those at crossroads and borderlands.",
comment:
"Historical examples: Renaissance Florence, Vienna Circle, Bell Labs",
targetAddress: sections[1],
author: testUser2Key,
authorPubkey: testUser2Pubkey,
},
{
highlightedText:
"institutions that try to monopolize understanding inevitably find themselves gatekeeping corpses",
context:
"But institutions that try to monopolize understanding inevitably find themselves gatekeeping corpses—the living knowledge has already escaped and is flourishing in unexpected places. By the time the gatekeepers notice, the game has moved.",
comment: null,
targetAddress: sections[2],
author: testUserKey,
authorPubkey: testUserPubkey,
},
];
async function publishEvent(event, relayUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relayUrl);
let published = false;
ws.on("open", () => {
console.log(`Connected to ${relayUrl}`);
ws.send(JSON.stringify(["EVENT", event]));
});
ws.on("message", (data) => {
const message = JSON.parse(data.toString());
if (message[0] === "OK" && message[1] === event.id) {
if (message[2]) {
console.log(
`✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`,
);
published = true;
ws.close();
resolve();
} else {
console.error(`✗ Relay rejected event: ${message[3]}`);
ws.close();
reject(new Error(message[3]));
}
}
});
ws.on("error", (error) => {
console.error(`WebSocket error: ${error.message}`);
reject(error);
});
ws.on("close", () => {
if (!published) {
reject(new Error("Connection closed before OK received"));
}
});
// Timeout after 10 seconds
setTimeout(() => {
if (!published) {
ws.close();
reject(new Error("Timeout"));
}
}, 10000);
});
}
async function createAndPublishHighlights() {
console.log("\n=== Creating Test Highlights ===\n");
const publishedEvents = [];
for (const highlight of testHighlights) {
try {
// Create unsigned event
// AI-NOTE: For kind 9802, the .content field contains the HIGHLIGHTED TEXT,
// not a comment. User annotations go in the optional ["comment", ...] tag.
const unsignedEvent = {
kind: 9802,
created_at: Math.floor(Date.now() / 1000),
tags: [
// Target section
["a", highlight.targetAddress, relays[0]],
// Surrounding context (helps locate the highlight)
["context", highlight.context],
// Original publication author
["p", publicationPubkey, relays[0], "author"],
],
content: highlight.highlightedText, // The actual highlighted text
pubkey: highlight.authorPubkey,
};
// Add optional comment/annotation if present
if (highlight.comment) {
unsignedEvent.tags.push(["comment", highlight.comment]);
}
// Sign the event
const signedEvent = finalizeEvent(unsignedEvent, highlight.author);
console.log(`\nCreating highlight on section:`);
console.log(
` Highlighted: "${highlight.highlightedText.substring(0, 60)}..."`,
);
if (highlight.comment) {
console.log(` Comment: "${highlight.comment.substring(0, 60)}..."`);
}
console.log(` Target: ${highlight.targetAddress}`);
console.log(` Event ID: ${signedEvent.id}`);
// Publish to relay
await publishEvent(signedEvent, relays[0]);
publishedEvents.push(signedEvent);
// Delay between publishes to avoid rate limiting
await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) {
console.error(`Failed to publish highlight: ${error.message}`);
}
}
console.log("\n=== Done! ===");
console.log(`\nPublished ${publishedEvents.length} total highlights`);
console.log("\nRefresh the page to see the highlights.");
console.log('Toggle "Show Highlights" to view them inline.');
}
// Run it
createAndPublishHighlights().catch(console.error);

193
deno.lock

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

60
doc/compose_tree.md

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

77
nips/09.md

@ -0,0 +1,77 @@ @@ -0,0 +1,77 @@
# NIP-09
## Event Deletion Request
`draft` `optional`
A special event with kind `5`, meaning "deletion request" is defined as having a
list of one or more `e` or `a` tags, each referencing an event the author is
requesting to be deleted. Deletion requests SHOULD include a `k` tag for the
kind of each event being requested for deletion.
The event's `content` field MAY contain a text note describing the reason for
the deletion request.
For example:
```jsonc
{
"kind": 5,
"pubkey": <32-bytes hex-encoded public key of the event creator>,
"tags": [
["e", "dcd59..464a2"],
["e", "968c5..ad7a4"],
["a", "<kind>:<pubkey>:<d-identifier>"],
["k", "1"],
["k", "30023"]
],
"content": "these posts were published by accident",
// other fields...
}
```
Relays SHOULD delete or stop publishing any referenced events that have an
identical `pubkey` as the deletion request. Clients SHOULD hide or otherwise
indicate a deletion request status for referenced events.
Relays SHOULD continue to publish/share the deletion request events
indefinitely, as clients may already have the event that's intended to be
deleted. Additionally, clients SHOULD broadcast deletion request events to other
relays which don't have it.
When an `a` tag is used, relays SHOULD delete all versions of the replaceable
event up to the `created_at` timestamp of the deletion request event.
## Client Usage
Clients MAY choose to fully hide any events that are referenced by valid
deletion request events. This includes text notes, direct messages, or other
yet-to-be defined event kinds. Alternatively, they MAY show the event along with
an icon or other indication that the author has "disowned" the event. The
`content` field MAY also be used to replace the deleted events' own content,
although a user interface should clearly indicate that this is a deletion
request reason, not the original content.
A client MUST validate that each event `pubkey` referenced in the `e` tag of the
deletion request is identical to the deletion request `pubkey`, before hiding or
deleting any event. Relays can not, in general, perform this validation and
should not be treated as authoritative.
Clients display the deletion request event itself in any way they choose, e.g.,
not at all, or with a prominent notice.
Clients MAY choose to inform the user that their request for deletion does not
guarantee deletion because it is impossible to delete events from all relays and
clients.
## Relay Usage
Relays MAY validate that a deletion request event only references events that
have the same `pubkey` as the deletion request itself, however this is not
required since relays may not have knowledge of all referenced events.
## Deletion Request of a Deletion Request
Publishing a deletion request event against a deletion request has no effect.
Clients and relays are not obliged to support "unrequest deletion"
functionality.

217
package-lock.json generated

@ -66,6 +66,7 @@ @@ -66,6 +66,7 @@
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.1.3",
"ws": "^8.18.3",
"yaml": "^2.5.0"
}
},
@ -3257,6 +3258,35 @@ @@ -3257,6 +3258,35 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/apexcharts": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.3.5.tgz",
@ -3420,6 +3450,20 @@ @@ -3420,6 +3450,20 @@
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@ -3432,6 +3476,20 @@ @@ -3432,6 +3476,20 @@
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.26.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
@ -3609,6 +3667,46 @@ @@ -3609,6 +3667,46 @@
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@ -4880,6 +4978,20 @@ @@ -4880,6 +4978,20 @@
"node": ">=10"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -5050,7 +5162,6 @@ @@ -5050,7 +5162,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -5370,6 +5481,20 @@ @@ -5370,6 +5481,20 @@
"node": ">=12"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@ -5411,7 +5536,7 @@ @@ -5411,7 +5536,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
@ -5431,7 +5556,7 @@ @@ -5431,7 +5556,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
@ -5448,6 +5573,17 @@ @@ -5448,6 +5573,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
@ -6084,6 +6220,17 @@ @@ -6084,6 +6220,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
@ -6923,6 +7070,34 @@ @@ -6923,6 +7070,34 @@
"pify": "^2.3.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -7486,6 +7661,20 @@ @@ -7486,6 +7661,20 @@
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/token-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz",
@ -7936,6 +8125,28 @@ @@ -7936,6 +8125,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

1
package.json

@ -75,6 +75,7 @@ @@ -75,6 +75,7 @@
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.1.3",
"ws": "^8.18.3",
"yaml": "^2.5.0"
}
}

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

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

124
src/lib/components/EventDetails.svelte

@ -14,8 +14,8 @@ @@ -14,8 +14,8 @@
import { navigateToEvent } from "$lib/utils/nostrEventService";
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
import Notifications from "$lib/components/Notifications.svelte";
import {
repostContent,
import {
repostContent,
quotedContent,
} from "$lib/snippets/EmbeddedSnippets.svelte";
import { repostKinds } from "$lib/consts";
@ -41,7 +41,10 @@ @@ -41,7 +41,10 @@
let authorDisplayName = $state<string | undefined>(undefined);
let showFullContent = $state(false);
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 {
// First try to get title from title tag
@ -253,13 +256,15 @@ @@ -253,13 +256,15 @@
return;
}
getUserMetadata(toNpub(event.pubkey) as string, undefined).then((profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
profile.name ||
event.pubkey;
});
getUserMetadata(toNpub(event.pubkey) as string, undefined).then(
(profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
profile.name ||
event.pubkey;
},
);
});
// --- Identifier helpers ---
@ -300,7 +305,11 @@ @@ -300,7 +305,11 @@
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {}
// 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;
}
@ -333,17 +342,17 @@ @@ -333,17 +342,17 @@
<div class="flex items-center space-x-2 min-w-0">
{#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400 min-w-0"
>Author: {@render userBadge(
toNpub(event.pubkey) || '',
profile?.display_name || undefined,
ndk,
)}</span
>
<span class="text-gray-600 dark:text-gray-400 min-w-0"
>Author: {@render userBadge(
toNpub(event.pubkey) || "",
profile?.display_name || undefined,
ndk,
)}</span
>
{:else}
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words"
>Author: {profile?.display_name || event.pubkey}</span
>
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words"
>Author: {profile?.display_name || event.pubkey}</span
>
{/if}
</div>
@ -351,13 +360,15 @@ @@ -351,13 +360,15 @@
<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="text-gray-700 dark:text-gray-300 flex-shrink-0"
>({getEventTypeDisplay(event)})</span
>({getEventTypeDisplay(event)})</span
>
</div>
<div class="flex flex-col space-y-1 min-w-0">
<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)}
</div>
</div>
@ -370,29 +381,41 @@ @@ -370,29 +381,41 @@
{#if event.kind !== 0}
{@const kind = event.kind}
{@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">
<span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span>
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
<span class="text-gray-700 dark:text-gray-300 font-semibold"
>Content:</span
>
<div class={shouldTruncate ? "max-h-32 overflow-hidden" : ""}>
{#if isRepost}
<!-- Repost content handling -->
{#if repostKinds.includes(event.kind)}
<!-- 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">
{event.kind === 6 ? 'Reposted content:' : 'Generic reposted content:'}
{event.kind === 6
? "Reposted content:"
: "Generic reposted content:"}
</div>
{@render repostContent(event.content)}
</div>
{:else if event.kind === 1 && event.getMatchingTags("q").length > 0}
<!-- 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">
Quote repost:
</div>
{@render quotedContent(event, [], ndk)}
{#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">
Added comment:
</div>
@ -407,7 +430,7 @@ @@ -407,7 +430,7 @@
{/if}
{:else}
<!-- Regular content -->
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
<div class={shouldTruncate ? "max-h-32 overflow-hidden" : ""}>
{#if repostKinds.includes(kind)}
{@html content}
{:else}
@ -428,35 +451,46 @@ @@ -428,35 +451,46 @@
<!-- If event is profile -->
{#if event.kind === 0}
<AProfilePreview event={event} profile={profile} communityStatusMap={communityStatusMap} />
<AProfilePreview {event} {profile} {communityStatusMap} />
{/if}
<ATechBlock>
{#snippet content()}
<Heading tag="h3" class="h-leather my-6">
Technical details
</Heading>
<Heading tag="h3" class="h-leather my-6">Technical details</Heading>
<Accordion flush class="w-full">
<AccordionItem open={false} >
<AccordionItem open={false}>
{#snippet header()}Identifiers{/snippet}
{#if event}
<div class="flex flex-col gap-2">
{#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">
<span class="min-w-24 text-gray-600 dark:text-gray-400">{identifier.label}:</span>
<div
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">
{#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"
onclick={() => navigateToIdentifier(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"
onclick={() =>
navigateToIdentifier(identifier.link ?? "")}
>
{identifier.value}
</button>
{: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}
</div>
<div class="justify-self-end">
<CopyToClipboard displayText="" copyText={identifier.value} />
<CopyToClipboard
displayText=""
copyText={identifier.value}
/>
</div>
</div>
{/each}
@ -494,8 +528,10 @@ @@ -494,8 +528,10 @@
/>
</div>
{#if event}
<pre class="p-4 wrap-break-word bg-highlight dark:bg-primary-900">
<code class="text-wrap">{JSON.stringify(event.rawEvent(), null, 2)}</code>
<pre class="p-4 wrap-break-word bg-highlight dark:bg-primary-900">
<code class="text-wrap"
>{JSON.stringify(event.rawEvent(), null, 2)}</code
>
</pre>
{/if}
</AccordionItem>

146
src/lib/components/EventKindFilter.svelte

@ -1,41 +1,41 @@ @@ -1,41 +1,41 @@
<script lang="ts">
import { visualizationConfig, enabledEventKinds } from '$lib/stores/visualizationConfig';
import { Button, Badge } from 'flowbite-svelte';
import { CloseCircleOutline } from 'flowbite-svelte-icons';
import {
visualizationConfig,
enabledEventKinds,
} from "$lib/stores/visualizationConfig";
import { Button, Badge } from "flowbite-svelte";
import { CloseCircleOutline } from "flowbite-svelte-icons";
import type { EventCounts } from "$lib/types";
import { NostrKind } from '$lib/types';
let {
onReload = () => {},
eventCounts = {}
} = $props<{
import { NostrKind } from "$lib/types";
let { onReload = () => {}, eventCounts = {} } = $props<{
onReload?: () => void;
eventCounts?: EventCounts;
}>();
let newKind = $state('');
let newKind = $state("");
let showAddInput = $state(false);
let inputError = $state('');
let inputError = $state("");
function validateKind(value: string): number | null {
if (!value || value.trim() === '') {
inputError = '';
if (!value || value.trim() === "") {
inputError = "";
return null;
}
const kind = parseInt(value.trim());
if (isNaN(kind)) {
inputError = 'Must be a number';
inputError = "Must be a number";
return null;
}
if (kind < 0) {
inputError = 'Must be positive';
inputError = "Must be positive";
return null;
}
if ($visualizationConfig.eventConfigs.some(ec => ec.kind === kind)) {
inputError = 'Already added';
if ($visualizationConfig.eventConfigs.some((ec) => ec.kind === kind)) {
inputError = "Already added";
return null;
}
inputError = '';
inputError = "";
return kind;
}
@ -43,19 +43,19 @@ @@ -43,19 +43,19 @@
const kind = validateKind(newKind);
if (kind != null) {
visualizationConfig.addEventKind(kind);
newKind = '';
newKind = "";
showAddInput = false;
inputError = '';
inputError = "";
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
if (e.key === "Enter") {
handleAddKind();
} else if (e.key === 'Escape') {
} else if (e.key === "Escape") {
showAddInput = false;
newKind = '';
inputError = '';
newKind = "";
inputError = "";
}
}
@ -69,12 +69,18 @@ @@ -69,12 +69,18 @@
function getKindName(kind: number): string {
switch (kind) {
case NostrKind.PublicationIndex: return 'Publication Index';
case NostrKind.PublicationContent: return 'Publication Content';
case NostrKind.Wiki: return 'Wiki';
case NostrKind.TextNote: return 'Text Note';
case NostrKind.UserMetadata: return 'Metadata';
default: return `Kind ${kind}`;
case NostrKind.PublicationIndex:
return "Publication Index";
case NostrKind.PublicationContent:
return "Publication Content";
case NostrKind.Wiki:
return "Wiki";
case NostrKind.TextNote:
return "Text Note";
case NostrKind.UserMetadata:
return "Metadata";
default:
return `Kind ${kind}`;
}
}
</script>
@ -84,15 +90,21 @@ @@ -84,15 +90,21 @@
{#each $visualizationConfig.eventConfigs as ec}
{@const isEnabled = ec.enabled !== false}
{@const isLoaded = (eventCounts[ec.kind] || 0) > 0}
{@const borderColor = isLoaded ? 'border-green-500' : 'border-red-500'}
{@const borderColor = isLoaded ? "border-green-500" : "border-red-500"}
<button
class="badge-container {isEnabled ? '' : 'disabled'} {isLoaded ? 'loaded' : 'not-loaded'}"
class="badge-container {isEnabled ? '' : 'disabled'} {isLoaded
? 'loaded'
: 'not-loaded'}"
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
color="dark"
class="flex items-center gap-1 px-2 py-1 {isEnabled ? '' : 'opacity-40'} border-2 {borderColor}"
<Badge
color="primary"
class="flex items-center gap-1 px-2 py-1 {isEnabled
? ''
: 'opacity-40'} border-2 {borderColor}"
>
<span class="text-xs">{ec.kind}</span>
{#if isLoaded}
@ -111,33 +123,44 @@ @@ -111,33 +123,44 @@
</Badge>
</button>
{/each}
{#if !showAddInput}
<Button
size="xs"
color="light"
onclick={() => showAddInput = true}
<Button
size="xs"
color="light"
onclick={() => (showAddInput = true)}
class="gap-1"
>
<span>+</span>
<span>Add Kind</span>
</Button>
{/if}
<Button
size="xs"
color="blue"
<Button
size="xs"
color="blue"
onclick={onReload}
class="gap-1"
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">
<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
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>
<span>Reload</span>
</Button>
</div>
{#if showAddInput}
<div class="flex items-center gap-2">
<input
@ -151,16 +174,14 @@ @@ -151,16 +174,14 @@
validateKind(value);
}}
/>
<Button size="xs" onclick={handleAddKind} disabled={!newKind}>
Add
</Button>
<Button
size="xs"
color="light"
<Button size="xs" onclick={handleAddKind} disabled={!newKind}>Add</Button>
<Button
size="xs"
color="light"
onclick={() => {
showAddInput = false;
newKind = '';
inputError = '';
newKind = "";
inputError = "";
}}
>
Cancel
@ -172,10 +193,11 @@ @@ -172,10 +193,11 @@
</p>
{/if}
{/if}
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<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>
</p>
<p class="flex items-center gap-2">
@ -193,12 +215,12 @@ @@ -193,12 +215,12 @@
cursor: pointer;
transition: transform 0.2s ease;
}
.badge-container:hover:not(.disabled) {
transform: scale(1.05);
}
.badge-container.disabled {
cursor: pointer;
}
</style>
</style>

10
src/lib/components/Navigation.svelte

@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
import { userStore } from "$lib/stores/userStore";
let { class: className = "" } = $props();
let userState = $derived($userStore);
</script>
@ -20,12 +20,14 @@ @@ -20,12 +20,14 @@
<NavBrand href="/">
<div class="flex flex-col">
<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>
</NavBrand>
</div>
<div class="flex md:order-2">
<Profile isNav={true} />
<Profile />
<NavHamburger class="btn-leather" />
</div>
<NavUl class="ul-leather">
@ -40,7 +42,7 @@ @@ -40,7 +42,7 @@
<NavLi href="/about">About</NavLi>
<NavLi href="/contact">Contact</NavLi>
<NavLi>
<DarkMode btnClass="btn-leather p-0" />
<DarkMode class="btn-leather p-0" />
</NavLi>
</NavUl>
</Navbar>

59
src/lib/components/Preview.svelte

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

775
src/lib/components/ZettelEditor.svelte

@ -5,10 +5,10 @@ @@ -5,10 +5,10 @@
QuestionCircleOutline,
ChartPieOutline,
} from "flowbite-svelte-icons";
import { EditorView, basicSetup } from "codemirror";
import { EditorState, StateField, StateEffect } from "@codemirror/state";
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 { onMount } from "svelte";
import {
@ -22,7 +22,11 @@ @@ -22,7 +22,11 @@
exportEventsFromTree,
} from "$lib/utils/asciidoc_publication_parser";
import { getNdkContext } from "$lib/ndk";
import Asciidoctor from "asciidoctor";
import Asciidoctor, { Document } from "asciidoctor";
import {
extractWikiLinks,
renderWikiLinksToHtml,
} from "$lib/utils/wiki_links";
// Initialize Asciidoctor processor
const asciidoctor = Asciidoctor();
@ -64,6 +68,9 @@ @@ -64,6 +68,9 @@
let generatedEvents = $state<any>(null);
let contentType = $state<"article" | "scattered-notes" | "none">("none");
// Dark mode state
let isDarkMode = $state(false);
// Note: updateEditorContent() is only called manually when needed
// The automatic effect was causing feedback loops with user typing
@ -159,13 +166,6 @@ @@ -159,13 +166,6 @@
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)
const getEventDTag = (event: any) => {
if (event?.tagValue) {
@ -179,11 +179,16 @@ @@ -179,11 +179,16 @@
};
// 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) => {
const matchesDTag = getEventDTag(event) === dTag;
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;
@ -218,16 +223,20 @@ @@ -218,16 +223,20 @@
} else {
// contentEvents can contain both 30040 and 30041 events at parse level 3+
// Use eventKind to find the correct event type
event = findEventByDTag(publicationResult.contentEvents, node.dTag, node.eventKind);
event = findEventByDTag(
publicationResult.contentEvents,
node.dTag,
node.eventKind,
);
}
const tags = event?.tags.filter((t: string[]) => t[0] === "t") || [];
// Extract all tags (t for hashtags, w for wiki links)
const tags = event?.tags || [];
// Extract the title from the title tag
const titleTag = event?.tags.find((t: string[]) => t[0] === "title");
const eventTitle = titleTag ? titleTag[1] : node.title;
// For content events, remove the first heading from content since we'll use the title tag
let processedContent = event?.content || "";
if (event && node.eventType === "content") {
@ -236,8 +245,8 @@ @@ -236,8 +245,8 @@
// since the title is displayed separately from the "title" tag
const lines = processedContent.split("\n");
const expectedHeading = `${"=".repeat(node.level)} ${node.title}`;
const titleHeadingIndex = lines.findIndex((line: string) =>
line.trim() === expectedHeading.trim(),
const titleHeadingIndex = lines.findIndex(
(line: string) => line.trim() === expectedHeading.trim(),
);
if (titleHeadingIndex !== -1) {
// Remove only the specific title heading line
@ -246,7 +255,6 @@ @@ -246,7 +255,6 @@
}
}
return {
title: eventTitle,
content: processedContent,
@ -354,6 +362,45 @@ @@ -354,6 +362,45 @@
provide: (f) => EditorView.decorations.from(f),
});
// State field to track wiki link decorations
const wikiLinkDecorations = StateField.define<DecorationSet>({
create(state) {
return createWikiLinkDecorations(state);
},
update(decorations, tr) {
// Update decorations when content changes
if (tr.docChanged) {
return createWikiLinkDecorations(tr.state);
}
return decorations.map(tr.changes);
},
provide: (f) => EditorView.decorations.from(f),
});
// Function to create wiki link decorations
function createWikiLinkDecorations(state: EditorState): DecorationSet {
const ranges: Array<{ from: number; to: number; decoration: any }> = [];
const content = state.doc.toString();
const wikiLinks = extractWikiLinks(content);
for (const link of wikiLinks) {
const className =
link.type === "auto"
? "cm-wiki-link-auto"
: link.type === "w"
? "cm-wiki-link-ref"
: "cm-wiki-link-def";
ranges.push({
from: link.startIndex,
to: link.endIndex,
decoration: Decoration.mark({ class: className }),
});
}
return RangeSet.of(ranges.map((r) => r.decoration.range(r.from, r.to)));
}
// Function to create header decorations based on parsed sections
function createHeaderDecorations(
state: EditorState,
@ -686,6 +733,30 @@ @@ -686,6 +733,30 @@
fontWeight: "500",
fontStyle: "italic",
},
// Wiki links - using theme primary colors (leather tones)
".cm-wiki-link-auto": {
color: "var(--color-primary-700)", // [[term]] (auto) - medium leather
fontWeight: "500",
backgroundColor:
"color-mix(in srgb, var(--color-primary-700) 10%, transparent)",
padding: "2px 4px",
borderRadius: "3px",
},
".cm-wiki-link-ref": {
color: "var(--color-primary-800)", // [[w:term]] (reference) - darker leather
fontWeight: "500",
backgroundColor:
"color-mix(in srgb, var(--color-primary-800) 10%, transparent)",
padding: "2px 4px",
borderRadius: "3px",
},
".cm-wiki-link-def": {
color: "#F59E0B", // amber-500 for [[d:term]] (definition)
fontWeight: "500",
backgroundColor: "rgba(245, 158, 11, 0.1)",
padding: "2px 4px",
borderRadius: "3px",
},
});
const state = EditorState.create({
@ -694,6 +765,7 @@ @@ -694,6 +765,7 @@
basicSetup,
markdown(), // AsciiDoc is similar to markdown syntax
headerDecorations,
wikiLinkDecorations,
headerHighlighting,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
@ -726,6 +798,44 @@ @@ -726,6 +798,44 @@
outline: "none",
},
}),
// Override background and text to match preview (gray-800 bg, gray-100 text)
...(isDarkMode
? [
EditorView.theme(
{
"&": {
backgroundColor: "#1f2937",
color: "#f3f4f6",
},
".cm-content": {
color: "#f3f4f6",
},
".cm-line": {
color: "#f3f4f6",
},
".cm-gutters": {
backgroundColor: "#1f2937",
borderColor: "#374151",
color: "#9ca3af",
},
".cm-activeLineGutter": {
backgroundColor: "#374151",
},
".cm-cursor": {
borderLeftColor: "#f3f4f6",
},
".cm-selectionBackground, ::selection": {
backgroundColor: "#374151 !important",
},
"&.cm-focused .cm-selectionBackground, &.cm-focused ::selection":
{
backgroundColor: "#4b5563 !important",
},
},
{ dark: true },
),
]
: []),
],
});
@ -753,9 +863,41 @@ @@ -753,9 +863,41 @@
// Mount CodeMirror when component mounts
onMount(() => {
// Initialize dark mode state
isDarkMode = document.documentElement.classList.contains("dark");
createEditor();
// Watch for dark mode changes
const observer = new MutationObserver(() => {
const newDarkMode = document.documentElement.classList.contains("dark");
if (newDarkMode !== isDarkMode) {
isDarkMode = newDarkMode;
// Recreate editor with new theme
if (editorView) {
const currentContent = editorView.state.doc.toString();
editorView.destroy();
createEditor();
// Restore content
if (editorView && currentContent !== content) {
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: currentContent,
},
});
}
}
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => {
observer.disconnect();
if (editorView) {
editorView.destroy();
}
@ -910,7 +1052,7 @@ @@ -910,7 +1052,7 @@
: 'w-full'} flex flex-col"
>
<div
class="flex-1 relative border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900"
class="flex-1 relative border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800"
style="overflow: hidden;"
>
<!-- CodeMirror Editor Container -->
@ -936,214 +1078,343 @@ @@ -936,214 +1078,343 @@
</h3>
</div>
<div class="flex-1 overflow-y-auto p-6 bg-white dark:bg-gray-900">
<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">
{#if !content.trim()}
<div
class="text-gray-500 dark:text-gray-400 text-sm text-center py-8"
>
Start typing to see the preview...
</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
<!-- Render full document with title if it's an article -->
{#if contentType === "article" && publicationResult?.metadata.title}
{@const documentHeader = content.split(/\n==\s+/)[0]}
<div
class="mb-6 border-b border-gray-200 dark:border-gray-700 pb-4"
>
<div class="asciidoc-content">
{@html asciidoctor.convert(documentHeader, {
standalone: false,
attributes: {
showtitle: true,
sectids: false,
},
})}
{#if !content.trim()}
<div
class="text-gray-500 dark:text-gray-400 text-sm text-center py-8"
>
Start typing to see the preview...
</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
<!-- Render full document with title if it's an article -->
{#if contentType === "article" && publicationResult?.metadata.title}
{@const documentHeader = content.split(/\n==\s+/)[0]}
<div
class="mb-6 border-b border-gray-200 dark:border-gray-700 pb-4"
>
<div class="asciidoc-content">
{@html asciidoctor.convert(documentHeader, {
standalone: false,
attributes: {
showtitle: true,
sectids: false,
},
})}
</div>
</div>
</div>
{/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>
{/if}
<!-- Title -->
<h2
class="text-lg font-bold text-gray-900 dark:text-gray-100"
>
{section.title}
</h2>
<!-- Tags (blue for index events) -->
{#if section.tags && section.tags.length > 0}
<div class="flex flex-wrap gap-2">
{#each section.tags as tag}
<span
class="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
{#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>
{/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>
<!-- Title at correct heading level -->
<div
class="prose prose-sm dark:prose-invert max-w-none"
>
{@html asciidoctor.convert(
`${"=".repeat(section.level)} ${section.title}`,
{
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
},
)}
<!-- Title -->
<h2
class="text-lg font-bold text-gray-900 dark:text-gray-100"
>
{section.title}
</h2>
<!-- Tags and wiki links -->
{#if section.tags && section.tags.length > 0}
{@const tTags = section.tags.filter(
(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>
{/if}
{/if}
</div>
<!-- Tags (green for content events) -->
{#if section.tags && section.tags.length > 0}
<div class="flex flex-wrap gap-2">
{#each section.tags as tag}
<span
class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
{: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>
{/if}
<!-- Content rendered as AsciiDoc -->
{#if section.content}
<!-- Title at correct heading level -->
<div
class="prose prose-sm dark:prose-invert max-w-none mt-4"
class="prose prose-sm dark:prose-invert max-w-none"
>
{@html (() => {
// Check if content contains nested headers
const hasNestedHeaders = section.content.includes('\n===') || section.content.includes('\n====');
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${section.content}`;
const rendered = asciidoctor.convert(fullDoc, {
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
{@html asciidoctor.convert(
`${"=".repeat(section.level)} ${section.title}`,
{
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
},
)}
</div>
<!-- Tags and wiki links (green for content events) -->
{#if section.tags && section.tags.length > 0}
{@const tTags = section.tags.filter(
(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-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>
{/if}
{/if}
<!-- Content rendered as AsciiDoc -->
{#if section.content}
<div
class="prose prose-sm dark:prose-invert max-w-none mt-4"
>
{@html (() => {
// Extract wiki links and replace with placeholders BEFORE Asciidoctor
const wikiLinks = extractWikiLinks(
section.content,
);
let contentWithPlaceholders = section.content;
const placeholders = new Map();
wikiLinks.forEach((link, index) => {
// Use a placeholder inside a passthrough macro - Asciidoctor will strip pass:[...] and leave the inner text
const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`;
const placeholder = `pass:[${innerPlaceholder}]`;
placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor)
contentWithPlaceholders =
contentWithPlaceholders.replace(
link.fullMatch,
placeholder,
);
});
// Extract just the content we want (remove the temporary structure)
// Find the section we care about
const sectionStart = rendered.indexOf(`<h${section.level}`);
if (sectionStart !== -1) {
const nextSectionStart = rendered.indexOf(`</h${section.level}>`, sectionStart);
if (nextSectionStart !== -1) {
// Get everything after our section header
const afterHeader = rendered.substring(nextSectionStart + `</h${section.level}>`.length);
// Find where the section ends (at the closing div)
const sectionEnd = afterHeader.lastIndexOf('</div>');
if (sectionEnd !== -1) {
const extracted = afterHeader.substring(0, sectionEnd);
return extracted;
// Check if content contains nested headers
const hasNestedHeaders =
contentWithPlaceholders.includes("\n===") ||
contentWithPlaceholders.includes("\n====");
let rendered: string | Document;
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)
// 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,
},
},
);
}
return rendered;
} else {
// Simple content without nested headers
return asciidoctor.convert(section.content, {
standalone: false,
attributes: {
showtitle: false,
sectids: false,
},
// 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
.toString()
.replace(regex, html);
});
}
})()}
</div>
{/if}
</div>
{/if}
<!-- Event boundary indicator -->
{#if index < parsedSections.length - 1}
<div class="mt-6 relative">
<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>
return rendered;
})()}
</div>
{/if}
</div>
<div class="relative flex justify-center">
<span
class="bg-white dark:bg-gray-900 px-3 text-xs text-gray-500 dark:text-gray-400"
>
Event Boundary
</span>
{/if}
<!-- Event boundary indicator -->
{#if index < parsedSections.length - 1}
<div class="mt-6 relative">
<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>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<div
class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-900 p-2 rounded border"
>
<strong>Event Count:</strong>
{#if generatedEvents}
{@const indexEvents = generatedEvents.contentEvents.filter(
(e: any) => e.kind === 30040,
)}
{@const contentOnlyEvents =
generatedEvents.contentEvents.filter(
(e: any) => e.kind === 30041,
<div
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>
{#if generatedEvents}
{@const indexEvents = generatedEvents.contentEvents.filter(
(e: any) => e.kind === 30040,
)}
{@const totalIndexEvents =
indexEvents.length + (generatedEvents.indexEvent ? 1 : 0)}
{@const totalEvents =
totalIndexEvents + contentOnlyEvents.length}
{totalEvents} event{totalEvents !== 1 ? "s" : ""}
({totalIndexEvents} index{totalIndexEvents !== 1
? " events"
: ""} + {contentOnlyEvents.length} content{contentOnlyEvents.length !==
1
? " events"
: ""})
{:else}
0 events
{/if}
</div>
{/if}
{@const contentOnlyEvents =
generatedEvents.contentEvents.filter(
(e: any) => e.kind === 30041,
)}
{@const totalIndexEvents =
indexEvents.length + (generatedEvents.indexEvent ? 1 : 0)}
{@const totalEvents =
totalIndexEvents + contentOnlyEvents.length}
{totalEvents} event{totalEvents !== 1 ? "s" : ""}
({totalIndexEvents} index{totalIndexEvents !== 1
? " events"
: ""} + {contentOnlyEvents.length} content{contentOnlyEvents.length !==
1
? " events"
: ""})
{:else}
0 events
{/if}
</div>
{/if}
</div>
</div>
</div>
@ -1297,6 +1568,54 @@ Understanding the nature of knowledge... @@ -1297,6 +1568,54 @@ Understanding the nature of knowledge...
</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">
Wiki Links
</h4>
<p class="text-xs mb-2">
Create semantic links between content using wiki link syntax:
</p>
<ul class="space-y-2 text-xs">
<li>
<code
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>
<code
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>
<code
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 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
>
</li>
</ul>
<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.
</p>
</div>
</div>
</div>
</div>
@ -1372,7 +1691,7 @@ Understanding the nature of knowledge... @@ -1372,7 +1691,7 @@ Understanding the nature of knowledge...
<!-- Hierarchical structure -->
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-3">
<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;">
{node.eventKind === 30040 ? "📁" : "📄"}
[{node.eventKind}] {node.title || "Untitled"}
@ -1454,3 +1773,43 @@ Understanding the nature of knowledge... @@ -1454,3 +1773,43 @@ Understanding the nature of knowledge...
{/if}
</div>
</div>
<style>
/* Wiki link styling in preview */
:global(.prose .wiki-link) {
text-decoration: none;
border-bottom: 1px dotted currentColor;
transition: all 0.2s;
}
:global(.prose .wiki-link:hover) {
border-bottom-style: solid;
}
/* Use theme primary colors (leather tones) */
:global(.prose .wiki-link-auto) {
color: var(--color-primary-700); /* medium leather */
}
:global(.prose .wiki-link-ref) {
color: var(--color-primary-800); /* darker leather */
}
:global(.prose .wiki-link-def) {
color: var(--color-warning-600); /* amber/yellow for definitions */
font-weight: 500;
}
/* Dark mode - use lighter shades for contrast */
:global(.dark .prose .wiki-link-auto) {
color: var(--color-primary-300); /* lighter leather for dark mode */
}
:global(.dark .prose .wiki-link-ref) {
color: var(--color-primary-400); /* medium-light leather for dark mode */
}
:global(.dark .prose .wiki-link-def) {
color: var(--color-warning-400); /* brighter amber for dark mode */
}
</style>

37
src/lib/components/cards/BlogHeader.svelte

@ -10,7 +10,8 @@ @@ -10,7 +10,8 @@
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { getNdkContext } from "$lib/ndk";
import { deleteEvent } from "$lib/services/deletion";
const {
rootId,
event,
@ -25,6 +26,38 @@ @@ -25,6 +26,38 @@
const ndk = getNdkContext();
/**
* Handle deletion of this blog article
*/
async function handleDelete() {
const confirmed = confirm(
"Are you sure you want to delete this article? This action will publish a deletion request to all relays."
);
if (!confirmed) return;
try {
await deleteEvent({
eventAddress: event.tagAddress(),
eventKind: event.kind,
reason: "User deleted article",
onSuccess: (deletionEventId) => {
console.log("[BlogHeader] Deletion event published:", deletionEventId);
// Call onBlogUpdate if provided to refresh the list
if (onBlogUpdate) {
onBlogUpdate();
}
},
onError: (error) => {
console.error("[BlogHeader] Deletion failed:", error);
alert(`Failed to delete article: ${error}`);
},
}, ndk);
} catch (error) {
console.error("[BlogHeader] Deletion error:", error);
}
}
let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
@ -106,7 +139,7 @@ @@ -106,7 +139,7 @@
<!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2">
<CardActions {event} />
<CardActions {event} onDelete={handleDelete} />
</div>
</div>
</Card>

520
src/lib/components/publications/CommentButton.svelte

@ -0,0 +1,520 @@ @@ -0,0 +1,520 @@
<script lang="ts">
import { Button, Textarea, P } from "flowbite-svelte";
import { getContext } from "svelte";
import type NDK from "@nostr-dev-kit/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { userStore } from "$lib/stores/userStore";
import { activeOutboxRelays, activeInboxRelays } from "$lib/ndk";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { ChevronDownOutline, ChevronUpOutline } from "flowbite-svelte-icons";
let {
address,
onCommentPosted,
inline = false,
}: {
address: string;
onCommentPosted?: () => void;
inline?: boolean;
} = $props();
const ndk: NDK = getContext("ndk");
// State management
let showCommentUI = $state(false);
let commentContent = $state("");
let isSubmitting = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
let showJsonPreview = $state(false);
// Build preview JSON for the comment event
let previewJson = $derived.by(() => {
if (!commentContent.trim()) return null;
const eventDetails = parseAddress(address);
if (!eventDetails) return null;
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
const relayHint = $activeOutboxRelays[0] || "";
return {
kind: 1111,
pubkey: $userStore.pubkey || "<your-pubkey>",
created_at: Math.floor(Date.now() / 1000),
tags: [
["A", address, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", address, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
],
content: commentContent,
id: "<calculated-on-signing>",
sig: "<calculated-on-signing>"
};
});
// Parse address to get event details
function parseAddress(address: string): { kind: number; pubkey: string; dTag: string } | null {
const parts = address.split(":");
if (parts.length !== 3) {
console.error("[CommentButton] Invalid address format:", address);
return null;
}
const [kindStr, pubkey, dTag] = parts;
const kind = parseInt(kindStr);
if (isNaN(kind)) {
console.error("[CommentButton] Invalid kind in address:", kindStr);
return null;
}
return { kind, pubkey, dTag };
}
// Create NIP-22 comment event
async function createCommentEvent(content: string): Promise<NDKEvent | null> {
const eventDetails = parseAddress(address);
if (!eventDetails) {
error = "Invalid event address";
return null;
}
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
// Get relay hint (use first available outbox relay)
const relayHint = $activeOutboxRelays[0] || "";
// Get the actual event to include its ID in tags
let eventId = "";
try {
const targetEvent = await ndk.fetchEvent({
kinds: [kind],
authors: [authorPubkey],
"#d": [dTag],
});
if (targetEvent) {
eventId = targetEvent.id;
}
} catch (err) {
console.warn("[CommentButton] Could not fetch target event ID:", err);
}
// Create the comment event following NIP-22 structure
const commentEvent = new NDKEvent(ndk);
commentEvent.kind = 1111;
commentEvent.content = content;
commentEvent.pubkey = $userStore.pubkey || ""; // Set pubkey from user store
// NIP-22 tags structure for top-level comments
commentEvent.tags = [
// Root scope - uppercase tags
["A", address, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
// Parent scope (same as root for top-level) - lowercase tags
["a", address, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
];
// Include e tag if we have the event ID
if (eventId) {
commentEvent.tags.push(["e", eventId, relayHint]);
}
console.log("[CommentButton] Created NIP-22 comment event:", {
kind: commentEvent.kind,
tags: commentEvent.tags,
content: commentEvent.content,
});
return commentEvent;
}
// Submit comment
async function submitComment() {
if (!commentContent.trim()) {
error = "Comment cannot be empty";
return;
}
if (!$userStore.signedIn || !$userStore.signer) {
error = "You must be signed in to comment";
return;
}
isSubmitting = true;
error = null;
success = false;
try {
const commentEvent = await createCommentEvent(commentContent);
if (!commentEvent) {
throw new Error("Failed to create comment event");
}
// Sign the event - create plain object to avoid proxy issues
const plainEvent = {
kind: Number(commentEvent.kind),
pubkey: String(commentEvent.pubkey),
created_at: Number(commentEvent.created_at ?? Math.floor(Date.now() / 1000)),
tags: commentEvent.tags.map((tag) => tag.map(String)),
content: String(commentEvent.content),
};
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) {
const signed = await window.nostr.signEvent(plainEvent);
commentEvent.sig = signed.sig;
if ("id" in signed) {
commentEvent.id = signed.id as string;
}
} else {
await commentEvent.sign($userStore.signer);
}
console.log("[CommentButton] Signed comment event:", commentEvent.rawEvent());
// Build relay list following the same pattern as eventServices
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
// Remove duplicates
const uniqueRelays = Array.from(new Set(relays));
console.log("[CommentButton] Publishing to relays:", uniqueRelays);
const signedEvent = {
...plainEvent,
id: commentEvent.id,
sig: commentEvent.sig,
};
// Publish to relays using WebSocketPool
let publishedCount = 0;
for (const relayUrl of uniqueRelays) {
try {
const ws = await WebSocketPool.instance.acquire(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
WebSocketPool.instance.release(ws);
reject(new Error("Timeout"));
}, 5000);
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
publishedCount++;
console.log(`[CommentButton] Published to ${relayUrl}`);
WebSocketPool.instance.release(ws);
resolve();
} else {
console.warn(`[CommentButton] ${relayUrl} rejected: ${message}`);
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
}
};
// Send the event to the relay
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
} catch (e) {
console.error(`[CommentButton] Failed to publish to ${relayUrl}:`, e);
}
}
if (publishedCount === 0) {
throw new Error("Failed to publish to any relays");
}
console.log(`[CommentButton] Published to ${publishedCount} relay(s)`);
// Success!
success = true;
commentContent = "";
showJsonPreview = false;
// Close UI after a delay
setTimeout(() => {
showCommentUI = false;
success = false;
// Trigger refresh of CommentViewer if callback provided
if (onCommentPosted) {
onCommentPosted();
}
}, 2000);
} catch (err) {
console.error("[CommentButton] Error submitting comment:", err);
error = err instanceof Error ? err.message : "Failed to post comment";
} finally {
isSubmitting = false;
}
}
// Cancel comment
function cancelComment() {
showCommentUI = false;
commentContent = "";
error = null;
success = false;
showJsonPreview = false;
}
// Toggle comment UI
function toggleCommentUI() {
if (!$userStore.signedIn) {
error = "You must be signed in to comment";
setTimeout(() => {
error = null;
}, 3000);
return;
}
showCommentUI = !showCommentUI;
error = null;
success = false;
showJsonPreview = false;
}
</script>
<!-- Hamburger Comment Button -->
<div class="comment-button-container" class:inline={inline}>
<button
class="single-line-button"
onclick={toggleCommentUI}
title="Add comment"
aria-label="Add comment"
>
<span class="line"></span>
<span class="line"></span>
<span class="line"></span>
</button>
<!-- Comment Creation UI -->
{#if showCommentUI}
<div class="comment-ui">
<div class="comment-header">
<h4>Add Comment</h4>
{#if $userStore.profile}
<div class="user-info">
{#if $userStore.profile.picture}
<img src={$userStore.profile.picture} alt={$userStore.profile.displayName || $userStore.profile.name || "User"} class="user-avatar" />
{/if}
<span class="user-name">{$userStore.profile.displayName || $userStore.profile.name || "Anonymous"}</span>
</div>
{/if}
</div>
<Textarea
bind:value={commentContent}
placeholder="Write your comment here..."
rows={4}
disabled={isSubmitting}
class="comment-textarea"
/>
{#if error}
<P class="error-message text-red-600 dark:text-red-400 text-sm mt-2">{error}</P>
{/if}
{#if success}
<P class="success-message text-green-600 dark:text-green-400 text-sm mt-2">Comment posted successfully!</P>
{/if}
<!-- JSON Preview Section -->
{#if showJsonPreview && previewJson}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900 mt-3">
<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>
</div>
{/if}
<div class="comment-actions-wrapper">
<Button
color="light"
size="sm"
onclick={() => showJsonPreview = !showJsonPreview}
class="flex items-center gap-1"
>
{#if showJsonPreview}
<ChevronUpOutline class="w-4 h-4" />
{:else}
<ChevronDownOutline class="w-4 h-4" />
{/if}
{showJsonPreview ? "Hide" : "Show"} JSON
</Button>
<div class="comment-actions">
<Button
size="sm"
color="alternative"
onclick={cancelComment}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="sm"
onclick={submitComment}
disabled={isSubmitting || !commentContent.trim()}
>
{isSubmitting ? "Posting..." : "Post Comment"}
</Button>
</div>
</div>
</div>
{/if}
</div>
<style>
.comment-button-container {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 0;
pointer-events: none;
}
.comment-button-container.inline {
position: relative;
height: auto;
pointer-events: auto;
}
.single-line-button {
position: absolute;
top: 4px;
right: 8px;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 24px;
height: 18px;
padding: 4px;
background: transparent;
border: none;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease-in-out;
z-index: 10;
pointer-events: auto;
}
.comment-button-container.inline .single-line-button {
position: relative;
top: 0;
right: 0;
opacity: 1;
}
.single-line-button:hover .line {
border-width: 3px;
}
.line {
display: block;
width: 100%;
height: 0;
border: none;
border-top: 2px dashed #6b7280;
transition: all 0.2s ease-in-out;
}
.comment-ui {
position: absolute;
top: 35px;
right: 8px;
min-width: 400px;
max-width: 600px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 20;
pointer-events: auto;
}
:global(.dark) .comment-ui {
background: #1f2937;
border-color: #374151;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.comment-header h4 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #111827;
}
:global(.dark) .comment-header h4 {
color: #f9fafb;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.user-name {
font-size: 14px;
color: #6b7280;
}
:global(.dark) .user-name {
color: #9ca3af;
}
.comment-actions-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.comment-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Make the comment UI responsive */
@media (max-width: 640px) {
.comment-ui {
min-width: 280px;
max-width: calc(100vw - 32px);
right: -8px;
}
}
</style>

282
src/lib/components/publications/CommentLayer.svelte

@ -0,0 +1,282 @@ @@ -0,0 +1,282 @@
<script lang="ts">
import { getNdkContext, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { generateMockCommentsForSections } from "$lib/utils/mockCommentData";
let {
eventId,
eventAddress,
eventIds = [],
eventAddresses = [],
comments = $bindable([]),
useMockComments = false,
}: {
eventId?: string;
eventAddress?: string;
eventIds?: string[];
eventAddresses?: string[];
comments?: NDKEvent[];
useMockComments?: boolean;
} = $props();
const ndk = getNdkContext();
// State management
let loading = $state(false);
/**
* Fetch comment events (kind 1111) for the current publication using WebSocketPool
*
* This follows the exact pattern from HighlightLayer.svelte to ensure reliability.
* Uses WebSocketPool with nostr-tools protocol instead of NDK subscriptions.
*/
async function fetchComments() {
// Prevent concurrent fetches
if (loading) {
console.log("[CommentLayer] Already loading, skipping fetch");
return;
}
// Collect all event IDs and addresses
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean);
const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean);
if (allEventIds.length === 0 && allAddresses.length === 0) {
console.warn("[CommentLayer] No event IDs or addresses provided");
return;
}
loading = true;
comments = [];
// AI-NOTE: Mock mode allows testing comment UI without publishing to relays
// This is useful for development and demonstrating the comment system
if (useMockComments) {
console.log(`[CommentLayer] MOCK MODE - Generating mock comments for ${allAddresses.length} sections`);
try {
// Generate mock comment data
const mockComments = generateMockCommentsForSections(allAddresses);
// Convert to NDKEvent instances (same as real events)
comments = mockComments.map(rawEvent => new NDKEventClass(ndk, rawEvent));
console.log(`[CommentLayer] Generated ${comments.length} mock comments`);
loading = false;
return;
} catch (err) {
console.error(`[CommentLayer] Error generating mock comments:`, err);
loading = false;
return;
}
}
console.log(`[CommentLayer] Fetching comments for:`, {
eventIds: allEventIds,
addresses: allAddresses
});
try {
// Build filter for kind 1111 comment events
// IMPORTANT: Use only #a tags because filters are AND, not OR
// If we include both #e and #a, relays will only return comments that have BOTH
const filter: any = {
kinds: [1111],
limit: 500,
};
// Prefer #a (addressable events) since they're more specific and persistent
if (allAddresses.length > 0) {
filter["#a"] = allAddresses;
} else if (allEventIds.length > 0) {
// Fallback to #e if no addresses available
filter["#e"] = allEventIds;
}
console.log(`[CommentLayer] Fetching with filter:`, JSON.stringify(filter, null, 2));
// Build explicit relay set (same pattern as HighlightLayer)
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
console.log(`[CommentLayer] Fetching from ${uniqueRelays.length} relays:`, uniqueRelays);
/**
* Use WebSocketPool with nostr-tools protocol instead of NDK
*
* Reasons for not using NDK:
* 1. NDK subscriptions mysteriously returned 0 events even when websocat confirmed events existed
* 2. Consistency - HighlightLayer, CommentButton, and HighlightSelectionHandler use WebSocketPool
* 3. Better debugging - direct access to WebSocket messages for troubleshooting
* 4. Proven reliability - battle-tested in the codebase for similar use cases
* 5. Performance control - explicit 5s timeout per relay, tunable as needed
*
* This matches the pattern in:
* - src/lib/components/publications/HighlightLayer.svelte:111-212
* - src/lib/components/publications/CommentButton.svelte:156-220
* - src/lib/components/publications/HighlightSelectionHandler.svelte:217-280
*/
const subscriptionId = `comments-${Date.now()}`;
const receivedEventIds = new Set<string>();
let eoseCount = 0;
const fetchPromises = uniqueRelays.map(async (relayUrl) => {
try {
console.log(`[CommentLayer] Connecting to ${relayUrl}`);
const ws = await WebSocketPool.instance.acquire(relayUrl);
return new Promise<void>((resolve) => {
const messageHandler = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
// Log ALL messages from relay.nostr.band for debugging
if (relayUrl.includes('relay.nostr.band')) {
console.log(`[CommentLayer] RAW message from ${relayUrl}:`, message);
}
if (message[0] === "EVENT" && message[1] === subscriptionId) {
const rawEvent = message[2];
console.log(`[CommentLayer] EVENT from ${relayUrl}:`, {
id: rawEvent.id,
kind: rawEvent.kind,
content: rawEvent.content.substring(0, 50),
tags: rawEvent.tags
});
// Avoid duplicates
if (!receivedEventIds.has(rawEvent.id)) {
receivedEventIds.add(rawEvent.id);
// Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent);
comments = [...comments, ndkEvent];
console.log(`[CommentLayer] Added comment, total now: ${comments.length}`);
}
} else if (message[0] === "EOSE" && message[1] === subscriptionId) {
eoseCount++;
console.log(`[CommentLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`);
// Close subscription
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
resolve();
} else if (message[0] === "NOTICE") {
console.warn(`[CommentLayer] NOTICE from ${relayUrl}:`, message[1]);
}
} catch (err) {
console.error(`[CommentLayer] Error processing message from ${relayUrl}:`, err);
}
};
ws.addEventListener("message", messageHandler);
// Send REQ
const req = ["REQ", subscriptionId, filter];
if (relayUrl.includes('relay.nostr.band')) {
console.log(`[CommentLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req));
} else {
console.log(`[CommentLayer] Sending REQ to ${relayUrl}`);
}
ws.send(JSON.stringify(req));
// Timeout per relay (5 seconds)
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
}
resolve();
}, 5000);
});
} catch (err) {
console.error(`[CommentLayer] Error connecting to ${relayUrl}:`, err);
}
});
// Wait for all relays to respond or timeout
await Promise.all(fetchPromises);
console.log(`[CommentLayer] Fetched ${comments.length} comments`);
if (comments.length > 0) {
console.log(`[CommentLayer] Comments summary:`, comments.map(c => ({
content: c.content.substring(0, 30) + "...",
address: c.tags.find(t => t[0] === "a")?.[1],
author: c.pubkey.substring(0, 8)
})));
}
loading = false;
} catch (err) {
console.error(`[CommentLayer] Error fetching comments:`, err);
loading = false;
}
}
// Track the last fetched event count to know when to refetch
let lastFetchedCount = $state(0);
let fetchTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for changes to event data - debounce and fetch when data stabilizes
$effect(() => {
const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0;
console.log(`[CommentLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`);
// Only fetch if:
// 1. We have event data
// 2. The count has changed since last fetch
// 3. We're not already loading
if (hasEventData && currentCount !== lastFetchedCount && !loading) {
// Clear any existing timeout
if (fetchTimeout) {
clearTimeout(fetchTimeout);
}
// Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => {
console.log(`[CommentLayer] Event data stabilized at ${currentCount} events, fetching comments...`);
lastFetchedCount = currentCount;
fetchComments();
}, 500);
}
// Cleanup timeout on effect cleanup
return () => {
if (fetchTimeout) {
clearTimeout(fetchTimeout);
}
};
});
/**
* Public method to refresh comments (e.g., after creating a new one)
*/
export function refresh() {
console.log("[CommentLayer] Manual refresh triggered");
// Clear existing comments
comments = [];
// Reset fetch count to force re-fetch
lastFetchedCount = 0;
fetchComments();
}
</script>
{#if loading}
<div 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 comments...</p>
</div>
{/if}

280
src/lib/components/publications/CommentPanel.svelte

@ -0,0 +1,280 @@ @@ -0,0 +1,280 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { getNdkContext } from "$lib/ndk";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import { ChevronDownOutline, ChevronRightOutline } from "flowbite-svelte-icons";
let {
comments = [],
sectionTitles = new Map<string, string>(),
}: {
comments: NDKEvent[];
sectionTitles?: Map<string, string>;
} = $props();
const ndk = getNdkContext();
// State management
let profiles = $state(new Map<string, any>());
let expandedSections = $state(new Set<string>());
/**
* Group comments by their target event address
* Extracts the target from #a or #e tags
*/
let groupedComments = $derived.by(() => {
const groups = new Map<string, NDKEvent[]>();
for (const comment of comments) {
// Look for #a tag first (addressable events - preferred)
const aTag = comment.tags.find(t => t[0] === "a");
if (aTag && aTag[1]) {
const address = aTag[1];
if (!groups.has(address)) {
groups.set(address, []);
}
groups.get(address)!.push(comment);
continue;
}
// Fallback to #e tag (event ID)
const eTag = comment.tags.find(t => t[0] === "e");
if (eTag && eTag[1]) {
const eventId = eTag[1];
if (!groups.has(eventId)) {
groups.set(eventId, []);
}
groups.get(eventId)!.push(comment);
}
}
console.log(`[CommentPanel] Grouped ${comments.length} comments into ${groups.size} sections`);
return groups;
});
/**
* Get a display label for a target address/id
* Uses provided section titles, or falls back to address/id
*/
function getTargetLabel(target: string): string {
// Check if we have a title for this address
if (sectionTitles.has(target)) {
return sectionTitles.get(target)!;
}
// Parse address format: kind:pubkey:d-tag
const parts = target.split(":");
if (parts.length === 3) {
const [kind, _pubkey, dTag] = parts;
if (kind === "30040") {
return "Comments on Collection";
}
if (kind === "30041" && dTag) {
return `Section: ${dTag}`;
}
}
// Fallback to truncated address/id
return target.length > 20 ? `${target.substring(0, 20)}...` : target;
}
/**
* Fetch profile for a pubkey
*/
async function fetchProfile(pubkey: string) {
if (profiles.has(pubkey)) return;
try {
const npub = toNpub(pubkey);
if (!npub) {
setFallbackProfile(pubkey);
return;
}
const profile = await getUserMetadata(npub, ndk, true);
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, profile);
profiles = newProfiles;
console.log(`[CommentPanel] Fetched profile for ${pubkey}:`, profile);
} catch (err) {
console.warn(`[CommentPanel] Failed to fetch profile for ${pubkey}:`, err);
setFallbackProfile(pubkey);
}
}
/**
* Set fallback profile using truncated npub
*/
function setFallbackProfile(pubkey: string) {
const npub = toNpub(pubkey) || pubkey;
const truncated = `${npub.slice(0, 12)}...${npub.slice(-4)}`;
const fallbackProfile = {
name: truncated,
displayName: truncated,
picture: null
};
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, fallbackProfile);
profiles = newProfiles;
}
/**
* Get display name for a pubkey
*/
function getDisplayName(pubkey: string): string {
const profile = profiles.get(pubkey);
if (profile) {
return profile.displayName || profile.name || profile.pubkey || pubkey;
}
// Return truncated npub while loading
const npub = toNpub(pubkey) || pubkey;
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
}
/**
* Toggle section expansion
*/
function toggleSection(target: string) {
const newExpanded = new Set(expandedSections);
if (newExpanded.has(target)) {
newExpanded.delete(target);
} else {
newExpanded.add(target);
}
expandedSections = newExpanded;
}
/**
* Format timestamp
*/
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else if (diffDays < 7) {
return `${diffDays}d ago`;
} else {
return date.toLocaleDateString();
}
}
/**
* Pre-fetch all profiles when comments change
*/
$effect(() => {
const uniquePubkeys = new Set(comments.map(c => c.pubkey));
console.log(`[CommentPanel] Pre-fetching ${uniquePubkeys.size} profiles`);
for (const pubkey of uniquePubkeys) {
fetchProfile(pubkey);
}
});
</script>
{#if comments.length > 0}
<div class="fixed right-4 top-20 bottom-4 w-96 bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden flex flex-col z-40">
<!-- Header -->
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Comments ({comments.length})
</h3>
</div>
<!-- Comment groups -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
{#each Array.from(groupedComments.entries()) as [target, targetComments]}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<!-- Section header -->
<button
class="w-full px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
onclick={() => toggleSection(target)}
>
<div class="flex items-center gap-2">
{#if expandedSections.has(target)}
<ChevronDownOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
{:else}
<ChevronRightOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
{/if}
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{getTargetLabel(target)}
</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
{targetComments.length} {targetComments.length === 1 ? 'comment' : 'comments'}
</span>
</button>
<!-- Comment list -->
{#if expandedSections.has(target)}
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{#each targetComments as comment (comment.id)}
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<!-- Comment header -->
<div class="flex items-start gap-3 mb-2">
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{getDisplayName(comment.pubkey)}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(comment.created_at || 0)}
</span>
</div>
</div>
</div>
<!-- Comment content -->
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none">
{@render basicMarkup(comment.content)}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
{#if groupedComments.size === 0 && comments.length > 0}
<div class="text-center py-8">
<p class="text-sm text-gray-500 dark:text-gray-400">
Comments loaded but couldn't determine their targets
</p>
</div>
{/if}
</div>
</div>
{/if}
<style>
/* Custom scrollbar for comment panel */
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
</style>

126
src/lib/components/publications/DeleteButton.svelte

@ -0,0 +1,126 @@ @@ -0,0 +1,126 @@
<script lang="ts">
import { Button, Modal } from "flowbite-svelte";
import { TrashBinOutline } from "flowbite-svelte-icons";
import { getContext } from "svelte";
import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { deleteEvent, canDeleteEvent } from "$lib/services/deletion";
import { userStore } from "$lib/stores/userStore";
let {
address,
leafEvent,
onDeleted,
}: {
address: string;
leafEvent: Promise<NDKEvent | null>;
onDeleted?: () => void;
} = $props();
const ndk: NDK = getContext("ndk");
let showDeleteModal = $state(false);
let isDeleting = $state(false);
let deleteError = $state<string | null>(null);
let resolvedEvent = $state<NDKEvent | null>(null);
// Check if user can delete this event
let canDelete = $derived(canDeleteEvent(resolvedEvent, ndk));
// Resolve the event promise
$effect(() => {
leafEvent.then(event => {
resolvedEvent = event;
});
});
async function handleDelete() {
if (!resolvedEvent) {
deleteError = "Event not found";
return;
}
isDeleting = true;
deleteError = null;
const result = await deleteEvent(
{
eventId: resolvedEvent.id,
eventAddress: address,
eventKind: resolvedEvent.kind,
reason: "Deleted by author",
onSuccess: (deletionEventId) => {
console.log(`[DeleteButton] Published deletion event: ${deletionEventId}`);
showDeleteModal = false;
onDeleted?.();
},
onError: (error) => {
console.error(`[DeleteButton] Deletion failed: ${error}`);
deleteError = error;
},
},
ndk,
);
isDeleting = false;
if (result.success) {
console.log(`[DeleteButton] Successfully deleted section: ${address}`);
}
}
function openDeleteModal() {
deleteError = null;
showDeleteModal = true;
}
</script>
{#if canDelete}
<Button
color="red"
size="xs"
class="single-line-button opacity-0 transition-opacity duration-200"
onclick={openDeleteModal}
>
<TrashBinOutline class="w-3 h-3 mr-1" />
Delete
</Button>
<Modal bind:open={showDeleteModal} size="sm" title="Delete Section">
<div class="text-center">
<TrashBinOutline class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" />
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
Are you sure you want to delete this section?
</h3>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">
This will publish a deletion request to all relays. Note that not all relays
may honor this request, and the content may remain visible on some relays.
</p>
{#if deleteError}
<p class="mb-5 text-sm text-red-500">{deleteError}</p>
{/if}
<div class="flex justify-center gap-4">
<Button
color="red"
disabled={isDeleting}
onclick={handleDelete}
>
{isDeleting ? "Deleting..." : "Yes, delete it"}
</Button>
<Button
color="alternative"
disabled={isDeleting}
onclick={() => (showDeleteModal = false)}
>
No, cancel
</Button>
</div>
</div>
</Modal>
{/if}
<style>
:global(.single-line-button) {
opacity: 0;
}
</style>

21
src/lib/components/publications/HighlightButton.svelte

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { FontHighlightOutline } from "flowbite-svelte-icons";
let { isActive = $bindable(false) }: { isActive?: boolean } = $props();
function toggleHighlightMode() {
isActive = !isActive;
}
</script>
<Button
color={isActive ? "primary" : "light"}
size="sm"
class="btn-leather {isActive ? 'ring-2 ring-primary-500' : ''}"
onclick={toggleHighlightMode}
title={isActive ? "Exit highlight mode" : "Enter highlight mode"}
>
<FontHighlightOutline class="w-4 h-4 mr-2" />
{isActive ? "Exit Highlight Mode" : "Add Highlight"}
</Button>

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

@ -0,0 +1,952 @@ @@ -0,0 +1,952 @@
<script lang="ts">
import {
getNdkContext,
activeInboxRelays,
activeOutboxRelays,
} from "$lib/ndk";
import { pubkeyToHue } from "$lib/utils/nostrUtils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { generateMockHighlightsForSections } from "$lib/utils/mockHighlightData";
import {
groupHighlightsByAuthor,
truncateHighlight,
encodeHighlightNaddr,
getRelaysFromHighlight,
getAuthorDisplayName,
sortHighlightsByTime,
} from "$lib/utils/highlightUtils";
import { unifiedProfileCache } from "$lib/utils/npubCache";
import { nip19 } from "nostr-tools";
import {
highlightByOffset,
getPlainText,
} from "$lib/utils/highlightPositioning";
let {
eventId,
eventAddress,
eventIds = [],
eventAddresses = [],
visible = $bindable(false),
useMockHighlights = false,
}: {
eventId?: string;
eventAddress?: string;
eventIds?: string[];
eventAddresses?: string[];
visible?: boolean;
useMockHighlights?: boolean;
} = $props();
const ndk = getNdkContext();
// State management
let highlights: NDKEvent[] = $state([]);
let loading = $state(false);
let containerRef: HTMLElement | null = $state(null);
let expandedAuthors = $state(new Set<string>());
let authorProfiles = $state(new Map<string, any>());
let copyFeedback = $state<string | null>(null);
// Derived state for color mapping
let colorMap = $derived.by(() => {
const map = new Map<string, string>();
highlights.forEach((highlight) => {
if (!map.has(highlight.pubkey)) {
const hue = pubkeyToHue(highlight.pubkey);
map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
}
});
return map;
});
// Derived state for grouped highlights
let groupedHighlights = $derived.by(() => {
return groupHighlightsByAuthor(highlights);
});
/**
* Fetch highlight events (kind 9802) for the current publication using NDK
* Or generate mock highlights if useMockHighlights is enabled
*/
async function fetchHighlights() {
// Prevent concurrent fetches
if (loading) {
console.log("[HighlightLayer] Already loading, skipping fetch");
return;
}
// Collect all event IDs and addresses
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(
Boolean,
);
const allAddresses = [
...(eventAddress ? [eventAddress] : []),
...eventAddresses,
].filter(Boolean);
if (allEventIds.length === 0 && allAddresses.length === 0) {
console.warn("[HighlightLayer] No event IDs or addresses provided");
return;
}
loading = true;
highlights = [];
// AI-NOTE: Mock mode allows testing highlight UI without publishing to relays
// This is useful for development and demonstrating the highlight system
if (useMockHighlights) {
console.log(
`[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`,
);
try {
// Generate mock highlight data
const mockHighlights = generateMockHighlightsForSections(allAddresses);
// Convert to NDKEvent instances (same as real events)
highlights = mockHighlights.map(
(rawEvent) => new NDKEventClass(ndk, rawEvent),
);
console.log(
`[HighlightLayer] Generated ${highlights.length} mock highlights`,
);
loading = false;
return;
} catch (err) {
console.error(
`[HighlightLayer] Error generating mock highlights:`,
err,
);
loading = false;
return;
}
}
console.log(`[HighlightLayer] Fetching highlights for:`, {
eventIds: allEventIds,
addresses: allAddresses,
});
try {
// Build filter for kind 9802 highlight events
// IMPORTANT: Use only #a tags because filters are AND, not OR
// If we include both #e and #a, relays will only return highlights that have BOTH
const filter: any = {
kinds: [9802],
limit: 500,
};
// Prefer #a (addressable events) since they're more specific and persistent
if (allAddresses.length > 0) {
filter["#a"] = allAddresses;
} else if (allEventIds.length > 0) {
// Fallback to #e if no addresses available
filter["#e"] = allEventIds;
}
console.log(
`[HighlightLayer] Fetching with filter:`,
JSON.stringify(filter, null, 2),
);
// Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton)
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
console.log(
`[HighlightLayer] Fetching from ${uniqueRelays.length} relays:`,
uniqueRelays,
);
/**
* Use WebSocketPool with nostr-tools protocol instead of NDK
*
* Reasons for not using NDK:
* 1. NDK subscriptions mysteriously returned 0 events even when websocat confirmed events existed
* 2. Consistency - CommentButton and HighlightSelectionHandler both use WebSocketPool pattern
* 3. Better debugging - direct access to WebSocket messages for troubleshooting
* 4. Proven reliability - battle-tested in the codebase for similar use cases
* 5. Performance control - explicit 5s timeout per relay, tunable as needed
*
* This matches the pattern in:
* - src/lib/components/publications/CommentButton.svelte:156-220
* - src/lib/components/publications/HighlightSelectionHandler.svelte:217-280
*/
const subscriptionId = `highlights-${Date.now()}`;
const receivedEventIds = new Set<string>();
let eoseCount = 0;
const fetchPromises = uniqueRelays.map(async (relayUrl) => {
try {
console.log(`[HighlightLayer] Connecting to ${relayUrl}`);
const ws = await WebSocketPool.instance.acquire(relayUrl);
return new Promise<void>((resolve) => {
const messageHandler = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
// Log ALL messages from relay.nostr.band for debugging
if (relayUrl.includes("relay.nostr.band")) {
console.log(
`[HighlightLayer] RAW message from ${relayUrl}:`,
message,
);
}
if (message[0] === "EVENT" && message[1] === subscriptionId) {
const rawEvent = message[2];
console.log(`[HighlightLayer] EVENT from ${relayUrl}:`, {
id: rawEvent.id,
kind: rawEvent.kind,
content: rawEvent.content.substring(0, 50),
tags: rawEvent.tags,
});
// Avoid duplicates
if (!receivedEventIds.has(rawEvent.id)) {
receivedEventIds.add(rawEvent.id);
// Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent);
highlights = [...highlights, ndkEvent];
console.log(
`[HighlightLayer] Added highlight, total now: ${highlights.length}`,
);
}
} else if (
message[0] === "EOSE" &&
message[1] === subscriptionId
) {
eoseCount++;
console.log(
`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`,
);
// Close subscription
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
resolve();
} else if (message[0] === "NOTICE") {
console.warn(
`[HighlightLayer] NOTICE from ${relayUrl}:`,
message[1],
);
}
} catch (err) {
console.error(
`[HighlightLayer] Error processing message from ${relayUrl}:`,
err,
);
}
};
ws.addEventListener("message", messageHandler);
// Send REQ
const req = ["REQ", subscriptionId, filter];
if (relayUrl.includes("relay.nostr.band")) {
console.log(
`[HighlightLayer] Sending REQ to ${relayUrl}:`,
JSON.stringify(req),
);
} else {
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`);
}
ws.send(JSON.stringify(req));
// Timeout per relay (5 seconds)
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
}
resolve();
}, 5000);
});
} catch (err) {
console.error(
`[HighlightLayer] Error connecting to ${relayUrl}:`,
err,
);
}
});
// Wait for all relays to respond or timeout
await Promise.all(fetchPromises);
console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`);
if (highlights.length > 0) {
console.log(
`[HighlightLayer] Highlights summary:`,
highlights.map((h) => ({
content: h.content.substring(0, 30) + "...",
address: h.tags.find((t) => t[0] === "a")?.[1],
author: h.pubkey.substring(0, 8),
})),
);
}
loading = false;
// Rendering is handled by the visibility/highlights effect
} catch (err) {
console.error(`[HighlightLayer] Error fetching highlights:`, err);
loading = false;
}
}
/**
* Apply highlight using position offsets
* @param offsetStart - Start character position
* @param offsetEnd - End character position
* @param color - The color to use for highlighting
* @param targetAddress - Optional address to limit search to specific section
*/
function highlightByPosition(
offsetStart: number,
offsetEnd: number,
color: string,
targetAddress?: string,
): boolean {
if (!containerRef) {
console.log(
`[HighlightLayer] Cannot highlight by position - no containerRef`,
);
return false;
}
// If we have a target address, search only in that section
let searchRoot: HTMLElement = containerRef;
if (targetAddress) {
const sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
searchRoot = sectionElement;
console.log(
`[HighlightLayer] Highlighting in specific section: ${targetAddress}`,
);
} else {
console.log(
`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`,
);
}
}
console.log(
`[HighlightLayer] Applying position-based highlight ${offsetStart}-${offsetEnd}`,
);
const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
if (result) {
console.log(
`[HighlightLayer] Successfully applied position-based highlight`,
);
} else {
console.log(`[HighlightLayer] Failed to apply position-based highlight`);
}
return result;
}
/**
* Find text in the DOM and highlight it (fallback method)
* @param text - The text to highlight
* @param color - The color to use for highlighting
* @param targetAddress - Optional address to limit search to specific section
*/
function findAndHighlightText(
text: string,
color: string,
targetAddress?: string,
): void {
if (!containerRef || !text || text.trim().length === 0) {
console.log(
`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`,
);
return;
}
// If we have a target address, search only in that section
let searchRoot: HTMLElement | Document = containerRef;
if (targetAddress) {
const sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
searchRoot = sectionElement;
console.log(
`[HighlightLayer] Searching in specific section: ${targetAddress}`,
);
} else {
console.log(
`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`,
);
}
}
console.log(
`[HighlightLayer] Searching for text: "${text}" in`,
searchRoot,
);
// Use TreeWalker to find all text nodes
const walker = document.createTreeWalker(
searchRoot,
NodeFilter.SHOW_TEXT,
null,
);
const textNodes: Node[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
// Search for the highlight text in text nodes
console.log(
`[HighlightLayer] Searching through ${textNodes.length} text nodes`,
);
for (const textNode of textNodes) {
const nodeText = textNode.textContent || "";
const index = nodeText.toLowerCase().indexOf(text.toLowerCase());
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),
),
);
const parent = textNode.parentNode;
if (!parent) continue;
// Skip if already highlighted
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
continue;
}
const before = nodeText.substring(0, index);
const match = nodeText.substring(index, index + text.length);
const after = nodeText.substring(index + text.length);
// Create highlight span
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.textContent = match;
// Replace the text node with the highlighted version
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(highlightSpan);
if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
console.log(`[HighlightLayer] Highlighted text:`, match);
return; // Only highlight first occurrence to avoid multiple highlights
}
}
console.log(`[HighlightLayer] No match found for text: "${text}"`);
}
/**
* Render all highlights on the page
*/
function renderHighlights() {
console.log(
`[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`,
);
if (!visible || !containerRef) {
console.log(
`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`,
);
return;
}
if (highlights.length === 0) {
console.log(`[HighlightLayer] No highlights to render`);
return;
}
// Clear existing highlights
clearHighlights();
console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`);
console.log(`[HighlightLayer] Container element:`, containerRef);
console.log(
`[HighlightLayer] Container has children:`,
containerRef.children.length,
);
// Apply each highlight
for (const highlight of highlights) {
const content = highlight.content;
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)";
// Extract the target address from the highlight's "a" tag
const aTag = highlight.tags.find((tag) => tag[0] === "a");
const targetAddress = aTag ? aTag[1] : undefined;
// Check for offset tags (position-based highlighting)
const offsetTag = highlight.tags.find((tag) => tag[0] === "offset");
const hasOffset =
offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined;
console.log(`[HighlightLayer] Rendering highlight:`, {
hasOffset,
offsetTag,
content: content.substring(0, 50),
contentLength: content.length,
targetAddress,
color,
allTags: highlight.tags,
});
if (hasOffset) {
// Use position-based highlighting
const offsetStart = parseInt(offsetTag[1], 10);
const offsetEnd = parseInt(offsetTag[2], 10);
if (!isNaN(offsetStart) && !isNaN(offsetEnd)) {
console.log(
`[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`,
);
highlightByPosition(offsetStart, offsetEnd, color, targetAddress);
} else {
console.log(
`[HighlightLayer] Invalid offset values, falling back to text search`,
);
if (content && content.trim().length > 0) {
findAndHighlightText(content, color, targetAddress);
}
}
} else {
// Fall back to text-based highlighting
console.log(`[HighlightLayer] Using text-based highlighting`);
if (content && content.trim().length > 0) {
findAndHighlightText(content, color, targetAddress);
} else {
console.log(`[HighlightLayer] Skipping highlight - empty content`);
}
}
}
// Check if any highlights were actually rendered
const renderedHighlights = containerRef.querySelectorAll("mark.highlight");
console.log(
`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`,
);
}
/**
* Clear all highlights from the page
*/
function clearHighlights() {
if (!containerRef) return;
const highlightElements = containerRef.querySelectorAll("mark.highlight");
highlightElements.forEach((el) => {
const parent = el.parentNode;
if (parent) {
// Replace highlight with plain text
const textNode = document.createTextNode(el.textContent || "");
parent.replaceChild(textNode, el);
// Normalize the parent to merge adjacent text nodes
parent.normalize();
}
});
console.log(
`[HighlightLayer] Cleared ${highlightElements.length} highlights`,
);
}
// Track the last fetched event count to know when to refetch
let lastFetchedCount = $state(0);
let fetchTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for changes to event data - debounce and fetch when data stabilizes
$effect(() => {
const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0;
console.log(
`[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`,
);
// Only fetch if:
// 1. We have event data
// 2. The count has changed since last fetch
// 3. We're not already loading
if (hasEventData && currentCount !== lastFetchedCount && !loading) {
// Clear any existing timeout
if (fetchTimeout) {
clearTimeout(fetchTimeout);
}
// Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => {
console.log(
`[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`,
);
lastFetchedCount = currentCount;
fetchHighlights();
}, 500);
}
// Cleanup timeout on effect cleanup
return () => {
if (fetchTimeout) {
clearTimeout(fetchTimeout);
}
};
});
// Watch for visibility AND highlights changes - render when both are ready
$effect(() => {
// This effect runs when either visible or highlights.length changes
const highlightCount = highlights.length;
console.log(
`[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`,
);
if (visible && highlightCount > 0) {
console.log(
`[HighlightLayer] Both visible and highlights ready, rendering...`,
);
renderHighlights();
} else if (!visible) {
clearHighlights();
}
});
// Fetch profiles when highlights change
$effect(() => {
const highlightCount = highlights.length;
if (highlightCount > 0) {
fetchAuthorProfiles();
}
});
/**
* Fetch author profiles for all unique pubkeys in highlights
*/
async function fetchAuthorProfiles() {
const uniquePubkeys = Array.from(groupedHighlights.keys());
console.log(
`[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`,
);
for (const pubkey of uniquePubkeys) {
try {
// Convert hex pubkey to npub for the profile cache
const npub = nip19.npubEncode(pubkey);
const profile = await unifiedProfileCache.getProfile(npub, ndk);
if (profile) {
authorProfiles.set(pubkey, profile);
// Trigger reactivity
authorProfiles = new Map(authorProfiles);
}
} catch (err) {
console.error(
`[HighlightLayer] Error fetching profile for ${pubkey}:`,
err,
);
}
}
}
/**
* Toggle expansion state for an author's highlights
*/
function toggleAuthor(pubkey: string) {
if (expandedAuthors.has(pubkey)) {
expandedAuthors.delete(pubkey);
} else {
expandedAuthors.add(pubkey);
}
// Trigger reactivity
expandedAuthors = new Set(expandedAuthors);
}
/**
* Scroll to a specific highlight in the document
*/
function scrollToHighlight(highlight: NDKEvent) {
console.log(
`[HighlightLayer] scrollToHighlight called for:`,
highlight.content.substring(0, 50),
);
if (!containerRef) {
console.warn(`[HighlightLayer] No containerRef available`);
return;
}
const content = highlight.content;
if (!content || content.trim().length === 0) {
console.warn(`[HighlightLayer] No content in highlight`);
return;
}
// Find the highlight mark element
const highlightMarks = containerRef.querySelectorAll("mark.highlight");
console.log(
`[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`,
);
// Try exact match first
for (const mark of highlightMarks) {
const markText = mark.textContent?.toLowerCase() || "";
const searchText = content.toLowerCase();
if (markText === searchText) {
console.log(
`[HighlightLayer] Found exact match, scrolling and flashing`,
);
// Scroll to this element
mark.scrollIntoView({ behavior: "smooth", block: "center" });
// Add a temporary flash effect
mark.classList.add("highlight-flash");
setTimeout(() => {
mark.classList.remove("highlight-flash");
}, 1500);
return;
}
}
// Try partial match (for position-based highlights that might be split)
for (const mark of highlightMarks) {
const markText = mark.textContent?.toLowerCase() || "";
const searchText = content.toLowerCase();
if (markText.includes(searchText) || searchText.includes(markText)) {
console.log(
`[HighlightLayer] Found partial match, scrolling and flashing`,
);
mark.scrollIntoView({ behavior: "smooth", block: "center" });
mark.classList.add("highlight-flash");
setTimeout(() => {
mark.classList.remove("highlight-flash");
}, 1500);
return;
}
}
console.warn(
`[HighlightLayer] Could not find highlight mark for:`,
content.substring(0, 50),
);
}
/**
* Copy highlight naddr to clipboard
*/
async function copyHighlightNaddr(highlight: NDKEvent) {
const relays = getRelaysFromHighlight(highlight);
const naddr = encodeHighlightNaddr(highlight, relays);
try {
await navigator.clipboard.writeText(naddr);
copyFeedback = highlight.id;
console.log(`[HighlightLayer] Copied naddr to clipboard:`, naddr);
// Clear feedback after 2 seconds
setTimeout(() => {
copyFeedback = null;
}, 2000);
} catch (err) {
console.error(`[HighlightLayer] Error copying to clipboard:`, err);
}
}
/**
* Bind to parent container element
*/
export function setContainer(element: HTMLElement | null) {
containerRef = element;
}
/**
* Public method to refresh highlights (e.g., after creating a new one)
*/
export function refresh() {
console.log("[HighlightLayer] Manual refresh triggered");
// Clear existing highlights
highlights = [];
clearHighlights();
// Reset fetch count to force re-fetch
lastFetchedCount = 0;
fetchHighlights();
}
</script>
{#if loading && visible}
<div
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>
{/if}
{#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"
>
<h4 class="text-sm font-semibold mb-3 text-gray-900 dark:text-gray-100">
Highlights
</h4>
<div class="space-y-2 max-h-96 overflow-y-auto">
{#each Array.from(groupedHighlights.entries()) as [pubkey, authorHighlights]}
{@const isExpanded = expandedAuthors.has(pubkey)}
{@const profile = authorProfiles.get(pubkey)}
{@const displayName = getAuthorDisplayName(profile, pubkey)}
{@const color = colorMap.get(pubkey) || "hsla(60, 70%, 60%, 0.3)"}
{@const sortedHighlights = sortHighlightsByTime(authorHighlights)}
<div class="border-b border-gray-200 dark:border-gray-700 pb-2">
<!-- Author header -->
<button
class="w-full flex items-center gap-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 p-2 rounded transition-colors"
onclick={() => toggleAuthor(pubkey)}
>
<div
class="w-3 h-3 rounded flex-shrink-0"
style="background-color: {color};"
></div>
<span
class="font-medium text-gray-900 dark:text-gray-100 flex-1 text-left truncate"
>
{displayName}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
({authorHighlights.length})
</span>
<svg
class="w-4 h-4 text-gray-500 transition-transform {isExpanded
? 'rotate-90'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
<!-- Expanded highlight list -->
{#if isExpanded}
<div class="mt-2 ml-5 space-y-2">
{#each sortedHighlights as highlight}
{@const truncated = useMockHighlights
? "test data"
: truncateHighlight(highlight.content)}
{@const showCopied = copyFeedback === highlight.id}
<div class="flex items-start gap-2 group">
<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"
onclick={() => scrollToHighlight(highlight)}
title={useMockHighlights
? "Mock highlight"
: highlight.content}
>
{truncated}
</button>
<button
class="flex-shrink-0 p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
onclick={() => copyHighlightNaddr(highlight)}
title="Copy naddr"
>
{#if showCopied}
<svg
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>
{: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"
>
<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>
{/if}
</button>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<style>
:global(mark.highlight) {
transition: background-color 0.2s ease;
}
:global(mark.highlight:hover) {
filter: brightness(1.1);
}
:global(mark.highlight.highlight-flash) {
animation: flash 1.5s ease-in-out;
}
@keyframes -global-flash {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(0.4);
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
}
}
</style>

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

@ -0,0 +1,472 @@ @@ -0,0 +1,472 @@
<script lang="ts">
import { getContext, onMount, onDestroy } from "svelte";
import { Button, Modal, Textarea, P } from "flowbite-svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { userStore } from "$lib/stores/userStore";
import { activeOutboxRelays, activeInboxRelays } from "$lib/ndk";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { ChevronDownOutline, ChevronUpOutline } from "flowbite-svelte-icons";
let {
isActive = false,
publicationEvent,
onHighlightCreated,
}: {
isActive: boolean;
publicationEvent: NDKEvent;
onHighlightCreated?: () => void;
} = $props();
const ndk: NDK = getContext("ndk");
let showConfirmModal = $state(false);
let selectedText = $state("");
let selectionContext = $state("");
let comment = $state("");
let isSubmitting = $state(false);
let feedbackMessage = $state("");
let showFeedback = $state(false);
let showJsonPreview = $state(false);
// Store the selection range and section info for creating highlight
let currentSelection: Selection | null = null;
let selectedSectionAddress = $state<string | undefined>(undefined);
let selectedSectionEventId = $state<string | undefined>(undefined);
// Build preview JSON for the highlight event
let previewJson = $derived.by(() => {
if (!selectedText) return null;
const useAddress = selectedSectionAddress || publicationEvent.tagAddress();
const useEventId = selectedSectionEventId || publicationEvent.id;
const tags: string[][] = [];
if (useAddress) {
tags.push(["a", useAddress, ""]);
} else if (useEventId) {
tags.push(["e", useEventId, ""]);
}
if (selectionContext) {
tags.push(["context", selectionContext]);
}
let authorPubkey = publicationEvent.pubkey;
if (useAddress && useAddress.includes(":")) {
authorPubkey = useAddress.split(":")[1];
}
if (authorPubkey) {
tags.push(["p", authorPubkey, "", "author"]);
}
if (comment.trim()) {
tags.push(["comment", comment.trim()]);
}
return {
kind: 9802,
pubkey: $userStore.pubkey || "<your-pubkey>",
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: selectedText,
id: "<calculated-on-signing>",
sig: "<calculated-on-signing>",
};
});
function handleMouseUp(event: MouseEvent) {
if (!isActive) return;
if (!$userStore.signedIn) {
showFeedbackMessage("Please sign in to create highlights", "error");
return;
}
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
const text = selection.toString().trim();
if (!text || text.length < 3) return;
// Check if the selection is within the publication content
const target = event.target as HTMLElement;
// Find the closest section element with an id (PublicationSection)
// Don't use closest('.publication-leather') as Details also has that class
const publicationSection = target.closest("section[id]") as HTMLElement;
if (!publicationSection) {
console.log("[HighlightSelectionHandler] No section[id] found, aborting");
return;
}
// Get the specific section's event address and ID from data attributes
const sectionAddress = publicationSection.dataset.eventAddress;
const sectionEventId = publicationSection.dataset.eventId;
console.log("[HighlightSelectionHandler] Selection in section:", {
element: publicationSection,
address: sectionAddress,
eventId: sectionEventId,
allDataAttrs: publicationSection.dataset,
sectionId: publicationSection.id,
});
currentSelection = selection;
selectedText = text;
selectedSectionAddress = sectionAddress;
selectedSectionEventId = sectionEventId;
selectionContext = ""; // Will be set below
// Get surrounding context (the paragraph or section)
const parentElement = selection.anchorNode?.parentElement;
if (parentElement) {
const contextElement = parentElement.closest("p, section, div");
if (contextElement) {
selectionContext = contextElement.textContent?.trim() || "";
}
}
showConfirmModal = true;
}
async function createHighlight() {
if (!$userStore.signer || !ndk) {
showFeedbackMessage("Please sign in to create highlights", "error");
return;
}
if (!$userStore.pubkey) {
showFeedbackMessage("User pubkey not available", "error");
return;
}
isSubmitting = true;
try {
const event = new NDKEvent(ndk);
event.kind = 9802;
event.content = selectedText;
event.pubkey = $userStore.pubkey; // Set pubkey from user store
// Use the specific section's address/ID if available, otherwise fall back to publication event
const useAddress =
selectedSectionAddress || publicationEvent.tagAddress();
const useEventId = selectedSectionEventId || publicationEvent.id;
console.log("[HighlightSelectionHandler] Creating highlight with:", {
address: useAddress,
eventId: useEventId,
fallbackUsed: !selectedSectionAddress,
});
const tags: string[][] = [];
// Always prefer addressable events for publications
if (useAddress) {
// Addressable event - use "a" tag
tags.push(["a", useAddress, ""]);
} else if (useEventId) {
// Regular event - use "e" tag
tags.push(["e", useEventId, ""]);
}
// Add context tag
if (selectionContext) {
tags.push(["context", selectionContext]);
}
// Add author tag - extract from address or use publication event
let authorPubkey = publicationEvent.pubkey;
if (useAddress && useAddress.includes(":")) {
// Extract pubkey from address format "kind:pubkey:identifier"
authorPubkey = useAddress.split(":")[1];
}
if (authorPubkey) {
tags.push(["p", authorPubkey, "", "author"]);
}
// Add comment tag if user provided a comment (quote highlight)
if (comment.trim()) {
tags.push(["comment", comment.trim()]);
}
event.tags = tags;
// Sign the event - create plain object to avoid proxy issues
const plainEvent = {
kind: Number(event.kind),
pubkey: String(event.pubkey),
created_at: Number(event.created_at ?? Math.floor(Date.now() / 1000)),
tags: event.tags.map((tag) => tag.map(String)),
content: String(event.content),
};
if (
typeof window !== "undefined" &&
window.nostr &&
window.nostr.signEvent
) {
const signed = await window.nostr.signEvent(plainEvent);
event.sig = signed.sig;
if ("id" in signed) {
event.id = signed.id as string;
}
} else {
await event.sign($userStore.signer);
}
// Build relay list following the same pattern as eventServices
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
// Remove duplicates
const uniqueRelays = Array.from(new Set(relays));
console.log(
"[HighlightSelectionHandler] Publishing to relays:",
uniqueRelays,
);
const signedEvent = {
...plainEvent,
id: event.id,
sig: event.sig,
};
// Publish to relays using WebSocketPool
let publishedCount = 0;
for (const relayUrl of uniqueRelays) {
try {
const ws = await WebSocketPool.instance.acquire(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
WebSocketPool.instance.release(ws);
reject(new Error("Timeout"));
}, 5000);
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
publishedCount++;
console.log(
`[HighlightSelectionHandler] Published to ${relayUrl}`,
);
WebSocketPool.instance.release(ws);
resolve();
} else {
console.warn(
`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`,
);
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
}
};
// Send the event to the relay
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
} catch (e) {
console.error(
`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`,
e,
);
}
}
if (publishedCount === 0) {
throw new Error("Failed to publish to any relays");
}
showFeedbackMessage(
`Highlight created and published to ${publishedCount} relay(s)!`,
"success",
);
// Clear the selection
if (currentSelection) {
currentSelection.removeAllRanges();
}
// Reset state
showConfirmModal = false;
selectedText = "";
selectionContext = "";
comment = "";
selectedSectionAddress = undefined;
selectedSectionEventId = undefined;
showJsonPreview = false;
currentSelection = null;
// Notify parent component
if (onHighlightCreated) {
onHighlightCreated();
}
} catch (error) {
console.error("Failed to create highlight:", error);
showFeedbackMessage(
"Failed to create highlight. Please try again.",
"error",
);
} finally {
isSubmitting = false;
}
}
function cancelHighlight() {
showConfirmModal = false;
selectedText = "";
selectionContext = "";
comment = "";
selectedSectionAddress = undefined;
selectedSectionEventId = undefined;
showJsonPreview = false;
// Clear the selection
if (currentSelection) {
currentSelection.removeAllRanges();
}
currentSelection = null;
}
function showFeedbackMessage(message: string, type: "success" | "error") {
feedbackMessage = message;
showFeedback = true;
setTimeout(() => {
showFeedback = false;
}, 3000);
}
onMount(() => {
// Only listen to mouseup on the document
document.addEventListener("mouseup", handleMouseUp);
});
onDestroy(() => {
document.removeEventListener("mouseup", handleMouseUp);
});
// Add visual indicator when highlight mode is active
$effect(() => {
if (isActive) {
document.body.classList.add("highlight-mode-active");
} else {
document.body.classList.remove("highlight-mode-active");
}
// Cleanup when component unmounts
return () => {
document.body.classList.remove("highlight-mode-active");
};
});
</script>
{#if showConfirmModal}
<Modal
title="Create Highlight"
bind:open={showConfirmModal}
autoclose={false}
size="md"
>
<div class="space-y-4">
<div>
<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"
>
<P class="text-sm italic">"{selectedText}"</P>
</div>
</div>
<div>
<label for="comment" class="block text-sm font-semibold mb-2">
Add a Comment (Optional):
</label>
<Textarea
id="comment"
bind:value={comment}
placeholder="Share your thoughts about this highlight..."
rows={3}
class="w-full"
/>
</div>
<!-- JSON Preview Section -->
{#if showJsonPreview && previewJson}
<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>
<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>
{/if}
<div class="flex justify-between items-center">
<Button
color="light"
size="sm"
onclick={() => (showJsonPreview = !showJsonPreview)}
class="flex items-center gap-1"
>
{#if showJsonPreview}
<ChevronUpOutline class="w-4 h-4" />
{:else}
<ChevronDownOutline class="w-4 h-4" />
{/if}
{showJsonPreview ? "Hide" : "Show"} JSON
</Button>
<div class="flex space-x-2">
<Button
color="alternative"
onclick={cancelHighlight}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
color="primary"
onclick={createHighlight}
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Create Highlight"}
</Button>
</div>
</div>
</div>
</Modal>
{/if}
{#if showFeedback}
<div
class="fixed bottom-4 right-4 z-50 p-4 rounded-lg shadow-lg {feedbackMessage.includes(
'success',
)
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'}"
>
{feedbackMessage}
</div>
{/if}
<style>
:global(body.highlight-mode-active .publication-leather) {
cursor: text;
user-select: text;
}
:global(body.highlight-mode-active .publication-leather *) {
cursor: text;
}
</style>

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

@ -7,7 +7,8 @@ @@ -7,7 +7,8 @@
SidebarGroup,
SidebarWrapper,
Heading,
CloseButton, uiHelpers
CloseButton,
uiHelpers,
} from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte";
import {
@ -24,14 +25,90 @@ @@ -24,14 +25,90 @@
import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte";
import ArticleNav from "$components/util/ArticleNav.svelte";
import { deleteEvent } from "$lib/services/deletion";
import { getNdkContext, activeOutboxRelays } from "$lib/ndk";
import { goto } from "$app/navigation";
import HighlightLayer from "./HighlightLayer.svelte";
import { EyeOutline, EyeSlashOutline } from "flowbite-svelte-icons";
import HighlightButton from "./HighlightButton.svelte";
import HighlightSelectionHandler from "./HighlightSelectionHandler.svelte";
import CommentLayer from "./CommentLayer.svelte";
import CommentButton from "./CommentButton.svelte";
import SectionComments from "./SectionComments.svelte";
import { Textarea, P } from "flowbite-svelte";
import { userStore } from "$lib/stores/userStore";
let { rootAddress, publicationType, indexEvent, publicationTree, toc } =
$props<{
rootAddress: string;
publicationType: string;
indexEvent: NDKEvent;
publicationTree: SveltePublicationTree;
toc: TocType;
}>();
const ndk = getNdkContext();
// Highlight layer state
let highlightsVisible = $state(false);
let highlightLayerRef: any = null;
let publicationContentRef: HTMLElement | null = $state(null);
// Comment layer state
let commentsVisible = $state(true);
let comments = $state<NDKEvent[]>([]);
let commentLayerRef: any = null;
let showArticleCommentUI = $state(false);
let articleCommentContent = $state("");
let isSubmittingArticleComment = $state(false);
let articleCommentError = $state<string | null>(null);
let articleCommentSuccess = $state(false);
// 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
let useMockComments = $state(
import.meta.env.VITE_USE_MOCK_COMMENTS === "true",
);
let useMockHighlights = $state(
import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true",
);
// Log initial state for debugging
console.log("[Publication] Mock data initialized:", {
envVars: {
VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS,
VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS,
},
});
// Derive all event IDs and addresses for highlight fetching
let allEventIds = $derived.by(() => {
const ids = [indexEvent.id];
leaves.forEach((leaf) => {
if (leaf?.id) ids.push(leaf.id);
});
return ids;
});
let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{
rootAddress: string;
publicationType: string;
indexEvent: NDKEvent;
publicationTree: SveltePublicationTree;
toc: TocType;
}>();
let allEventAddresses = $derived.by(() => {
const addresses = [rootAddress];
leaves.forEach((leaf) => {
if (leaf) {
const addr = leaf.tagAddress();
if (addr) addresses.push(addr);
}
});
return addresses;
});
// Filter comments for the root publication (kind 30040)
let articleComments = $derived(
comments.filter((comment) => {
// Check if comment targets the root publication via #a tag
const aTag = comment.tags.find((t) => t[0] === "a");
return aTag && aTag[1] === rootAddress;
}),
);
// #region Loading
let leaves = $state<Array<NDKEvent | null>>([]);
@ -41,6 +118,8 @@ @@ -41,6 +118,8 @@
let activeAddress = $state<string | null>(null);
let loadedAddresses = $state<Set<string>>(new Set());
let hasInitialized = $state(false);
let highlightModeActive = $state(false);
let publicationDeleted = $state(false);
let observer: IntersectionObserver;
@ -49,9 +128,11 @@ @@ -49,9 +128,11 @@
console.warn("[Publication] publicationTree is not available");
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;
try {
@ -84,7 +165,9 @@ @@ -84,7 +165,9 @@
console.error("[Publication] Error loading more content:", error);
} finally {
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}`,
);
}
}
@ -121,12 +204,12 @@ @@ -121,12 +204,12 @@
lastElementRef = null;
loadedAddresses = new Set();
hasInitialized = false;
// Reset the publication tree iterator to prevent duplicate events
if (typeof publicationTree.resetIterator === 'function') {
if (typeof publicationTree.resetIterator === "function") {
publicationTree.resetIterator();
}
// 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
setTimeout(() => {
@ -184,6 +267,129 @@ @@ -184,6 +267,129 @@
return currentBlog && currentBlogEvent && window.innerWidth < 1140;
}
function toggleHighlights() {
highlightsVisible = !highlightsVisible;
}
function toggleComments() {
commentsVisible = !commentsVisible;
}
function handleCommentPosted() {
console.log("[Publication] Comment posted, refreshing comment layer");
// Refresh the comment layer after a short delay to allow relay indexing
setTimeout(() => {
if (commentLayerRef) {
commentLayerRef.refresh();
}
}, 500);
}
async function submitArticleComment() {
if (!articleCommentContent.trim()) {
articleCommentError = "Comment cannot be empty";
return;
}
isSubmittingArticleComment = true;
articleCommentError = null;
articleCommentSuccess = false;
try {
// Parse the root address to get event details
const parts = rootAddress.split(":");
if (parts.length !== 3) {
throw new Error("Invalid address format");
}
const [kindStr, authorPubkey, dTag] = parts;
const kind = parseInt(kindStr);
// Create comment event (kind 1111)
const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent(
ndk,
);
commentEvent.kind = 1111;
commentEvent.content = articleCommentContent;
// Get relay hint
const relayHint = $activeOutboxRelays[0] || "";
// Add tags following NIP-22
commentEvent.tags = [
["A", rootAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", rootAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
];
// Sign and publish
await commentEvent.sign();
await commentEvent.publish();
console.log("[Publication] Article comment published:", commentEvent.id);
articleCommentSuccess = true;
articleCommentContent = "";
// Close UI and refresh after delay
setTimeout(() => {
showArticleCommentUI = false;
articleCommentSuccess = false;
handleCommentPosted();
}, 1500);
} catch (err) {
console.error("[Publication] Error posting article comment:", err);
articleCommentError =
err instanceof Error ? err.message : "Failed to post comment";
} finally {
isSubmittingArticleComment = false;
}
}
/**
* Handles deletion of the entire publication
*/
async function handleDeletePublication() {
const confirmed = confirm(
"Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays.",
);
if (!confirmed) return;
try {
await deleteEvent(
{
eventAddress: indexEvent.tagAddress(),
eventKind: indexEvent.kind,
reason: "User deleted publication",
onSuccess: (deletionEventId) => {
console.log(
"[Publication] Deletion event published:",
deletionEventId,
);
publicationDeleted = true;
// Redirect after 2 seconds
setTimeout(() => {
goto("/publications");
}, 2000);
},
onError: (error) => {
console.error("[Publication] Failed to delete publication:", error);
alert(`Failed to delete publication: ${error}`);
},
},
ndk,
);
} catch (error) {
console.error("[Publication] Error deleting publication:", error);
alert(`Error: ${error}`);
}
}
// #endregion
/**
@ -232,14 +438,19 @@ @@ -232,14 +438,19 @@
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading && !isDone && publicationTree) {
if (
entry.isIntersecting &&
!isLoading &&
!isDone &&
publicationTree
) {
loadMore(1);
}
});
},
{ threshold: 0.5 },
);
// AI-NOTE: Removed duplicate loadMore call
// Initial content loading is handled by the $effect that watches publicationTree
// This prevents duplicate loading when both onMount and $effect trigger
@ -249,62 +460,211 @@ @@ -249,62 +460,211 @@
};
});
// Setup highlight layer container reference
$effect(() => {
if (publicationContentRef && highlightLayerRef) {
highlightLayerRef.setContainer(publicationContentRef);
}
});
// #endregion
</script>
<!-- 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 -->
<ArticleNav
publicationType={publicationType}
rootId={indexEvent.id}
indexEvent={indexEvent}
/>
<ArticleNav {publicationType} rootId={indexEvent.id} {indexEvent} />
<!-- Highlight selection handler -->
<HighlightSelectionHandler
isActive={highlightModeActive}
publicationEvent={indexEvent}
onHighlightCreated={() => {
highlightModeActive = false;
// Refresh highlights after a short delay to allow relay indexing
setTimeout(() => {
if (highlightLayerRef) {
console.log("[Publication] Refreshing highlights after creation");
highlightLayerRef.refresh();
}
}, 500);
}}
/>
<!-- Three-column row -->
<div class="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 $publicationColumnVisibility.toc}
<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"
activeUrl={`#${activeAddress ?? ""}`}
classes={{
div: 'dark:bg-primary-900 bg-primary-50',
active: 'bg-primary-100 dark:bg-primary-800 p-2 rounded-lg',
nonactive: 'bg-primary-50 dark:bg-primary-900',
}}
div: "dark:bg-primary-900 bg-primary-50",
active: "bg-primary-100 dark:bg-primary-800 p-2 rounded-lg",
nonactive: "bg-primary-50 dark:bg-primary-900",
}}
>
<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
{rootAddress}
{toc}
depth={2}
onSectionFocused={(address: string) => publicationTree.setBookmark(address)}
onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)}
onLoadMore={() => {
if (!isLoading && !isDone && publicationTree) {
loadMore(4);
}
}}
if (!isLoading && !isDone && publicationTree) {
loadMore(4);
}
}}
/>
</SidebarWrapper>
</Sidebar>
{/if}
{/if}
</div>
<div class="mt-[70px]">
<!-- Default publications -->
{#if $publicationColumnVisibility.main}
<!-- 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">
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={indexEvent} />
<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) -->
<div class="relative">
<!-- Main header content - centered -->
<div class="max-w-4xl mx-auto px-4">
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details
event={indexEvent}
onDelete={handleDeletePublication}
/>
</div>
{#if publicationDeleted}
<Alert color="yellow" class="mb-4">
<ExclamationCircleOutline class="w-5 h-5 inline mr-2" />
Publication deleted. Redirecting to publications page...
</Alert>
{/if}
</div>
<!-- Mobile article comments - shown below header on smaller screens -->
<div class="xl:hidden mt-4 max-w-4xl mx-auto px-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
</div>
<!-- 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)))]"
>
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
</div>
</div>
<!-- Action buttons row -->
<div class="flex justify-between gap-2 mb-4">
<div class="flex gap-2">
<Button
color="light"
size="sm"
onclick={() => (showArticleCommentUI = !showArticleCommentUI)}
>
{showArticleCommentUI ? "Close Comment" : "Comment On Article"}
</Button>
<HighlightButton bind:isActive={highlightModeActive} />
</div>
<div class="flex gap-2">
<Button color="light" size="sm" onclick={toggleComments}>
{#if commentsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Comments
{:else}
<EyeOutline class="w-4 h-4 mr-2" />
Show Comments
{/if}
</Button>
<Button color="light" size="sm" onclick={toggleHighlights}>
{#if highlightsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Highlights
{:else}
<EyeOutline class="w-4 h-4 mr-2" />
Show Highlights
{/if}
</Button>
</div>
</div>
<!-- Article Comment UI -->
{#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="space-y-3">
<h4 class="font-semibold text-gray-900 dark:text-white">
Comment on Article
</h4>
<Textarea
bind:value={articleCommentContent}
placeholder="Write your comment on this article..."
rows={4}
disabled={isSubmittingArticleComment}
/>
{#if articleCommentError}
<P class="text-red-600 dark:text-red-400 text-sm"
>{articleCommentError}</P
>
{/if}
{#if articleCommentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm"
>Comment posted successfully!</P
>
{/if}
<div class="flex gap-2">
<Button
onclick={submitArticleComment}
disabled={isSubmittingArticleComment}
>
{isSubmittingArticleComment ? "Posting..." : "Post Comment"}
</Button>
<Button
color="light"
onclick={() => (showArticleCommentUI = false)}
>
Cancel
</Button>
</div>
</div>
</div>
{/if}
<!-- Publication sections/cards -->
{#each leaves as leaf, i}
{#if leaf == null}
@ -320,6 +680,8 @@ @@ -320,6 +680,8 @@
{address}
{publicationTree}
{toc}
allComments={comments}
{commentsVisible}
ref={(el) => onPublicationSectionMounted(el, address)}
/>
{/if}
@ -328,7 +690,9 @@ @@ -328,7 +690,9 @@
{#if isLoading}
<Button disabled color="primary">Loading...</Button>
{:else if !isDone}
<Button color="primary" onclick={() => loadMore(1)}>Show More</Button>
<Button color="primary" onclick={() => loadMore(1)}
>Show More</Button
>
{:else}
<p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication.
@ -347,7 +711,7 @@ @@ -347,7 +711,7 @@
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={indexEvent} />
<Details event={indexEvent} onDelete={handleDeletePublication} />
</div>
<!-- List blog excerpts -->
{#each leaves as leaf, i}
@ -373,57 +737,82 @@ @@ -373,57 +737,82 @@
{/if}
</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 -->
{#if $publicationColumnVisibility.discussion}
<Sidebar
class="z-10 ml-4 fixed top-[162px] h-[calc(100vh-165px)] overflow-y-auto"
classes={{
div: 'bg-transparent'
}}
>
<SidebarWrapper>
<SidebarGroup>
<div class="flex justify-between items-baseline">
<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"
outline
onclick={closeDiscussion}
>
<CloseOutline />
</Button>
</div>
<div class="flex flex-col space-y-4">
<!-- TODO
{#if $publicationColumnVisibility.discussion}
<Sidebar
class="z-10 ml-4 fixed top-[162px] h-[calc(100vh-165px)] overflow-y-auto"
classes={{
div: "bg-transparent",
}}
>
<SidebarWrapper>
<SidebarGroup>
<div class="flex justify-between items-baseline">
<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"
outline
onclick={closeDiscussion}
>
<CloseOutline />
</Button>
</div>
<div class="flex flex-col space-y-4">
<!-- TODO
alternative for other publications and
when blog is not opened, but discussion is opened from the list
-->
{#if showBlogHeader() && currentBlog && currentBlogEvent}
<BlogHeader
rootId={currentBlog}
event={currentBlogEvent}
onBlogUpdate={loadBlog}
active={true}
/>
{/if}
<div class="flex flex-col w-full space-y-4">
<Card class="ArticleBox card-leather w-full grid max-w-xl">
<div class="flex flex-col my-2">
<span>Unknown</span>
<span class="text-gray-500">1.1.1970</span>
</div>
<div class="flex flex-col flex-grow space-y-4">
This is a very intelligent comment placeholder that applies to
all the content equally well.
</div>
</Card>
{#if showBlogHeader() && currentBlog && currentBlogEvent}
<BlogHeader
rootId={currentBlog}
event={currentBlogEvent}
onBlogUpdate={loadBlog}
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}
</div>
</div>
</div>
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}
</div>
</div>
</div>
<!-- Highlight Layer Component -->
<HighlightLayer
bind:this={highlightLayerRef}
eventIds={allEventIds}
eventAddresses={allEventAddresses}
bind:visible={highlightsVisible}
{useMockHighlights}
/>
<!-- Comment Layer Component -->
<CommentLayer
bind:this={commentLayerRef}
eventIds={allEventIds}
eventAddresses={allEventAddresses}
bind:comments
{useMockComments}
/>

33
src/lib/components/publications/PublicationHeader.svelte

@ -8,11 +8,42 @@ @@ -8,11 +8,42 @@
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { indexKind } from "$lib/consts";
import { deleteEvent } from "$lib/services/deletion";
const { event } = $props<{ event: NDKEvent }>();
const ndk = getNdkContext();
/**
* Handle deletion of this publication
*/
async function handleDelete() {
const confirmed = confirm(
"Are you sure you want to delete this publication? This action will publish a deletion request to all relays."
);
if (!confirmed) return;
try {
await deleteEvent({
eventAddress: event.tagAddress(),
eventKind: event.kind,
reason: "User deleted publication",
onSuccess: (deletionEventId) => {
console.log("[PublicationHeader] Deletion event published:", deletionEventId);
// Optionally refresh the feed or remove the card
window.location.reload();
},
onError: (error) => {
console.error("[PublicationHeader] Deletion failed:", error);
alert(`Failed to delete publication: ${error}`);
},
}, ndk);
} catch (error) {
console.error("[PublicationHeader] Deletion error:", error);
}
}
function getRelayUrls(): string[] {
return $activeInboxRelays;
}
@ -86,7 +117,7 @@ @@ -86,7 +117,7 @@
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto break-words overflow-hidden">version: {version}</h3>
{/if}
<div class="flex ml-auto">
<CardActions {event} />
<CardActions {event} onDelete={handleDelete} />
</div>
</div>
</div>

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

@ -13,6 +13,9 @@ @@ -13,6 +13,9 @@
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
import NDK from "@nostr-dev-kit/ndk";
import CardActions from "$components/util/CardActions.svelte";
import SectionComments from "./SectionComments.svelte";
import { deleteEvent } from "$lib/services/deletion";
let {
address,
@ -21,6 +24,8 @@ @@ -21,6 +24,8 @@
publicationTree,
toc,
ref,
allComments = [],
commentsVisible = true,
}: {
address: string;
rootAddress: string;
@ -28,15 +33,40 @@ @@ -28,15 +33,40 @@
publicationTree: SveltePublicationTree;
toc: TocType;
ref: (ref: HTMLElement) => void;
allComments?: NDKEvent[];
commentsVisible?: boolean;
} = $props();
const asciidoctor: Asciidoctor = getContext("asciidoctor");
const ndk: NDK = getContext("ndk");
// Filter comments for this section
let sectionComments = $derived(
allComments.filter((comment) => {
// Check if comment targets this section via #a tag
const aTag = comment.tags.find((t) => t[0] === "a");
return aTag && aTag[1] === address;
}),
);
let leafEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(address),
);
let leafEventId = $state<string>("");
$effect(() => {
leafEvent.then((e) => {
if (e?.id) {
leafEventId = e.id;
console.log(
`[PublicationSection] Set leafEventId for ${address}:`,
e.id,
);
}
});
});
let rootEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(rootAddress),
);
@ -56,7 +86,7 @@ @@ -56,7 +86,7 @@
let leafContent: Promise<string | Document> = $derived.by(async () => {
const event = await leafEvent;
const content = event?.content ?? "";
// AI-NOTE: Kind 30023 events contain Markdown content, not AsciiDoc
// Use parseAdvancedmarkup for 30023 events, Asciidoctor for 30041/30818 events
if (event?.kind === 30023) {
@ -64,7 +94,10 @@ @@ -64,7 +94,10 @@
} else {
// For 30041 and 30818 events, use Asciidoctor (AsciiDoc)
const converted = asciidoctor.convert(content);
const processed = await postProcessAdvancedAsciidoctorHtml(converted.toString(), ndk);
const processed = await postProcessAdvancedAsciidoctorHtml(
converted.toString(),
ndk,
);
return processed;
}
});
@ -134,37 +167,146 @@ @@ -134,37 +167,146 @@
let sectionRef: HTMLElement;
/**
* Handle deletion of this section
*/
async function handleDelete() {
const event = await leafEvent;
if (!event) return;
const confirmed = confirm(
"Are you sure you want to delete this section? This action will publish a deletion request to all relays.",
);
if (!confirmed) return;
try {
await deleteEvent(
{
eventAddress: address,
eventKind: event.kind,
reason: "User deleted section",
onSuccess: (deletionEventId) => {
console.log(
"[PublicationSection] Deletion event published:",
deletionEventId,
);
// Refresh the page to reflect the deletion
window.location.reload();
},
onError: (error) => {
console.error("[PublicationSection] Deletion failed:", error);
alert(`Failed to delete section: ${error}`);
},
},
ndk,
);
} catch (error) {
console.error("[PublicationSection] Deletion error:", error);
}
}
$effect(() => {
if (!sectionRef) {
return;
}
ref(sectionRef);
// Log data attributes for debugging
console.log(`[PublicationSection] Section mounted:`, {
address,
leafEventId,
dataAddress: sectionRef.dataset.eventAddress,
dataEventId: sectionRef.dataset.eventId,
});
});
</script>
<section
id={address}
bind:this={sectionRef}
class="publication-leather content-visibility-auto"
>
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
<TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
depth,
)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)}
<!-- Wrapper for positioning context -->
<div class="relative w-full">
<section
id={address}
bind:this={sectionRef}
class="publication-leather content-visibility-auto section-with-comment"
data-event-address={address}
data-event-id={leafEventId}
>
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
<TextPlaceholder size="2xl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
<!-- Main content area - centered -->
<div class="section-content relative max-w-4xl mx-auto px-4">
<!-- Mobile menu - shown only on smaller screens -->
<div class="xl:hidden absolute top-2 right-2 z-10">
{#await leafEvent then event}
{#if event}
<CardActions
{event}
sectionAddress={address}
onDelete={handleDelete}
/>
{/if}
{/await}
</div>
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
depth,
)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)}
{/if}
{@render contentParagraph(
leafContent.toString(),
publicationType ?? "article",
false,
)}
</div>
<!-- Mobile comments - shown below content on smaller screens -->
<div class="xl:hidden mt-8 max-w-4xl mx-auto px-4">
<SectionComments
sectionAddress={address}
comments={sectionComments}
visible={commentsVisible}
/>
</div>
{/await}
</section>
<!-- Right sidebar elements - positioned very close to content, responsive width -->
{#await leafEvent then event}
{#if event}
<!-- Three-dot menu - positioned at top-center on XL+ screens -->
<div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-[20%] z-10"
>
<CardActions {event} sectionAddress={address} onDelete={handleDelete} />
</div>
{/if}
{@render contentParagraph(
leafContent.toString(),
publicationType ?? "article",
false,
)}
{/await}
</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)))]"
>
<SectionComments
sectionAddress={address}
comments={sectionComments}
visible={commentsVisible}
/>
</div>
</div>
<style>
.section-with-comment {
position: relative;
}
.section-with-comment:hover :global(.single-line-button) {
opacity: 1 !important;
}
</style>

928
src/lib/components/publications/SectionComments.svelte

@ -0,0 +1,928 @@ @@ -0,0 +1,928 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { getNdkContext } from "$lib/ndk";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import { ChevronDownOutline, ChevronRightOutline, DotsVerticalOutline, TrashBinOutline, ClipboardCleanOutline, EyeOutline } from "flowbite-svelte-icons";
import { nip19 } from "nostr-tools";
import { Button, Popover, Modal, Textarea, P } from "flowbite-svelte";
import { deleteEvent, canDeleteEvent } from "$lib/services/deletion";
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
let {
sectionAddress,
comments = [],
visible = true,
}: {
sectionAddress: string;
comments: NDKEvent[];
visible?: boolean;
} = $props();
const ndk = getNdkContext();
// State management
let profiles = $state(new Map<string, any>());
let expandedThreads = $state(new Set<string>());
let detailsModalOpen = $state<string | null>(null);
let deletingComments = $state(new Set<string>());
let replyingTo = $state<string | null>(null);
let replyContent = $state("");
let isSubmittingReply = $state(false);
let replyError = $state<string | null>(null);
let replySuccess = $state<string | null>(null);
// Subscribe to userStore
let user = $derived($userStore);
/**
* Parse comment threading structure
* Root comments have no 'e' tag with 'reply' marker
*/
function buildThreadStructure(allComments: NDKEvent[]) {
const rootComments: NDKEvent[] = [];
const repliesByParent = new Map<string, NDKEvent[]>();
for (const comment of allComments) {
// Check if this is a reply by looking for 'e' tags with 'reply' marker
const replyTag = comment.tags.find(t => t[0] === 'e' && t[3] === 'reply');
if (replyTag) {
const parentId = replyTag[1];
if (!repliesByParent.has(parentId)) {
repliesByParent.set(parentId, []);
}
repliesByParent.get(parentId)!.push(comment);
} else {
// This is a root comment (no reply tag)
rootComments.push(comment);
}
}
return { rootComments, repliesByParent };
}
let threadStructure = $derived(buildThreadStructure(comments));
/**
* Count replies for a comment thread
*/
function countReplies(commentId: string, repliesMap: Map<string, NDKEvent[]>): number {
const directReplies = repliesMap.get(commentId) || [];
let count = directReplies.length;
// Recursively count nested replies
for (const reply of directReplies) {
count += countReplies(reply.id, repliesMap);
}
return count;
}
/**
* Get display name for a pubkey
*/
function getDisplayName(pubkey: string): string {
const profile = profiles.get(pubkey);
if (profile) {
return profile.displayName || profile.name || profile.pubkey || pubkey;
}
const npub = toNpub(pubkey) || pubkey;
return `${npub.slice(0, 12)}...`;
}
/**
* Format timestamp
*/
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else if (diffDays < 7) {
return `${diffDays}d ago`;
} else {
return date.toLocaleDateString();
}
}
/**
* Fetch profile for a pubkey
*/
async function fetchProfile(pubkey: string) {
if (profiles.has(pubkey)) return;
try {
const npub = toNpub(pubkey);
if (!npub) {
setFallbackProfile(pubkey);
return;
}
const profile = await getUserMetadata(npub, ndk, true);
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, profile);
profiles = newProfiles;
} catch (err) {
setFallbackProfile(pubkey);
}
}
function setFallbackProfile(pubkey: string) {
const npub = toNpub(pubkey) || pubkey;
const truncated = `${npub.slice(0, 12)}...`;
const fallbackProfile = {
name: truncated,
displayName: truncated,
picture: null
};
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, fallbackProfile);
profiles = newProfiles;
}
/**
* Toggle thread expansion
*/
function toggleThread(commentId: string) {
const newExpanded = new Set(expandedThreads);
if (newExpanded.has(commentId)) {
newExpanded.delete(commentId);
} else {
newExpanded.add(commentId);
}
expandedThreads = newExpanded;
}
/**
* Render nested replies recursively
*/
function renderReplies(parentId: string, repliesMap: Map<string, NDKEvent[]>, level: number = 0) {
const replies = repliesMap.get(parentId) || [];
return replies;
}
/**
* Copy nevent to clipboard
*/
async function copyNevent(event: NDKEvent) {
try {
const nevent = nip19.neventEncode({
id: event.id,
author: event.pubkey,
kind: event.kind,
});
await navigator.clipboard.writeText(nevent);
console.log('Copied nevent to clipboard:', nevent);
} catch (err) {
console.error('Failed to copy nevent:', err);
}
}
/**
* Navigate to event details page
*/
function viewEventDetails(comment: NDKEvent) {
const nevent = nip19.neventEncode({
id: comment.id,
author: comment.pubkey,
kind: comment.kind,
});
goto(`/events?id=${encodeURIComponent(nevent)}`);
}
/**
* Check if user can delete a comment
*/
function canDelete(comment: NDKEvent): boolean {
return canDeleteEvent(comment, ndk);
}
/**
* Submit a reply to a comment
*/
async function submitReply(parentComment: NDKEvent) {
if (!replyContent.trim()) {
replyError = "Reply cannot be empty";
return;
}
if (!user.signedIn || !user.signer) {
replyError = "You must be signed in to reply";
return;
}
isSubmittingReply = true;
replyError = null;
replySuccess = null;
try {
const { NDKEvent: NDKEventClass } = await import("@nostr-dev-kit/ndk");
const { activeOutboxRelays } = await import("$lib/ndk");
// Get relay hint
const relays = activeOutboxRelays;
let relayHint = "";
relays.subscribe((r) => { relayHint = r[0] || ""; })();
// Create reply event (kind 1111)
const replyEvent = new NDKEventClass(ndk);
replyEvent.kind = 1111;
replyEvent.content = replyContent;
// Parse section address to get root event details
const rootParts = sectionAddress.split(":");
if (rootParts.length !== 3) {
throw new Error("Invalid section address format");
}
const [rootKindStr, rootAuthorPubkey, rootDTag] = rootParts;
const rootKind = parseInt(rootKindStr);
// NIP-22 reply tags structure:
// - Root tags (A, K, P) point to the section/article
// - Parent tags (a, k, p) point to the parent comment
// - Add 'e' tag with 'reply' marker for the parent comment
replyEvent.tags = [
// Root scope - uppercase tags (point to section)
["A", sectionAddress, relayHint, rootAuthorPubkey],
["K", rootKind.toString()],
["P", rootAuthorPubkey, relayHint],
// Parent scope - lowercase tags (point to parent comment)
["a", `1111:${parentComment.pubkey}:`, relayHint],
["k", "1111"],
["p", parentComment.pubkey, relayHint],
// Reply marker
["e", parentComment.id, relayHint, "reply"],
];
console.log("[SectionComments] Creating reply with tags:", replyEvent.tags);
// Sign and publish
await replyEvent.sign();
await replyEvent.publish();
console.log("[SectionComments] Reply published:", replyEvent.id);
replySuccess = parentComment.id;
replyContent = "";
// Close reply UI after a delay
setTimeout(() => {
replyingTo = null;
replySuccess = null;
}, 2000);
} catch (err) {
console.error("[SectionComments] Error submitting reply:", err);
replyError = err instanceof Error ? err.message : "Failed to submit reply";
} finally {
isSubmittingReply = false;
}
}
/**
* Delete a comment
*/
async function handleDeleteComment(comment: NDKEvent) {
if (!canDelete(comment)) return;
if (!confirm('Are you sure you want to delete this comment?')) {
return;
}
const newDeleting = new Set(deletingComments);
newDeleting.add(comment.id);
deletingComments = newDeleting;
try {
const result = await deleteEvent({
eventId: comment.id,
eventKind: comment.kind,
reason: 'User deleted comment',
}, ndk);
if (result.success) {
console.log('[SectionComments] Comment deleted successfully');
// Note: The comment will still show in the UI until the page is refreshed
// or the parent component refetches comments
} else {
alert(`Failed to delete comment: ${result.error}`);
}
} catch (err) {
console.error('[SectionComments] Error deleting comment:', err);
alert('Failed to delete comment');
} finally {
const newDeleting = new Set(deletingComments);
newDeleting.delete(comment.id);
deletingComments = newDeleting;
}
}
/**
* Pre-fetch profiles for all comment authors
*/
$effect(() => {
const uniquePubkeys = new Set(comments.map(c => c.pubkey));
for (const pubkey of uniquePubkeys) {
fetchProfile(pubkey);
}
});
</script>
{#if visible && threadStructure.rootComments.length > 0}
<div class="space-y-1">
{#each threadStructure.rootComments as rootComment (rootComment.id)}
{@const replyCount = countReplies(rootComment.id, threadStructure.repliesByParent)}
{@const isExpanded = expandedThreads.has(rootComment.id)}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm">
<!-- Multi-row collapsed view -->
{#if !isExpanded}
<div class="flex gap-2 px-3 py-2 text-sm">
<button
class="flex-shrink-0 mt-1"
onclick={() => toggleThread(rootComment.id)}
aria-label="Expand comment"
>
<ChevronRightOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
<div class="flex-1 min-w-0">
<p class="line-clamp-3 text-gray-700 dark:text-gray-300 mb-1">
{rootComment.content}
</p>
<div class="flex items-center gap-2 text-xs">
<button
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
onclick={(e) => { e.stopPropagation(); copyNevent(rootComment); }}
title="Copy nevent to clipboard"
>
{getDisplayName(rootComment.pubkey)}
</button>
{#if replyCount > 0}
<span class="text-gray-400 dark:text-gray-500"></span>
<span class="text-blue-600 dark:text-blue-400">
{replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</span>
{/if}
<span class="text-gray-400 dark:text-gray-500"></span>
<button
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
onclick={(e) => {
e.stopPropagation();
replyingTo = replyingTo === rootComment.id ? null : rootComment.id;
replyError = null;
replySuccess = null;
// Auto-expand when replying from collapsed view
if (!expandedThreads.has(rootComment.id)) {
toggleThread(rootComment.id);
}
}}
>
Reply
</button>
</div>
</div>
<!-- Actions menu in collapsed view -->
<div class="flex-shrink-0 mt-1">
<button
id="comment-actions-collapsed-{rootComment.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Comment actions"
onclick={(e) => { e.stopPropagation(); }}
>
<DotsVerticalOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#comment-actions-collapsed-{rootComment.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
detailsModalOpen = rootComment.id;
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(rootComment);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(rootComment)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(rootComment);
}}
disabled={deletingComments.has(rootComment.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(rootComment.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div>
{:else}
<!-- Expanded view -->
<div class="flex flex-col">
<!-- Expanded header row -->
<div class="flex items-center gap-2 px-3 py-2 text-sm border-b border-gray-200 dark:border-gray-700">
<button
class="flex-shrink-0"
onclick={() => toggleThread(rootComment.id)}
aria-label="Collapse comment"
>
<ChevronDownOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
<button
class="flex-shrink-0 font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
onclick={(e) => { e.stopPropagation(); copyNevent(rootComment); }}
title="Copy nevent to clipboard"
>
{getDisplayName(rootComment.pubkey)}
</button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(rootComment.created_at || 0)}
</span>
{#if replyCount > 0}
<span class="text-xs text-blue-600 dark:text-blue-400">
{replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</span>
{/if}
<!-- Actions menu -->
<div class="ml-auto">
<button
id="comment-actions-{rootComment.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Comment actions"
>
<DotsVerticalOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#comment-actions-{rootComment.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
detailsModalOpen = rootComment.id;
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(rootComment);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(rootComment)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(rootComment);
}}
disabled={deletingComments.has(rootComment.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(rootComment.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div>
<!-- Full content -->
<div class="px-3 py-3">
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none mb-3">
{@render basicMarkup(rootComment.content)}
</div>
<!-- Reply button -->
<div class="mb-3">
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = replyingTo === rootComment.id ? null : rootComment.id;
replyError = null;
replySuccess = null;
}}
>
{replyingTo === rootComment.id ? 'Cancel Reply' : 'Reply'}
</Button>
</div>
<!-- Reply UI -->
{#if replyingTo === rootComment.id}
<div class="mb-4 border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-700">
<Textarea
bind:value={replyContent}
placeholder="Write your reply..."
rows={3}
disabled={isSubmittingReply}
class="mb-2"
/>
{#if replyError}
<P class="text-red-600 dark:text-red-400 text-sm mb-2">{replyError}</P>
{/if}
{#if replySuccess === rootComment.id}
<P class="text-green-600 dark:text-green-400 text-sm mb-2">Reply posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={() => submitReply(rootComment)}
disabled={isSubmittingReply || !replyContent.trim()}
>
{isSubmittingReply ? 'Posting...' : 'Post Reply'}
</Button>
<Button
size="sm"
color="light"
onclick={() => {
replyingTo = null;
replyContent = "";
replyError = null;
}}
>
Cancel
</Button>
</div>
</div>
{/if}
<!-- Replies -->
{#if replyCount > 0}
<div class="pl-4 border-l-2 border-gray-200 dark:border-gray-600 space-y-2">
{#each renderReplies(rootComment.id, threadStructure.repliesByParent) as reply (reply.id)}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded p-3">
<div class="flex items-center gap-2 mb-2">
<button
class="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
onclick={(e) => { e.stopPropagation(); copyNevent(reply); }}
title="Copy nevent to clipboard"
>
{getDisplayName(reply.pubkey)}
</button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(reply.created_at || 0)}
</span>
<!-- Three-dot menu for reply -->
<div class="ml-auto flex items-center gap-2">
<button
id="reply-actions-{reply.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Reply actions"
onclick={(e) => { e.stopPropagation(); }}
>
<DotsVerticalOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#reply-actions-{reply.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
detailsModalOpen = reply.id;
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(reply);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(reply)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(reply);
}}
disabled={deletingComments.has(reply.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(reply.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div>
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none mb-2">
{@render basicMarkup(reply.content)}
</div>
<!-- Reply button for first-level reply -->
<div class="mb-2">
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = replyingTo === reply.id ? null : reply.id;
replyError = null;
replySuccess = null;
}}
>
{replyingTo === reply.id ? 'Cancel Reply' : 'Reply'}
</Button>
</div>
<!-- Reply UI for first-level reply -->
{#if replyingTo === reply.id}
<div class="mb-3 border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-800">
<Textarea
bind:value={replyContent}
placeholder="Write your reply..."
rows={3}
disabled={isSubmittingReply}
class="mb-2"
/>
{#if replyError}
<P class="text-red-600 dark:text-red-400 text-sm mb-2">{replyError}</P>
{/if}
{#if replySuccess === reply.id}
<P class="text-green-600 dark:text-green-400 text-sm mb-2">Reply posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={() => submitReply(reply)}
disabled={isSubmittingReply || !replyContent.trim()}
>
{isSubmittingReply ? 'Posting...' : 'Post Reply'}
</Button>
<Button
size="sm"
color="light"
onclick={() => {
replyingTo = null;
replyContent = "";
replyError = null;
}}
>
Cancel
</Button>
</div>
</div>
{/if}
<!-- Nested replies (one level deep) -->
{#each renderReplies(reply.id, threadStructure.repliesByParent) as nestedReply (nestedReply.id)}
<div class="ml-4 mt-2 bg-gray-100 dark:bg-gray-600/30 rounded p-2">
<div class="flex items-center gap-2 mb-1">
<button
class="text-xs font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
onclick={(e) => { e.stopPropagation(); copyNevent(nestedReply); }}
title="Copy nevent to clipboard"
>
{getDisplayName(nestedReply.pubkey)}
</button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(nestedReply.created_at || 0)}
</span>
<!-- Three-dot menu for nested reply -->
<div class="ml-auto flex items-center gap-2">
<button
id="nested-reply-actions-{nestedReply.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Nested reply actions"
onclick={(e) => { e.stopPropagation(); }}
>
<DotsVerticalOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#nested-reply-actions-{nestedReply.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
detailsModalOpen = nestedReply.id;
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(nestedReply);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(nestedReply)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(nestedReply);
}}
disabled={deletingComments.has(nestedReply.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(nestedReply.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div>
<div class="text-xs text-gray-700 dark:text-gray-300 mb-2">
{@render basicMarkup(nestedReply.content)}
</div>
<!-- Reply button for nested reply -->
<div class="mb-1">
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = replyingTo === nestedReply.id ? null : nestedReply.id;
replyError = null;
replySuccess = null;
}}
>
{replyingTo === nestedReply.id ? 'Cancel Reply' : 'Reply'}
</Button>
</div>
<!-- Reply UI for nested reply -->
{#if replyingTo === nestedReply.id}
<div class="mb-2 border border-gray-300 dark:border-gray-600 rounded-lg p-2 bg-white dark:bg-gray-800">
<Textarea
bind:value={replyContent}
placeholder="Write your reply..."
rows={2}
disabled={isSubmittingReply}
class="mb-2 text-xs"
/>
{#if replyError}
<P class="text-red-600 dark:text-red-400 text-xs mb-1">{replyError}</P>
{/if}
{#if replySuccess === nestedReply.id}
<P class="text-green-600 dark:text-green-400 text-xs mb-1">Reply posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button
size="xs"
onclick={() => submitReply(nestedReply)}
disabled={isSubmittingReply || !replyContent.trim()}
>
{isSubmittingReply ? 'Posting...' : 'Post Reply'}
</Button>
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = null;
replyContent = "";
replyError = null;
}}
>
Cancel
</Button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Details Modal -->
{#if detailsModalOpen}
{@const comment = comments.find(c => c.id === detailsModalOpen)}
{#if comment}
<Modal
title="Comment Details"
open={true}
autoclose
outsideclose
size="lg"
class="modal-leather"
onclose={() => detailsModalOpen = null}
>
<div class="space-y-4">
<div class="flex justify-center pb-2">
<Button
color="primary"
onclick={() => {
viewEventDetails(comment);
}}
>
View on Event Page
</Button>
</div>
<div>
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto max-h-[500px] overflow-y-auto">{JSON.stringify({
id: comment.id,
pubkey: comment.pubkey,
created_at: comment.created_at,
kind: comment.kind,
tags: comment.tags,
content: comment.content,
sig: comment.sig
}, null, 2)}</pre>
</div>
</div>
</Modal>
{/if}
{/if}
<style>
/* Ensure proper text wrapping */
.prose {
word-wrap: break-word;
overflow-wrap: break-word;
}
</style>

2
src/lib/components/util/ArticleNav.svelte

@ -211,7 +211,7 @@ @@ -211,7 +211,7 @@
<span class="hidden sm:inline">Close</span>
</Button>
{/if}
{#if publicationType !== "blog" && !$publicationColumnVisibility.discussion}
{#if !$publicationColumnVisibility.discussion}
<Button
class="btn-leather !hidden sm:flex !w-auto"
outline={true}

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

@ -1,27 +1,41 @@ @@ -1,27 +1,41 @@
<script lang="ts">
import { Button, Modal, Popover } from "flowbite-svelte";
import { Button, Modal, Popover, Textarea, P } from "flowbite-svelte";
import {
DotsVerticalOutline,
EyeOutline,
ClipboardCleanOutline,
TrashBinOutline,
MessageDotsOutline,
ChevronDownOutline,
ChevronUpOutline,
} from "flowbite-svelte-icons";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, getNdkContext } from "$lib/ndk";
import {
activeInboxRelays,
activeOutboxRelays,
getNdkContext,
} from "$lib/ndk";
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import LazyImage from "$components/util/LazyImage.svelte";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
// Component props
let { event } = $props<{ event: NDKEvent }>();
let { event, onDelete, sectionAddress } = $props<{
event: NDKEvent;
onDelete?: () => void;
sectionAddress?: string; // If provided, shows "Comment on section" option
}>();
const ndk = getNdkContext();
// Subscribe to userStore
let user = $state($userStore);
userStore.subscribe((val) => (user = val));
// Subscribe to userStore (Svelte 5 runes pattern)
let user = $derived($userStore);
// Derive metadata from event
let title = $derived(
@ -62,6 +76,72 @@ @@ -62,6 +76,72 @@
let detailsModalOpen: boolean = $state(false);
let isOpen: boolean = $state(false);
// Comment modal state
let commentModalOpen: boolean = $state(false);
let commentContent: string = $state("");
let isSubmittingComment: boolean = $state(false);
let commentError: string | null = $state(null);
let commentSuccess: boolean = $state(false);
let showJsonPreview: boolean = $state(false);
// Build preview JSON for the comment event
let previewJson = $derived.by(() => {
if (!commentContent.trim() || !sectionAddress) return null;
const eventDetails = parseAddress(sectionAddress);
if (!eventDetails) return null;
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
const relayHint = $activeOutboxRelays[0] || "";
return {
kind: 1111,
pubkey: user.pubkey || "<your-pubkey>",
created_at: Math.floor(Date.now() / 1000),
tags: [
["A", sectionAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", sectionAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
],
content: commentContent,
id: "<calculated-on-signing>",
sig: "<calculated-on-signing>",
};
});
// Check if user can delete this event (must be the author)
let canDelete = $derived.by(() => {
const result =
user.signedIn && user.pubkey === event.pubkey && onDelete !== undefined;
console.log("[CardActions] canDelete check:", {
userSignedIn: user.signedIn,
userPubkey: user.pubkey,
eventPubkey: event.pubkey,
onDeleteProvided: onDelete !== undefined,
canDelete: result,
});
return result;
});
// Determine delete button text based on event kind
let deleteButtonText = $derived.by(() => {
if (event.kind === 30040) {
// Kind 30040 is an index/publication
return "Delete publication";
} else if (event.kind === 30041) {
// Kind 30041 is a section
return "Delete section";
} else if (event.kind === 30023) {
// Kind 30023 is a long-form article
return "Delete article";
} else {
return "Delete";
}
});
/**
* Selects the appropriate relay set based on user state and feed type
* - Uses active inbox relays from the new relay management system
@ -123,6 +203,208 @@ @@ -123,6 +203,208 @@
const nevent = getIdentifier("nevent");
goto(`/events?id=${encodeURIComponent(nevent)}`);
}
/**
* Opens the comment modal
*/
function openCommentModal() {
if (!user.signedIn) {
commentError = "You must be signed in to comment";
setTimeout(() => {
commentError = null;
}, 3000);
return;
}
closePopover();
commentModalOpen = true;
commentContent = "";
commentError = null;
commentSuccess = false;
showJsonPreview = false;
}
/**
* Parse address to get event details
*/
function parseAddress(
address: string,
): { kind: number; pubkey: string; dTag: string } | null {
const parts = address.split(":");
if (parts.length !== 3) {
console.error("[CardActions] Invalid address format:", address);
return null;
}
const [kindStr, pubkey, dTag] = parts;
const kind = parseInt(kindStr);
if (isNaN(kind)) {
console.error("[CardActions] Invalid kind in address:", kindStr);
return null;
}
return { kind, pubkey, dTag };
}
/**
* Submit comment
*/
async function submitComment() {
if (!sectionAddress || !user.pubkey) {
commentError = "Invalid state - cannot submit comment";
return;
}
const eventDetails = parseAddress(sectionAddress);
if (!eventDetails) {
commentError = "Invalid event address";
return;
}
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
isSubmittingComment = true;
commentError = null;
try {
// Get relay hint
const relayHint = $activeOutboxRelays[0] || "";
// Fetch target event to get its ID
let eventId = "";
try {
const targetEvent = await ndk.fetchEvent({
kinds: [kind],
authors: [authorPubkey],
"#d": [dTag],
});
if (targetEvent) {
eventId = targetEvent.id;
}
} catch (err) {
console.warn("[CardActions] Could not fetch target event ID:", err);
}
// Create comment event (NIP-22)
const commentEvent = new NDKEventClass(ndk);
commentEvent.kind = 1111;
commentEvent.content = commentContent;
commentEvent.pubkey = user.pubkey;
commentEvent.tags = [
["A", sectionAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", sectionAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
];
if (eventId) {
commentEvent.tags.push(["e", eventId, relayHint]);
}
// Sign event
const plainEvent = {
kind: Number(commentEvent.kind),
pubkey: String(commentEvent.pubkey),
created_at: Number(
commentEvent.created_at ?? Math.floor(Date.now() / 1000),
),
tags: commentEvent.tags.map((tag) => tag.map(String)),
content: String(commentEvent.content),
};
if (
typeof window !== "undefined" &&
window.nostr &&
window.nostr.signEvent
) {
const signed = await window.nostr.signEvent(plainEvent);
commentEvent.sig = signed.sig;
if ("id" in signed) {
commentEvent.id = signed.id as string;
}
} else if (user.signer) {
await commentEvent.sign(user.signer);
}
// Publish to relays
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
const signedEvent = {
...plainEvent,
id: commentEvent.id,
sig: commentEvent.sig,
};
let publishedCount = 0;
for (const relayUrl of uniqueRelays) {
try {
const ws = await WebSocketPool.instance.acquire(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
WebSocketPool.instance.release(ws);
reject(new Error("Timeout"));
}, 5000);
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
publishedCount++;
WebSocketPool.instance.release(ws);
resolve();
} else {
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
}
};
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
} catch (e) {
console.error(`[CardActions] Failed to publish to ${relayUrl}:`, e);
}
}
if (publishedCount === 0) {
throw new Error("Failed to publish to any relays");
}
commentSuccess = true;
setTimeout(() => {
commentModalOpen = false;
commentSuccess = false;
commentContent = "";
showJsonPreview = false;
}, 2000);
} catch (err) {
console.error("[CardActions] Error submitting comment:", err);
commentError =
err instanceof Error ? err.message : "Failed to post comment";
} finally {
isSubmittingComment = false;
}
}
/**
* Cancel comment
*/
function cancelComment() {
commentModalOpen = false;
commentContent = "";
commentError = null;
commentSuccess = false;
showJsonPreview = false;
}
</script>
<div
@ -135,7 +417,7 @@ @@ -135,7 +417,7 @@
type="button"
id="dots-{event.id}"
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"
>
<DotsVerticalOutline class="h-6 w-6" />
@ -153,6 +435,16 @@ @@ -153,6 +435,16 @@
<div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col text-nowrap">
<ul class="space-y-2">
{#if sectionAddress}
<li>
<button
class="btn-leather w-full text-left"
onclick={openCommentModal}
>
<MessageDotsOutline class="inline mr-2" /> Comment on section
</button>
</li>
{/if}
<li>
<button
class="btn-leather w-full text-left"
@ -175,6 +467,20 @@ @@ -175,6 +467,20 @@
icon={ClipboardCleanOutline}
/>
</li>
{#if canDelete}
<li>
<button
class="btn-leather w-full text-left text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
onclick={() => {
closePopover();
onDelete?.();
}}
>
<TrashBinOutline class="inline mr-2" />
{deleteButtonText}
</button>
</li>
{/if}
</ul>
</div>
</div>
@ -265,4 +571,99 @@ @@ -265,4 +571,99 @@
</button>
</div>
</Modal>
<!-- Comment Modal -->
{#if sectionAddress}
<Modal
class="modal-leather"
title="Add Comment"
bind:open={commentModalOpen}
autoclose={false}
outsideclose={true}
size="md"
>
<div class="space-y-4">
{#if user.profile}
<div
class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700"
>
{#if user.profile.picture}
<img
src={user.profile.picture}
alt={user.profile.displayName || user.profile.name || "User"}
class="w-10 h-10 rounded-full object-cover"
/>
{/if}
<span class="font-medium text-gray-900 dark:text-gray-100">
{user.profile.displayName || user.profile.name || "Anonymous"}
</span>
</div>
{/if}
<Textarea
bind:value={commentContent}
placeholder="Write your comment here..."
rows={6}
disabled={isSubmittingComment}
class="w-full"
/>
{#if commentError}
<P class="text-red-600 dark:text-red-400 text-sm">{commentError}</P>
{/if}
{#if commentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm"
>Comment posted successfully!</P
>
{/if}
<!-- JSON Preview Section -->
{#if showJsonPreview && previewJson}
<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>
<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>
{/if}
<div class="flex justify-between items-center gap-3 pt-2">
<Button
color="light"
size="sm"
onclick={() => (showJsonPreview = !showJsonPreview)}
class="flex items-center gap-1"
>
{#if showJsonPreview}
<ChevronUpOutline class="w-4 h-4" />
{:else}
<ChevronDownOutline class="w-4 h-4" />
{/if}
{showJsonPreview ? "Hide" : "Show"} JSON
</Button>
<div class="flex gap-3">
<Button
color="alternative"
onclick={cancelComment}
disabled={isSubmittingComment}
>
Cancel
</Button>
<Button
color="primary"
onclick={submitComment}
disabled={isSubmittingComment || !commentContent.trim()}
>
{isSubmittingComment ? "Posting..." : "Post Comment"}
</Button>
</div>
</div>
</div>
</Modal>
{/if}
</div>

11
src/lib/components/util/Details.svelte

@ -14,7 +14,11 @@ @@ -14,7 +14,11 @@
// isModal
// - don't show interactions in modal view
// - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props();
let { event, isModal = false, onDelete } = $props<{
event: any;
isModal?: boolean;
onDelete?: () => void;
}>();
let title: string = $derived(getMatchingTags(event, "title")[0]?.[1]);
let author: string = $derived(
@ -43,6 +47,7 @@ @@ -43,6 +47,7 @@
);
let rootId: string = $derived(getMatchingTags(event, "d")[0]?.[1] ?? null);
let kind = $derived(event.kind);
let address: string = $derived(`${kind}:${event.pubkey}:${rootId}`);
let authorTag: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "",
@ -67,7 +72,9 @@ @@ -67,7 +72,9 @@
<P class="text-base font-normal"
>{@render userBadge(event.pubkey, undefined, ndk)}</P
>
<CardActions {event}></CardActions>
<div class="flex flex-row gap-2 items-center">
<CardActions {event} {onDelete}></CardActions>
</div>
</div>
{/if}
<div

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

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

119
src/lib/services/deletion.ts

@ -0,0 +1,119 @@ @@ -0,0 +1,119 @@
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
export interface DeletionOptions {
eventId?: string;
eventAddress?: string;
eventKind?: number;
reason?: string;
onSuccess?: (deletionEventId: string) => void;
onError?: (error: string) => void;
}
export interface DeletionResult {
success: boolean;
deletionEventId?: string;
error?: string;
}
/**
* Deletes a Nostr event by publishing a kind 5 deletion request (NIP-09)
* @param options - Deletion options
* @param ndk - NDK instance
* @returns Promise resolving to deletion result
*/
export async function deleteEvent(
options: DeletionOptions,
ndk: NDK,
): Promise<DeletionResult> {
const { eventId, eventAddress, eventKind, reason = "", onSuccess, onError } =
options;
if (!eventId && !eventAddress) {
const error = "Either eventId or eventAddress must be provided";
onError?.(error);
return { success: false, error };
}
if (!ndk?.activeUser) {
const error = "Please log in first";
onError?.(error);
return { success: false, error };
}
try {
// Create deletion event (kind 5)
const deletionEvent = new NDKEvent(ndk);
deletionEvent.kind = 5;
deletionEvent.created_at = Math.floor(Date.now() / 1000);
deletionEvent.content = reason;
deletionEvent.pubkey = ndk.activeUser.pubkey;
// Build tags based on what we have
const tags: string[][] = [];
if (eventId) {
// Add 'e' tag for event ID
tags.push(["e", eventId]);
}
if (eventAddress) {
// Add 'a' tag for replaceable event address
tags.push(["a", eventAddress]);
}
if (eventKind) {
// Add 'k' tag for event kind (recommended by NIP-09)
tags.push(["k", eventKind.toString()]);
}
deletionEvent.tags = tags;
// Sign the deletion event
await deletionEvent.sign();
// Publish to all available relays
const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
if (allRelayUrls.length === 0) {
throw new Error("No relays available in NDK pool");
}
const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk);
const publishedToRelays = await deletionEvent.publish(relaySet);
if (publishedToRelays.size > 0) {
console.log(
`[deletion.ts] Published deletion request to ${publishedToRelays.size} relays`,
);
const result = { success: true, deletionEventId: deletionEvent.id };
onSuccess?.(deletionEvent.id);
return result;
} else {
throw new Error("Failed to publish deletion request to any relays");
}
} catch (error) {
const errorMessage = error instanceof Error
? error.message
: "Unknown error";
console.error(`[deletion.ts] Error deleting event: ${errorMessage}`);
onError?.(errorMessage);
return { success: false, error: errorMessage };
}
}
/**
* Checks if the current user has permission to delete an event
* @param event - The event to check
* @param ndk - NDK instance
* @returns True if the user can delete the event
*/
export function canDeleteEvent(event: NDKEvent | null, ndk: NDK): boolean {
if (!event || !ndk?.activeUser) {
return false;
}
// User can only delete their own events
return event.pubkey === ndk.activeUser.pubkey;
}

29
src/lib/services/publisher.ts

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

263
src/lib/utils/asciidoc_ast_parser.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
/**
* AST-based AsciiDoc parsing using Asciidoctor's native document structure
*
*
* This replaces the manual regex parsing in asciidoc_metadata.ts with proper
* AST traversal, leveraging Asciidoctor's built-in parsing capabilities.
*/
@ -30,27 +30,33 @@ export interface ASTParsedDocument { @@ -30,27 +30,33 @@ export interface ASTParsedDocument {
/**
* 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 document = asciidoctor.load(content, { standalone: false }) as Document;
return {
title: document.getTitle() || '',
content: document.getContent() || '',
title: document.getTitle() || "",
content: document.getContent() || "",
attributes: document.getAttributes(),
sections: extractSectionsFromAST(document, parseLevel)
sections: extractSectionsFromAST(document, parseLevel),
};
}
/**
* 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();
// Collect all sections at all levels up to parseLevel
const allSections: ASTSection[] = [];
function collectSections(sections: any[]) {
for (const section of sections) {
const asciidoctorLevel = section.getLevel();
@ -58,17 +64,17 @@ function extractSectionsFromAST(document: Document, parseLevel: number): ASTSect @@ -58,17 +64,17 @@ function extractSectionsFromAST(document: Document, parseLevel: number): ASTSect
// Asciidoctor: == is level 1, === is level 2, etc.
// Our app: == is level 2, === is level 3, etc.
const appLevel = asciidoctorLevel + 1;
if (appLevel <= parseLevel) {
allSections.push({
title: section.getTitle() || '',
content: section.getContent() || '',
title: section.getTitle() || "",
content: section.getContent() || "",
level: appLevel,
attributes: section.getAttributes() || {},
subsections: []
subsections: [],
});
}
// Recursively collect subsections
const subsections = section.getSections?.() || [];
if (subsections.length > 0) {
@ -76,9 +82,9 @@ function extractSectionsFromAST(document: Document, parseLevel: number): ASTSect @@ -76,9 +82,9 @@ function extractSectionsFromAST(document: Document, parseLevel: number): ASTSect
}
}
}
collectSections(directSections);
return allSections;
}
@ -87,15 +93,15 @@ function extractSectionsFromAST(document: Document, parseLevel: number): ASTSect @@ -87,15 +93,15 @@ function extractSectionsFromAST(document: Document, parseLevel: number): ASTSect
*/
function extractSubsections(section: any, parseLevel: number): ASTSection[] {
const subsections = section.getSections?.() || [];
return subsections
.filter((sub: any) => (sub.getLevel() + 1) <= parseLevel)
.map((sub: any) => ({
title: sub.getTitle() || '',
content: sub.getContent() || '',
title: sub.getTitle() || "",
content: sub.getContent() || "",
level: sub.getLevel() + 1, // Convert to app level
attributes: sub.getAttributes() || {},
subsections: extractSubsections(sub, parseLevel)
subsections: extractSubsections(sub, parseLevel),
}));
}
@ -104,83 +110,113 @@ function extractSubsections(section: any, parseLevel: number): ASTSection[] { @@ -104,83 +110,113 @@ function extractSubsections(section: any, parseLevel: number): ASTSection[] {
* This integrates with Michael's PublicationTree architecture
*/
export async function createPublicationTreeFromAST(
content: string,
ndk: NDK,
parseLevel: number = 2
content: string,
ndk: NDK,
parseLevel: number = 2,
): Promise<PublicationTree> {
const parsed = parseAsciiDocAST(content, parseLevel);
// Create root 30040 index event from document metadata
const rootEvent = createIndexEventFromAST(parsed, ndk);
const tree = new PublicationTree(rootEvent, ndk);
// Add sections as 30041 events
// Add sections as 30041 events with proper namespacing
for (const section of parsed.sections) {
const contentEvent = createContentEventFromSection(section, ndk);
const contentEvent = createContentEventFromSection(
section,
ndk,
parsed.title,
);
await tree.addEvent(contentEvent, rootEvent);
}
return tree;
}
/**
* 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);
event.kind = 30040;
event.created_at = Math.floor(Date.now() / 1000);
// Generate d-tag from title
const dTag = generateDTag(parsed.title);
const [mTag, MTag] = getMimeTags(30040);
const tags: string[][] = [
["d", dTag],
mTag,
MTag,
["title", parsed.title]
["title", parsed.title],
];
// Add document attributes as tags
addAttributesAsTags(tags, parsed.attributes);
// Generate publication abbreviation for namespacing sections
const pubAbbrev = generateTitleAbbreviation(parsed.title);
// Add a-tags for each section (30041 content events)
parsed.sections.forEach(section => {
// Using new format: kind:pubkey:{abbv}-{section-d-tag}
parsed.sections.forEach((section) => {
const sectionDTag = generateDTag(section.title);
tags.push(["a", `30041:${ndk.activeUser?.pubkey || 'pubkey'}:${sectionDTag}`]);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push([
"a",
`30041:${ndk.activeUser?.pubkey || "pubkey"}:${namespacedDTag}`,
]);
});
event.tags = tags;
event.content = parsed.content;
return event;
}
/**
* Create a 30041 content event from an AST section
* Note: This function needs the publication title for proper namespacing
* but the current implementation doesn't have access to it.
* Consider using createPublicationTreeFromAST instead which handles this correctly.
*/
function createContentEventFromSection(section: ASTSection, ndk: NDK): NDKEvent {
function createContentEventFromSection(
section: ASTSection,
ndk: NDK,
publicationTitle?: string,
): NDKEvent {
const event = new NDKEvent(ndk);
event.kind = 30041;
event.created_at = Math.floor(Date.now() / 1000);
const dTag = generateDTag(section.title);
// Generate namespaced d-tag if publication title is provided
const sectionDTag = generateDTag(section.title);
let dTag = sectionDTag;
if (publicationTitle) {
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
dTag = `${pubAbbrev}-${sectionDTag}`;
}
const [mTag, MTag] = getMimeTags(30041);
const tags: string[][] = [
["d", dTag],
mTag,
MTag,
["title", section.title]
["title", section.title],
];
// Add section attributes as tags
addAttributesAsTags(tags, section.attributes);
event.tags = tags;
event.content = section.content;
return event;
}
@ -195,32 +231,92 @@ function generateDTag(title: string): string { @@ -195,32 +231,92 @@ function generateDTag(title: string): string {
.replace(/^-|-$/g, "");
}
/**
* Generate title abbreviation from first letters of each word
* Used for namespacing section a-tags
* @param title - The publication title
* @returns Abbreviation string (e.g., "My Test Article" "mta")
*/
function generateTitleAbbreviation(title: string): string {
if (!title || !title.trim()) {
return "u"; // "untitled"
}
// Split on non-alphanumeric characters and filter out empty strings
const words = title
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 0);
if (words.length === 0) {
return "u";
}
// Take first letter of each word and join
return words
.map((word) => word.charAt(0).toLowerCase())
.join("");
}
/**
* 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 = [
'attribute-undefined', 'attribute-missing', 'appendix-caption', 'appendix-refsig',
'caution-caption', 'chapter-refsig', 'example-caption', 'figure-caption',
'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'
"attribute-undefined",
"attribute-missing",
"appendix-caption",
"appendix-refsig",
"caution-caption",
"chapter-refsig",
"example-caption",
"figure-caption",
"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
if (attributes.author) tags.push(["author", attributes.author]);
if (attributes.version) tags.push(["version", attributes.version]);
if (attributes.description) tags.push(["summary", attributes.description]);
if (attributes.tags) {
attributes.tags.split(',').forEach(tag =>
tags.push(["t", tag.trim()])
);
attributes.tags.split(",").forEach((tag) => tags.push(["t", tag.trim()]));
}
// Add custom attributes (non-system)
Object.entries(attributes).forEach(([key, value]) => {
if (!systemAttributes.includes(key) && value) {
@ -233,14 +329,21 @@ function addAttributesAsTags(tags: string[][], attributes: Record<string, string @@ -233,14 +329,21 @@ function addAttributesAsTags(tags: string[][], attributes: Record<string, string
* Tree processor extension for Asciidoctor
* This can be registered to automatically populate PublicationTree during parsing
*/
export function createPublicationTreeProcessor(ndk: NDK, parseLevel: number = 2) {
return function(extensions: any) {
extensions.treeProcessor(function(this: any) {
export function createPublicationTreeProcessor(
ndk: NDK,
parseLevel: number = 2,
) {
return function (extensions: any) {
extensions.treeProcessor(function (this: any) {
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
const publicationTree = createPublicationTreeFromDocument(document, ndk, parseLevel);
document.setAttribute('publicationTree', publicationTree);
const publicationTree = createPublicationTreeFromDocument(
document,
ndk,
parseLevel,
);
document.setAttribute("publicationTree", publicationTree);
});
});
};
@ -250,24 +353,28 @@ export function createPublicationTreeProcessor(ndk: NDK, parseLevel: number = 2) @@ -250,24 +353,28 @@ export function createPublicationTreeProcessor(ndk: NDK, parseLevel: number = 2)
* Helper function to create PublicationTree from Asciidoctor Document
*/
async function createPublicationTreeFromDocument(
document: Document,
ndk: NDK,
parseLevel: number
document: Document,
ndk: NDK,
parseLevel: number,
): Promise<PublicationTree> {
const parsed: ASTParsedDocument = {
title: document.getTitle() || '',
content: document.getContent() || '',
title: document.getTitle() || "",
content: document.getContent() || "",
attributes: document.getAttributes(),
sections: extractSectionsFromAST(document, parseLevel)
sections: extractSectionsFromAST(document, parseLevel),
};
const rootEvent = createIndexEventFromAST(parsed, ndk);
const tree = new PublicationTree(rootEvent, ndk);
for (const section of parsed.sections) {
const contentEvent = createContentEventFromSection(section, ndk);
const contentEvent = createContentEventFromSection(
section,
ndk,
parsed.title,
);
await tree.addEvent(contentEvent, rootEvent);
}
return tree;
}
}

13
src/lib/utils/asciidoc_parser.ts

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

84
src/lib/utils/asciidoc_publication_parser.ts

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

4
src/lib/utils/event_input_utils.ts

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

70
src/lib/utils/fetch_publication_highlights.ts

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
import type NDK from "@nostr-dev-kit/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
/**
* Fetches all highlight events (kind 9802) for sections referenced in a publication event (kind 30040).
*
* @param publicationEvent - The kind 30040 event containing "a" tags referencing sections (kind 30041)
* @param ndk - The NDK instance to use for fetching events
* @returns A Map of section addresses to arrays of highlight events
*
* @example
* ```typescript
* const highlights = await fetchHighlightsForPublication(publicationEvent, ndk);
* // Returns: Map {
* // "30041:pubkey:section-id" => [highlightEvent1, highlightEvent2],
* // "30041:pubkey:another-section" => [highlightEvent3]
* // }
* ```
*/
export async function fetchHighlightsForPublication(
publicationEvent: NDKEvent,
ndk: NDK,
): Promise<Map<string, NDKEvent[]>> {
// Extract all "a" tags from the publication event
const aTags = publicationEvent.getMatchingTags("a");
// Filter for only 30041 (section) references
const sectionAddresses: string[] = [];
aTags.forEach((tag: string[]) => {
if (tag[1]) {
const parts = tag[1].split(":");
// Check if it's a 30041 kind reference and has the correct format
if (parts.length >= 3 && parts[0] === "30041") {
// Handle d-tags with colons by joining everything after the pubkey
const sectionAddress = tag[1];
sectionAddresses.push(sectionAddress);
}
}
});
// If no section references found, return empty map
if (sectionAddresses.length === 0) {
return new Map();
}
// Fetch all highlight events (kind 9802) that reference these sections
const highlightEvents = await ndk.fetchEvents({
kinds: [9802],
"#a": sectionAddresses,
});
// Group highlights by section address
const highlightsBySection = new Map<string, NDKEvent[]>();
highlightEvents.forEach((highlight: NDKEvent) => {
const highlightATags = highlight.getMatchingTags("a");
highlightATags.forEach((tag: string[]) => {
const sectionAddress = tag[1];
// Only include if this section is in our original list
if (sectionAddress && sectionAddresses.includes(sectionAddress)) {
if (!highlightsBySection.has(sectionAddress)) {
highlightsBySection.set(sectionAddress, []);
}
highlightsBySection.get(sectionAddress)!.push(highlight);
}
});
});
return highlightsBySection;
}

241
src/lib/utils/highlightPositioning.ts

@ -0,0 +1,241 @@ @@ -0,0 +1,241 @@
/**
* Utility for position-based text highlighting in the DOM
*
* Highlights text by character offset rather than text search,
* making highlights resilient to minor content changes.
*/
/**
* Get all text nodes within an element, excluding script/style tags
*/
function getTextNodes(element: HTMLElement): Text[] {
const textNodes: Text[] = [];
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip text in script/style tags
const parent = node.parentElement;
if (
parent && (parent.tagName === "SCRIPT" || parent.tagName === "STYLE")
) {
return NodeFilter.FILTER_REJECT;
}
// Skip empty text nodes
if (!node.textContent || node.textContent.trim().length === 0) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
},
);
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
return textNodes;
}
/**
* Calculate the total text length from text nodes
*/
function getTotalTextLength(textNodes: Text[]): number {
return textNodes.reduce(
(total, node) => total + (node.textContent?.length || 0),
0,
);
}
/**
* Find text node and local offset for a given global character position
*/
function findNodeAtOffset(
textNodes: Text[],
globalOffset: number,
): { node: Text; localOffset: number } | null {
let currentOffset = 0;
for (const node of textNodes) {
const nodeLength = node.textContent?.length || 0;
if (globalOffset < currentOffset + nodeLength) {
return {
node,
localOffset: globalOffset - currentOffset,
};
}
currentOffset += nodeLength;
}
return null;
}
/**
* Highlight text by character offset within a container element
*
* @param container - The root element to search within
* @param startOffset - Character position where highlight starts (0-indexed)
* @param endOffset - Character position where highlight ends (exclusive)
* @param color - Background color for the highlight
* @returns true if highlight was applied, false otherwise
*/
export function highlightByOffset(
container: HTMLElement,
startOffset: number,
endOffset: number,
color: string,
): boolean {
console.log(
`[highlightByOffset] Attempting to highlight chars ${startOffset}-${endOffset}`,
);
// Validate inputs
if (startOffset < 0 || endOffset <= startOffset) {
console.warn(
`[highlightByOffset] Invalid offsets: ${startOffset}-${endOffset}`,
);
return false;
}
// Get all text nodes
const textNodes = getTextNodes(container);
if (textNodes.length === 0) {
console.warn(`[highlightByOffset] No text nodes found in container`);
return false;
}
const totalLength = getTotalTextLength(textNodes);
console.log(
`[highlightByOffset] Total text length: ${totalLength}, nodes: ${textNodes.length}`,
);
// Validate offsets are within bounds
if (startOffset >= totalLength) {
console.warn(
`[highlightByOffset] Start offset ${startOffset} exceeds total length ${totalLength}`,
);
return false;
}
// Adjust end offset if it exceeds content
const adjustedEndOffset = Math.min(endOffset, totalLength);
// Find the nodes containing start and end positions
const startPos = findNodeAtOffset(textNodes, startOffset);
const endPos = findNodeAtOffset(textNodes, adjustedEndOffset);
if (!startPos || !endPos) {
console.warn(`[highlightByOffset] Could not locate positions in DOM`);
return false;
}
console.log(`[highlightByOffset] Found positions:`, {
startNode: startPos.node.textContent?.substring(0, 20),
startLocal: startPos.localOffset,
endNode: endPos.node.textContent?.substring(0, 20),
endLocal: endPos.localOffset,
});
// Create the highlight mark element
const createHighlightMark = (text: string): HTMLElement => {
const mark = document.createElement("mark");
mark.className = "highlight";
mark.style.backgroundColor = color;
mark.style.borderRadius = "2px";
mark.style.padding = "2px 0";
mark.textContent = text;
return mark;
};
try {
// Case 1: Highlight is within a single text node
if (startPos.node === endPos.node) {
const text = startPos.node.textContent || "";
const before = text.substring(0, startPos.localOffset);
const highlighted = text.substring(
startPos.localOffset,
endPos.localOffset,
);
const after = text.substring(endPos.localOffset);
const parent = startPos.node.parentNode;
if (!parent) return false;
// Create fragment with before + highlight + after
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(createHighlightMark(highlighted));
if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, startPos.node);
console.log(
`[highlightByOffset] Applied single-node highlight: "${highlighted}"`,
);
return true;
}
// Case 2: Highlight spans multiple text nodes
let currentNode: Text | null = startPos.node;
let isFirstNode = true;
let nodeIndex = textNodes.indexOf(currentNode);
while (currentNode && nodeIndex <= textNodes.indexOf(endPos.node)) {
const parent = currentNode.parentNode;
if (!parent) break;
const text = currentNode.textContent || "";
let fragment = document.createDocumentFragment();
if (isFirstNode) {
// First node: split at start offset
const before = text.substring(0, startPos.localOffset);
const highlighted = text.substring(startPos.localOffset);
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(createHighlightMark(highlighted));
isFirstNode = false;
} else if (currentNode === endPos.node) {
// Last node: split at end offset
const highlighted = text.substring(0, endPos.localOffset);
const after = text.substring(endPos.localOffset);
fragment.appendChild(createHighlightMark(highlighted));
if (after) fragment.appendChild(document.createTextNode(after));
} else {
// Middle node: highlight entirely
fragment.appendChild(createHighlightMark(text));
}
parent.replaceChild(fragment, currentNode);
nodeIndex++;
currentNode = textNodes[nodeIndex] || null;
}
console.log(`[highlightByOffset] Applied multi-node highlight`);
return true;
} catch (err) {
console.error(`[highlightByOffset] Error applying highlight:`, err);
return false;
}
}
/**
* Get the plain text content of an element (without HTML tags)
* Useful for debugging and validation
*/
export function getPlainText(element: HTMLElement): string {
const textNodes = getTextNodes(element);
return textNodes.map((node) => node.textContent).join("");
}
/**
* Get the character count of visible text in an element
*/
export function getTextLength(element: HTMLElement): number {
return getPlainText(element).length;
}

167
src/lib/utils/highlightUtils.ts

@ -0,0 +1,167 @@ @@ -0,0 +1,167 @@
/**
* Utility functions for highlight management
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
export interface GroupedHighlight {
pubkey: string;
highlights: NDKEvent[];
count: number;
}
/**
* Groups highlights by author pubkey
* Returns a Map with pubkey as key and array of highlights as value
*/
export function groupHighlightsByAuthor(
highlights: NDKEvent[],
): Map<string, NDKEvent[]> {
const grouped = new Map<string, NDKEvent[]>();
for (const highlight of highlights) {
const pubkey = highlight.pubkey;
const existing = grouped.get(pubkey) || [];
existing.push(highlight);
grouped.set(pubkey, existing);
}
return grouped;
}
/**
* Truncates highlight text to specified length, breaking at word boundaries
* @param text - The text to truncate
* @param maxLength - Maximum length (default: 50)
* @returns Truncated text with ellipsis if needed
*/
export function truncateHighlight(
text: string,
maxLength: number = 50,
): string {
if (!text || text.length <= maxLength) {
return text;
}
// Find the last space before maxLength
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(" ");
// If there's a space, break there; otherwise use the full maxLength
if (lastSpace > 0) {
return truncated.slice(0, lastSpace) + "...";
}
return truncated + "...";
}
/**
* Encodes a highlight event as an naddr with relay hints
* @param event - The highlight event (kind 9802)
* @param relays - Array of relay URLs to include as hints
* @returns naddr string
*/
export function encodeHighlightNaddr(
event: NDKEvent,
relays: string[] = [],
): string {
try {
// 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
// But per NIP-19, naddr is for addressable events (with d-tag)
// For non-addressable events like kind 9802, we should use nevent
const nevent = nip19.neventEncode({
id: event.id,
relays: relays.length > 0 ? relays : undefined,
author: event.pubkey,
kind: event.kind,
});
return nevent;
} catch (error) {
console.error("Error encoding highlight naddr:", error);
// Fallback to just the event id
return event.id;
}
}
/**
* Creates a shortened npub for display
* @param pubkey - The hex pubkey
* @param length - Number of characters to show from start (default: 8)
* @returns Shortened npub like "npub1abc...xyz"
*/
export function shortenNpub(pubkey: string, length: number = 8): string {
try {
const npub = nip19.npubEncode(pubkey);
// npub format: "npub1" + bech32 encoded data
// Show first part and last part
if (npub.length <= length + 10) {
return npub;
}
const start = npub.slice(0, length + 5); // "npub1" + first chars
const end = npub.slice(-4); // last chars
return `${start}...${end}`;
} catch (error) {
console.error("Error creating shortened npub:", error);
// Fallback to shortened hex
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
}
/**
* Extracts relay URLs from a highlight event's tags or metadata
* @param event - The highlight event
* @returns Array of relay URLs
*/
export function getRelaysFromHighlight(event: NDKEvent): string[] {
const relays: string[] = [];
// Check for relay hints in tags (e.g., ["a", "30041:pubkey:id", "relay-url"])
for (const tag of event.tags) {
if ((tag[0] === "a" || tag[0] === "e" || tag[0] === "p") && tag[2]) {
relays.push(tag[2]);
}
}
// Also include relay from the event if available
if (event.relay?.url) {
relays.push(event.relay.url);
}
// Deduplicate
return [...new Set(relays)];
}
/**
* Sorts highlights within a group by creation time (newest first)
* @param highlights - Array of highlight events
* @returns Sorted array
*/
export function sortHighlightsByTime(highlights: NDKEvent[]): NDKEvent[] {
return [...highlights].sort((a, b) => {
const timeA = a.created_at || 0;
const timeB = b.created_at || 0;
return timeB - timeA; // Newest first
});
}
/**
* Gets the display name for a highlight author
* Priority: displayName > name > shortened npub
*/
export function getAuthorDisplayName(
profile:
| { name?: string; displayName?: string; display_name?: string }
| null,
pubkey: string,
): string {
if (profile) {
return profile.displayName || profile.display_name || profile.name ||
shortenNpub(pubkey);
}
return shortenNpub(pubkey);
}

179
src/lib/utils/mockCommentData.ts

@ -0,0 +1,179 @@ @@ -0,0 +1,179 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
/**
* Generate mock comment data for testing comment UI and threading
* Creates realistic thread structures with root comments and nested replies
*/
const loremIpsumComments = [
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.",
"Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores.",
"Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.",
"At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti.",
"Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio.",
"Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae.",
];
const loremIpsumReplies = [
"Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur.",
"Vel illum qui dolorem eum fugiat quo voluptas nulla pariatur.",
"Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat.",
"Omnis voluptas assumenda est, omnis dolor repellendus.",
"Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur.",
"Facere possimus, omnis voluptas assumenda est.",
"Sed ut perspiciatis unde omnis iste natus error.",
"Accusantium doloremque laudantium, totam rem aperiam.",
];
const mockPubkeys = [
"a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
"b2c3d4e5f6789012345678901234567890123456789012345678901234abcde",
"c3d4e5f6789012345678901234567890123456789012345678901234abcdef0",
"d4e5f6789012345678901234567890123456789012345678901234abcdef01",
];
/**
* Create a mock NDKEvent that looks like a real comment
*/
function createMockComment(
id: string,
content: string,
pubkey: string,
targetAddress: string,
createdAt: number,
replyToId?: string,
replyToAuthor?: string,
): any {
const tags: string[][] = [
["A", targetAddress, "wss://relay.damus.io", pubkey],
["K", "30041"],
["P", pubkey, "wss://relay.damus.io"],
["a", targetAddress, "wss://relay.damus.io"],
["k", "30041"],
["p", pubkey, "wss://relay.damus.io"],
];
if (replyToId && replyToAuthor) {
tags.push(["e", replyToId, "wss://relay.damus.io", "reply"]);
tags.push(["p", replyToAuthor, "wss://relay.damus.io"]);
}
// Return a plain object that matches NDKEvent structure
return {
id,
kind: 1111,
pubkey,
created_at: createdAt,
content,
tags,
sig: "mock-signature-" + id,
};
}
/**
* Generate mock comment thread structure
* @param sectionAddress - The section address to attach comments to
* @param numRootComments - Number of root comments to generate (default: 3)
* @param numRepliesPerThread - Number of replies per thread (default: 2)
* @returns Array of mock comment objects
*/
export function generateMockComments(
sectionAddress: string,
numRootComments: number = 3,
numRepliesPerThread: number = 2,
): any[] {
const comments: any[] = [];
const now = Math.floor(Date.now() / 1000);
let commentIndex = 0;
// Generate root comments
for (let i = 0; i < numRootComments; i++) {
const rootId = `mock-root-${i}-${Date.now()}`;
const rootPubkey = mockPubkeys[i % mockPubkeys.length];
const rootContent = loremIpsumComments[i % loremIpsumComments.length];
const rootCreatedAt = now - (numRootComments - i) * 3600; // Stagger by hours
const rootComment = createMockComment(
rootId,
rootContent,
rootPubkey,
sectionAddress,
rootCreatedAt,
);
comments.push(rootComment);
// Generate replies to this root comment
for (let j = 0; j < numRepliesPerThread; j++) {
const replyId = `mock-reply-${i}-${j}-${Date.now()}`;
const replyPubkey = mockPubkeys[(i + j + 1) % mockPubkeys.length];
const replyContent =
loremIpsumReplies[commentIndex % loremIpsumReplies.length];
const replyCreatedAt = rootCreatedAt + (j + 1) * 1800; // 30 min after each
const reply = createMockComment(
replyId,
replyContent,
replyPubkey,
sectionAddress,
replyCreatedAt,
rootId,
rootPubkey,
);
comments.push(reply);
// Optionally add a nested reply (reply to reply)
if (j === 0 && i < 2) {
const nestedId = `mock-nested-${i}-${j}-${Date.now()}`;
const nestedPubkey = mockPubkeys[(i + j + 2) % mockPubkeys.length];
const nestedContent =
loremIpsumReplies[(commentIndex + 1) % loremIpsumReplies.length];
const nestedCreatedAt = replyCreatedAt + 900; // 15 min after reply
const nested = createMockComment(
nestedId,
nestedContent,
nestedPubkey,
sectionAddress,
nestedCreatedAt,
replyId,
replyPubkey,
);
comments.push(nested);
}
commentIndex++;
}
}
return comments;
}
/**
* Generate mock comments for multiple sections
* @param sectionAddresses - Array of section addresses
* @returns Array of all mock comments across all sections
*/
export function generateMockCommentsForSections(
sectionAddresses: string[],
): any[] {
const allComments: any[] = [];
sectionAddresses.forEach((address, index) => {
// Vary the number of comments per section
const numRoot = 2 + (index % 3); // 2-4 root comments
const numReplies = 1 + (index % 2); // 1-2 replies per thread
const sectionComments = generateMockComments(address, numRoot, numReplies);
allComments.push(...sectionComments);
});
return allComments;
}

200
src/lib/utils/mockHighlightData.ts

@ -0,0 +1,200 @@ @@ -0,0 +1,200 @@
/**
* Generate mock highlight data (kind 9802) for testing highlight UI
* Creates realistic highlight events with context and optional annotations
*/
// Sample highlighted text snippets (things users might actually highlight)
const highlightedTexts = [
"Knowledge that tries to stay put inevitably becomes ossified",
"The attempt to hold knowledge still is like trying to photograph a river",
"Understanding emerges not from rigid frameworks but from fluid engagement",
"Traditional institutions struggle with the natural promiscuity of ideas",
"Thinking without permission means refusing predetermined categories",
"The most valuable insights often come from unexpected juxtapositions",
"Anarchistic knowledge rejects the notion of authorized interpreters",
"Every act of reading is an act of creative interpretation",
"Hierarchy in knowledge systems serves power, not understanding",
"The boundary between creator and consumer is an artificial construction",
];
// Context strings (surrounding text to help locate the highlight)
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.",
"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.",
"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.",
"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.",
"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.",
"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)
const annotations = [
"This perfectly captures the institutional problem",
"Key insight - worth revisiting",
"Reminds me of Deleuze on rhizomatic structures",
"Fundamental critique of academic gatekeeping",
"The core argument in one sentence",
null, // Some highlights have no annotation
"Important for understanding the broader thesis",
null,
"Connects to earlier discussion on page 12",
null,
];
// Mock pubkeys - MUST be exactly 64 hex characters
const mockPubkeys = [
"a1b2c3d4e5f67890123456789012345678901234567890123456789012345678",
"b2c3d4e5f67890123456789012345678901234567890123456789012345678ab",
"c3d4e5f67890123456789012345678901234567890123456789012345678abcd",
"d4e5f67890123456789012345678901234567890123456789012345678abcdef",
"e5f6789012345678901234567890123456789012345678901234567890abcdef",
];
/**
* Create a mock highlight event (kind 9802)
*
* AI-NOTE: Unlike comments (kind 1111), highlights have:
* - content field = the highlighted text itself (NOT a user comment)
* - ["context", ...] tag with surrounding text to help locate the highlight
* - Optional ["comment", ...] tag for user annotations
* - Optional ["offset", start, end] tag for position-based highlighting
* - Single lowercase ["a", targetAddress] tag (not uppercase/lowercase pairs)
*/
function createMockHighlight(
id: string,
highlightedText: string,
context: string,
targetAddress: string,
pubkey: string,
createdAt: number,
authorPubkey: string,
annotation?: string | null,
offsetStart?: number,
offsetEnd?: number,
): any {
const tags: string[][] = [
["a", targetAddress, "wss://relay.damus.io"],
["context", context],
["p", authorPubkey, "wss://relay.damus.io", "author"],
];
// Add optional annotation
if (annotation) {
tags.push(["comment", annotation]);
}
// Add optional offset for position-based highlighting
if (offsetStart !== undefined && offsetEnd !== undefined) {
tags.push(["offset", offsetStart.toString(), offsetEnd.toString()]);
}
return {
id,
kind: 9802,
pubkey,
created_at: createdAt,
content: highlightedText, // The highlighted text itself
tags,
sig: "mock-signature-" + id,
};
}
/**
* Generate mock highlights for a section
* @param sectionAddress - The section address to attach highlights to
* @param authorPubkey - The author's pubkey (for the "p" tag)
* @param numHighlights - Number of highlights to generate (default: 3-5 random)
* @returns Array of mock highlight objects
*/
export function generateMockHighlights(
sectionAddress: string,
authorPubkey: string,
numHighlights: number = Math.floor(Math.random() * 2) + 2, // 2-3 highlights
): any[] {
const highlights: any[] = [];
const now = Math.floor(Date.now() / 1000);
// Generate position-based highlights at the beginning of each section
// For test mode, we use simple placeholder text and rely on offset-based highlighting
// The offset tags will highlight the ACTUAL text at those positions in the section
for (let i = 0; i < numHighlights; i++) {
const id = `mock-highlight-${i}-${Date.now()}-${
Math.random().toString(36).substring(7)
}`;
const highlighterPubkey = mockPubkeys[i % mockPubkeys.length];
const annotation = annotations[i % annotations.length];
const createdAt = now - (numHighlights - i) * 7200; // Stagger by 2 hours
// Create sequential highlights at the beginning of the section
// Each highlight is exactly 100 characters
const highlightLength = 100;
const offsetStart = i * 120; // Space between highlights (120 chars apart)
const offsetEnd = offsetStart + highlightLength;
// Use placeholder text - the actual highlighted text will be determined by the offsets
const placeholderText = `Test highlight ${i + 1}`;
const placeholderContext = `This is test highlight ${
i + 1
} at position ${offsetStart}-${offsetEnd}`;
const highlight = createMockHighlight(
id,
placeholderText,
placeholderContext,
sectionAddress,
highlighterPubkey,
createdAt,
authorPubkey,
annotation,
offsetStart,
offsetEnd,
);
highlights.push(highlight);
}
return highlights;
}
/**
* Generate mock highlights for multiple sections
* @param sectionAddresses - Array of section addresses
* @param authorPubkey - The publication author's pubkey
* @returns Array of all mock highlights across all sections
*/
export function generateMockHighlightsForSections(
sectionAddresses: string[],
authorPubkey: string =
"dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06",
): any[] {
const allHighlights: any[] = [];
sectionAddresses.forEach((address, index) => {
// Each section gets 2 highlights at the very beginning (positions 0-100 and 120-220)
const numHighlights = 2;
const sectionHighlights = generateMockHighlights(
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);
});
console.log(
`[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;
}

31
src/lib/utils/nostrUtils.ts

@ -610,6 +610,37 @@ export async function signEvent(event: { @@ -610,6 +610,37 @@ export async function signEvent(event: {
return bytesToHex(sig);
}
/**
* Converts a pubkey to a consistent hue value (0-360) for color mapping.
* The same pubkey will always produce the same hue.
* @param pubkey The pubkey to convert (hex or npub format)
* @returns A hue value between 0 and 360
*/
export function pubkeyToHue(pubkey: string): number {
// Normalize pubkey to hex format
let hexPubkey = pubkey;
try {
if (pubkey.startsWith("npub")) {
const decoded = nip19.decode(pubkey);
if (decoded.type === "npub") {
hexPubkey = decoded.data as string;
}
}
} catch {
// If decode fails, use the original pubkey
}
// Hash the pubkey using SHA-256
const hash = sha256(hexPubkey);
// Use the first 4 bytes to generate a number
const num = (hash[0] << 24) | (hash[1] << 16) | (hash[2] << 8) | hash[3];
// Map to 0-360 range
return Math.abs(num) % 360;
}
/**
* Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:"
* if they are not already prefixed and are not part of a hyperlink

61
src/lib/utils/publication_tree_factory.ts

@ -120,10 +120,15 @@ function createIndexEvent(parsed: any, ndk: NDK): NDKEvent { @@ -120,10 +120,15 @@ function createIndexEvent(parsed: any, ndk: NDK): NDKEvent {
// Add document attributes as tags
addDocumentAttributesToTags(tags, parsed.attributes, event.pubkey);
// Generate publication abbreviation for namespacing sections
const pubAbbrev = generateTitleAbbreviation(parsed.title);
// Add a-tags for each section (30041 references)
// Using new format: kind:pubkey:{abbv}-{section-d-tag}
parsed.sections.forEach((section: any) => {
const sectionDTag = generateDTag(section.title);
tags.push(["a", `30041:${event.pubkey}:${sectionDTag}`]);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedDTag}`]);
});
event.tags = tags;
@ -147,10 +152,19 @@ function createContentEvent( @@ -147,10 +152,19 @@ function createContentEvent(
// Use placeholder pubkey for preview if no active user
event.pubkey = ndk.activeUser?.pubkey || "preview-placeholder-pubkey";
const dTag = generateDTag(section.title);
// Generate namespaced d-tag using publication abbreviation
const sectionDTag = generateDTag(section.title);
const pubAbbrev = generateTitleAbbreviation(documentParsed.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
const [mTag, MTag] = getMimeTags(30041);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", section.title]];
const tags: string[][] = [
["d", namespacedDTag],
mTag,
MTag,
["title", section.title],
];
// Add section-specific attributes
addSectionAttributesToTags(tags, section.attributes);
@ -175,8 +189,8 @@ function detectContentType( @@ -175,8 +189,8 @@ function detectContentType(
// Check if the "title" is actually just the first section title
// This happens when AsciiDoc starts with == instead of =
const titleMatchesFirstSection =
parsed.sections.length > 0 && parsed.title === parsed.sections[0].title;
const titleMatchesFirstSection = parsed.sections.length > 0 &&
parsed.title === parsed.sections[0].title;
if (hasDocTitle && hasSections && !titleMatchesFirstSection) {
return "article";
@ -200,6 +214,32 @@ function generateDTag(title: string): string { @@ -200,6 +214,32 @@ function generateDTag(title: string): string {
);
}
/**
* Generate title abbreviation from first letters of each word
* Used for namespacing section a-tags
* @param title - The publication title
* @returns Abbreviation string (e.g., "My Test Article" "mta")
*/
function generateTitleAbbreviation(title: string): string {
if (!title || !title.trim()) {
return "u"; // "untitled"
}
// Split on non-alphanumeric characters and filter out empty strings
const words = title
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 0);
if (words.length === 0) {
return "u";
}
// Take first letter of each word and join
return words
.map((word) => word.charAt(0).toLowerCase())
.join("");
}
/**
* Add document attributes as Nostr tags
*/
@ -246,8 +286,9 @@ function inheritDocumentAttributes( @@ -246,8 +286,9 @@ function inheritDocumentAttributes(
documentAttributes: Record<string, string>,
) {
// Inherit selected document attributes
if (documentAttributes.language)
if (documentAttributes.language) {
tags.push(["language", documentAttributes.language]);
}
if (documentAttributes.type) tags.push(["type", documentAttributes.type]);
}
@ -328,9 +369,11 @@ function generateIndexContent(parsed: any): string { @@ -328,9 +369,11 @@ function generateIndexContent(parsed: any): string {
${parsed.sections.length} sections available:
${parsed.sections
.map((section: any, i: number) => `${i + 1}. ${section.title}`)
.join("\n")}`;
${
parsed.sections
.map((section: any, i: number) => `${i + 1}. ${section.title}`)
.join("\n")
}`;
}
/**

232
src/lib/utils/publication_tree_processor.ts

@ -11,9 +11,10 @@ import { PublicationTree } from "$lib/data_structures/publication_tree"; @@ -11,9 +11,10 @@ import { PublicationTree } from "$lib/data_structures/publication_tree";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { getMimeTags } from "$lib/utils/mime";
import { extractWikiLinks, wikiLinksToTags } from "$lib/utils/wiki_links";
// For debugging tree structure
const DEBUG = process.env.DEBUG_TREE_PROCESSOR === false;
const DEBUG = process.env.DEBUG_TREE_PROCESSOR === "true";
export interface ProcessorResult {
tree: PublicationTree;
indexEvent: NDKEvent | null;
@ -126,7 +127,9 @@ export function registerPublicationTreeProcessor( @@ -126,7 +127,9 @@ export function registerPublicationTreeProcessor(
};
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) {
console.error("[TreeProcessor] Error processing document:", error);
@ -332,11 +335,11 @@ function parseSegmentContent( @@ -332,11 +335,11 @@ function parseSegmentContent(
// Extract content (everything after attributes, but stop at child sections)
const contentLines = sectionLines.slice(contentStartIdx);
// Find where to stop content extraction based on parse level
let contentEndIdx = contentLines.length;
const currentSectionLevel = sectionLines[0].match(/^(=+)/)?.[1].length || 2;
for (let i = 0; i < contentLines.length; i++) {
const line = contentLines[i];
const headerMatch = line.match(/^(=+)\s+/);
@ -349,7 +352,7 @@ function parseSegmentContent( @@ -349,7 +352,7 @@ function parseSegmentContent(
}
}
}
const content = contentLines.slice(0, contentEndIdx).join("\n").trim();
// Debug logging for Level 3+ content extraction
@ -362,7 +365,6 @@ function parseSegmentContent( @@ -362,7 +365,6 @@ function parseSegmentContent(
console.log(` extracted content:`, JSON.stringify(content));
}
return { attributes, content };
}
@ -377,8 +379,8 @@ function detectContentType( @@ -377,8 +379,8 @@ function detectContentType(
const hasSections = segments.length > 0;
// Check if the title matches the first section title
const titleMatchesFirstSection =
segments.length > 0 && title === segments[0].title;
const titleMatchesFirstSection = segments.length > 0 &&
title === segments[0].title;
if (hasDocTitle && hasSections && !titleMatchesFirstSection) {
return "article";
@ -435,6 +437,7 @@ function buildScatteredNotesStructure( @@ -435,6 +437,7 @@ function buildScatteredNotesStructure(
const eventStructure: EventStructureNode[] = [];
const firstSegment = segments[0];
// No publication title for scattered notes
const rootEvent = createContentEvent(firstSegment, ndk);
const tree = new PublicationTree(rootEvent, ndk);
contentEvents.push(rootEvent);
@ -528,21 +531,31 @@ function buildLevel2Structure( @@ -528,21 +531,31 @@ function buildLevel2Structure(
// Group segments by level 2 sections
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
const pubAbbrev = generateTitleAbbreviation(title);
for (const group of level2Groups) {
const contentEvent = createContentEvent(group, ndk);
const contentEvent = createContentEvent(group, ndk, title);
contentEvents.push(contentEvent);
const sectionDTag = generateDTag(group.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
const childNode = {
title: group.title,
level: group.level,
eventType: "content" as const,
eventKind: 30041 as const,
dTag: generateDTag(group.title),
dTag: namespacedDTag,
children: [],
};
console.log(`[TreeProcessor] Adding child node:`, childNode.title);
eventStructure[0].children.push(childNode);
}
@ -590,7 +603,8 @@ function buildHierarchicalStructure( @@ -590,7 +603,8 @@ function buildHierarchicalStructure(
rootNode,
contentEvents,
ndk,
parseLevel
parseLevel,
title,
);
return { tree, indexEvent, contentEvents, eventStructure };
@ -618,10 +632,15 @@ function createIndexEvent( @@ -618,10 +632,15 @@ function createIndexEvent(
// Add document attributes as tags
addDocumentAttributesToTags(tags, attributes, event.pubkey);
// Generate publication abbreviation for namespacing sections
const pubAbbrev = generateTitleAbbreviation(title);
// Add a-tags for each content section
// Using new format: kind:pubkey:{abbv}-{section-d-tag}
segments.forEach((segment) => {
const sectionDTag = generateDTag(segment.title);
tags.push(["a", `30041:${event.pubkey}:${sectionDTag}`]);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedDTag}`]);
});
event.tags = tags;
@ -635,13 +654,25 @@ function createIndexEvent( @@ -635,13 +654,25 @@ function createIndexEvent(
/**
* Create a 30041 content event from segment
*/
function createContentEvent(segment: ContentSegment, ndk: NDK): NDKEvent {
function createContentEvent(
segment: ContentSegment,
ndk: NDK,
publicationTitle?: string,
): NDKEvent {
const event = new NDKEvent(ndk);
event.kind = 30041;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = ndk.activeUser?.pubkey || "preview-placeholder-pubkey";
const dTag = generateDTag(segment.title);
// Generate namespaced d-tag if publication title is provided
const sectionDTag = generateDTag(segment.title);
let dTag = sectionDTag;
if (publicationTitle) {
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
dTag = `${pubAbbrev}-${sectionDTag}`;
}
const [mTag, MTag] = getMimeTags(30041);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", segment.title]];
@ -649,10 +680,20 @@ function createContentEvent(segment: ContentSegment, ndk: NDK): NDKEvent { @@ -649,10 +680,20 @@ function createContentEvent(segment: ContentSegment, ndk: NDK): NDKEvent {
// Add segment attributes as tags
addSectionAttributesToTags(tags, segment.attributes);
// Extract and add wiki link tags from content
const wikiLinks = extractWikiLinks(segment.content);
if (wikiLinks.length > 0) {
const wikiTags = wikiLinksToTags(wikiLinks);
tags.push(...wikiTags);
console.log(
`[TreeProcessor] Added ${wikiTags.length} wiki link tags:`,
wikiTags,
);
}
event.tags = tags;
event.content = segment.content;
return event;
}
@ -690,6 +731,32 @@ function generateDTag(title: string): string { @@ -690,6 +731,32 @@ function generateDTag(title: string): string {
);
}
/**
* Generate title abbreviation from first letters of each word
* Used for namespacing section a-tags
* @param title - The publication title
* @returns Abbreviation string (e.g., "My Test Article" "mta")
*/
function generateTitleAbbreviation(title: string): string {
if (!title || !title.trim()) {
return "u"; // "untitled"
}
// Split on non-alphanumeric characters and filter out empty strings
const words = title
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 0);
if (words.length === 0) {
return "u";
}
// Take first letter of each word and join
return words
.map((word) => word.charAt(0).toLowerCase())
.join("");
}
/**
* Add document attributes as Nostr tags
*/
@ -820,17 +887,18 @@ function groupSegmentsByLevel2(segments: ContentSegment[]): ContentSegment[] { @@ -820,17 +887,18 @@ function groupSegmentsByLevel2(segments: ContentSegment[]): ContentSegment[] {
s.level > 2 &&
s.startLine > segment.startLine &&
(segments.find(
(next) => next.level <= 2 && next.startLine > segment.startLine,
)?.startLine || Infinity) > s.startLine,
(next) => next.level <= 2 && next.startLine > segment.startLine,
)?.startLine || Infinity) > s.startLine,
);
// Combine the level 2 content with all nested content
let combinedContent = segment.content;
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({
...segment,
content: combinedContent,
@ -847,22 +915,22 @@ function groupSegmentsByLevel2(segments: ContentSegment[]): ContentSegment[] { @@ -847,22 +915,22 @@ function groupSegmentsByLevel2(segments: ContentSegment[]): ContentSegment[] {
*/
function buildHierarchicalGroups(
segments: ContentSegment[],
parseLevel: number
parseLevel: number,
): HierarchicalNode[] {
const groups: HierarchicalNode[] = [];
// Group segments by their parent-child relationships
const segmentsByLevel: Map<number, ContentSegment[]> = new Map();
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
for (const segment of segmentsByLevel.get(2) || []) {
const node = buildNodeHierarchy(segment, segments, parseLevel);
groups.push(node);
}
return groups;
}
@ -872,22 +940,23 @@ function buildHierarchicalGroups( @@ -872,22 +940,23 @@ function buildHierarchicalGroups(
function buildNodeHierarchy(
segment: ContentSegment,
allSegments: ContentSegment[],
parseLevel: number
parseLevel: number,
): HierarchicalNode {
// 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.startLine <= segment.startLine) return false;
// Check if this segment is within our section's bounds
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;
return s.startLine < endLine;
});
// Recursively build child nodes
const childNodes: HierarchicalNode[] = [];
for (const child of directChildren) {
@ -899,15 +968,15 @@ function buildNodeHierarchy( @@ -899,15 +968,15 @@ function buildNodeHierarchy(
childNodes.push({
segment: child,
children: [],
hasChildren: false
hasChildren: false,
});
}
}
return {
segment,
children: childNodes,
hasChildren: childNodes.length > 0
hasChildren: childNodes.length > 0,
};
}
@ -925,21 +994,35 @@ function processHierarchicalGroup( @@ -925,21 +994,35 @@ function processHierarchicalGroup(
parentStructureNode: EventStructureNode,
contentEvents: NDKEvent[],
ndk: NDK,
parseLevel: number
parseLevel: number,
publicationTitle: string,
): void {
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
for (const node of nodes) {
if (node.hasChildren && node.segment.level < parseLevel) {
// This section has children and is not at parse level
// Create BOTH an index event AND a content event
// 1. Create the index event (30040)
const indexEvent = createIndexEventForHierarchicalNode(node, ndk);
const indexEvent = createIndexEventForHierarchicalNode(
node,
ndk,
publicationTitle,
);
contentEvents.push(indexEvent);
// 2. Create the content event (30041) for the section's own content
const contentEvent = createContentEvent(node.segment, ndk);
const contentEvent = createContentEvent(
node.segment,
ndk,
publicationTitle,
);
contentEvents.push(contentEvent);
const sectionDTag = generateDTag(node.segment.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
// 3. Add index node to structure
const indexNode: EventStructureNode = {
title: node.segment.title,
@ -950,36 +1033,44 @@ function processHierarchicalGroup( @@ -950,36 +1033,44 @@ function processHierarchicalGroup(
children: [],
};
parentStructureNode.children.push(indexNode);
// 4. Add content node as first child of index
indexNode.children.push({
title: node.segment.title,
level: node.segment.level,
eventType: "content",
eventKind: 30041,
dTag: generateDTag(node.segment.title),
dTag: namespacedDTag,
children: [],
});
// 5. Process children recursively
processHierarchicalGroup(
node.children,
indexNode,
contentEvents,
ndk,
parseLevel
parseLevel,
publicationTitle,
);
} else {
// This is either a leaf node or at parse level - just create content event
const contentEvent = createContentEvent(node.segment, ndk);
const contentEvent = createContentEvent(
node.segment,
ndk,
publicationTitle,
);
contentEvents.push(contentEvent);
const sectionDTag = generateDTag(node.segment.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
parentStructureNode.children.push({
title: node.segment.title,
level: node.segment.level,
eventType: "content",
eventKind: 30041,
dTag: generateDTag(node.segment.title),
dTag: namespacedDTag,
children: [],
});
}
@ -991,7 +1082,8 @@ function processHierarchicalGroup( @@ -991,7 +1082,8 @@ function processHierarchicalGroup(
*/
function createIndexEventForHierarchicalNode(
node: HierarchicalNode,
ndk: NDK
ndk: NDK,
publicationTitle: string,
): NDKEvent {
const event = new NDKEvent(ndk);
event.kind = 30040;
@ -1001,33 +1093,42 @@ function createIndexEventForHierarchicalNode( @@ -1001,33 +1093,42 @@ function createIndexEventForHierarchicalNode(
const dTag = generateDTag(node.segment.title);
const [mTag, MTag] = getMimeTags(30040);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", node.segment.title]];
const tags: string[][] = [
["d", dTag],
mTag,
MTag,
["title", node.segment.title],
];
// Add section attributes as tags
addSectionAttributesToTags(tags, node.segment.attributes);
// Add a-tags for the section's own content event
tags.push(["a", `30041:${event.pubkey}:${dTag}`]);
// Add a-tags for each child section
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
// Add a-tags for the section's own content event with namespace
const sectionDTag = generateDTag(node.segment.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedDTag}`]);
// Add a-tags for each child section with namespace
for (const child of node.children) {
const childDTag = generateDTag(child.segment.title);
const namespacedChildDTag = `${pubAbbrev}-${childDTag}`;
if (child.hasChildren && child.segment.level < node.segment.level + 1) {
// Child will be an index
tags.push(["a", `30040:${event.pubkey}:${childDTag}`]);
} else {
// Child will be content
tags.push(["a", `30041:${event.pubkey}:${childDTag}`]);
// Child will be content with namespace
tags.push(["a", `30041:${event.pubkey}:${namespacedChildDTag}`]);
}
}
event.tags = tags;
event.content = ""; // NKBIP-01: Index events must have empty content
event.content = ""; // NKBIP-01: Index events must have empty content
return event;
}
/**
* Build hierarchical segment structure for Level 3+ parsing
*/
@ -1043,8 +1144,9 @@ function buildSegmentHierarchy( @@ -1043,8 +1144,9 @@ function buildSegmentHierarchy(
s.level > 2 &&
s.startLine > level2Segment.startLine &&
(segments.find(
(next) => next.level <= 2 && next.startLine > level2Segment.startLine,
)?.startLine || Infinity) > s.startLine,
(next) =>
next.level <= 2 && next.startLine > level2Segment.startLine,
)?.startLine || Infinity) > s.startLine,
);
hierarchy.push({
@ -1059,10 +1161,13 @@ function buildSegmentHierarchy( @@ -1059,10 +1161,13 @@ function buildSegmentHierarchy(
/**
* Create a 30040 index event for a section with children
* Note: This function appears to be unused in the current codebase
* but is updated for consistency with the new namespacing scheme
*/
function createIndexEventForSection(
section: HierarchicalSegment,
ndk: NDK,
publicationTitle: string,
): NDKEvent {
const event = new NDKEvent(ndk);
event.kind = 30040;
@ -1077,10 +1182,13 @@ function createIndexEventForSection( @@ -1077,10 +1182,13 @@ function createIndexEventForSection(
// Add section attributes as tags
addSectionAttributesToTags(tags, section.attributes);
// Add a-tags for each child content section
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
// Add a-tags for each child content section with namespace
section.children.forEach((child) => {
const childDTag = generateDTag(child.title);
tags.push(["a", `30041:${event.pubkey}:${childDTag}`]);
const namespacedChildDTag = `${pubAbbrev}-${childDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedChildDTag}`]);
});
event.tags = tags;

144
src/lib/utils/wiki_links.ts

@ -0,0 +1,144 @@ @@ -0,0 +1,144 @@
/**
* Wiki link parsing and tag generation utilities
* Supports [[term]], [[w:term]], and [[d:term]] syntax
*/
export interface WikiLink {
fullMatch: string;
type: "w" | "d" | "auto"; // auto means [[term]] without explicit prefix
term: string;
displayText: string;
startIndex: number;
endIndex: number;
}
/**
* Extracts all wiki links from AsciiDoc content.
* Supports three formats:
* - [[term]] - Auto (will query both w and d tags)
* - [[w:term]] - Explicit reference/mention (backward link)
* - [[d:term]] - Explicit definition (forward link)
*/
export function extractWikiLinks(content: string): WikiLink[] {
const wikiLinks: WikiLink[] = [];
// Match [[prefix:term]] or [[term]]
// Captures: optional prefix (w: or d:), term, optional display text after |
const regex = /\[\[(?:(w|d):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g;
let match;
while ((match = regex.exec(content)) !== null) {
const prefix = match[1]; // 'w', 'd', or undefined
const term = match[2].trim();
const customDisplay = match[3]?.trim();
wikiLinks.push({
fullMatch: match[0],
type: prefix ? (prefix as "w" | "d") : "auto",
term,
displayText: customDisplay || term,
startIndex: match.index,
endIndex: match.index + match[0].length,
});
}
return wikiLinks;
}
/**
* Converts a term to a clean tag format (lowercase, hyphenated).
* Example: "Knowledge Graphs" -> "knowledge-graphs"
*/
export function termToTag(term: string): string {
return term
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
/**
* Generates Nostr event tags from wiki links.
* Format: ['w', 'tag-slug', 'Display Text'] or ['d', 'tag-slug']
*/
export function wikiLinksToTags(wikiLinks: WikiLink[]): string[][] {
const tags: string[][] = [];
for (const link of wikiLinks) {
const tagSlug = termToTag(link.term);
if (link.type === "w" || link.type === "auto") {
// Reference tag includes display text
tags.push(["w", tagSlug, link.displayText]);
}
if (link.type === "d") {
// Definition tag (no display text, it IS the thing)
tags.push(["d", tagSlug]);
}
}
return tags;
}
/**
* Replaces wiki link syntax with HTML for preview rendering.
* Can be customized for different rendering styles.
*/
export function renderWikiLinksToHtml(
content: string,
options: {
linkClass?: string;
wLinkClass?: string;
dLinkClass?: string;
onClickHandler?: (type: "w" | "d" | "auto", term: string) => string;
} = {},
): string {
const {
linkClass = "wiki-link",
wLinkClass = "wiki-link-reference",
dLinkClass = "wiki-link-definition",
onClickHandler,
} = options;
return content.replace(
/\[\[(?:(w|d):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, prefix, term, customDisplay) => {
const displayText = customDisplay?.trim() || term.trim();
const type = prefix ? prefix : "auto";
const tagSlug = termToTag(term);
// Determine CSS classes
let classes = linkClass;
if (type === "w") classes += ` ${wLinkClass}`;
else if (type === "d") classes += ` ${dLinkClass}`;
// Generate href or onclick
const action = onClickHandler
? `onclick="${onClickHandler(type, tagSlug)}"`
: `href="#wiki/${type}/${encodeURIComponent(tagSlug)}"`;
// Add title attribute showing the type
const title = type === "w"
? "Wiki reference (mentions this concept)"
: type === "d"
? "Wiki definition (defines this concept)"
: "Wiki link (searches both references and definitions)";
return `<a class="${classes}" ${action} title="${title}" data-wiki-type="${type}" data-wiki-term="${tagSlug}">${displayText}</a>`;
},
);
}
/**
* Converts wiki links to plain text (for content storage).
* Preserves the display text if custom, otherwise uses the term.
*/
export function wikiLinksToPlainText(content: string): string {
return content.replace(
/\[\[(?:w|d:)?([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, term, customDisplay) => {
return customDisplay?.trim() || term.trim();
},
);
}

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

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

906
tests/unit/commentButton.test.ts

@ -0,0 +1,906 @@ @@ -0,0 +1,906 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { get, writable } from "svelte/store";
import type { UserState } from "../../src/lib/stores/userStore.ts";
import { NDKEvent } from "@nostr-dev-kit/ndk";
// Mock userStore
const createMockUserStore = (signedIn: boolean = false) => {
const store = writable<UserState>({
pubkey: signedIn ? "a".repeat(64) : null,
npub: signedIn ? "npub1test" : null,
profile: signedIn
? {
name: "Test User",
displayName: "Test User",
picture: "https://example.com/avatar.jpg",
}
: null,
relays: { inbox: [], outbox: [] },
loginMethod: signedIn ? "extension" : null,
ndkUser: null,
signer: signedIn ? { sign: vi.fn() } as any : null,
signedIn,
});
return store;
};
// Mock activeOutboxRelays
const mockActiveOutboxRelays = writable<string[]>(["wss://relay.example.com"]);
// Mock NDK
const createMockNDK = () => ({
fetchEvent: vi.fn(),
publish: vi.fn(),
});
describe("CommentButton - Address Parsing", () => {
it("parses valid event address correctly", () => {
const address = "30041:abc123def456:my-article";
const parts = address.split(":");
expect(parts).toHaveLength(3);
const [kindStr, pubkey, dTag] = parts;
const kind = parseInt(kindStr);
expect(kind).toBe(30041);
expect(pubkey).toBe("abc123def456");
expect(dTag).toBe("my-article");
expect(isNaN(kind)).toBe(false);
});
it("handles dTag with colons correctly", () => {
const address = "30041:abc123:article:with:colons";
const parts = address.split(":");
expect(parts.length).toBeGreaterThanOrEqual(3);
const [kindStr, pubkey, ...dTagParts] = parts;
const dTag = dTagParts.join(":");
expect(parseInt(kindStr)).toBe(30041);
expect(pubkey).toBe("abc123");
expect(dTag).toBe("article:with:colons");
});
it("returns null for invalid address format (too few parts)", () => {
const address = "30041:abc123";
const parts = address.split(":");
if (parts.length !== 3) {
expect(parts.length).toBeLessThan(3);
}
});
it("returns null for invalid address format (invalid kind)", () => {
const address = "invalid:abc123:dtag";
const parts = address.split(":");
const kind = parseInt(parts[0]);
expect(isNaN(kind)).toBe(true);
});
it("parses different publication kinds correctly", () => {
const addresses = [
"30040:pubkey:section-id", // Zettel section
"30041:pubkey:article-id", // Long-form article
"30818:pubkey:wiki-id", // Wiki article
"30023:pubkey:blog-id", // Blog post
];
addresses.forEach((address) => {
const parts = address.split(":");
const kind = parseInt(parts[0]);
expect(isNaN(kind)).toBe(false);
expect(kind).toBeGreaterThan(0);
});
});
});
describe("CommentButton - NIP-22 Event Creation", () => {
let mockNDK: any;
let mockUserStore: any;
let mockActiveOutboxRelays: any;
beforeEach(() => {
mockNDK = createMockNDK();
mockUserStore = createMockUserStore(true);
mockActiveOutboxRelays = writable(["wss://relay.example.com"]);
});
afterEach(() => {
vi.clearAllMocks();
});
it("creates kind 1111 comment event", async () => {
const address = "30041:" + "a".repeat(64) + ":my-article";
const content = "This is my comment";
// Mock event creation
const commentEvent = new NDKEvent(mockNDK);
commentEvent.kind = 1111;
commentEvent.content = content;
expect(commentEvent.kind).toBe(1111);
expect(commentEvent.content).toBe(content);
});
it("includes correct uppercase tags (A, K, P) for root", () => {
const address = "30041:" + "b".repeat(64) + ":article-id";
const authorPubkey = "b".repeat(64);
const kind = 30041;
const relayHint = "wss://relay.example.com";
const tags = [
["A", address, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
];
// Verify uppercase root tags
expect(tags[0][0]).toBe("A");
expect(tags[0][1]).toBe(address);
expect(tags[0][2]).toBe(relayHint);
expect(tags[0][3]).toBe(authorPubkey);
expect(tags[1][0]).toBe("K");
expect(tags[1][1]).toBe(kind.toString());
expect(tags[2][0]).toBe("P");
expect(tags[2][1]).toBe(authorPubkey);
expect(tags[2][2]).toBe(relayHint);
});
it("includes correct lowercase tags (a, k, p) for parent", () => {
const address = "30041:" + "c".repeat(64) + ":article-id";
const authorPubkey = "c".repeat(64);
const kind = 30041;
const relayHint = "wss://relay.example.com";
const tags = [
["a", address, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
];
// Verify lowercase parent tags
expect(tags[0][0]).toBe("a");
expect(tags[0][1]).toBe(address);
expect(tags[0][2]).toBe(relayHint);
expect(tags[1][0]).toBe("k");
expect(tags[1][1]).toBe(kind.toString());
expect(tags[2][0]).toBe("p");
expect(tags[2][1]).toBe(authorPubkey);
expect(tags[2][2]).toBe(relayHint);
});
it("includes e tag with event ID when available", () => {
const eventId = "d".repeat(64);
const relayHint = "wss://relay.example.com";
const eTag = ["e", eventId, relayHint];
expect(eTag[0]).toBe("e");
expect(eTag[1]).toBe(eventId);
expect(eTag[2]).toBe(relayHint);
expect(eTag[1]).toHaveLength(64);
});
it("creates complete NIP-22 tag structure", () => {
const address = "30041:" + "e".repeat(64) + ":test-article";
const authorPubkey = "e".repeat(64);
const kind = 30041;
const eventId = "f".repeat(64);
const relayHint = "wss://relay.example.com";
const tags = [
// Root scope - uppercase tags
["A", address, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
// Parent scope - lowercase tags
["a", address, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
// Event ID
["e", eventId, relayHint],
];
// Verify all tags are present
expect(tags).toHaveLength(7);
// Verify root tags
expect(tags.filter((t) => t[0] === "A")).toHaveLength(1);
expect(tags.filter((t) => t[0] === "K")).toHaveLength(1);
expect(tags.filter((t) => t[0] === "P")).toHaveLength(1);
// Verify parent tags
expect(tags.filter((t) => t[0] === "a")).toHaveLength(1);
expect(tags.filter((t) => t[0] === "k")).toHaveLength(1);
expect(tags.filter((t) => t[0] === "p")).toHaveLength(1);
// Verify event tag
expect(tags.filter((t) => t[0] === "e")).toHaveLength(1);
});
it("uses correct relay hints from activeOutboxRelays", () => {
const relays: string[] = get(mockActiveOutboxRelays);
const relayHint = relays[0];
expect(relayHint).toBe("wss://relay.example.com");
expect(relays).toHaveLength(1);
});
it("handles multiple outbox relays correctly", () => {
const multipleRelays = writable([
"wss://relay1.example.com",
"wss://relay2.example.com",
"wss://relay3.example.com",
]);
const relays = get(multipleRelays);
const relayHint = relays[0];
expect(relayHint).toBe("wss://relay1.example.com");
expect(relays).toHaveLength(3);
});
it("handles empty relay list gracefully", () => {
const emptyRelays = writable<string[]>([]);
const relays = get(emptyRelays);
const relayHint = relays[0] || "";
expect(relayHint).toBe("");
});
});
describe("CommentButton - Event Signing and Publishing", () => {
let mockNDK: any;
let mockSigner: any;
beforeEach(() => {
mockNDK = createMockNDK();
mockSigner = {
sign: vi.fn().mockResolvedValue(undefined),
};
});
afterEach(() => {
vi.clearAllMocks();
});
it("signs event with user signer", async () => {
const commentEvent = new NDKEvent(mockNDK);
commentEvent.kind = 1111;
commentEvent.content = "Test comment";
await mockSigner.sign(commentEvent);
expect(mockSigner.sign).toHaveBeenCalledWith(commentEvent);
expect(mockSigner.sign).toHaveBeenCalledTimes(1);
});
it("publishes to outbox relays", async () => {
const publishMock = vi.fn().mockResolvedValue(
new Set(["wss://relay.example.com"]),
);
const commentEvent = new NDKEvent(mockNDK);
commentEvent.publish = publishMock;
const publishedRelays = await commentEvent.publish();
expect(publishMock).toHaveBeenCalled();
expect(publishedRelays.size).toBeGreaterThan(0);
});
it("handles publishing errors gracefully", async () => {
const publishMock = vi.fn().mockResolvedValue(new Set());
const commentEvent = new NDKEvent(mockNDK);
commentEvent.publish = publishMock;
const publishedRelays = await commentEvent.publish();
expect(publishedRelays.size).toBe(0);
});
it("throws error when publishing fails", async () => {
const publishMock = vi.fn().mockRejectedValue(new Error("Network error"));
const commentEvent = new NDKEvent(mockNDK);
commentEvent.publish = publishMock;
await expect(commentEvent.publish()).rejects.toThrow("Network error");
});
});
describe("CommentButton - User Authentication", () => {
it("requires user to be signed in", () => {
const signedOutStore = createMockUserStore(false);
const user = get(signedOutStore);
expect(user.signedIn).toBe(false);
expect(user.signer).toBeNull();
});
it("shows error when user is not signed in", () => {
const signedOutStore = createMockUserStore(false);
const user = get(signedOutStore);
if (!user.signedIn || !user.signer) {
const error = "You must be signed in to comment";
expect(error).toBe("You must be signed in to comment");
}
});
it("allows commenting when user is signed in", () => {
const signedInStore = createMockUserStore(true);
const user = get(signedInStore);
expect(user.signedIn).toBe(true);
expect(user.signer).not.toBeNull();
});
it("displays user profile information when signed in", () => {
const signedInStore = createMockUserStore(true);
const user = get(signedInStore);
expect(user.profile).not.toBeNull();
expect(user.profile?.displayName).toBe("Test User");
expect(user.profile?.picture).toBe("https://example.com/avatar.jpg");
});
it("handles missing user profile gracefully", () => {
const storeWithoutProfile = writable<UserState>({
pubkey: "a".repeat(64),
npub: "npub1test",
profile: null,
relays: { inbox: [], outbox: [] },
loginMethod: "extension",
ndkUser: null,
signer: { sign: vi.fn() } as any,
signedIn: true,
});
const user = get(storeWithoutProfile);
const displayName = user.profile?.displayName || user.profile?.name ||
"Anonymous";
expect(displayName).toBe("Anonymous");
});
});
describe("CommentButton - User Interactions", () => {
it("prevents submission of empty comment", () => {
const commentContent = "";
const isEmpty = !commentContent.trim();
expect(isEmpty).toBe(true);
});
it("allows submission of non-empty comment", () => {
const commentContent = "This is a valid comment";
const isEmpty = !commentContent.trim();
expect(isEmpty).toBe(false);
});
it("handles whitespace-only comments as empty", () => {
const commentContent = " \n\t ";
const isEmpty = !commentContent.trim();
expect(isEmpty).toBe(true);
});
it("clears input after successful comment", () => {
let commentContent = "This is my comment";
// Simulate successful submission
commentContent = "";
expect(commentContent).toBe("");
});
it("closes comment UI after successful posting", () => {
let showCommentUI = true;
// Simulate successful post with delay
setTimeout(() => {
showCommentUI = false;
}, 0);
// Initially still open
expect(showCommentUI).toBe(true);
});
it("calls onCommentPosted callback when provided", () => {
const onCommentPosted = vi.fn();
// Simulate successful comment post
onCommentPosted();
expect(onCommentPosted).toHaveBeenCalled();
});
});
describe("CommentButton - UI State Management", () => {
it("button is hidden by default", () => {
const sectionHovered = false;
const showCommentUI = false;
const visible = sectionHovered || showCommentUI;
expect(visible).toBe(false);
});
it("button appears on section hover", () => {
const sectionHovered = true;
const showCommentUI = false;
const visible = sectionHovered || showCommentUI;
expect(visible).toBe(true);
});
it("button remains visible when comment UI is shown", () => {
const sectionHovered = false;
const showCommentUI = true;
const visible = sectionHovered || showCommentUI;
expect(visible).toBe(true);
});
it("toggles comment UI when button is clicked", () => {
let showCommentUI = false;
// Simulate button click
showCommentUI = !showCommentUI;
expect(showCommentUI).toBe(true);
// Click again
showCommentUI = !showCommentUI;
expect(showCommentUI).toBe(false);
});
it("resets error state when toggling UI", () => {
let error: string | null = "Previous error";
let success = true;
// Simulate UI toggle
error = null;
success = false;
expect(error).toBeNull();
expect(success).toBe(false);
});
it("shows error message when present", () => {
const error = "Failed to post comment";
expect(error).toBeDefined();
expect(error.length).toBeGreaterThan(0);
});
it("shows success message after posting", () => {
const success = true;
const successMessage = "Comment posted successfully!";
if (success) {
expect(successMessage).toBe("Comment posted successfully!");
}
});
it("disables submit button when submitting", () => {
const isSubmitting = true;
const disabled = isSubmitting;
expect(disabled).toBe(true);
});
it("disables submit button when comment is empty", () => {
const commentContent = "";
const isSubmitting = false;
const disabled = isSubmitting || !commentContent.trim();
expect(disabled).toBe(true);
});
it("enables submit button when comment is valid", () => {
const commentContent = "Valid comment";
const isSubmitting = false;
const disabled = isSubmitting || !commentContent.trim();
expect(disabled).toBe(false);
});
});
describe("CommentButton - Edge Cases", () => {
it("handles invalid address format gracefully", () => {
const invalidAddresses = [
"",
"invalid",
"30041:",
":pubkey:dtag",
"30041:pubkey",
"not-a-number:pubkey:dtag",
];
invalidAddresses.forEach((address) => {
const parts = address.split(":");
const isValid = parts.length === 3 && !isNaN(parseInt(parts[0]));
expect(isValid).toBe(false);
});
});
it("handles network errors during event fetch", async () => {
const mockNDK = {
fetchEvent: vi.fn().mockRejectedValue(new Error("Network error")),
};
let eventId = "";
try {
await mockNDK.fetchEvent({});
} catch (err) {
// Handle gracefully, continue without event ID
eventId = "";
}
expect(eventId).toBe("");
});
it("handles missing relay information", () => {
const emptyRelays: string[] = [];
const relayHint = emptyRelays[0] || "";
expect(relayHint).toBe("");
});
it("handles very long comment text without truncation", () => {
const longComment = "a".repeat(10000);
const content = longComment;
expect(content.length).toBe(10000);
expect(content).toBe(longComment);
});
it("handles special characters in comments", () => {
const specialComments = [
'Comment with "quotes"',
"Comment with emoji 😊",
"Comment with\nnewlines",
"Comment with\ttabs",
"Comment with <html> tags",
"Comment with & ampersands",
];
specialComments.forEach((comment) => {
expect(comment.length).toBeGreaterThan(0);
expect(typeof comment).toBe("string");
});
});
it("handles event creation failure", async () => {
const address = "invalid:address";
const parts = address.split(":");
if (parts.length !== 3) {
const error = "Invalid event address";
expect(error).toBe("Invalid event address");
}
});
it("handles signing errors", async () => {
const mockSigner = {
sign: vi.fn().mockRejectedValue(new Error("Signing failed")),
};
const event = { kind: 1111, content: "test" };
await expect(mockSigner.sign(event)).rejects.toThrow("Signing failed");
});
it("handles publish failure when no relays accept event", async () => {
const publishMock = vi.fn().mockResolvedValue(new Set());
const relaySet = await publishMock();
if (relaySet.size === 0) {
const error = "Failed to publish to any relays";
expect(error).toBe("Failed to publish to any relays");
}
});
});
describe("CommentButton - Cancel Functionality", () => {
it("clears comment content when canceling", () => {
let commentContent = "This comment will be canceled";
// Simulate cancel
commentContent = "";
expect(commentContent).toBe("");
});
it("closes comment UI when canceling", () => {
let showCommentUI = true;
// Simulate cancel
showCommentUI = false;
expect(showCommentUI).toBe(false);
});
it("clears error state when canceling", () => {
let error: string | null = "Some error";
// Simulate cancel
error = null;
expect(error).toBeNull();
});
it("clears success state when canceling", () => {
let success = true;
// Simulate cancel
success = false;
expect(success).toBe(false);
});
});
describe("CommentButton - Event Fetching", () => {
let mockNDK: any;
beforeEach(() => {
mockNDK = createMockNDK();
});
afterEach(() => {
vi.clearAllMocks();
});
it("fetches target event to get event ID", async () => {
const address = "30041:" + "a".repeat(64) + ":article";
const parts = address.split(":");
const [kindStr, authorPubkey, dTag] = parts;
const kind = parseInt(kindStr);
const mockEvent = {
id: "b".repeat(64),
kind,
pubkey: authorPubkey,
tags: [["d", dTag]],
};
mockNDK.fetchEvent.mockResolvedValue(mockEvent);
const targetEvent = await mockNDK.fetchEvent({
kinds: [kind],
authors: [authorPubkey],
"#d": [dTag],
});
expect(mockNDK.fetchEvent).toHaveBeenCalled();
expect(targetEvent?.id).toBe("b".repeat(64));
});
it("continues without event ID when fetch fails", async () => {
mockNDK.fetchEvent.mockRejectedValue(new Error("Fetch failed"));
let eventId = "";
try {
const targetEvent = await mockNDK.fetchEvent({});
if (targetEvent) {
eventId = targetEvent.id;
}
} catch (err) {
// Continue without event ID
eventId = "";
}
expect(eventId).toBe("");
});
it("handles null event from fetch", async () => {
mockNDK.fetchEvent.mockResolvedValue(null);
const targetEvent = await mockNDK.fetchEvent({});
let eventId = "";
if (targetEvent) {
eventId = targetEvent.id;
}
expect(eventId).toBe("");
});
});
describe("CommentButton - CSS Classes and Styling", () => {
it("applies visible class when section is hovered", () => {
const sectionHovered = true;
const showCommentUI = false;
const hasVisibleClass = sectionHovered || showCommentUI;
expect(hasVisibleClass).toBe(true);
});
it("removes visible class when not hovered and UI closed", () => {
const sectionHovered = false;
const showCommentUI = false;
const hasVisibleClass = sectionHovered || showCommentUI;
expect(hasVisibleClass).toBe(false);
});
it("button has correct aria-label", () => {
const ariaLabel = "Add comment";
expect(ariaLabel).toBe("Add comment");
});
it("button has correct title attribute", () => {
const title = "Add comment";
expect(title).toBe("Add comment");
});
it("submit button shows loading state when submitting", () => {
const isSubmitting = true;
const buttonText = isSubmitting ? "Posting..." : "Post Comment";
expect(buttonText).toBe("Posting...");
});
it("submit button shows normal state when not submitting", () => {
const isSubmitting = false;
const buttonText = isSubmitting ? "Posting..." : "Post Comment";
expect(buttonText).toBe("Post Comment");
});
});
describe("CommentButton - NIP-22 Compliance", () => {
it("uses kind 1111 for comment events", () => {
const kind = 1111;
expect(kind).toBe(1111);
});
it("includes all required NIP-22 tags for addressable events", () => {
const requiredRootTags = ["A", "K", "P"];
const requiredParentTags = ["a", "k", "p"];
const tags = [
["A", "address", "relay", "pubkey"],
["K", "kind"],
["P", "pubkey", "relay"],
["a", "address", "relay"],
["k", "kind"],
["p", "pubkey", "relay"],
];
requiredRootTags.forEach((tag) => {
expect(tags.some((t) => t[0] === tag)).toBe(true);
});
requiredParentTags.forEach((tag) => {
expect(tags.some((t) => t[0] === tag)).toBe(true);
});
});
it("A tag includes relay hint and author pubkey", () => {
const aTag = ["A", "30041:pubkey:dtag", "wss://relay.com", "pubkey"];
expect(aTag).toHaveLength(4);
expect(aTag[0]).toBe("A");
expect(aTag[2]).toMatch(/^wss:\/\//);
expect(aTag[3]).toBeTruthy();
});
it("P tag includes relay hint", () => {
const pTag = ["P", "pubkey", "wss://relay.com"];
expect(pTag).toHaveLength(3);
expect(pTag[0]).toBe("P");
expect(pTag[2]).toMatch(/^wss:\/\//);
});
it("lowercase tags for parent scope match root tags", () => {
const address = "30041:pubkey:dtag";
const kind = "30041";
const pubkey = "pubkey";
const relay = "wss://relay.com";
const rootTags = [
["A", address, relay, pubkey],
["K", kind],
["P", pubkey, relay],
];
const parentTags = [
["a", address, relay],
["k", kind],
["p", pubkey, relay],
];
// Verify parent tags match root tags (lowercase)
expect(parentTags[0][1]).toBe(rootTags[0][1]); // address
expect(parentTags[1][1]).toBe(rootTags[1][1]); // kind
expect(parentTags[2][1]).toBe(rootTags[2][1]); // pubkey
});
});
describe("CommentButton - Integration Scenarios", () => {
it("complete comment flow for signed-in user", () => {
const userStore = createMockUserStore(true);
const user = get(userStore);
// User is signed in
expect(user.signedIn).toBe(true);
// Comment content is valid
const content = "Great article!";
expect(content.trim().length).toBeGreaterThan(0);
// Address is valid
const address = "30041:" + "a".repeat(64) + ":article";
const parts = address.split(":");
expect(parts.length).toBe(3);
// Event would be created with kind 1111
const kind = 1111;
expect(kind).toBe(1111);
});
it("prevents comment flow for signed-out user", () => {
const userStore = createMockUserStore(false);
const user = get(userStore);
expect(user.signedIn).toBe(false);
if (!user.signedIn) {
const error = "You must be signed in to comment";
expect(error).toBeTruthy();
}
});
it("handles comment with event ID lookup", async () => {
const mockNDK = createMockNDK();
const eventId = "c".repeat(64);
mockNDK.fetchEvent.mockResolvedValue({ id: eventId });
const targetEvent = await mockNDK.fetchEvent({});
const tags = [
["e", targetEvent.id, "wss://relay.com"],
];
expect(tags[0][1]).toBe(eventId);
});
it("handles comment without event ID lookup", () => {
const eventId = "";
const tags = [
["A", "address", "relay", "pubkey"],
["K", "kind"],
["P", "pubkey", "relay"],
["a", "address", "relay"],
["k", "kind"],
["p", "pubkey", "relay"],
];
// No e tag should be included
expect(tags.filter((t) => t[0] === "e")).toHaveLength(0);
// But all other required tags should be present
expect(tags.length).toBe(6);
});
});

136
tests/unit/deletion.test.ts

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

320
tests/unit/fetchPublicationHighlights.test.ts

@ -0,0 +1,320 @@ @@ -0,0 +1,320 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { fetchHighlightsForPublication } from "../../src/lib/utils/fetch_publication_highlights.ts";
// Mock NDKEvent class
class MockNDKEvent {
kind: number;
pubkey: string;
content: string;
tags: string[][];
created_at: number;
id: string;
sig: string;
constructor(event: {
kind: number;
pubkey: string;
content: string;
tags: string[][];
created_at?: number;
id?: string;
sig?: string;
}) {
this.kind = event.kind;
this.pubkey = event.pubkey;
this.content = event.content;
this.tags = event.tags;
this.created_at = event.created_at || Date.now() / 1000;
this.id = event.id || "mock-id";
this.sig = event.sig || "mock-sig";
}
getMatchingTags(tagName: string): string[][] {
return this.tags.filter((tag) => tag[0] === tagName);
}
tagValue(tagName: string): string | undefined {
const tag = this.tags.find((tag) => tag[0] === tagName);
return tag ? tag[1] : undefined;
}
}
describe("fetchHighlightsForPublication", () => {
let mockNDK: NDK;
let publicationEvent: NDKEvent;
let mockHighlights: MockNDKEvent[];
beforeEach(() => {
// Create the sample 30040 publication event from the user's example
publicationEvent = new MockNDKEvent({
kind: 30040,
pubkey:
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1",
content: "",
tags: [
["d", "document-test"],
["title", "Document Test"],
["author", "unknown"],
["version", "1"],
["m", "application/json"],
["M", "meta-data/index/replaceable"],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading",
],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading",
],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document",
],
["t", "a-tags"],
["t", "testfile"],
["t", "asciimath"],
["t", "latexmath"],
["image", "https://i.nostr.build/5kWwbDR04joIASVx.png"],
],
created_at: 1744910311,
id: "4585ed74a0be37655aa887340d239f0bbb9df5476165d912f098c55a71196fef",
sig:
"e6a832dcfc919c913acee62cb598211544bc8e03a3f61c016eb3bf6c8cb4fb333eff8fecc601517604c7a8029dfa73591f3218465071a532f4abfe8c0bf3662d",
}) as unknown as NDKEvent;
// Create mock highlight events for different sections
mockHighlights = [
new MockNDKEvent({
kind: 9802,
pubkey: "user-pubkey-1",
content: "This is an interesting point",
tags: [
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
],
["context", "surrounding text here"],
[
"p",
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1",
"",
"author",
],
],
id: "highlight-1",
}),
new MockNDKEvent({
kind: 9802,
pubkey: "user-pubkey-2",
content: "Another highlight on same section",
tags: [
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
],
["context", "more surrounding text"],
[
"p",
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1",
"",
"author",
],
],
id: "highlight-2",
}),
new MockNDKEvent({
kind: 9802,
pubkey: "user-pubkey-3",
content: "Highlight on different section",
tags: [
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading",
],
["context", "different section text"],
[
"p",
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1",
"",
"author",
],
],
id: "highlight-3",
}),
];
// Mock NDK instance
mockNDK = {
fetchEvents: vi.fn(async (filter) => {
// Return highlights that match the filter
const aTagFilter = filter["#a"];
if (aTagFilter) {
return new Set(
mockHighlights.filter((highlight) =>
aTagFilter.includes(highlight.tagValue("a") || "")
),
);
}
return new Set();
}),
} as unknown as NDK;
});
it("should extract section references from 30040 publication event", async () => {
const result = await fetchHighlightsForPublication(
publicationEvent,
mockNDK,
);
// Should have results for the sections that have highlights
expect(result.size).toBeGreaterThan(0);
expect(
result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
),
).toBe(true);
});
it("should fetch highlights for each section reference", async () => {
const result = await fetchHighlightsForPublication(
publicationEvent,
mockNDK,
);
// First section should have 2 highlights
const firstSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
);
expect(firstSectionHighlights?.length).toBe(2);
// Second section should have 1 highlight
const secondSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading",
);
expect(secondSectionHighlights?.length).toBe(1);
});
it("should group highlights by section address", async () => {
const result = await fetchHighlightsForPublication(
publicationEvent,
mockNDK,
);
const firstSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
);
// Verify the highlights are correctly grouped
expect(firstSectionHighlights?.[0].content).toBe(
"This is an interesting point",
);
expect(firstSectionHighlights?.[1].content).toBe(
"Another highlight on same section",
);
});
it("should not include sections without highlights", async () => {
const result = await fetchHighlightsForPublication(
publicationEvent,
mockNDK,
);
// Sections without highlights should not be in the result
expect(
result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading",
),
).toBe(false);
expect(
result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document",
),
).toBe(false);
});
it("should handle publication with no section references", async () => {
const emptyPublication = new MockNDKEvent({
kind: 30040,
pubkey: "test-pubkey",
content: "",
tags: [
["d", "empty-doc"],
["title", "Empty Document"],
],
}) as unknown as NDKEvent;
const result = await fetchHighlightsForPublication(
emptyPublication,
mockNDK,
);
expect(result.size).toBe(0);
});
it("should only process 30041 kind references, ignoring other a-tags", async () => {
const mixedPublication = new MockNDKEvent({
kind: 30040,
pubkey: "test-pubkey",
content: "",
tags: [
["d", "mixed-doc"],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
],
["a", "30023:some-pubkey:blog-post"], // Different kind, should be ignored
["a", "1:some-pubkey"], // Different kind, should be ignored
],
}) as unknown as NDKEvent;
const result = await fetchHighlightsForPublication(
mixedPublication,
mockNDK,
);
// Should call fetchEvents with only the 30041 reference
expect(mockNDK.fetchEvents).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
"#a": [
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
],
}),
);
});
it("should handle d-tags with colons correctly", async () => {
const colonPublication = new MockNDKEvent({
kind: 30040,
pubkey: "test-pubkey",
content: "",
tags: [
["d", "colon-doc"],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons",
],
],
}) as unknown as NDKEvent;
const result = await fetchHighlightsForPublication(
colonPublication,
mockNDK,
);
// Should correctly parse the section address with colons
expect(mockNDK.fetchEvents).toHaveBeenCalledWith(
expect.objectContaining({
"#a": [
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons",
],
}),
);
});
});

870
tests/unit/highlightLayer.test.ts

@ -0,0 +1,870 @@ @@ -0,0 +1,870 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pubkeyToHue } from "../../src/lib/utils/nostrUtils";
import { nip19 } from "nostr-tools";
describe("pubkeyToHue", () => {
describe("Consistency", () => {
it("returns consistent hue for same pubkey", () => {
const pubkey = "a".repeat(64);
const hue1 = pubkeyToHue(pubkey);
const hue2 = pubkeyToHue(pubkey);
expect(hue1).toBe(hue2);
});
it("returns same hue for same pubkey called multiple times", () => {
const pubkey = "abc123def456".repeat(5) + "abcd";
const hues = Array.from({ length: 10 }, () => pubkeyToHue(pubkey));
expect(new Set(hues).size).toBe(1); // All hues should be the same
});
});
describe("Range Validation", () => {
it("returns hue in valid range (0-360)", () => {
const pubkeys = [
"a".repeat(64),
"f".repeat(64),
"0".repeat(64),
"9".repeat(64),
"abc123def456".repeat(5) + "abcd",
"123456789abc".repeat(5) + "def0",
];
pubkeys.forEach((pubkey) => {
const hue = pubkeyToHue(pubkey);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
});
it("returns integer hue value", () => {
const pubkey = "a".repeat(64);
const hue = pubkeyToHue(pubkey);
expect(Number.isInteger(hue)).toBe(true);
});
});
describe("Format Handling", () => {
it("handles hex format pubkeys", () => {
const hexPubkey = "abcdef123456789".repeat(4) + "0123";
const hue = pubkeyToHue(hexPubkey);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it("handles npub format pubkeys", () => {
const hexPubkey = "a".repeat(64);
const npub = nip19.npubEncode(hexPubkey);
const hue = pubkeyToHue(npub);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it("returns same hue for hex and npub format of same pubkey", () => {
const hexPubkey = "abc123def456".repeat(5) + "abcd";
const npub = nip19.npubEncode(hexPubkey);
const hueFromHex = pubkeyToHue(hexPubkey);
const hueFromNpub = pubkeyToHue(npub);
expect(hueFromHex).toBe(hueFromNpub);
});
});
describe("Uniqueness", () => {
it("different pubkeys generate different hues", () => {
const pubkey1 = "a".repeat(64);
const pubkey2 = "b".repeat(64);
const pubkey3 = "c".repeat(64);
const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2);
const hue3 = pubkeyToHue(pubkey3);
expect(hue1).not.toBe(hue2);
expect(hue2).not.toBe(hue3);
expect(hue1).not.toBe(hue3);
});
it("generates diverse hues for multiple pubkeys", () => {
const pubkeys = Array.from(
{ length: 10 },
(_, i) => String.fromCharCode(97 + i).repeat(64),
);
const hues = pubkeys.map((pk) => pubkeyToHue(pk));
const uniqueHues = new Set(hues);
// Most pubkeys should generate unique hues (allowing for some collisions)
expect(uniqueHues.size).toBeGreaterThan(7);
});
});
describe("Edge Cases", () => {
it("handles empty string input", () => {
const hue = pubkeyToHue("");
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it("handles invalid npub format gracefully", () => {
const invalidNpub = "npub1invalid";
const hue = pubkeyToHue(invalidNpub);
// Should still return a valid hue even if decode fails
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it("handles short input strings", () => {
const shortInput = "abc";
const hue = pubkeyToHue(shortInput);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it("handles special characters", () => {
const specialInput = "!@#$%^&*()";
const hue = pubkeyToHue(specialInput);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
});
describe("Color Distribution", () => {
it("distributes colors across the spectrum", () => {
// Generate hues for many different pubkeys
const pubkeys = Array.from(
{ length: 50 },
(_, i) => i.toString().repeat(16),
);
const hues = pubkeys.map((pk) => pubkeyToHue(pk));
// Check that we have hues in different ranges of the spectrum
const hasLowHues = hues.some((h) => h < 120);
const hasMidHues = hues.some((h) => h >= 120 && h < 240);
const hasHighHues = hues.some((h) => h >= 240);
expect(hasLowHues).toBe(true);
expect(hasMidHues).toBe(true);
expect(hasHighHues).toBe(true);
});
});
});
describe("HighlightLayer Component", () => {
let mockNdk: any;
let mockSubscription: any;
let eventHandlers: Map<string, Function>;
beforeEach(() => {
eventHandlers = new Map();
// Mock NDK subscription
mockSubscription = {
on: vi.fn((event: string, handler: Function) => {
eventHandlers.set(event, handler);
}),
stop: vi.fn(),
};
mockNdk = {
subscribe: vi.fn(() => mockSubscription),
};
// Mock DOM APIs
global.document = {
createTreeWalker: vi.fn(() => ({
nextNode: vi.fn(() => null),
})),
createDocumentFragment: vi.fn(() => ({
appendChild: vi.fn(),
})),
createTextNode: vi.fn((text: string) => ({
textContent: text,
})),
createElement: vi.fn((tag: string) => ({
className: "",
style: {},
textContent: "",
})),
} as any;
});
afterEach(() => {
vi.clearAllMocks();
});
describe("NDK Subscription", () => {
it("fetches kind 9802 events with correct filter when eventId provided", () => {
const eventId = "a".repeat(64);
// Simulate calling fetchHighlights
mockNdk.subscribe({ kinds: [9802], "#e": [eventId], limit: 100 });
expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
"#e": [eventId],
limit: 100,
}),
);
});
it("fetches kind 9802 events with correct filter when eventAddress provided", () => {
const eventAddress = "30040:" + "a".repeat(64) + ":chapter-1";
// Simulate calling fetchHighlights
mockNdk.subscribe({ kinds: [9802], "#a": [eventAddress], limit: 100 });
expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
"#a": [eventAddress],
limit: 100,
}),
);
});
it("fetches with both eventId and eventAddress filters when both provided", () => {
const eventId = "a".repeat(64);
const eventAddress = "30040:" + "b".repeat(64) + ":chapter-1";
// Simulate calling fetchHighlights
mockNdk.subscribe({
kinds: [9802],
"#e": [eventId],
"#a": [eventAddress],
limit: 100,
});
expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
"#e": [eventId],
"#a": [eventAddress],
limit: 100,
}),
);
});
it("cleans up subscription on unmount", () => {
mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Simulate unmount by calling stop
mockSubscription.stop();
expect(mockSubscription.stop).toHaveBeenCalled();
});
});
describe("Color Mapping", () => {
it("maps highlights to colors correctly", () => {
const pubkey1 = "a".repeat(64);
const pubkey2 = "b".repeat(64);
const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2);
const expectedColor1 = `hsla(${hue1}, 70%, 60%, 0.3)`;
const expectedColor2 = `hsla(${hue2}, 70%, 60%, 0.3)`;
expect(expectedColor1).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
expect(expectedColor2).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
expect(expectedColor1).not.toBe(expectedColor2);
});
it("uses consistent color for same pubkey", () => {
const pubkey = "abc123def456".repeat(5) + "abcd";
const hue = pubkeyToHue(pubkey);
const color1 = `hsla(${hue}, 70%, 60%, 0.3)`;
const color2 = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color1).toBe(color2);
});
it("generates semi-transparent colors with 0.3 opacity", () => {
const pubkey = "a".repeat(64);
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain("0.3");
});
it("uses HSL color format with correct values", () => {
const pubkey = "a".repeat(64);
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
// Verify format: hsla(hue, 70%, 60%, 0.3)
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
});
});
describe("Highlight Events", () => {
it("handles no highlights gracefully", () => {
const highlights: any[] = [];
expect(highlights.length).toBe(0);
// Component should render without errors
});
it("handles single highlight from one user", () => {
const mockHighlight = {
id: "highlight1",
kind: 9802,
pubkey: "a".repeat(64),
content: "highlighted text",
created_at: Date.now(),
tags: [],
};
const highlights = [mockHighlight];
expect(highlights.length).toBe(1);
expect(highlights[0].pubkey).toBe("a".repeat(64));
});
it("handles multiple highlights from same user", () => {
const pubkey = "a".repeat(64);
const mockHighlights = [
{
id: "highlight1",
kind: 9802,
pubkey: pubkey,
content: "first highlight",
created_at: Date.now(),
tags: [],
},
{
id: "highlight2",
kind: 9802,
pubkey: pubkey,
content: "second highlight",
created_at: Date.now(),
tags: [],
},
];
expect(mockHighlights.length).toBe(2);
expect(mockHighlights[0].pubkey).toBe(mockHighlights[1].pubkey);
// Should use same color for both
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
});
it("handles multiple highlights from different users", () => {
const pubkey1 = "a".repeat(64);
const pubkey2 = "b".repeat(64);
const pubkey3 = "c".repeat(64);
const mockHighlights = [
{
id: "highlight1",
kind: 9802,
pubkey: pubkey1,
content: "highlight from user 1",
created_at: Date.now(),
tags: [],
},
{
id: "highlight2",
kind: 9802,
pubkey: pubkey2,
content: "highlight from user 2",
created_at: Date.now(),
tags: [],
},
{
id: "highlight3",
kind: 9802,
pubkey: pubkey3,
content: "highlight from user 3",
created_at: Date.now(),
tags: [],
},
];
expect(mockHighlights.length).toBe(3);
// Each should have different color
const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2);
const hue3 = pubkeyToHue(pubkey3);
expect(hue1).not.toBe(hue2);
expect(hue2).not.toBe(hue3);
expect(hue1).not.toBe(hue3);
});
it("prevents duplicate highlights", () => {
const mockHighlight = {
id: "highlight1",
kind: 9802,
pubkey: "a".repeat(64),
content: "highlighted text",
created_at: Date.now(),
tags: [],
};
const highlights = [mockHighlight];
// Try to add duplicate
const isDuplicate = highlights.some((h) => h.id === mockHighlight.id);
expect(isDuplicate).toBe(true);
// Should not add duplicate
});
it("handles empty content gracefully", () => {
const mockHighlight = {
id: "highlight1",
kind: 9802,
pubkey: "a".repeat(64),
content: "",
created_at: Date.now(),
tags: [],
};
// Should not crash
expect(mockHighlight.content).toBe("");
});
it("handles whitespace-only content", () => {
const mockHighlight = {
id: "highlight1",
kind: 9802,
pubkey: "a".repeat(64),
content: " \n\t ",
created_at: Date.now(),
tags: [],
};
const trimmed = mockHighlight.content.trim();
expect(trimmed.length).toBe(0);
});
});
describe("Highlighter Legend", () => {
it("displays legend with correct color for single highlighter", () => {
const pubkey = "abc123def456".repeat(5) + "abcd";
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
const legend = {
pubkey: pubkey,
color: color,
shortPubkey: `${pubkey.slice(0, 8)}...`,
};
expect(legend.color).toBe(color);
expect(legend.shortPubkey).toBe(`${pubkey.slice(0, 8)}...`);
});
it("displays legend with colors for multiple highlighters", () => {
const pubkeys = [
"a".repeat(64),
"b".repeat(64),
"c".repeat(64),
];
const legendEntries = pubkeys.map((pubkey) => ({
pubkey,
color: `hsla(${pubkeyToHue(pubkey)}, 70%, 60%, 0.3)`,
shortPubkey: `${pubkey.slice(0, 8)}...`,
}));
expect(legendEntries.length).toBe(3);
// Each should have unique color
const colors = legendEntries.map((e) => e.color);
const uniqueColors = new Set(colors);
expect(uniqueColors.size).toBe(3);
});
it("shows truncated pubkey in legend", () => {
const pubkey = "abcdefghijklmnop".repeat(4);
const shortPubkey = `${pubkey.slice(0, 8)}...`;
expect(shortPubkey).toBe("abcdefgh...");
expect(shortPubkey.length).toBeLessThan(pubkey.length);
});
it("displays highlight count", () => {
const highlights = [
{ id: "1", pubkey: "a".repeat(64), content: "text1" },
{ id: "2", pubkey: "b".repeat(64), content: "text2" },
{ id: "3", pubkey: "a".repeat(64), content: "text3" },
];
expect(highlights.length).toBe(3);
// Count unique highlighters
const uniqueHighlighters = new Set(highlights.map((h) => h.pubkey));
expect(uniqueHighlighters.size).toBe(2);
});
});
describe("Text Matching", () => {
it("matches text case-insensitively", () => {
const searchText = "Hello World";
const contentText = "hello world";
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0);
});
it("handles special characters in search text", () => {
const searchText = 'text with "quotes" and symbols!';
const contentText = 'This is text with "quotes" and symbols! in it.';
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0);
});
it("handles Unicode characters", () => {
const searchText = "café résumé";
const contentText = "The café résumé was excellent.";
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0);
});
it("handles multi-line text", () => {
const searchText = "line one\nline two";
const contentText = "This is line one\nline two in the document.";
const index = contentText.indexOf(searchText);
expect(index).toBeGreaterThanOrEqual(0);
});
it("does not match partial words when searching for whole words", () => {
const searchText = "cat";
const contentText = "The category is important.";
// Simple word boundary check
const wordBoundaryMatch = new RegExp(`\\b${searchText}\\b`, "i").test(
contentText,
);
expect(wordBoundaryMatch).toBe(false);
});
});
describe("Subscription Lifecycle", () => {
it("registers EOSE event handler", () => {
const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Verify that 'on' method is available for registering handlers
expect(subscription.on).toBeDefined();
// Register EOSE handler
subscription.on("eose", () => {
subscription.stop();
});
// Verify on was called
expect(subscription.on).toHaveBeenCalledWith(
"eose",
expect.any(Function),
);
});
it("registers error event handler", () => {
const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Verify that 'on' method is available for registering handlers
expect(subscription.on).toBeDefined();
// Register error handler
subscription.on("error", () => {
subscription.stop();
});
// Verify on was called
expect(subscription.on).toHaveBeenCalledWith(
"error",
expect.any(Function),
);
});
it("stops subscription on timeout", async () => {
vi.useFakeTimers();
mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Fast-forward time by 10 seconds
vi.advanceTimersByTime(10000);
// Subscription should be stopped after timeout
// Note: This would be tested in the actual component
vi.useRealTimers();
});
it("handles multiple subscription cleanup calls safely", () => {
mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Call stop multiple times
mockSubscription.stop();
mockSubscription.stop();
mockSubscription.stop();
expect(mockSubscription.stop).toHaveBeenCalledTimes(3);
// Should not throw errors
});
});
describe("Performance", () => {
it("handles large number of highlights efficiently", () => {
const startTime = Date.now();
const highlights = Array.from({ length: 1000 }, (_, i) => ({
id: `highlight${i}`,
kind: 9802,
pubkey: (i % 10).toString().repeat(64),
content: `highlighted text ${i}`,
created_at: Date.now(),
tags: [],
}));
// Generate colors for all highlights
const colorMap = new Map<string, string>();
highlights.forEach((h) => {
if (!colorMap.has(h.pubkey)) {
const hue = pubkeyToHue(h.pubkey);
colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
}
});
const endTime = Date.now();
const duration = endTime - startTime;
expect(highlights.length).toBe(1000);
expect(colorMap.size).toBe(10);
expect(duration).toBeLessThan(1000); // Should complete in less than 1 second
});
});
});
describe("Integration Tests", () => {
describe("Toggle Functionality", () => {
it("toggle button shows highlights when clicked", () => {
let highlightsVisible = false;
// Simulate toggle
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(true);
});
it("toggle button hides highlights when clicked again", () => {
let highlightsVisible = true;
// Simulate toggle
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(false);
});
it("toggle state persists between interactions", () => {
let highlightsVisible = false;
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(true);
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(false);
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(true);
});
});
describe("Color Format Validation", () => {
it("generates semi-transparent colors with 0.3 opacity", () => {
const pubkeys = [
"a".repeat(64),
"b".repeat(64),
"c".repeat(64),
];
pubkeys.forEach((pubkey) => {
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain("0.3");
});
});
it("uses HSL color format with correct saturation and lightness", () => {
const pubkey = "a".repeat(64);
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain("70%");
expect(color).toContain("60%");
});
it("generates valid CSS color strings", () => {
const pubkeys = Array.from(
{ length: 20 },
(_, i) => String.fromCharCode(97 + i).repeat(64),
);
pubkeys.forEach((pubkey) => {
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
// Validate CSS color format
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
});
});
});
describe("End-to-End Flow", () => {
it("complete highlight workflow", () => {
// 1. Start with no highlights visible
let highlightsVisible = false;
let highlights: any[] = [];
expect(highlightsVisible).toBe(false);
expect(highlights.length).toBe(0);
// 2. Fetch highlights
const mockHighlights = [
{
id: "h1",
kind: 9802,
pubkey: "a".repeat(64),
content: "first highlight",
created_at: Date.now(),
tags: [],
},
{
id: "h2",
kind: 9802,
pubkey: "b".repeat(64),
content: "second highlight",
created_at: Date.now(),
tags: [],
},
];
highlights = mockHighlights;
expect(highlights.length).toBe(2);
// 3. Generate color map
const colorMap = new Map<string, string>();
highlights.forEach((h) => {
if (!colorMap.has(h.pubkey)) {
const hue = pubkeyToHue(h.pubkey);
colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
}
});
expect(colorMap.size).toBe(2);
// 4. Toggle visibility
highlightsVisible = true;
expect(highlightsVisible).toBe(true);
// 5. Verify colors are different
const colors = Array.from(colorMap.values());
expect(colors[0]).not.toBe(colors[1]);
// 6. Toggle off
highlightsVisible = false;
expect(highlightsVisible).toBe(false);
});
it("handles event updates correctly", () => {
let eventId = "event1";
let highlights: any[] = [];
// Initial load
highlights = [
{
id: "h1",
kind: 9802,
pubkey: "a".repeat(64),
content: "highlight 1",
created_at: Date.now(),
tags: [],
},
];
expect(highlights.length).toBe(1);
// Event changes
eventId = "event2";
highlights = [];
expect(highlights.length).toBe(0);
// New highlights loaded
highlights = [
{
id: "h2",
kind: 9802,
pubkey: "b".repeat(64),
content: "highlight 2",
created_at: Date.now(),
tags: [],
},
];
expect(highlights.length).toBe(1);
expect(highlights[0].id).toBe("h2");
});
});
describe("Error Handling", () => {
it("handles missing event ID and address gracefully", () => {
const eventId = undefined;
const eventAddress = undefined;
// Should not attempt to fetch
expect(eventId).toBeUndefined();
expect(eventAddress).toBeUndefined();
});
it("handles subscription errors gracefully", () => {
const error = new Error("Subscription failed");
// Should log error but not crash
expect(error.message).toBe("Subscription failed");
});
it("handles malformed highlight events", () => {
const malformedHighlight = {
id: "h1",
kind: 9802,
pubkey: "", // Empty pubkey
content: undefined, // Missing content
created_at: Date.now(),
tags: [],
};
// Should handle gracefully
expect(malformedHighlight.pubkey).toBe("");
expect(malformedHighlight.content).toBeUndefined();
});
});
});

875
tests/unit/highlightSelection.test.ts

@ -0,0 +1,875 @@ @@ -0,0 +1,875 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
// Mock flowbite-svelte components
vi.mock("flowbite-svelte", () => ({
Button: vi.fn().mockImplementation((props) => ({
$$render: () =>
`<button data-testid="button">${props.children || ""}</button>`,
})),
Modal: vi.fn().mockImplementation(() => ({
$$render: () => `<div data-testid="modal"></div>`,
})),
Textarea: vi.fn().mockImplementation(() => ({
$$render: () => `<textarea data-testid="textarea"></textarea>`,
})),
P: vi.fn().mockImplementation(() => ({
$$render: () => `<p data-testid="p"></p>`,
})),
}));
// Mock flowbite-svelte-icons
vi.mock("flowbite-svelte-icons", () => ({
FontHighlightOutline: vi.fn().mockImplementation(() => ({
$$render: () => `<svg data-testid="highlight-icon"></svg>`,
})),
}));
describe("HighlightButton Component Logic", () => {
let isActive: boolean;
beforeEach(() => {
isActive = false;
});
describe("Initial State", () => {
it("should initialize with inactive state", () => {
expect(isActive).toBe(false);
});
it("should have correct inactive label", () => {
const label = isActive ? "Exit Highlight Mode" : "Add Highlight";
expect(label).toBe("Add Highlight");
});
it("should have correct inactive title", () => {
const title = isActive ? "Exit highlight mode" : "Enter highlight mode";
expect(title).toBe("Enter highlight mode");
});
it("should have correct inactive color", () => {
const color = isActive ? "primary" : "light";
expect(color).toBe("light");
});
it("should not have ring styling when inactive", () => {
const ringClass = isActive ? "ring-2 ring-primary-500" : "";
expect(ringClass).toBe("");
});
});
describe("Toggle Functionality", () => {
it("should toggle to active state when clicked", () => {
// Simulate toggle
isActive = !isActive;
expect(isActive).toBe(true);
});
it("should toggle back to inactive state on second click", () => {
// Simulate two toggles
isActive = !isActive;
isActive = !isActive;
expect(isActive).toBe(false);
});
it("should show correct label when active", () => {
isActive = true;
const label = isActive ? "Exit Highlight Mode" : "Add Highlight";
expect(label).toBe("Exit Highlight Mode");
});
it("should show correct title when active", () => {
isActive = true;
const title = isActive ? "Exit highlight mode" : "Enter highlight mode";
expect(title).toBe("Exit highlight mode");
});
});
describe("Active State Styling", () => {
it("should apply primary color when active", () => {
isActive = true;
const color = isActive ? "primary" : "light";
expect(color).toBe("primary");
});
it("should apply ring styling when active", () => {
isActive = true;
const ringClass = isActive ? "ring-2 ring-primary-500" : "";
expect(ringClass).toBe("ring-2 ring-primary-500");
});
});
});
describe("HighlightSelectionHandler Component Logic", () => {
let mockNDK: NDKEvent;
let mockUserStore: any;
let mockSelection: Selection;
let mockPublicationEvent: NDKEvent;
let isActive: boolean;
beforeEach(() => {
// Reset mocks
vi.clearAllMocks();
isActive = false;
// Mock document and DOM elements
const mockElement = {
createElement: vi.fn((tag: string) => ({
tagName: tag.toUpperCase(),
textContent: "",
className: "",
closest: vi.fn(),
parentElement: null,
})),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
body: {
classList: {
add: vi.fn(),
remove: vi.fn(),
},
},
};
global.document = mockElement as any;
// Mock NDK event
mockPublicationEvent = {
id: "test-event-id",
pubkey: "test-pubkey",
kind: 30023,
tagAddress: vi.fn().mockReturnValue("30023:test-pubkey:test-d-tag"),
tags: [],
content: "",
} as unknown as NDKEvent;
// Mock user store
mockUserStore = {
signedIn: true,
signer: {
sign: vi.fn().mockResolvedValue(undefined),
},
};
// Mock window.getSelection
const mockParagraph = {
textContent: "This is the full paragraph context",
closest: vi.fn(),
};
mockSelection = {
toString: vi.fn().mockReturnValue("Selected text from publication"),
isCollapsed: false,
removeAllRanges: vi.fn(),
anchorNode: {
parentElement: mockParagraph,
},
} as unknown as Selection;
global.window = {
getSelection: vi.fn().mockReturnValue(mockSelection),
} as any;
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Selection Detection", () => {
it("should ignore mouseup events when isActive is false", () => {
isActive = false;
const shouldProcess = isActive;
expect(shouldProcess).toBe(false);
});
it("should process mouseup events when isActive is true", () => {
isActive = true;
const shouldProcess = isActive;
expect(shouldProcess).toBe(true);
});
it("should ignore collapsed selections", () => {
const selection = { isCollapsed: true } as Selection;
const shouldIgnore = selection.isCollapsed;
expect(shouldIgnore).toBe(true);
});
it("should process non-collapsed selections", () => {
const selection = { isCollapsed: false } as Selection;
const shouldIgnore = selection.isCollapsed;
expect(shouldIgnore).toBe(false);
});
it("should ignore selections with less than 3 characters", () => {
const text = "ab";
const isValid = text.length >= 3;
expect(isValid).toBe(false);
});
it("should accept selections with 3 or more characters", () => {
const text = "abc";
const isValid = text.length >= 3;
expect(isValid).toBe(true);
});
it("should ignore empty selections after trim", () => {
const text = " ";
const trimmed = text.trim();
const isValid = trimmed.length >= 3;
expect(isValid).toBe(false);
});
});
describe("User Authentication", () => {
it("should reject selection when user not signed in", () => {
const userStore = { signedIn: false };
expect(userStore.signedIn).toBe(false);
});
it("should process selection when user signed in", () => {
const userStore = { signedIn: true };
expect(userStore.signedIn).toBe(true);
});
it("should check for signer before creating highlight", () => {
const userStore = {
signedIn: true,
signer: { sign: vi.fn() },
};
expect(userStore.signer).toBeDefined();
});
it("should reject creation without signer", () => {
const userStore = {
signedIn: true,
signer: null,
};
expect(userStore.signer).toBeNull();
});
});
describe("Publication Context Detection", () => {
it("should detect selection within publication-leather class", () => {
const mockElement = {
className: "publication-leather",
closest: vi.fn((selector: string) => {
return selector === ".publication-leather" ? mockElement : null;
}),
};
const target = mockElement;
const publicationSection = target.closest(".publication-leather");
expect(publicationSection).toBeTruthy();
});
it("should reject selection outside publication-leather class", () => {
const mockElement = {
className: "other-section",
closest: vi.fn((selector: string) => {
return selector === ".publication-leather" ? null : mockElement;
}),
};
const target = mockElement;
const publicationSection = target.closest(".publication-leather");
expect(publicationSection).toBeNull();
});
});
describe("Context Extraction", () => {
it("should extract context from parent paragraph", () => {
const paragraph = {
textContent:
"This is the full paragraph context with selected text inside.",
};
const context = paragraph.textContent?.trim() || "";
expect(context).toBe(
"This is the full paragraph context with selected text inside.",
);
});
it("should extract context from parent section", () => {
const section = {
textContent: "Full section context including selected text.",
};
const context = section.textContent?.trim() || "";
expect(context).toBe("Full section context including selected text.");
});
it("should extract context from parent div", () => {
const div = {
textContent: "Full div context including selected text.",
};
const context = div.textContent?.trim() || "";
expect(context).toBe("Full div context including selected text.");
});
it("should handle missing context gracefully", () => {
const context = "";
expect(context).toBe("");
});
});
describe("NIP-84 Event Creation - Addressable Events", () => {
it("should use 'a' tag for addressable events", () => {
const eventAddress = "30023:pubkey:d-tag";
const tags: string[][] = [];
if (eventAddress) {
tags.push(["a", eventAddress, ""]);
}
expect(tags).toContainEqual(["a", eventAddress, ""]);
});
it("should create event with correct kind 9802", () => {
const event = {
kind: 9802,
content: "",
tags: [],
};
expect(event.kind).toBe(9802);
});
it("should include selected text as content", () => {
const selectedText = "This is the selected highlight text";
const event = {
kind: 9802,
content: selectedText,
tags: [],
};
expect(event.content).toBe(selectedText);
});
it("should include context tag", () => {
const context = "This is the surrounding context";
const tags: string[][] = [];
if (context) {
tags.push(["context", context]);
}
expect(tags).toContainEqual(["context", context]);
});
it("should include author p-tag with role", () => {
const pubkey = "author-pubkey-hex";
const tags: string[][] = [];
if (pubkey) {
tags.push(["p", pubkey, "", "author"]);
}
expect(tags).toContainEqual(["p", pubkey, "", "author"]);
});
it("should include comment tag when comment provided", () => {
const comment = "This is my insightful comment";
const tags: string[][] = [];
if (comment.trim()) {
tags.push(["comment", comment.trim()]);
}
expect(tags).toContainEqual(["comment", comment]);
});
it("should not include comment tag when comment is empty", () => {
const comment = "";
const tags: string[][] = [];
if (comment.trim()) {
tags.push(["comment", comment.trim()]);
}
expect(tags).not.toContainEqual(["comment", ""]);
expect(tags.length).toBe(0);
});
it("should not include comment tag when comment is only whitespace", () => {
const comment = " ";
const tags: string[][] = [];
if (comment.trim()) {
tags.push(["comment", comment.trim()]);
}
expect(tags.length).toBe(0);
});
});
describe("NIP-84 Event Creation - Regular Events", () => {
it("should use 'e' tag for regular events", () => {
const eventId = "regular-event-id";
const eventAddress = null; // No address means regular event
const tags: string[][] = [];
if (eventAddress) {
tags.push(["a", eventAddress, ""]);
} else {
tags.push(["e", eventId, ""]);
}
expect(tags).toContainEqual(["e", eventId, ""]);
});
it("should prefer addressable event over regular event", () => {
const eventId = "regular-event-id";
const eventAddress = "30023:pubkey:d-tag";
const tags: string[][] = [];
if (eventAddress) {
tags.push(["a", eventAddress, ""]);
} else {
tags.push(["e", eventId, ""]);
}
expect(tags).toContainEqual(["a", eventAddress, ""]);
expect(tags).not.toContainEqual(["e", eventId, ""]);
});
});
describe("Complete Event Structure", () => {
it("should create complete highlight event with all required tags", () => {
const selectedText = "Highlighted text";
const context = "Full context paragraph";
const pubkey = "author-pubkey";
const eventAddress = "30023:pubkey:d-tag";
const event = {
kind: 9802,
content: selectedText,
tags: [
["a", eventAddress, ""],
["context", context],
["p", pubkey, "", "author"],
],
};
expect(event.kind).toBe(9802);
expect(event.content).toBe(selectedText);
expect(event.tags).toHaveLength(3);
expect(event.tags[0]).toEqual(["a", eventAddress, ""]);
expect(event.tags[1]).toEqual(["context", context]);
expect(event.tags[2]).toEqual(["p", pubkey, "", "author"]);
});
it("should create complete quote highlight with comment", () => {
const selectedText = "Highlighted text";
const context = "Full context paragraph";
const pubkey = "author-pubkey";
const eventAddress = "30023:pubkey:d-tag";
const comment = "My thoughtful comment";
const event = {
kind: 9802,
content: selectedText,
tags: [
["a", eventAddress, ""],
["context", context],
["p", pubkey, "", "author"],
["comment", comment],
],
};
expect(event.kind).toBe(9802);
expect(event.content).toBe(selectedText);
expect(event.tags).toHaveLength(4);
expect(event.tags[3]).toEqual(["comment", comment]);
});
it("should handle event without context", () => {
const selectedText = "Highlighted text";
const context = "";
const pubkey = "author-pubkey";
const eventId = "event-id";
const tags: string[][] = [];
tags.push(["e", eventId, ""]);
if (context) {
tags.push(["context", context]);
}
tags.push(["p", pubkey, "", "author"]);
expect(tags).toHaveLength(2);
expect(tags).not.toContainEqual(["context", ""]);
});
});
describe("Event Signing and Publishing", () => {
it("should sign event before publishing", async () => {
const mockSigner = {
sign: vi.fn().mockResolvedValue(undefined),
};
const mockEvent = {
kind: 9802,
content: "test",
tags: [],
sign: vi.fn().mockResolvedValue(undefined),
publish: vi.fn().mockResolvedValue(undefined),
};
await mockEvent.sign(mockSigner);
expect(mockEvent.sign).toHaveBeenCalledWith(mockSigner);
});
it("should publish event after signing", async () => {
const mockEvent = {
sign: vi.fn().mockResolvedValue(undefined),
publish: vi.fn().mockResolvedValue(undefined),
};
await mockEvent.sign({});
await mockEvent.publish();
expect(mockEvent.publish).toHaveBeenCalled();
});
it("should handle signing errors", async () => {
const mockEvent = {
sign: vi.fn().mockRejectedValue(new Error("Signing failed")),
};
await expect(mockEvent.sign({})).rejects.toThrow("Signing failed");
});
it("should handle publishing errors", async () => {
const mockEvent = {
sign: vi.fn().mockResolvedValue(undefined),
publish: vi.fn().mockRejectedValue(new Error("Publishing failed")),
};
await mockEvent.sign({});
await expect(mockEvent.publish()).rejects.toThrow("Publishing failed");
});
});
describe("Selection Cleanup", () => {
it("should clear selection after successful highlight creation", () => {
const mockSelection = {
removeAllRanges: vi.fn(),
};
mockSelection.removeAllRanges();
expect(mockSelection.removeAllRanges).toHaveBeenCalled();
});
it("should reset selectedText after creation", () => {
let selectedText = "Some text";
selectedText = "";
expect(selectedText).toBe("");
});
it("should reset comment after creation", () => {
let comment = "Some comment";
comment = "";
expect(comment).toBe("");
});
it("should reset context after creation", () => {
let context = "Some context";
context = "";
expect(context).toBe("");
});
it("should close modal after creation", () => {
let showModal = true;
showModal = false;
expect(showModal).toBe(false);
});
});
describe("Cancel Functionality", () => {
it("should clear selection when cancelled", () => {
const mockSelection = {
removeAllRanges: vi.fn(),
};
// Simulate cancel
mockSelection.removeAllRanges();
expect(mockSelection.removeAllRanges).toHaveBeenCalled();
});
it("should reset all state when cancelled", () => {
let selectedText = "text";
let comment = "comment";
let context = "context";
let showModal = true;
// Simulate cancel
selectedText = "";
comment = "";
context = "";
showModal = false;
expect(selectedText).toBe("");
expect(comment).toBe("");
expect(context).toBe("");
expect(showModal).toBe(false);
});
});
describe("Feedback Messages", () => {
it("should show success message after creation", () => {
const message = "Highlight created successfully!";
const type = "success";
expect(message).toBe("Highlight created successfully!");
expect(type).toBe("success");
});
it("should show error message on failure", () => {
const message = "Failed to create highlight. Please try again.";
const type = "error";
expect(message).toBe("Failed to create highlight. Please try again.");
expect(type).toBe("error");
});
it("should show error when not signed in", () => {
const message = "Please sign in to create highlights";
const type = "error";
expect(message).toBe("Please sign in to create highlights");
expect(type).toBe("error");
});
it("should auto-hide feedback after delay", () => {
let showFeedback = true;
// Simulate timeout
setTimeout(() => {
showFeedback = false;
}, 3000);
// Initially shown
expect(showFeedback).toBe(true);
});
});
describe("Event Listeners", () => {
it("should add mouseup listener on mount", () => {
const mockAddEventListener = vi.fn();
document.addEventListener = mockAddEventListener;
document.addEventListener("mouseup", () => {});
expect(mockAddEventListener).toHaveBeenCalledWith(
"mouseup",
expect.any(Function),
);
});
it("should remove mouseup listener on unmount", () => {
const mockRemoveEventListener = vi.fn();
document.removeEventListener = mockRemoveEventListener;
const handler = () => {};
document.removeEventListener("mouseup", handler);
expect(mockRemoveEventListener).toHaveBeenCalledWith("mouseup", handler);
});
});
describe("Highlight Mode Body Class", () => {
it("should add highlight-mode-active class when active", () => {
const mockClassList = {
add: vi.fn(),
remove: vi.fn(),
};
document.body.classList = mockClassList as any;
// Simulate active mode
document.body.classList.add("highlight-mode-active");
expect(mockClassList.add).toHaveBeenCalledWith("highlight-mode-active");
});
it("should remove highlight-mode-active class when inactive", () => {
const mockClassList = {
add: vi.fn(),
remove: vi.fn(),
};
document.body.classList = mockClassList as any;
// Simulate inactive mode
document.body.classList.remove("highlight-mode-active");
expect(mockClassList.remove).toHaveBeenCalledWith(
"highlight-mode-active",
);
});
it("should clean up class on unmount", () => {
const mockClassList = {
add: vi.fn(),
remove: vi.fn(),
};
document.body.classList = mockClassList as any;
// Simulate cleanup
document.body.classList.remove("highlight-mode-active");
expect(mockClassList.remove).toHaveBeenCalledWith(
"highlight-mode-active",
);
});
});
describe("Modal Display", () => {
it("should show modal when text is selected", () => {
let showModal = false;
// Simulate successful selection
showModal = true;
expect(showModal).toBe(true);
});
it("should display selected text in modal", () => {
const selectedText = "This is the selected text";
const displayText = `"${selectedText}"`;
expect(displayText).toBe('"This is the selected text"');
});
it("should provide textarea for optional comment", () => {
let comment = "";
const placeholder = "Share your thoughts about this highlight...";
expect(placeholder).toBe("Share your thoughts about this highlight...");
expect(comment).toBe("");
});
it("should disable buttons while submitting", () => {
const isSubmitting = true;
const disabled = isSubmitting;
expect(disabled).toBe(true);
});
it("should show 'Creating...' text while submitting", () => {
const isSubmitting = true;
const buttonText = isSubmitting ? "Creating..." : "Create Highlight";
expect(buttonText).toBe("Creating...");
});
it("should show normal text when not submitting", () => {
const isSubmitting = false;
const buttonText = isSubmitting ? "Creating..." : "Create Highlight";
expect(buttonText).toBe("Create Highlight");
});
});
describe("Callback Execution", () => {
it("should call onHighlightCreated callback after creation", () => {
const mockCallback = vi.fn();
// Simulate successful creation
mockCallback();
expect(mockCallback).toHaveBeenCalled();
});
it("should not call callback if creation fails", () => {
const mockCallback = vi.fn();
// Simulate failed creation - callback not called
expect(mockCallback).not.toHaveBeenCalled();
});
});
describe("Integration Scenarios", () => {
it("should handle complete highlight workflow", () => {
// Setup
let isActive = true;
let showModal = false;
let selectedText = "";
const userSignedIn = true;
const selection = {
toString: () => "Selected text for highlighting",
isCollapsed: false,
};
// User selects text
if (isActive && userSignedIn && !selection.isCollapsed) {
selectedText = selection.toString();
showModal = true;
}
expect(selectedText).toBe("Selected text for highlighting");
expect(showModal).toBe(true);
});
it("should handle complete quote highlight workflow with comment", () => {
// Setup
let isActive = true;
let showModal = false;
let selectedText = "";
let comment = "";
const userSignedIn = true;
const selection = {
toString: () => "Selected text",
isCollapsed: false,
};
// User selects text
if (isActive && userSignedIn && !selection.isCollapsed) {
selectedText = selection.toString();
showModal = true;
}
// User adds comment
comment = "This is insightful";
// Create event with comment
const tags: string[][] = [];
if (comment.trim()) {
tags.push(["comment", comment]);
}
expect(selectedText).toBe("Selected text");
expect(comment).toBe("This is insightful");
expect(tags).toContainEqual(["comment", "This is insightful"]);
});
it("should reject workflow when user not signed in", () => {
let isActive = true;
let showModal = false;
const userSignedIn = false;
const selection = {
toString: () => "Selected text",
isCollapsed: false,
};
// User tries to select text
if (isActive && userSignedIn && !selection.isCollapsed) {
showModal = true;
}
expect(showModal).toBe(false);
});
it("should handle workflow cancellation", () => {
// Setup initial state
let showModal = true;
let selectedText = "Some text";
let comment = "Some comment";
const mockSelection = {
removeAllRanges: vi.fn(),
};
// User cancels
showModal = false;
selectedText = "";
comment = "";
mockSelection.removeAllRanges();
expect(showModal).toBe(false);
expect(selectedText).toBe("");
expect(comment).toBe("");
expect(mockSelection.removeAllRanges).toHaveBeenCalled();
});
});
});

121
tests/unit/publication_tree_processor.test.ts

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

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

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
/**
* Test-Driven Development for ZettelPublisher Enhancement
* Based on understanding_knowledge.adoc, desire.adoc, and docreference.md
*
*
* Key Requirements Discovered:
* 1. ITERATIVE parsing (not recursive): sections at target level become events
* 2. Level 2: == sections become 30041 events containing ALL subsections (===, ====, etc.)
@ -14,8 +14,8 @@ @@ -14,8 +14,8 @@
* 7. Custom attributes: all :key: value pairs preserved as event tags
*/
import fs from 'fs';
import path from 'path';
import fs from "fs";
import path from "path";
// Test framework
interface TestCase {
@ -40,7 +40,9 @@ class TestFramework { @@ -40,7 +40,9 @@ class TestFramework {
},
toEqual: (expected: any) => {
if (JSON.stringify(actual) === JSON.stringify(expected)) return true;
throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
},
toContain: (expected: any) => {
if (actual && actual.includes && actual.includes(expected)) return true;
@ -48,9 +50,11 @@ class TestFramework { @@ -48,9 +50,11 @@ class TestFramework {
},
not: {
toContain: (expected: any) => {
if (actual && actual.includes && !actual.includes(expected)) return true;
if (actual && actual.includes && !actual.includes(expected)) {
return true;
}
throw new Error(`Expected "${actual}" NOT to contain "${expected}"`);
}
},
},
toBeTruthy: () => {
if (actual) return true;
@ -58,14 +62,18 @@ class TestFramework { @@ -58,14 +62,18 @@ class TestFramework {
},
toHaveLength: (expected: number) => {
if (actual && actual.length === expected) return true;
throw new Error(`Expected length ${expected}, got ${actual ? actual.length : 'undefined'}`);
}
throw new Error(
`Expected length ${expected}, got ${
actual ? actual.length : "undefined"
}`,
);
},
};
}
async run() {
console.log(`🧪 Running ${this.tests.length} tests...\n`);
for (const { name, fn } of this.tests) {
try {
await fn();
@ -87,57 +95,68 @@ class TestFramework { @@ -87,57 +95,68 @@ class TestFramework {
const test = new TestFramework();
// Load test data files
const testDataPath = path.join(process.cwd(), 'test_data', 'AsciidocFiles');
const understandingKnowledge = fs.readFileSync(path.join(testDataPath, 'understanding_knowledge.adoc'), 'utf-8');
const desire = fs.readFileSync(path.join(testDataPath, 'desire.adoc'), 'utf-8');
const testDataPath = path.join(process.cwd(), "test_data", "AsciidocFiles");
const understandingKnowledge = fs.readFileSync(
path.join(testDataPath, "understanding_knowledge.adoc"),
"utf-8",
);
const desire = fs.readFileSync(path.join(testDataPath, "desire.adoc"), "utf-8");
// =============================================================================
// PHASE 1: Core Data Structure Tests (Based on Real Test Data)
// =============================================================================
test.test('Understanding Knowledge: Document metadata should be extracted from = level', () => {
test.test("Understanding Knowledge: Document metadata should be extracted from = level", () => {
// Expected 30040 metadata from understanding_knowledge.adoc
const expectedDocMetadata = {
title: 'Understanding Knowledge',
image: 'https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg',
published: '2025-04-21',
language: 'en, ISO-639-1',
tags: ['knowledge', 'philosophy', 'education'],
type: 'text'
title: "Understanding Knowledge",
image: "https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg",
published: "2025-04-21",
language: "en, ISO-639-1",
tags: ["knowledge", "philosophy", "education"],
type: "text",
};
// Test will pass when document parsing extracts these correctly
test.expect(expectedDocMetadata.title).toBe('Understanding Knowledge');
test.expect(expectedDocMetadata.title).toBe("Understanding Knowledge");
test.expect(expectedDocMetadata.tags).toHaveLength(3);
test.expect(expectedDocMetadata.type).toBe('text');
test.expect(expectedDocMetadata.type).toBe("text");
});
test.test('Desire: Document metadata should include all custom attributes', () => {
test.test("Desire: Document metadata should include all custom attributes", () => {
// Expected 30040 metadata from desire.adoc
const expectedDocMetadata = {
title: 'Desire Part 1: Mimesis',
image: 'https://i.nostr.build/hGzyi4c3YhTwoCCe.png',
published: '2025-07-02',
language: 'en, ISO-639-1',
tags: ['memetics', 'philosophy', 'desire'],
type: 'podcastArticle'
title: "Desire Part 1: Mimesis",
image: "https://i.nostr.build/hGzyi4c3YhTwoCCe.png",
published: "2025-07-02",
language: "en, ISO-639-1",
tags: ["memetics", "philosophy", "desire"],
type: "podcastArticle",
};
test.expect(expectedDocMetadata.type).toBe('podcastArticle');
test.expect(expectedDocMetadata.tags).toContain('memetics');
test.expect(expectedDocMetadata.type).toBe("podcastArticle");
test.expect(expectedDocMetadata.tags).toContain("memetics");
});
test.test('Iterative ParsedAsciiDoc interface should support level-based parsing', () => {
test.test("Iterative ParsedAsciiDoc interface should support level-based parsing", () => {
// Test the ITERATIVE interface structure (not recursive)
// Based on docreference.md - Level 2 parsing example
const mockLevel2Structure = {
metadata: { title: 'Programming Fundamentals Guide', tags: ['programming', 'fundamentals'] },
content: 'This is the main introduction to the programming guide.',
title: 'Programming Fundamentals Guide',
metadata: {
title: "Programming Fundamentals Guide",
tags: ["programming", "fundamentals"],
},
content: "This is the main introduction to the programming guide.",
title: "Programming Fundamentals Guide",
sections: [
{
metadata: { title: 'Data Structures', tags: ['arrays', 'lists', 'trees'], difficulty: 'intermediate' },
content: `Understanding fundamental data structures is crucial for effective programming.
metadata: {
title: "Data Structures",
tags: ["arrays", "lists", "trees"],
difficulty: "intermediate",
},
content:
`Understanding fundamental data structures is crucial for effective programming.
=== Arrays and Lists
@ -155,11 +174,16 @@ Linked lists use pointers to connect elements. @@ -155,11 +174,16 @@ Linked lists use pointers to connect elements.
=== Trees and Graphs
Tree and graph structures enable hierarchical and networked data representation.`,
title: 'Data Structures'
title: "Data Structures",
},
{
metadata: { title: 'Algorithms', tags: ['sorting', 'searching', 'optimization'], difficulty: 'advanced' },
content: `Algorithmic thinking forms the foundation of efficient problem-solving.
metadata: {
title: "Algorithms",
tags: ["sorting", "searching", "optimization"],
difficulty: "advanced",
},
content:
`Algorithmic thinking forms the foundation of efficient problem-solving.
=== Sorting Algorithms
@ -172,54 +196,64 @@ Bubble sort repeatedly steps through the list, compares adjacent elements. @@ -172,54 +196,64 @@ Bubble sort repeatedly steps through the list, compares adjacent elements.
==== Quick Sort
Quick sort uses divide-and-conquer approach with pivot selection.`,
title: 'Algorithms'
}
]
title: "Algorithms",
},
],
};
// Verify ITERATIVE structure: only level 2 sections, containing ALL subsections
test.expect(mockLevel2Structure.sections).toHaveLength(2);
test.expect(mockLevel2Structure.sections[0].title).toBe('Data Structures');
test.expect(mockLevel2Structure.sections[0].content).toContain('=== Arrays and Lists');
test.expect(mockLevel2Structure.sections[0].content).toContain('==== Dynamic Arrays');
test.expect(mockLevel2Structure.sections[1].content).toContain('==== Quick Sort');
test.expect(mockLevel2Structure.sections[0].title).toBe("Data Structures");
test.expect(mockLevel2Structure.sections[0].content).toContain(
"=== Arrays and Lists",
);
test.expect(mockLevel2Structure.sections[0].content).toContain(
"==== Dynamic Arrays",
);
test.expect(mockLevel2Structure.sections[1].content).toContain(
"==== Quick Sort",
);
});
// =============================================================================
// PHASE 2: Content Processing Tests (Header Separation)
// =============================================================================
test.test('Section content should NOT contain its own header', () => {
test.test("Section content should NOT contain its own header", () => {
// From understanding_knowledge.adoc: "== Preface" section
const expectedPrefaceContent = `[NOTE]
This essay was written to outline and elaborate on the purpose of the Nostr client Alexandria. No formal academic citations are included as this serves primarily as a conceptual foundation, inviting readers to experience related ideas connecting and forming as more content becomes uploaded. Traces of AI edits and guidance are left, but the essay style is still my own. Over time this essay may change its wording, structure and content.
-- liminal`;
// Should NOT contain "== Preface"
test.expect(expectedPrefaceContent).not.toContain('== Preface');
test.expect(expectedPrefaceContent).toContain('[NOTE]');
test.expect(expectedPrefaceContent).not.toContain("== Preface");
test.expect(expectedPrefaceContent).toContain("[NOTE]");
});
test.test('Introduction section should separate from its subsections', () => {
test.test("Introduction section should separate from its subsections", () => {
// From understanding_knowledge.adoc
const expectedIntroContent = `image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library]`;
const expectedIntroContent =
`image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library]`;
// Should NOT contain subsection content or headers
test.expect(expectedIntroContent).not.toContain('=== Why Investigate');
test.expect(expectedIntroContent).not.toContain('Understanding the nature of knowledge');
test.expect(expectedIntroContent).toContain('image:https://i.nostr.build');
test.expect(expectedIntroContent).not.toContain("=== Why Investigate");
test.expect(expectedIntroContent).not.toContain(
"Understanding the nature of knowledge",
);
test.expect(expectedIntroContent).toContain("image:https://i.nostr.build");
});
test.test('Subsection content should be cleanly separated', () => {
test.test("Subsection content should be cleanly separated", () => {
// "=== Why Investigate the Nature of Knowledge?" subsection
const expectedSubsectionContent = `Understanding the nature of knowledge itself is fundamental, distinct from simply studying how we learn or communicate. Knowledge exests first as representations within individuals, separate from how we interact with it...`;
const expectedSubsectionContent =
`Understanding the nature of knowledge itself is fundamental, distinct from simply studying how we learn or communicate. Knowledge exests first as representations within individuals, separate from how we interact with it...`;
// Should NOT contain its own header
test.expect(expectedSubsectionContent).not.toContain('=== Why Investigate');
test.expect(expectedSubsectionContent).toContain('Understanding the nature');
test.expect(expectedSubsectionContent).not.toContain("=== Why Investigate");
test.expect(expectedSubsectionContent).toContain("Understanding the nature");
});
test.test('Deep headers (====) should have proper newlines', () => {
test.test("Deep headers (====) should have proper newlines", () => {
// From "=== The Four Perspectives" section with ==== subsections
const expectedFormatted = `
==== 1. The Building Blocks (Material Cause)
@ -230,188 +264,226 @@ Just as living organisms are made up of cells, knowledge systems are built from @@ -230,188 +264,226 @@ Just as living organisms are made up of cells, knowledge systems are built from
If you've ever seen how mushrooms connect through underground networks...`;
test.expect(expectedFormatted).toContain('\n==== 1. The Building Blocks (Material Cause)\n');
test.expect(expectedFormatted).toContain('\n==== 2. The Pattern of Organization (Formal Cause)\n');
test.expect(expectedFormatted).toContain(
"\n==== 1. The Building Blocks (Material Cause)\n",
);
test.expect(expectedFormatted).toContain(
"\n==== 2. The Pattern of Organization (Formal Cause)\n",
);
});
// =============================================================================
// PHASE 3: Publishing Logic Tests (30040/30041 Structure)
// =============================================================================
test.test('Understanding Knowledge should create proper 30040 index event', () => {
test.test("Understanding Knowledge should create proper 30040 index event", () => {
// Expected 30040 index event structure
const expectedIndexEvent = {
kind: 30040,
content: '', // Index events have empty content
content: "", // Index events have empty content
tags: [
['d', 'understanding-knowledge'],
['title', 'Understanding Knowledge'],
['image', 'https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg'],
['published', '2025-04-21'],
['language', 'en, ISO-639-1'],
['t', 'knowledge'],
['t', 'philosophy'],
['t', 'education'],
['type', 'text'],
["d", "understanding-knowledge"],
["title", "Understanding Knowledge"],
["image", "https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg"],
["published", "2025-04-21"],
["language", "en, ISO-639-1"],
["t", "knowledge"],
["t", "philosophy"],
["t", "education"],
["type", "text"],
// a-tags referencing sections
['a', '30041:pubkey:understanding-knowledge-preface'],
['a', '30041:pubkey:understanding-knowledge-introduction-knowledge-as-a-living-ecosystem'],
['a', '30041:pubkey:understanding-knowledge-i-material-cause-the-substance-of-knowledge'],
["a", "30041:pubkey:understanding-knowledge-preface"],
[
"a",
"30041:pubkey:understanding-knowledge-introduction-knowledge-as-a-living-ecosystem",
],
[
"a",
"30041:pubkey:understanding-knowledge-i-material-cause-the-substance-of-knowledge",
],
// ... more a-tags for each section
]
],
};
test.expect(expectedIndexEvent.kind).toBe(30040);
test.expect(expectedIndexEvent.content).toBe('');
test.expect(expectedIndexEvent.tags.filter(([k]) => k === 't')).toHaveLength(3);
test.expect(expectedIndexEvent.tags.find(([k, v]) => k === 'type' && v === 'text')).toBeTruthy();
test.expect(expectedIndexEvent.content).toBe("");
test.expect(expectedIndexEvent.tags.filter(([k]) => k === "t")).toHaveLength(
3,
);
test.expect(
expectedIndexEvent.tags.find(([k, v]) => k === "type" && v === "text"),
).toBeTruthy();
});
test.test('Understanding Knowledge sections should create proper 30041 events', () => {
test.test("Understanding Knowledge sections should create proper 30041 events", () => {
// Expected 30041 events for main sections
const expectedSectionEvents = [
{
kind: 30041,
content: `[NOTE]\nThis essay was written to outline and elaborate on the purpose of the Nostr client Alexandria...`,
content:
`[NOTE]\nThis essay was written to outline and elaborate on the purpose of the Nostr client Alexandria...`,
tags: [
['d', 'understanding-knowledge-preface'],
['title', 'Preface']
]
["d", "understanding-knowledge-preface"],
["title", "Preface"],
],
},
{
kind: 30041,
kind: 30041,
content: `image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library]`,
tags: [
['d', 'understanding-knowledge-introduction-knowledge-as-a-living-ecosystem'],
['title', 'Introduction: Knowledge as a Living Ecosystem']
]
}
[
"d",
"understanding-knowledge-introduction-knowledge-as-a-living-ecosystem",
],
["title", "Introduction: Knowledge as a Living Ecosystem"],
],
},
];
expectedSectionEvents.forEach(event => {
expectedSectionEvents.forEach((event) => {
test.expect(event.kind).toBe(30041);
test.expect(event.content).toBeTruthy();
test.expect(event.tags.find(([k]) => k === 'd')).toBeTruthy();
test.expect(event.tags.find(([k]) => k === 'title')).toBeTruthy();
test.expect(event.tags.find(([k]) => k === "d")).toBeTruthy();
test.expect(event.tags.find(([k]) => k === "title")).toBeTruthy();
});
});
test.test('Level-based parsing should create correct 30040/30041 structure', () => {
test.test("Level-based parsing should create correct 30040/30041 structure", () => {
// Based on docreference.md examples
// Level 2 parsing: only == sections become events, containing all subsections
const expectedLevel2Events = {
mainIndex: {
kind: 30040,
content: '',
content: "",
tags: [
['d', 'programming-fundamentals-guide'],
['title', 'Programming Fundamentals Guide'],
['a', '30041:author_pubkey:data-structures'],
['a', '30041:author_pubkey:algorithms']
]
["d", "programming-fundamentals-guide"],
["title", "Programming Fundamentals Guide"],
["a", "30041:author_pubkey:data-structures"],
["a", "30041:author_pubkey:algorithms"],
],
},
dataStructuresSection: {
kind: 30041,
content: 'Understanding fundamental data structures...\n\n=== Arrays and Lists\n\n...==== Dynamic Arrays\n\n...==== Linked Lists\n\n...',
content:
"Understanding fundamental data structures...\n\n=== Arrays and Lists\n\n...==== Dynamic Arrays\n\n...==== Linked Lists\n\n...",
tags: [
['d', 'data-structures'],
['title', 'Data Structures'],
['difficulty', 'intermediate']
]
}
["d", "data-structures"],
["title", "Data Structures"],
["difficulty", "intermediate"],
],
},
};
// Level 3 parsing: == sections become 30040 indices, === sections become 30041 events
const expectedLevel3Events = {
mainIndex: {
kind: 30040,
content: '',
content: "",
tags: [
['d', 'programming-fundamentals-guide'],
['title', 'Programming Fundamentals Guide'],
['a', '30040:author_pubkey:data-structures'], // Now references sub-index
['a', '30040:author_pubkey:algorithms']
]
["d", "programming-fundamentals-guide"],
["title", "Programming Fundamentals Guide"],
["a", "30040:author_pubkey:data-structures"], // Now references sub-index
["a", "30040:author_pubkey:algorithms"],
],
},
dataStructuresIndex: {
kind: 30040,
content: '',
content: "",
tags: [
['d', 'data-structures'],
['title', 'Data Structures'],
['a', '30041:author_pubkey:data-structures-content'],
['a', '30041:author_pubkey:arrays-and-lists'],
['a', '30041:author_pubkey:trees-and-graphs']
]
["d", "data-structures"],
["title", "Data Structures"],
["a", "30041:author_pubkey:data-structures-content"],
["a", "30041:author_pubkey:arrays-and-lists"],
["a", "30041:author_pubkey:trees-and-graphs"],
],
},
arraysAndListsSection: {
kind: 30041,
content: 'Arrays are contiguous...\n\n==== Dynamic Arrays\n\n...==== Linked Lists\n\n...',
content:
"Arrays are contiguous...\n\n==== Dynamic Arrays\n\n...==== Linked Lists\n\n...",
tags: [
['d', 'arrays-and-lists'],
['title', 'Arrays and Lists']
]
}
["d", "arrays-and-lists"],
["title", "Arrays and Lists"],
],
},
};
test.expect(expectedLevel2Events.mainIndex.kind).toBe(30040);
test.expect(expectedLevel2Events.dataStructuresSection.kind).toBe(30041);
test.expect(expectedLevel2Events.dataStructuresSection.content).toContain('=== Arrays and Lists');
test.expect(expectedLevel2Events.dataStructuresSection.content).toContain(
"=== Arrays and Lists",
);
test.expect(expectedLevel3Events.dataStructuresIndex.kind).toBe(30040);
test.expect(expectedLevel3Events.arraysAndListsSection.content).toContain('==== Dynamic Arrays');
test.expect(expectedLevel3Events.arraysAndListsSection.content).toContain(
"==== Dynamic Arrays",
);
});
// =============================================================================
// PHASE 4: Smart Publishing System Tests
// =============================================================================
test.test('Content type detection should work for both test files', () => {
test.test("Content type detection should work for both test files", () => {
const testCases = [
{
name: 'Understanding Knowledge (article)',
name: "Understanding Knowledge (article)",
content: understandingKnowledge,
expected: 'article'
expected: "article",
},
{
name: 'Desire (article)',
content: desire,
expected: 'article'
name: "Desire (article)",
content: desire,
expected: "article",
},
{
name: 'Scattered notes format',
content: '== Note 1\nContent\n\n== Note 2\nMore content',
expected: 'scattered-notes'
}
name: "Scattered notes format",
content: "== Note 1\nContent\n\n== Note 2\nMore content",
expected: "scattered-notes",
},
];
testCases.forEach(({ name, content, expected }) => {
const hasDocTitle = content.trim().startsWith('=') && !content.trim().startsWith('==');
const hasSections = content.includes('==');
const hasDocTitle = content.trim().startsWith("=") &&
!content.trim().startsWith("==");
const hasSections = content.includes("==");
let detected;
if (hasDocTitle) {
detected = 'article';
detected = "article";
} else if (hasSections) {
detected = 'scattered-notes';
detected = "scattered-notes";
} else {
detected = 'none';
detected = "none";
}
console.log(` ${name}: detected ${detected}`);
test.expect(detected).toBe(expected);
});
});
test.test('Parse level should affect event structure correctly', () => {
test.test("Parse level should affect event structure correctly", () => {
// Understanding Knowledge has structure: = > == (6 sections) > === (many subsections) > ====
// Based on actual content analysis
const levelEventCounts = [
{ level: 1, description: 'Only document index', events: 1 },
{ level: 2, description: 'Document index + level 2 sections (==)', events: 7 }, // 1 index + 6 sections
{ level: 3, description: 'Document index + section indices + level 3 subsections (===)', events: 20 }, // More complex
{ level: 4, description: 'Full hierarchy including level 4 (====)', events: 35 }
{ level: 1, description: "Only document index", events: 1 },
{
level: 2,
description: "Document index + level 2 sections (==)",
events: 7,
}, // 1 index + 6 sections
{
level: 3,
description:
"Document index + section indices + level 3 subsections (===)",
events: 20,
}, // More complex
{
level: 4,
description: "Full hierarchy including level 4 (====)",
events: 35,
},
];
levelEventCounts.forEach(({ level, description, events }) => {
@ -424,27 +496,27 @@ test.test('Parse level should affect event structure correctly', () => { @@ -424,27 +496,27 @@ test.test('Parse level should affect event structure correctly', () => {
// PHASE 5: Integration Tests (End-to-End Workflow)
// =============================================================================
test.test('Full Understanding Knowledge publishing workflow (Level 2)', async () => {
test.test("Full Understanding Knowledge publishing workflow (Level 2)", async () => {
// Mock the complete ITERATIVE workflow
const mockWorkflow = {
parseLevel2: (content: string) => ({
metadata: {
title: 'Understanding Knowledge',
image: 'https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg',
published: '2025-04-21',
tags: ['knowledge', 'philosophy', 'education'],
type: 'text'
title: "Understanding Knowledge",
image: "https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg",
published: "2025-04-21",
tags: ["knowledge", "philosophy", "education"],
type: "text",
},
title: 'Understanding Knowledge',
content: 'Introduction content before any sections',
title: "Understanding Knowledge",
content: "Introduction content before any sections",
sections: [
{
title: 'Preface',
content: '[NOTE]\nThis essay was written to outline...',
metadata: { title: 'Preface' }
{
title: "Preface",
content: "[NOTE]\nThis essay was written to outline...",
metadata: { title: "Preface" },
},
{
title: 'Introduction: Knowledge as a Living Ecosystem',
{
title: "Introduction: Knowledge as a Living Ecosystem",
// Contains ALL subsections (===, ====) in content
content: `image:https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg[library]
@ -461,41 +533,46 @@ Traditionally, knowledge has been perceived as a static repository... @@ -461,41 +533,46 @@ Traditionally, knowledge has been perceived as a static repository...
===== 1. The Building Blocks (Material Cause)
Just as living organisms are made up of cells...`,
metadata: { title: 'Introduction: Knowledge as a Living Ecosystem' }
}
metadata: { title: "Introduction: Knowledge as a Living Ecosystem" },
},
// ... 4 more sections (Material Cause, Formal Cause, Efficient Cause, Final Cause)
]
],
}),
buildLevel2Events: (parsed: any) => ({
indexEvent: {
kind: 30040,
content: '',
indexEvent: {
kind: 30040,
content: "",
tags: [
['d', 'understanding-knowledge'],
['title', parsed.title],
['image', parsed.metadata.image],
['t', 'knowledge'], ['t', 'philosophy'], ['t', 'education'],
['type', 'text'],
['a', '30041:pubkey:preface'],
['a', '30041:pubkey:introduction-knowledge-as-a-living-ecosystem']
]
["d", "understanding-knowledge"],
["title", parsed.title],
["image", parsed.metadata.image],
["t", "knowledge"],
["t", "philosophy"],
["t", "education"],
["type", "text"],
["a", "30041:pubkey:preface"],
["a", "30041:pubkey:introduction-knowledge-as-a-living-ecosystem"],
],
},
sectionEvents: parsed.sections.map((s: any) => ({
kind: 30041,
content: s.content,
tags: [
['d', s.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')],
['title', s.title]
]
}))
["d", s.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")],
["title", s.title],
],
})),
}),
publish: (events: any) => ({
success: true,
published: events.sectionEvents.length + 1,
eventIds: ['main-index', ...events.sectionEvents.map((_: any, i: number) => `section-${i}`)]
})
eventIds: [
"main-index",
...events.sectionEvents.map((_: any, i: number) => `section-${i}`),
],
}),
};
// Test the full Level 2 workflow
@ -503,29 +580,38 @@ Just as living organisms are made up of cells...`, @@ -503,29 +580,38 @@ Just as living organisms are made up of cells...`,
const events = mockWorkflow.buildLevel2Events(parsed);
const result = mockWorkflow.publish(events);
test.expect(parsed.metadata.title).toBe('Understanding Knowledge');
test.expect(parsed.metadata.title).toBe("Understanding Knowledge");
test.expect(parsed.sections).toHaveLength(2);
test.expect(events.indexEvent.kind).toBe(30040);
test.expect(events.sectionEvents).toHaveLength(2);
test.expect(events.sectionEvents[1].content).toContain('=== Why Investigate'); // Contains subsections
test.expect(events.sectionEvents[1].content).toContain('===== 1. The Building Blocks'); // Contains deeper levels
test.expect(events.sectionEvents[1].content).toContain("=== Why Investigate"); // Contains subsections
test.expect(events.sectionEvents[1].content).toContain(
"===== 1. The Building Blocks",
); // Contains deeper levels
test.expect(result.success).toBeTruthy();
test.expect(result.published).toBe(3); // 1 index + 2 sections
});
test.test('Error handling for malformed content', () => {
test.test("Error handling for malformed content", () => {
const invalidCases = [
{ content: '== Section\n=== Subsection\n==== Missing content', error: 'Empty content sections' },
{ content: '= Title\n\n== Section\n==== Skipped level', error: 'Invalid header nesting' },
{ content: '', error: 'Empty document' }
{
content: "== Section\n=== Subsection\n==== Missing content",
error: "Empty content sections",
},
{
content: "= Title\n\n== Section\n==== Skipped level",
error: "Invalid header nesting",
},
{ content: "", error: "Empty document" },
];
invalidCases.forEach(({ content, error }) => {
// Mock error detection
const hasEmptySections = content.includes('Missing content');
const hasSkippedLevels = content.includes('====') && !content.includes('===');
const isEmpty = content.trim() === '';
const hasEmptySections = content.includes("Missing content");
const hasSkippedLevels = content.includes("====") &&
!content.includes("===");
const isEmpty = content.trim() === "";
const shouldError = hasEmptySections || hasSkippedLevels || isEmpty;
test.expect(shouldError).toBeTruthy();
});
@ -535,26 +621,40 @@ test.test('Error handling for malformed content', () => { @@ -535,26 +621,40 @@ test.test('Error handling for malformed content', () => {
// Test Execution
// =============================================================================
console.log('🎯 ZettelPublisher Test-Driven Development (ITERATIVE)\n');
console.log('📋 Test Data Analysis:');
console.log(`- Understanding Knowledge: ${understandingKnowledge.split('\n').length} lines`);
console.log(`- Desire: ${desire.split('\n').length} lines`);
console.log('- Both files use = document title with metadata directly underneath');
console.log('- Sections use == with deep nesting (===, ====, =====)');
console.log('- Custom attributes like :type: podcastArticle need preservation');
console.log('- CRITICAL: Structure is ITERATIVE not recursive (per docreference.md)\n');
test.run().then(success => {
console.log("🎯 ZettelPublisher Test-Driven Development (ITERATIVE)\n");
console.log("📋 Test Data Analysis:");
console.log(
`- Understanding Knowledge: ${
understandingKnowledge.split("\n").length
} lines`,
);
console.log(`- Desire: ${desire.split("\n").length} lines`);
console.log(
"- Both files use = document title with metadata directly underneath",
);
console.log("- Sections use == with deep nesting (===, ====, =====)");
console.log("- Custom attributes like :type: podcastArticle need preservation");
console.log(
"- CRITICAL: Structure is ITERATIVE not recursive (per docreference.md)\n",
);
test.run().then((success) => {
if (success) {
console.log('\n🎉 All tests defined! Ready for ITERATIVE implementation.');
console.log('\n📋 Implementation Plan:');
console.log('1. ✅ Update ParsedAsciiDoc interface for ITERATIVE parsing');
console.log('2. ✅ Fix content processing (header separation, custom attributes)');
console.log('3. ✅ Implement level-based publishing logic (30040/30041 structure)');
console.log('4. ✅ Add parse-level controlled event generation');
console.log('5. ✅ Create context-aware UI with level selector');
console.log('\n🔄 Each level can be developed and tested independently!');
console.log("\n🎉 All tests defined! Ready for ITERATIVE implementation.");
console.log("\n📋 Implementation Plan:");
console.log("1. ✅ Update ParsedAsciiDoc interface for ITERATIVE parsing");
console.log(
"2. ✅ Fix content processing (header separation, custom attributes)",
);
console.log(
"3. ✅ Implement level-based publishing logic (30040/30041 structure)",
);
console.log("4. ✅ Add parse-level controlled event generation");
console.log("5. ✅ Create context-aware UI with level selector");
console.log("\n🔄 Each level can be developed and tested independently!");
} else {
console.log('\n❌ Tests ready - implement ITERATIVE features to make them pass!');
console.log(
"\n❌ Tests ready - implement ITERATIVE features to make them pass!",
);
}
}).catch(console.error);
}).catch(console.error);

Loading…
Cancel
Save