From 0abbe62515abea53aad4def045e057bd750348f6 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 23:51:40 +0100 Subject: [PATCH] fix: prevent nostr URI replacement inside markdown link URLs Prevents nested markdown link issues when nostr identifiers appear in URLs. The replaceNostrUrisInMarkdown functions now skip nostr URIs that are already inside markdown link syntax [text](url) to avoid creating malformed nested links. --- src/utils/nostrUriResolver.tsx | 66 ++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index d524a9c3..3c59c616 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -107,17 +107,71 @@ export function getNostrUriLabel(encoded: string): string { } } +/** + * Process markdown to replace nostr URIs while skipping those inside markdown links + * This prevents nested markdown link issues when nostr identifiers appear in URLs + */ +function replaceNostrUrisSafely( + markdown: string, + getReplacement: (encoded: string) => string +): string { + // Pattern to match markdown links: [text](url) + // Uses a more robust approach to handle URLs with special characters + // Match: [ followed by any text (including escaped brackets), then ]( then URL (can contain parentheses if escaped), then ) + const markdownLinkRegex = /\[(?:[^\]]|\\\])*\]\((?:[^)]|\\\))*\)/g + + // Track positions where we're inside a markdown link URL + const linkRanges: Array<{ start: number, end: number }> = [] + let match: RegExpExecArray | null + + // Reset regex lastIndex + markdownLinkRegex.lastIndex = 0 + + // Find all markdown links and track their URL positions + while ((match = markdownLinkRegex.exec(markdown)) !== null) { + const linkStart = match.index + const linkEnd = linkStart + match[0].length + + // Find the start of the URL part (after "]( ) + const urlStartMatch = match[0].match(/\]\(/) + if (urlStartMatch) { + const urlStartOffset = match[0].indexOf(urlStartMatch[0]) + urlStartMatch[0].length + const urlEndOffset = match[0].length - 1 // -1 to exclude the closing ) + + linkRanges.push({ + start: linkStart + urlStartOffset, + end: linkStart + urlEndOffset + }) + } + } + + // Check if a position is inside any markdown link URL + const isInsideLinkUrl = (pos: number): boolean => { + return linkRanges.some(range => pos >= range.start && pos < range.end) + } + + // Replace nostr URIs, but skip those inside link URLs + // Callback params: (match, encoded, type, offset, string) + return markdown.replace(NOSTR_URI_REGEX, (match, encoded, type, offset) => { + // Check if this match is inside a markdown link URL + if (isInsideLinkUrl(offset)) { + // Don't replace - return original match + return match + } + + // encoded is already the NIP-19 identifier without nostr: prefix (from capture group) + return getReplacement(encoded) + }) +} + /** * Replace nostr: URIs in markdown with proper markdown links * This converts: nostr:npub1... to [label](link) */ export function replaceNostrUrisInMarkdown(markdown: string): string { - return markdown.replace(NOSTR_URI_REGEX, (match) => { - // Extract just the NIP-19 identifier (without nostr: prefix) - const encoded = match.replace(/^nostr:/, '') + return replaceNostrUrisSafely(markdown, (encoded) => { const link = createNostrLink(encoded) const label = getNostrUriLabel(encoded) - return `[${label}](${link})` }) } @@ -132,9 +186,7 @@ export function replaceNostrUrisInMarkdownWithTitles( markdown: string, articleTitles: Map ): string { - return markdown.replace(NOSTR_URI_REGEX, (match) => { - // Extract just the NIP-19 identifier (without nostr: prefix) - const encoded = match.replace(/^nostr:/, '') + return replaceNostrUrisSafely(markdown, (encoded) => { const link = createNostrLink(encoded) // For articles, use the resolved title if available