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