nip27: rewrite to support urls and references in a simpler API for rich UIs.
This commit is contained in:
parent
19ae9837a7
commit
266dbdf766
117
nip27.test.ts
117
nip27.test.ts
|
@ -1,68 +1,77 @@
|
||||||
import { test, expect } from 'bun:test'
|
import { test, expect } from 'bun:test'
|
||||||
import { matchAll, replaceAll } from './nip27.ts'
|
import { parse } from './nip27.ts'
|
||||||
|
|
||||||
test('matchAll', () => {
|
test('first: parse simple content with 1 url and 1 nostr uri', () => {
|
||||||
const result = matchAll(
|
const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
|
||||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
const blocks = Array.from(parse(content))
|
||||||
)
|
|
||||||
|
|
||||||
expect([...result]).toEqual([
|
expect(blocks).toEqual([
|
||||||
{
|
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
|
||||||
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
{ type: 'text', text: ' check out my profile:' },
|
||||||
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||||
decoded: {
|
{ type: 'text', text: '; and this cool image ' },
|
||||||
type: 'npub',
|
{ type: 'image', url: 'https://images.com/image.jpg' },
|
||||||
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
|
||||||
},
|
|
||||||
start: 6,
|
|
||||||
end: 75,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
|
||||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
|
||||||
decoded: {
|
|
||||||
type: 'note',
|
|
||||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
|
||||||
},
|
|
||||||
start: 78,
|
|
||||||
end: 147,
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('matchAll with an invalid nip19', () => {
|
test('second: parse content with 3 urls of different types', () => {
|
||||||
const result = matchAll(
|
const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
|
||||||
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
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: {
|
type: 'text',
|
||||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
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: 'note',
|
|
||||||
},
|
|
||||||
end: 193,
|
|
||||||
start: 124,
|
|
||||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
|
||||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
|
||||||
},
|
},
|
||||||
|
{ type: 'url', url: 'https://ok.com/' },
|
||||||
|
{ type: 'text', text: '!' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('replaceAll', () => {
|
test('third: parse complex content with 4 nostr uris and 3 urls', () => {
|
||||||
const content =
|
const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
|
||||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
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 }) => {
|
expect(blocks).toEqual([
|
||||||
switch (decoded.type) {
|
{ type: 'text', text: 'Look at these profiles ' },
|
||||||
case 'npub':
|
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||||
return '@alex'
|
{ type: 'text', text: ' ' },
|
||||||
case 'note':
|
{
|
||||||
return '!1234'
|
type: 'reference',
|
||||||
default:
|
pointer: {
|
||||||
return value
|
pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
|
||||||
}
|
relays: ['wss://qwieu.com'],
|
||||||
})
|
},
|
||||||
|
},
|
||||||
expect(result).toEqual('Hello @alex!\n\n!1234')
|
{ 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' },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
212
nip27.ts
212
nip27.ts
|
@ -1,63 +1,169 @@
|
||||||
import { decode } from './nip19.ts'
|
import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
|
||||||
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
|
|
||||||
|
|
||||||
/** Regex to find NIP-21 URIs inside event content. */
|
export type Block =
|
||||||
export const regex = (): RegExp => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
| {
|
||||||
|
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. */
|
const noCharacter = /\W/m
|
||||||
export interface NostrURIMatch extends NostrURI {
|
const noURLCharacter = /\W |\W$|$|,| /m
|
||||||
/** Index where the URI begins in the event content. */
|
|
||||||
start: number
|
|
||||||
/** Index where the URI ends in the event content. */
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find and decode all NIP-21 URIs. */
|
export function* parse(content: string): Iterable<Block> {
|
||||||
export function* matchAll(content: string): Iterable<NostrURIMatch> {
|
const max = content.length
|
||||||
const matches = content.matchAll(regex())
|
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) {
|
if (content.substring(u - 5, u) === 'nostr') {
|
||||||
try {
|
const m = content.substring(u + 60).match(noCharacter)
|
||||||
const [uri, value] = match
|
const end = m ? u + 60 + m.index! : max
|
||||||
|
try {
|
||||||
|
let pointer: ProfilePointer | AddressPointer | EventPointer
|
||||||
|
let { data, type } = decode(content.substring(u + 1, end))
|
||||||
|
|
||||||
yield {
|
switch (type) {
|
||||||
uri: uri as `nostr:${string}`,
|
case 'npub':
|
||||||
value,
|
pointer = { pubkey: data } as ProfilePointer
|
||||||
decoded: decode(value),
|
break
|
||||||
start: match.index!,
|
case 'nsec':
|
||||||
end: match.index! + uri.length,
|
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) {
|
} else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') {
|
||||||
// do nothing
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if (prevIndex !== max) {
|
||||||
* Replace all occurrences of Nostr URIs in the text.
|
yield { type: 'text', text: content.substring(prevIndex) }
|
||||||
*
|
}
|
||||||
* 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),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue