feat: resolve NIP-19 identifiers in article content

- Add nostrUriResolver utility to detect and replace nostr: URIs
- Support npub, note, nprofile, nevent, and naddr identifiers
- Convert nostr: URIs to clickable njump.me links
- Process markdown before rendering to handle nostr mentions
- Add CSS styling for nostr-uri-link class
- Implements NIP-19 and NIP-27 (nostr: URI scheme)

Nostr-native articles can now contain references like:
- nostr:npub1... → @npub1abc...
- nostr:note1... → note:note1abc...
- nostr:naddr1... → article:identifier

All identifiers become clickable links to njump.me
This commit is contained in:
Gigi
2025-10-11 01:42:03 +01:00
parent 6783ff23f9
commit 8a69d5bc6b
4 changed files with 167 additions and 4 deletions

View File

@@ -0,0 +1,134 @@
import React from 'react'
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
import { DecodeResult } from 'nostr-tools/nip19'
/**
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
* Also matches bare identifiers without the nostr: prefix
*/
const NOSTR_URI_REGEX = /(?:nostr:)?((npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi
/**
* Extract all nostr URIs from text
*/
export function extractNostrUris(text: string): string[] {
const matches = text.match(NOSTR_URI_REGEX)
if (!matches) return []
// Extract just the NIP-19 identifier (without nostr: prefix)
return matches.map(match => {
const cleanMatch = match.replace(/^nostr:/, '')
return cleanMatch
})
}
/**
* Decode a NIP-19 identifier and return a human-readable link
*/
export function createNostrLink(encoded: string): string {
try {
const decoded = decode(encoded)
switch (decoded.type) {
case 'npub':
return `https://njump.me/${encoded}`
case 'nprofile':
return `https://njump.me/${encoded}`
case 'note':
return `https://njump.me/${encoded}`
case 'nevent':
return `https://njump.me/${encoded}`
case 'naddr':
return `https://njump.me/${encoded}`
default:
return `https://njump.me/${encoded}`
}
} catch (error) {
console.warn('Failed to decode nostr URI:', encoded, error)
return `https://njump.me/${encoded}`
}
}
/**
* Get a display label for a nostr URI
*/
export function getNostrUriLabel(encoded: string): string {
try {
const decoded = decode(encoded)
switch (decoded.type) {
case 'npub':
return `@${encoded.slice(0, 12)}...`
case 'nprofile':
const npub = npubEncode(decoded.data.pubkey)
return `@${npub.slice(0, 12)}...`
case 'note':
return `note:${encoded.slice(5, 12)}...`
case 'nevent':
const note = noteEncode(decoded.data.id)
return `note:${note.slice(5, 12)}...`
case 'naddr':
return `article:${decoded.data.identifier || 'untitled'}`
default:
return encoded.slice(0, 16) + '...'
}
} catch (error) {
return encoded.slice(0, 16) + '...'
}
}
/**
* Replace nostr: URIs in markdown with proper markdown links
* This converts: nostr:npub1... to [label](link)
*/
export function replaceNostrUrisInMarkdown(markdown: string): string {
return markdown.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
})
}
/**
* Replace nostr: URIs in HTML with clickable links
* This is used when processing HTML content directly
*/
export function replaceNostrUrisInHTML(html: string): string {
return html.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)
return `<a href="${link}" class="nostr-uri-link" target="_blank" rel="noopener noreferrer">${label}</a>`
})
}
/**
* Get decoded information from a nostr URI for detailed display
*/
export function getNostrUriInfo(encoded: string): {
type: string
decoded: DecodeResult | null
link: string
label: string
} {
let decoded: DecodeResult | null = null
try {
decoded = decode(encoded)
} catch (error) {
// ignore decoding errors
}
return {
type: decoded?.type || 'unknown',
decoded,
link: createNostrLink(encoded),
label: getNostrUriLabel(encoded)
}
}