From eb97dbd9efe70af8eb1251a919a043f7ac61992e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 22 Apr 2023 18:13:20 -0500 Subject: [PATCH 1/4] Add NIP-21 and NIP-27 modules for parsing nostr URIs --- index.ts | 2 ++ nip21.test.js | 24 +++++++++++++++++++++ nip21.ts | 37 ++++++++++++++++++++++++++++++++ nip27.test.js | 40 ++++++++++++++++++++++++++++++++++ nip27.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+) create mode 100644 nip21.test.js create mode 100644 nip21.ts create mode 100644 nip27.test.js create mode 100644 nip27.ts diff --git a/index.ts b/index.ts index 6fbe9e2..c1f8047 100644 --- a/index.ts +++ b/index.ts @@ -11,7 +11,9 @@ export * as nip06 from './nip06' export * as nip10 from './nip10' export * as nip13 from './nip13' export * as nip19 from './nip19' +export * as nip21 from './nip21' export * as nip26 from './nip26' +export * as nip27 from './nip27' export * as nip39 from './nip39' export * as nip42 from './nip42' export * as nip57 from './nip57' diff --git a/nip21.test.js b/nip21.test.js new file mode 100644 index 0000000..6712f58 --- /dev/null +++ b/nip21.test.js @@ -0,0 +1,24 @@ +/* eslint-env jest */ +const {nip21} = require('./lib/nostr.cjs') + +test('test', () => { + expect(nip21.test('nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(true) + expect(nip21.test('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')).toBe(true) + expect(nip21.test(' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false) + expect(nip21.test('nostr:')).toBe(false) + expect(nip21.test('nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false) + expect(nip21.test('gggggg')).toBe(false) +}) + +test('parse', () => { + const result = nip21.parse('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky') + + expect(result).toEqual({ + uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', + value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', + decoded: { + type: 'note', + data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b', + }, + }) +}) diff --git a/nip21.ts b/nip21.ts new file mode 100644 index 0000000..06692c6 --- /dev/null +++ b/nip21.ts @@ -0,0 +1,37 @@ +import * as nip19 from './nip19' +import * as nip21 from './nip21' + +/** + * Bech32 regex. + * @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32 + */ +export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/ + +/** Nostr URI regex, eg `nostr:npub1...` */ +export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`) + +/** Test whether the value is a Nostr URI. */ +export function test(value: unknown): value is `nostr:${string}` { + return typeof value === 'string' && new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value) +} + +/** Parsed Nostr URI data. */ +export interface NostrURI { + /** Full URI including the `nostr:` protocol. */ + uri: `nostr:${string}` + /** The bech32-encoded data (eg `npub1...`). */ + value: string + /** Decoded bech32 string, according to NIP-19. */ + decoded: nip19.DecodeResult +} + +/** Parse and decode a Nostr URI. */ +export function parse(uri: string): NostrURI { + const match = uri.match(new RegExp(`^${nip21.NOSTR_URI_REGEX.source}$`)) + if (!match) throw new Error(`Invalid Nostr URI: ${uri}`) + return { + uri: match[0] as `nostr:${string}`, + value: match[1], + decoded: nip19.decode(match[1]), + } +} diff --git a/nip27.test.js b/nip27.test.js new file mode 100644 index 0000000..9e29906 --- /dev/null +++ b/nip27.test.js @@ -0,0 +1,40 @@ +/* eslint-env jest */ +const {nip27} = require('./lib/nostr.cjs') + +test('find', () => { + const result = nip27.find( + 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', + ) + + 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, + }]) +}) + +test('replaceAll', () => { + const content = + 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' + + const result = nip27.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') +}) diff --git a/nip27.ts b/nip27.ts new file mode 100644 index 0000000..e415839 --- /dev/null +++ b/nip27.ts @@ -0,0 +1,59 @@ +import * as nip19 from './nip19' +import * as nip21 from './nip21' + +/** Regex to find NIP-21 URIs inside event content. */ +export const regex = () => new RegExp(`\\b${nip21.NOSTR_URI_REGEX.source}\\b`, 'g') + +/** Match result for a Nostr URI in event content. */ +export interface NostrURIMatch extends nip21.NostrURI { + /** 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 find(content: string): NostrURIMatch[] { + const matches = content.matchAll(regex()) + + return [...matches].map((match) => { + const [uri, value] = match + + return { + uri: uri as `nostr:${string}`, + value, + decoded: nip19.decode(value), + start: match.index!, + end: match.index! + uri.length, + } + }) +} + +/** + * 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: nip21.NostrURI) => string): string { + return content.replaceAll(regex(), (uri, value) => { + return replacer({ + uri: uri as `nostr:${string}`, + value, + decoded: nip19.decode(value), + }) + }) +} From dcf101c6c20c71bd161b1d6bd3ace5da6418a975 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 22 Apr 2023 18:33:09 -0500 Subject: [PATCH 2/4] yarn format --- nip21.test.js | 32 +++++++++++++++++++++++++------- nip21.ts | 10 +++++++--- nip27.test.js | 39 ++++++++++++++++++++++++--------------- nip27.ts | 14 +++++++++----- 4 files changed, 65 insertions(+), 30 deletions(-) diff --git a/nip21.test.js b/nip21.test.js index 6712f58..acc9562 100644 --- a/nip21.test.js +++ b/nip21.test.js @@ -2,23 +2,41 @@ const {nip21} = require('./lib/nostr.cjs') test('test', () => { - expect(nip21.test('nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(true) - expect(nip21.test('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')).toBe(true) - expect(nip21.test(' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false) + expect( + nip21.test( + 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6' + ) + ).toBe(true) + expect( + nip21.test( + 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' + ) + ).toBe(true) + expect( + nip21.test( + ' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6' + ) + ).toBe(false) expect(nip21.test('nostr:')).toBe(false) - expect(nip21.test('nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false) + expect( + nip21.test( + 'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6' + ) + ).toBe(false) expect(nip21.test('gggggg')).toBe(false) }) test('parse', () => { - const result = nip21.parse('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky') + const result = nip21.parse( + 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' + ) expect(result).toEqual({ uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', decoded: { type: 'note', - data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b', - }, + data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b' + } }) }) diff --git a/nip21.ts b/nip21.ts index 06692c6..a5608a2 100644 --- a/nip21.ts +++ b/nip21.ts @@ -5,14 +5,18 @@ import * as nip21 from './nip21' * Bech32 regex. * @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32 */ -export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/ +export const BECH32_REGEX = + /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/ /** Nostr URI regex, eg `nostr:npub1...` */ export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`) /** Test whether the value is a Nostr URI. */ export function test(value: unknown): value is `nostr:${string}` { - return typeof value === 'string' && new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value) + return ( + typeof value === 'string' && + new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value) + ) } /** Parsed Nostr URI data. */ @@ -32,6 +36,6 @@ export function parse(uri: string): NostrURI { return { uri: match[0] as `nostr:${string}`, value: match[1], - decoded: nip19.decode(match[1]), + decoded: nip19.decode(match[1]) } } diff --git a/nip27.test.js b/nip27.test.js index 9e29906..f7436fd 100644 --- a/nip27.test.js +++ b/nip27.test.js @@ -3,29 +3,38 @@ const {nip27} = require('./lib/nostr.cjs') test('find', () => { const result = nip27.find( - 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', + 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' ) - 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(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 + } + ]) }) test('replaceAll', () => { const content = 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' - const result = nip27.replaceAll(content, ({ decoded, value }) => { + const result = nip27.replaceAll(content, ({decoded, value}) => { switch (decoded.type) { case 'npub': return '@alex' diff --git a/nip27.ts b/nip27.ts index e415839..5ebd49f 100644 --- a/nip27.ts +++ b/nip27.ts @@ -2,7 +2,8 @@ import * as nip19 from './nip19' import * as nip21 from './nip21' /** Regex to find NIP-21 URIs inside event content. */ -export const regex = () => new RegExp(`\\b${nip21.NOSTR_URI_REGEX.source}\\b`, 'g') +export const regex = () => + new RegExp(`\\b${nip21.NOSTR_URI_REGEX.source}\\b`, 'g') /** Match result for a Nostr URI in event content. */ export interface NostrURIMatch extends nip21.NostrURI { @@ -16,7 +17,7 @@ export interface NostrURIMatch extends nip21.NostrURI { export function find(content: string): NostrURIMatch[] { const matches = content.matchAll(regex()) - return [...matches].map((match) => { + return [...matches].map(match => { const [uri, value] = match return { @@ -24,7 +25,7 @@ export function find(content: string): NostrURIMatch[] { value, decoded: nip19.decode(value), start: match.index!, - end: match.index! + uri.length, + end: match.index! + uri.length } }) } @@ -48,12 +49,15 @@ export function find(content: string): NostrURIMatch[] { * }) * ``` */ -export function replaceAll(content: string, replacer: (match: nip21.NostrURI) => string): string { +export function replaceAll( + content: string, + replacer: (match: nip21.NostrURI) => string +): string { return content.replaceAll(regex(), (uri, value) => { return replacer({ uri: uri as `nostr:${string}`, value, - decoded: nip19.decode(value), + decoded: nip19.decode(value) }) }) } From 6a037d16588ed865394bc1d7f5fa35069cd94d07 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 22 Apr 2023 19:06:53 -0500 Subject: [PATCH 3/4] nip27.find --> nip27.matchAll --- nip27.test.js | 4 ++-- nip27.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nip27.test.js b/nip27.test.js index f7436fd..2946722 100644 --- a/nip27.test.js +++ b/nip27.test.js @@ -1,8 +1,8 @@ /* eslint-env jest */ const {nip27} = require('./lib/nostr.cjs') -test('find', () => { - const result = nip27.find( +test('matchAll', () => { + const result = nip27.matchAll( 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' ) diff --git a/nip27.ts b/nip27.ts index 5ebd49f..00bcad4 100644 --- a/nip27.ts +++ b/nip27.ts @@ -14,7 +14,7 @@ export interface NostrURIMatch extends nip21.NostrURI { } /** Find and decode all NIP-21 URIs. */ -export function find(content: string): NostrURIMatch[] { +export function matchAll(content: string): NostrURIMatch[] { const matches = content.matchAll(regex()) return [...matches].map(match => { From 45c07a5f451a2f14facba5fbfa22dbd5cbe4677f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 22 Apr 2023 19:22:06 -0500 Subject: [PATCH 4/4] nip27: make `matchAll` a generator function --- nip27.test.js | 2 +- nip27.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nip27.test.js b/nip27.test.js index 2946722..24c63ac 100644 --- a/nip27.test.js +++ b/nip27.test.js @@ -6,7 +6,7 @@ test('matchAll', () => { 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' ) - expect(result).toEqual([ + expect([...result]).toEqual([ { uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', diff --git a/nip27.ts b/nip27.ts index 00bcad4..7fa5be8 100644 --- a/nip27.ts +++ b/nip27.ts @@ -14,20 +14,20 @@ export interface NostrURIMatch extends nip21.NostrURI { } /** Find and decode all NIP-21 URIs. */ -export function matchAll(content: string): NostrURIMatch[] { +export function * matchAll(content: string): Iterable { const matches = content.matchAll(regex()) - return [...matches].map(match => { + for (const match of matches) { const [uri, value] = match - return { + yield { uri: uri as `nostr:${string}`, value, decoded: nip19.decode(value), start: match.index!, end: match.index! + uri.length } - }) + } } /**