libp2r2p 0.10.14 → 0.10.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -371,13 +371,84 @@ event. Their `assert…` counterparts return the original event or throw a
371
371
  `ValidationError` with a stable code.
372
372
 
373
373
  NIP-27 text references live in `libp2r2p/nip27`. `extractMedia()` splits
374
- content into text, URL, profile, event, relay, NIP-05 and hashtag items in
374
+ content into text, URL, profile, event, relay, NIP-05, app and hashtag items in
375
375
  occurrence order, accepting the optional `@` and `nostr:` mention prefixes
376
376
  plus NIP-05 in its standard, root and custom compact spellings.
377
377
  `decodeReference()` parses a single reference, and `decodeMediaMetadata()`
378
378
  reads the file/media metadata carried in a URL fragment
379
379
  (`#m=image/png&dim=640x480&...`).
380
380
 
381
+ App references are a library extension to NIP-27. `extractMedia()` recognizes
382
+ encoded `+…` app entities and named references such as `+hallway@fiatjaf.com`,
383
+ `++myapp@bob@example.com` and `+++myapp@npub1…`, with optional `nostr:` before
384
+ the entire reference. Named authors accept the NIP-05 spellings above, `npub`,
385
+ `nprofile` and hex pubkeys; app names may be URL-encoded. The one-to-three `+`
386
+ prefix selects `main`, `next` or `draft` respectively.
387
+
388
+ Bare app names use the `defaultAppAuthor` option, which defaults to
389
+ `'44billion.net'`: `+apps` identifies the same app as `+apps@44billion.net`.
390
+ The option accepts a NIP-05 reference (including compact spellings), `npub`,
391
+ `nprofile` or hex pubkey and is validated locally on every call. An invalid
392
+ value throws `ValidationError` with code `INVALID_DEFAULT_APP_AUTHOR`.
393
+ Explicit authors and encoded app entities are never overridden. Malformed
394
+ explicit authors remain text instead of falling back to the default.
395
+
396
+ ```js
397
+ extractMedia('+apps', { defaultAppAuthor: 'bob@example.com' })
398
+ // app.user: { type: 'nip05', local: 'bob', domain: 'example.com', raw: 'bob.example.com' }
399
+ // app.original remains '+apps'.
400
+ ```
401
+
402
+ These references produce `{ key: 'app', app: { original, ...decoded } }`, where
403
+ `decoded` is the result of `decodeAppUrl()` from `libp2r2p/url`, with a missing
404
+ named author filled from `defaultAppAuthor` as described above. Named apps
405
+ include `type: 'named'`, `prefix`, `channel`, `appName` (the manifest's `d` tag)
406
+ and the decoded `user`; entities include `type: 'entity'` and `entity`, usable
407
+ with `appDecode()` from `libp2r2p/nip19`. Extraction performs no network lookup
408
+ and does not verify that the app exists. For example:
409
+
410
+ ```js
411
+ extractMedia('Open nostr:++myapp@bob@example.com')
412
+ // [
413
+ // { key: 'text', text: { value: 'Open ' } },
414
+ // { key: 'app', app: {
415
+ // original: 'nostr:++myapp@bob@example.com', type: 'named',
416
+ // prefix: '++', channel: 'next', appName: 'myapp',
417
+ // user: { type: 'nip05', local: 'bob', domain: 'example.com', raw: 'bob.example.com' }
418
+ // } }
419
+ // ]
420
+ ```
421
+
422
+ App references must be standalone inline tokens, not concatenated to event
423
+ pointers or embedded in URL paths. Invalid app candidates remain text.
424
+ Bare names can use letters, numbers, dots, underscores, hyphens and tildes;
425
+ URL-encode spaces, `@` and other punctuation in them.
426
+ `+naddr1…` is recognized as an app only for site-manifest kinds; unprefixed
427
+ `naddr1…` and `nostr:naddr1…` keep their existing `event` item shape.
428
+
429
+ `compactWhitespace(text, options?)` is also exported from `libp2r2p/nip27` as an
430
+ opt-in display helper. It removes carriage returns, collapses spaces and
431
+ tabs, removes spaces around line breaks, limits consecutive line breaks to
432
+ two, keeps the first eight line breaks (replacing subsequent runs with a
433
+ space), and trims the result. Empty strings are accepted; non-string inputs
434
+ throw `ValidationError` with code `INVALID_WHITESPACE_TEXT`. It is not
435
+ applied automatically by `extractMedia()`.
436
+
437
+ The option `maxLineBreaks` (default `8`) sets the total limit and accepts a
438
+ non-negative safe integer. Setting it to `0` replaces all line-break runs
439
+ with spaces. `consecutiveLineBreakThreshold` (default `3`) sets the minimum
440
+ run length that collapses to **two** line breaks; shorter runs are preserved.
441
+ It accepts safe integers of at least `3`. Either option accepts `Infinity`
442
+ to disable its rule. Runs are collapsed before applying the total limit,
443
+ and trimming happens last. Invalid options throw `ValidationError` with
444
+ code `INVALID_WHITESPACE_OPTIONS`.
445
+
446
+ ```js
447
+ import { compactWhitespace } from 'libp2r2p/nip27'
448
+
449
+ compactWhitespace(text, { maxLineBreaks: 12, consecutiveLineBreakThreshold: 5 })
450
+ ```
451
+
381
452
  User references (`npub`, `nprofile`, hex pubkeys and every NIP-05 spelling)
382
453
  are handled by `libp2r2p/nip27`: `decodeUserReference()` returns the decoded
383
454
  form with its canonical compact spelling, `encodeUserReference()` returns
@@ -0,0 +1,33 @@
1
+ import { ValidationError } from '../../error/index.js'
2
+
3
+ // Compacts display text, defaulting to collapsing runs of three or more line
4
+ // breaks to two and keeping eight overall. extractMedia preserves input.
5
+ export function compactWhitespace (text, options = {}) {
6
+ if (typeof text !== 'string') {
7
+ throw new ValidationError('INVALID_WHITESPACE_TEXT', { message: 'TEXT_SHOULD_BE_A_STRING' })
8
+ }
9
+
10
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
11
+ throw new ValidationError('INVALID_WHITESPACE_OPTIONS', { message: 'OPTIONS_SHOULD_BE_AN_OBJECT' })
12
+ }
13
+ const { maxLineBreaks = 8, consecutiveLineBreakThreshold = 3 } = options
14
+ if (maxLineBreaks !== Infinity && (!Number.isSafeInteger(maxLineBreaks) || maxLineBreaks < 0)) {
15
+ throw new ValidationError('INVALID_WHITESPACE_OPTIONS', { message: 'MAX_LINE_BREAKS_SHOULD_BE_A_NON_NEGATIVE_SAFE_INTEGER_OR_INFINITY' })
16
+ }
17
+ if (consecutiveLineBreakThreshold !== Infinity && (!Number.isSafeInteger(consecutiveLineBreakThreshold) || consecutiveLineBreakThreshold < 3)) {
18
+ throw new ValidationError('INVALID_WHITESPACE_OPTIONS', { message: 'CONSECUTIVE_LINE_BREAK_THRESHOLD_SHOULD_BE_A_SAFE_INTEGER_AT_LEAST_THREE_OR_INFINITY' })
19
+ }
20
+
21
+ let remainingLineBreaks = maxLineBreaks
22
+ return text
23
+ .replace(/\r/g, '')
24
+ .replace(/[\t ]+/g, ' ')
25
+ .replace(/ ?\n ?/g, '\n')
26
+ .replace(/\n+/g, lineBreaks => {
27
+ const consecutive = lineBreaks.length >= consecutiveLineBreakThreshold ? 2 : lineBreaks.length
28
+ const kept = Math.min(remainingLineBreaks, consecutive)
29
+ remainingLineBreaks -= kept
30
+ return lineBreaks.slice(0, kept) + (kept < consecutive ? ' ' : '')
31
+ })
32
+ .trim()
33
+ }
package/nip27/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { ValidationError } from '../error/index.js'
2
2
  import {
3
+ NAPP_ENTITY_REGEX,
3
4
  naddrDecode,
4
5
  neventDecode,
5
6
  noteDecode,
6
7
  nrelayDecode
7
8
  } from '../nip19/index.js'
8
9
  import { queryProfile } from '../nip05/index.js'
9
- import { normalizeRelayUrl } from '../url/index.js'
10
+ import { normalizeRelayUrl, tryDecodeAppUrl } from '../url/index.js'
10
11
  import {
11
12
  decodeUserReference,
12
13
  encodeUserReference,
@@ -14,6 +15,7 @@ import {
14
15
  } from './helpers/user-reference.js'
15
16
 
16
17
  export { decodeUserReference, encodeUserReference, tryDecodeUserReference }
18
+ export { compactWhitespace } from './helpers/compact-whitespace.js'
17
19
 
18
20
  const BECH32_BODY = '[ac-hj-np-z02-9]'
19
21
  const BOUNDARY_PREFIX = /(?<=^|[\s"«„「¡¿:{([])/.source
@@ -36,6 +38,15 @@ const NIP05_AT_CUSTOM = `@(?<nip05AtCustom>${NIP05_LOCAL}\\.${NIP05_DOMAIN})`
36
38
  const NIP05_BARE_ROOT = '(?<nip05BareRoot>[a-z0-9-]+\\.[a-z]{2,63})'
37
39
  const NIP05_BARE_CUSTOM = `(?<nip05BareCustom>${NIP05_LOCAL}\\.${NIP05_DOMAIN})`
38
40
 
41
+ // App references are a library extension. Names may contain punctuation, but
42
+ // author spellings and encoded entities delimit the token. decodeAppUrl owns
43
+ // validation. Literal + inside a name must be encoded, so a named candidate
44
+ // cannot consume a neighboring app reference. Prose punctuation stays outside.
45
+ const APP_SOURCE = '(?:nostr:)?(?<app>' +
46
+ NAPP_ENTITY_REGEX.source.slice(1, -1) + '|' +
47
+ /\+{1,3}[^\s/<>+]+?@[a-zA-Z0-9._%@-]*[a-zA-Z0-9]/.source + '|' +
48
+ /\+{1,3}[\p{L}\p{N}._%~-]*[\p{L}\p{N}_%~-]/.source + ')'
49
+
39
50
  function entitySource (name) {
40
51
  const bodyLength = name === 'npub' ? '58' : (name === 'nrelay' ? '10,5000' : '58,5000')
41
52
  return `(?:@|nostr:)?(?<${name}>${name}1${BECH32_BODY}{${bodyLength}})`
@@ -65,6 +76,7 @@ function getReferencesRegex (bareNip05) {
65
76
  ]
66
77
  const alternatives = [
67
78
  URL_SOURCE,
79
+ APP_SOURCE,
68
80
  NIP05_STANDARD,
69
81
  ...nip05Compact,
70
82
  ...ENTITY_SOURCES,
@@ -276,7 +288,17 @@ function decodeFragmentValue (value) {
276
288
  }
277
289
  }
278
290
 
279
- function getReferenceItem (original, groups, { getMimeType }) {
291
+ function getReferenceItem (original, groups, { getMimeType, defaultAppUser }) {
292
+ if (groups.app) {
293
+ const app = tryDecodeAppUrl(groups.app)
294
+ // An omitted author uses the configured default. An explicit but invalid
295
+ // author must never silently resolve to a different app.
296
+ if (app?.type === 'named' && !app.user && !groups.app.includes('@')) app.user = defaultAppUser
297
+ return app && (app.type === 'entity' || app.user)
298
+ ? { key: 'app', app: { original, ...app } }
299
+ : { key: 'text', text: { value: original } }
300
+ }
301
+
280
302
  if (groups.url) {
281
303
  const url = `${groups.protocol ? '' : 'https://'}${groups.url}`
282
304
  let mediaMetadata = {}
@@ -376,10 +398,20 @@ function getReferenceItem (original, groups, { getMimeType }) {
376
398
  // Bare compact NIP-05 spellings (`bob.example.com`) are only recognized when
377
399
  // `{ bareNip05: true }` is passed, since they are otherwise indistinguishable
378
400
  // from plain hostnames; prefixed forms (`@bob.example.com`) always work.
379
- export function extractMedia (content, { bareNip05 = false, getMimeType } = {}) {
401
+ // App references (+encodedEntity, +app@author or +app, with optional nostr:) return
402
+ // { key: 'app', app: { original, ...decodeAppUrlResult } } without resolution.
403
+ // Missing app authors use defaultAppAuthor, validated as a user reference.
404
+ export function extractMedia (content, { bareNip05 = false, getMimeType, defaultAppAuthor = '44billion.net' } = {}) {
380
405
  if (typeof content !== 'string') {
381
406
  throw new ValidationError('INVALID_MEDIA_CONTENT', { message: 'CONTENT_SHOULD_BE_A_STRING' })
382
407
  }
408
+ let defaultAppUser
409
+ try {
410
+ defaultAppUser = decodeUserReference(defaultAppAuthor)
411
+ } catch (cause) {
412
+ if (!(cause instanceof ValidationError)) throw cause
413
+ throw new ValidationError('INVALID_DEFAULT_APP_AUTHOR', { cause })
414
+ }
383
415
  const regex = getReferencesRegex(bareNip05)
384
416
  const items = []
385
417
  let end = 0
@@ -391,7 +423,7 @@ export function extractMedia (content, { bareNip05 = false, getMimeType } = {})
391
423
  }
392
424
  const original = match[0]
393
425
  end = start + original.length
394
- const item = getReferenceItem(original, match.groups, { getMimeType })
426
+ const item = getReferenceItem(original, match.groups, { getMimeType, defaultAppUser })
395
427
  if (item) items.push(item)
396
428
  }
397
429
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.14",
3
+ "version": "0.10.16",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",