diff --git a/nip27.test.ts b/nip27.test.ts index 02de0c0..141a8ad 100644 --- a/nip27.test.ts +++ b/nip27.test.ts @@ -1,68 +1,77 @@ import { test, expect } from 'bun:test' -import { matchAll, replaceAll } from './nip27.ts' +import { parse } from './nip27.ts' -test('matchAll', () => { - const result = matchAll( - 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', - ) +test('first: parse simple content with 1 url and 1 nostr uri', () => { + const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg` + const blocks = Array.from(parse(content)) - expect([...result]).toEqual([ - { - uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', - value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', - decoded: { - type: 'npub', - data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6', - }, - start: 6, - end: 75, - }, - { - uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', - value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', - decoded: { - type: 'note', - data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b', - }, - start: 78, - end: 147, - }, + expect(blocks).toEqual([ + { type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } }, + { type: 'text', text: ' check out my profile:' }, + { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } }, + { type: 'text', text: '; and this cool image ' }, + { type: 'image', url: 'https://images.com/image.jpg' }, ]) }) -test('matchAll with an invalid nip19', () => { - const result = matchAll( - 'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', - ) +test('second: parse content with 3 urls of different types', () => { + const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music: +http://music.com/song.mp3 +and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!` + const blocks = Array.from(parse(content)) - expect([...result]).toEqual([ + expect(blocks).toEqual([ + { type: 'text', text: ':' }, + { type: 'relay', url: 'wss://oa.ao/' }, + { type: 'text', text: "; this was a relay and now here's a video -> " }, + { type: 'video', url: 'https://videos.com/video.mp4' }, + { type: 'text', text: '! and some music:\n' }, + { type: 'audio', url: 'http://music.com/song.mp3' }, + { type: 'text', text: '\nand a regular link: ' }, + { type: 'url', url: 'https://regular.com/page?ok=true' }, { - decoded: { - data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b', - type: 'note', - }, - end: 193, - start: 124, - uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', - value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', + type: 'text', + text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ', }, + { type: 'url', url: 'https://ok.com/' }, + { type: 'text', text: '!' }, ]) }) -test('replaceAll', () => { - const content = - 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' +test('third: parse complex content with 4 nostr uris and 3 urls', () => { + const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl + here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s + with a video https://example.com/vid.webm and finally https://example.com/docs` + const blocks = Array.from(parse(content)) - const result = replaceAll(content, ({ decoded, value }) => { - switch (decoded.type) { - case 'npub': - return '@alex' - case 'note': - return '!1234' - default: - return value - } - }) - - expect(result).toEqual('Hello @alex!\n\n!1234') + expect(blocks).toEqual([ + { type: 'text', text: 'Look at these profiles ' }, + { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } }, + { type: 'text', text: ' ' }, + { + type: 'reference', + pointer: { + pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9', + relays: ['wss://qwieu.com'], + }, + }, + { type: 'text', text: ' check this event ' }, + { + type: 'reference', + pointer: { + id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba', + relays: ['wss://zjbdksa.aswjdkn'], + author: undefined, + kind: undefined, + }, + }, + { type: 'text', text: "\n here's an image " }, + { type: 'image', url: 'https://example.com/pic.png' }, + { type: 'text', text: ' and another profile ' }, + { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } }, + { type: 'text', text: '\n with a video ' }, + { type: 'video', url: 'https://example.com/vid.webm' }, + { type: 'text', text: ' and finally ' }, + { type: 'url', url: 'https://example.com/docs' }, + ]) }) diff --git a/nip27.ts b/nip27.ts index d45224c..7c5d61c 100644 --- a/nip27.ts +++ b/nip27.ts @@ -1,63 +1,169 @@ -import { decode } from './nip19.ts' -import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts' +import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts' -/** Regex to find NIP-21 URIs inside event content. */ -export const regex = (): RegExp => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g') +export type Block = + | { + type: 'text' + text: string + } + | { + type: 'reference' + pointer: ProfilePointer | AddressPointer | EventPointer + } + | { + type: 'url' + url: string + } + | { + type: 'relay' + url: string + } + | { + type: 'image' + url: string + } + | { + type: 'video' + url: string + } + | { + type: 'audio' + url: string + } -/** Match result for a Nostr URI in event content. */ -export interface NostrURIMatch extends NostrURI { - /** Index where the URI begins in the event content. */ - start: number - /** Index where the URI ends in the event content. */ - end: number -} +const noCharacter = /\W/m +const noURLCharacter = /\W |\W$|$|,| /m -/** Find and decode all NIP-21 URIs. */ -export function* matchAll(content: string): Iterable { - const matches = content.matchAll(regex()) +export function* parse(content: string): Iterable { + const max = content.length + let prevIndex = 0 + let index = 0 + while (index < max) { + let u = content.indexOf(':', index) + if (u === -1) { + // reached end + break + } - for (const match of matches) { - try { - const [uri, value] = match + if (content.substring(u - 5, u) === 'nostr') { + const m = content.substring(u + 60).match(noCharacter) + const end = m ? u + 60 + m.index! : max + try { + let pointer: ProfilePointer | AddressPointer | EventPointer + let { data, type } = decode(content.substring(u + 1, end)) - yield { - uri: uri as `nostr:${string}`, - value, - decoded: decode(value), - start: match.index!, - end: match.index! + uri.length, + switch (type) { + case 'npub': + pointer = { pubkey: data } as ProfilePointer + break + case 'nsec': + case 'note': + // ignore this, treat it as not a valid uri + index = end + 1 + continue + default: + pointer = data as any + } + + if (prevIndex !== u - 5) { + yield { type: 'text', text: content.substring(prevIndex, u - 5) } + } + yield { type: 'reference', pointer } + index = end + prevIndex = index + continue + } catch (_err) { + // ignore this, not a valid nostr uri + index = u + 1 + continue } - } catch (_e) { - // do nothing + } else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') { + const m = content.substring(u + 4).match(noURLCharacter) + const end = m ? u + 4 + m.index! : max + const prefixLen = content[u - 1] === 's' ? 5 : 4 + try { + let url = new URL(content.substring(u - prefixLen, end)) + if (url.hostname.indexOf('.') === -1) { + throw new Error('invalid url') + } + + if (prevIndex !== u - prefixLen) { + yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) } + } + + if ( + url.pathname.endsWith('.png') || + url.pathname.endsWith('.jpg') || + url.pathname.endsWith('.jpeg') || + url.pathname.endsWith('.gif') || + url.pathname.endsWith('.webp') + ) { + yield { type: 'image', url: url.toString() } + index = end + prevIndex = index + continue + } + if ( + url.pathname.endsWith('.mp4') || + url.pathname.endsWith('.avi') || + url.pathname.endsWith('.webm') || + url.pathname.endsWith('.mkv') + ) { + yield { type: 'video', url: url.toString() } + index = end + prevIndex = index + continue + } + if ( + url.pathname.endsWith('.mp3') || + url.pathname.endsWith('.aac') || + url.pathname.endsWith('.ogg') || + url.pathname.endsWith('.opus') + ) { + yield { type: 'audio', url: url.toString() } + index = end + prevIndex = index + continue + } + + yield { type: 'url', url: url.toString() } + index = end + prevIndex = index + continue + } catch (_err) { + // ignore this, not a valid url + index = end + 1 + continue + } + } else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') { + const m = content.substring(u + 4).match(noURLCharacter) + const end = m ? u + 4 + m.index! : max + const prefixLen = content[u - 1] === 's' ? 3 : 2 + try { + let url = new URL(content.substring(u - prefixLen, end)) + if (url.hostname.indexOf('.') === -1) { + throw new Error('invalid ws url') + } + + if (prevIndex !== u - prefixLen) { + yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) } + } + yield { type: 'relay', url: url.toString() } + index = end + prevIndex = index + continue + } catch (_err) { + // ignore this, not a valid url + index = end + 1 + continue + } + } else { + // ignore this, it is nothing + index = u + 1 + continue } } -} -/** - * Replace all occurrences of Nostr URIs in the text. - * - * WARNING: using this on an HTML string is potentially unsafe! - * - * @example - * ```ts - * nip27.replaceAll(event.content, ({ decoded, value }) => { - * switch(decoded.type) { - * case 'npub': - * return renderMention(decoded) - * case 'note': - * return renderNote(decoded) - * default: - * return value - * } - * }) - * ``` - */ -export function replaceAll(content: string, replacer: (match: NostrURI) => string): string { - return content.replaceAll(regex(), (uri, value: string) => { - return replacer({ - uri: uri as `nostr:${string}`, - value, - decoded: decode(value), - }) - }) + if (prevIndex !== max) { + yield { type: 'text', text: content.substring(prevIndex) } + } }