12 KiB
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
-
Node.js packages:
nostr-toolsandwsnpm install nostr-tools ws -
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
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://thecitadel.nostr1.com";
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:
{
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
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://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
-
Run the script:
node create-test-highlights.js -
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! === -
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
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 seconds
Issue: Highlights don't appear after publishing
Possible causes:
- Wrong section address - verify with
check-publication-structure.js - HighlightLayer not fetching from the relay you published to
- Browser cache - hard refresh (Ctrl+Shift+R)
Debug steps:
// 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:
- Open the publication in browser
- Select the text you want to highlight
- Copy a larger surrounding context (2-3 sentences)
- Use that as the
contextvalue
Key Patterns to Remember
- Content field = highlighted text (NOT a comment)
- Context tag helps locate the highlight in the source document
- Comment tag is optional user annotation
- No threading - highlights are flat, not threaded like comments
- Single lowercase 'a' tag - not uppercase/lowercase pairs like comments
- Always verify addresses with
check-publication-structure.jsfirst
Adapting for Different Publications
To use this technique on a different publication:
- Get the publication's naddr from the URL
- Run
check-publication-structure.jswith that naddr - Update these values in
create-test-highlights.js:publicationPubkeyrootAddresssectionsarray
- Update
highlightedTextandcontextto match actual publication content - 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 implementationsrc/lib/components/publications/HighlightSelectionHandler.svelte- Event creation- NIP-19 (Address encoding): https://github.com/nostr-protocol/nips/blob/master/19.md