From a8fc3499e4c750cae79660d219741c196087462e Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 30 Aug 2026 18:16:38 -0300 Subject: [PATCH] nip27: emit start and ending offsets. --- jsr.json | 2 +- nip27.test.ts | 123 ++++++++++++++++++++++++++++++++------------------ nip27.ts | 56 +++++++++++++++++------ package.json | 2 +- 4 files changed, 122 insertions(+), 61 deletions(-) diff --git a/jsr.json b/jsr.json index da96950..f133628 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@nostr/tools", - "version": "2.25.0", + "version": "2.25.1", "exports": { ".": "./index.ts", "./core": "./core.ts", diff --git a/nip27.test.ts b/nip27.test.ts index 443e835..096c3be 100644 --- a/nip27.test.ts +++ b/nip27.test.ts @@ -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 }, ]) }) diff --git a/nip27.ts b/nip27.ts index 2839c82..8db1b0c 100644 --- a/nip27.ts +++ b/nip27.ts @@ -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 { 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 { } 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 { } 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 { } 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 { ) { // 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 { } if (prevIndex !== max) { - yield { type: 'text', text: content.slice(prevIndex) } + yield { type: 'text', text: content.slice(prevIndex), start: prevIndex, end: max } } } diff --git a/package.json b/package.json index ee4a9fe..6d43f53 100644 --- a/package.json +++ b/package.json @@ -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",