From eb97dbd9efe70af8eb1251a919a043f7ac61992e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 22 Apr 2023 18:13:20 -0500 Subject: [PATCH] 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), + }) + }) +}