remarkNostr.ts raw
1 import type { PhrasingContent, Root, Text } from 'mdast'
2 import type { Plugin } from 'unified'
3 import { visit } from 'unist-util-visit'
4 import { NostrNode } from './types'
5
6 const NOSTR_REGEX =
7 /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
8 const NOSTR_REFERENCE_REGEX =
9 /\[[^\]]+\]\[(nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+))\]/g
10
11 export const remarkNostr: Plugin<[], Root> = () => {
12 return (tree) => {
13 visit(tree, 'text', (node: Text, index, parent) => {
14 if (!parent || typeof index !== 'number') return
15
16 const text = node.value
17
18 // First, handle reference-style nostr links [text][nostr:...]
19 const refMatches = Array.from(text.matchAll(NOSTR_REFERENCE_REGEX))
20 // Then, handle direct nostr links that are not part of reference links
21 const directMatches = Array.from(text.matchAll(NOSTR_REGEX)).filter((directMatch) => {
22 return !refMatches.some(
23 (refMatch) =>
24 directMatch.index! >= refMatch.index! &&
25 directMatch.index! < refMatch.index! + refMatch[0].length
26 )
27 })
28
29 // Combine and sort matches by position
30 const allMatches = [
31 ...refMatches.map((match) => ({
32 ...match,
33 type: 'reference' as const,
34 bech32Id: match[2],
35 rawText: match[0]
36 })),
37 ...directMatches.map((match) => ({
38 ...match,
39 type: 'direct' as const,
40 bech32Id: match[1],
41 rawText: match[0]
42 }))
43 ].sort((a, b) => a.index! - b.index!)
44
45 if (allMatches.length === 0) return
46
47 const children: (Text | NostrNode)[] = []
48 let lastIndex = 0
49
50 allMatches.forEach((match) => {
51 const matchStart = match.index!
52 const matchEnd = matchStart + match[0].length
53
54 // Add text before the match
55 if (matchStart > lastIndex) {
56 children.push({
57 type: 'text',
58 value: text.slice(lastIndex, matchStart)
59 })
60 }
61
62 // Create custom nostr node with type information
63 const nostrNode: NostrNode = {
64 type: 'nostr',
65 data: {
66 hName: 'nostr',
67 hProperties: {
68 bech32Id: match.bech32Id,
69 rawText: match.rawText
70 }
71 }
72 }
73 children.push(nostrNode)
74
75 lastIndex = matchEnd
76 })
77
78 // Add remaining text after the last match
79 if (lastIndex < text.length) {
80 children.push({
81 type: 'text',
82 value: text.slice(lastIndex)
83 })
84 }
85
86 // Type assertion to tell TypeScript these are valid AST nodes
87 parent.children.splice(index, 1, ...(children as PhrasingContent[]))
88 })
89 }
90 }
91