nip27: emit start and ending offsets.

This commit is contained in:
fiatjaf
2026-09-04 01:20:52 -03:00
parent a8c6976feb
commit a8fc3499e4
4 changed files with 122 additions and 61 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nostr/tools",
"version": "2.25.0",
"version": "2.25.1",
"exports": {
".": "./index.ts",
"./core": "./core.ts",
+78 -45
View File
@@ -7,11 +7,21 @@ test('first: parse simple content with 1 url and 1 nostr uri', () => {
const blocks = Array.from(parse(content))
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' },
{
type: 'reference',
pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' },
start: 0,
end: 69,
},
{ type: 'text', text: ' check out my profile:', start: 69, end: 91 },
{
type: 'reference',
pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' },
start: 91,
end: 160,
},
{ type: 'text', text: '; and this cool image ', start: 160, end: 182 },
{ type: 'image', url: 'https://images.com/image.jpg', start: 182, end: 210 },
])
})
@@ -22,20 +32,22 @@ and a regular link: https://regular.com/page?ok=true. and now a broken link: htt
const blocks = Array.from(parse(content))
expect(blocks).toEqual([
{ type: 'text', text: ':' },
{ type: 'relay', url: 'wss://oa.ao/a/' },
{ 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' },
{ type: 'text', text: ':', start: 0, end: 1 },
{ type: 'relay', url: 'wss://oa.ao/a/', start: 1, end: 15 },
{ type: 'text', text: "; this was a relay and now here's a video -> ", start: 15, end: 60 },
{ type: 'video', url: 'https://videos.com/video.mp4', start: 60, end: 88 },
{ type: 'text', text: '! and some music:\n', start: 88, end: 106 },
{ type: 'audio', url: 'http://music.com/song.mp3', start: 106, end: 131 },
{ type: 'text', text: '\nand a regular link: ', start: 131, end: 152 },
{ type: 'url', url: 'https://regular.com/page?ok=true', start: 152, end: 184 },
{
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 ',
start: 184,
end: 408,
},
{ type: 'url', url: 'https://ok.com/' },
{ type: 'text', text: '!' },
{ type: 'url', url: 'https://ok.com/', start: 408, end: 422 },
{ type: 'text', text: '!', start: 422, end: 423 },
])
})
@@ -46,17 +58,24 @@ test('third: parse complex content with 4 nostr uris and 3 urls', () => {
const blocks = Array.from(parse(content))
expect(blocks).toEqual([
{ type: 'text', text: 'Look at these profiles ' },
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
{ type: 'text', text: ' ' },
{ type: 'text', text: 'Look at these profiles ', start: 0, end: 23 },
{
type: 'reference',
pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' },
start: 23,
end: 92,
},
{ type: 'text', text: ' ', start: 92, end: 93 },
{
type: 'reference',
pointer: {
pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
relays: ['wss://qwieu.com'],
},
start: 93,
end: 196,
},
{ type: 'text', text: ' check this event ' },
{ type: 'text', text: ' check this event ', start: 196, end: 214 },
{
type: 'reference',
pointer: {
@@ -65,15 +84,22 @@ test('third: parse complex content with 4 nostr uris and 3 urls', () => {
author: undefined,
kind: undefined,
},
start: 214,
end: 325,
},
{ 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' },
{ type: 'text', text: "\n here's an image ", start: 325, end: 346 },
{ type: 'image', url: 'https://example.com/pic.png', start: 346, end: 373 },
{ type: 'text', text: ' and another profile ', start: 373, end: 394 },
{
type: 'reference',
pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' },
start: 394,
end: 463,
},
{ type: 'text', text: '\n with a video ', start: 463, end: 481 },
{ type: 'video', url: 'https://example.com/vid.webm', start: 481, end: 509 },
{ type: 'text', text: ' and finally ', start: 509, end: 522 },
{ type: 'url', url: 'https://example.com/docs', start: 522, end: 546 },
])
})
@@ -94,29 +120,34 @@ test('parse content with hashtags and emoji shortcodes', () => {
const blocks = Array.from(parse(event))
expect(blocks).toEqual([
{ type: 'text', text: 'hey ' },
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
{ type: 'text', text: ' check out ' },
{ type: 'emoji', shortcode: 'alpaca', url: 'https://example.com/alpaca.png' },
{ type: 'emoji', shortcode: 'alpaca', url: 'https://example.com/alpaca.png' },
{ type: 'text', text: ' ' },
{ type: 'hashtag', value: 'alpaca' },
{ type: 'text', text: ' at ' },
{ type: 'relay', url: 'wss://alpaca.com/' },
{ type: 'text', text: '! ' },
{ type: 'emoji', shortcode: 'star', url: 'https://example.com/star.png' },
{ type: 'text', text: '\n\n' },
{ type: 'hashtag', value: 'WORDS' },
{ type: 'text', text: ' ' },
{ type: 'hashtag', value: '486' },
{ type: 'text', text: ' 5/6' },
{ type: 'text', text: 'hey ', start: 0, end: 4 },
{
type: 'reference',
pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' },
start: 4,
end: 73,
},
{ type: 'text', text: ' check out ', start: 73, end: 84 },
{ type: 'emoji', shortcode: 'alpaca', url: 'https://example.com/alpaca.png', start: 84, end: 92 },
{ type: 'emoji', shortcode: 'alpaca', url: 'https://example.com/alpaca.png', start: 92, end: 100 },
{ type: 'text', text: ' ', start: 100, end: 101 },
{ type: 'hashtag', value: 'alpaca', start: 101, end: 108 },
{ type: 'text', text: ' at ', start: 108, end: 112 },
{ type: 'relay', url: 'wss://alpaca.com/', start: 112, end: 128 },
{ type: 'text', text: '! ', start: 128, end: 130 },
{ type: 'emoji', shortcode: 'star', url: 'https://example.com/star.png', start: 130, end: 136 },
{ type: 'text', text: '\n\n', start: 136, end: 138 },
{ type: 'hashtag', value: 'WORDS', start: 138, end: 144 },
{ type: 'text', text: ' ', start: 144, end: 145 },
{ type: 'hashtag', value: '486', start: 145, end: 149 },
{ type: 'text', text: ' 5/6', start: 149, end: 153 },
])
})
test('emoji shortcodes are treated as text if no event tags', () => {
const blocks = Array.from(parse('hello :alpaca:'))
expect(blocks).toEqual([{ type: 'text', text: 'hello :alpaca:' }])
expect(blocks).toEqual([{ type: 'text', text: 'hello :alpaca:', start: 0, end: 14 }])
})
test("a thing that didn't work well in the wild", () => {
@@ -129,7 +160,9 @@ test("a thing that didn't work well in the wild", () => {
{
type: 'text',
text: `Crowdsourcing doesn't mean just users clicking, by the way (although that could be possible too), it means a bunch of machines competing: `,
start: 0,
end: 138,
},
{ type: 'url', url: 'https://leaderboard.sbstats.uk/' },
{ type: 'url', url: 'https://leaderboard.sbstats.uk/', start: 138, end: 169 },
])
})
+42 -14
View File
@@ -5,39 +5,57 @@ export type Block =
| {
type: 'text'
text: string
start: number
end: number
}
| {
type: 'reference'
pointer: ProfilePointer | AddressPointer | EventPointer
start: number
end: number
}
| {
type: 'url'
url: string
start: number
end: number
}
| {
type: 'relay'
url: string
start: number
end: number
}
| {
type: 'image'
url: string
start: number
end: number
}
| {
type: 'video'
url: string
start: number
end: number
}
| {
type: 'audio'
url: string
start: number
end: number
}
| {
type: 'emoji'
shortcode: string
url: string
start: number
end: number
}
| {
type: 'hashtag'
value: string
start: number
end: number
}
const noCharacter = /\W/m
@@ -72,8 +90,8 @@ export function* parse(content: string | NostrEvent): Iterable<Block> {
if (h === 0 || content[h - 1].match(noCharacter)) {
const m = content.slice(h + 1, h + MAX_HASHTAG_LENGTH).match(noCharacter)
const end = m ? h + 1 + m.index! : max
yield { type: 'text', text: content.slice(prevIndex, h) }
yield { type: 'hashtag', value: content.slice(h + 1, end) }
yield { type: 'text', text: content.slice(prevIndex, h), start: prevIndex, end: h }
yield { type: 'hashtag', value: content.slice(h + 1, end), start: h, end }
index = end
prevIndex = index
continue mainloop
@@ -108,9 +126,9 @@ export function* parse(content: string | NostrEvent): Iterable<Block> {
}
if (prevIndex !== u - 5) {
yield { type: 'text', text: content.slice(prevIndex, u - 5) }
yield { type: 'text', text: content.slice(prevIndex, u - 5), start: prevIndex, end: u - 5 }
}
yield { type: 'reference', pointer }
yield { type: 'reference', pointer, start: u - 5, end }
index = end
prevIndex = index
continue mainloop
@@ -130,29 +148,34 @@ export function* parse(content: string | NostrEvent): Iterable<Block> {
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.slice(prevIndex, u - prefixLen) }
yield {
type: 'text',
text: content.slice(prevIndex, u - prefixLen),
start: prevIndex,
end: u - prefixLen,
}
}
if (/\.(png|jpe?g|gif|webp|heic|svg)$/i.test(url.pathname)) {
yield { type: 'image', url: url.toString() }
yield { type: 'image', url: url.toString(), start: u - prefixLen, end }
index = end
prevIndex = index
continue mainloop
}
if (/\.(mp4|avi|webm|mkv|mov)$/i.test(url.pathname)) {
yield { type: 'video', url: url.toString() }
yield { type: 'video', url: url.toString(), start: u - prefixLen, end }
index = end
prevIndex = index
continue mainloop
}
if (/\.(mp3|aac|ogg|opus|wav|flac)$/i.test(url.pathname)) {
yield { type: 'audio', url: url.toString() }
yield { type: 'audio', url: url.toString(), start: u - prefixLen, end }
index = end
prevIndex = index
continue mainloop
}
yield { type: 'url', url: url.toString() }
yield { type: 'url', url: url.toString(), start: u - prefixLen, end }
index = end
prevIndex = index
continue mainloop
@@ -172,9 +195,14 @@ export function* parse(content: string | NostrEvent): Iterable<Block> {
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.slice(prevIndex, u - prefixLen) }
yield {
type: 'text',
text: content.slice(prevIndex, u - prefixLen),
start: prevIndex,
end: u - prefixLen,
}
}
yield { type: 'relay', url: url.toString() }
yield { type: 'relay', url: url.toString(), start: u - prefixLen, end }
index = end
prevIndex = index
continue mainloop
@@ -193,9 +221,9 @@ export function* parse(content: string | NostrEvent): Iterable<Block> {
) {
// found an emoji
if (prevIndex !== u) {
yield { type: 'text', text: content.slice(prevIndex, u) }
yield { type: 'text', text: content.slice(prevIndex, u), start: prevIndex, end: u }
}
yield emoji
yield { ...emoji, start: u, end: u + emoji.shortcode.length + 2 }
index = u + emoji.shortcode.length + 2
prevIndex = index
continue mainloop
@@ -209,6 +237,6 @@ export function* parse(content: string | NostrEvent): Iterable<Block> {
}
if (prevIndex !== max) {
yield { type: 'text', text: content.slice(prevIndex) }
yield { type: 'text', text: content.slice(prevIndex), start: prevIndex, end: max }
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "nostr-tools",
"version": "2.25.0",
"version": "2.25.1",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",