Fix nostr mention parsing when immediately followed by text

Issue: Nostr mentions (e.g., nostr:npub...) were not correctly parsed when
immediately followed by text without whitespace. For example:
"nostr:npub1...how does this work?" would fail to parse the mention.

Solution:
- Created custom nostr mention transformer with proper word boundaries
- Uses length constraints to prevent matching too many characters:
  * npub/note: exactly 58 bech32 chars after prefix (63 total)
  * nprofile/nevent/naddr: 40-300 bech32 chars (TLV encoded, variable length)
- Added transformer BEFORE default textNoteTransformers to handle edge cases
- Includes comprehensive test coverage

Files:
- src/lib/nostr-mention-transformer.ts: New custom transformer
- src/lib/nostr-mention-transformer.test.ts: Test suite with 10 tests
- src/components/nostr/RichText.tsx: Added transformer to defaults

Tests: All 874 tests passing
Build: Success
Lint: No new errors (47 warnings, all pre-existing)
This commit is contained in:
Claude
2026-01-15 19:33:20 +00:00
parent 4e8a8a0e90
commit f3603fbb46
3 changed files with 293 additions and 1 deletions

View File

@@ -12,6 +12,7 @@ import { Nip } from "./RichText/Nip";
import { Relay } from "./RichText/Relay";
import { nipReferences } from "@/lib/nip-transformer";
import { relayReferences } from "@/lib/relay-transformer";
import { nostrMentionReferences } from "@/lib/nostr-mention-transformer";
import type { NostrEvent } from "@/types/nostr";
import type { Root } from "applesauce-content/nast";
@@ -23,8 +24,11 @@ const { useRenderedContent } = Hooks;
// Custom cache key for our extended transformers
const GrimoireContentSymbol = Symbol.for("grimoire-content");
// Default transformers including our custom NIP and relay transformers
// Default transformers including our custom NIP, relay, and mention transformers
// IMPORTANT: nostrMentionReferences must come BEFORE textNoteTransformers
// to handle edge cases where mentions are immediately followed by text without whitespace
export const defaultTransformers = [
nostrMentionReferences,
...textNoteTransformers,
nipReferences,
relayReferences,

View File

@@ -0,0 +1,207 @@
import { describe, it, expect } from "vitest";
import { nostrMentionReferences } from "./nostr-mention-transformer";
import type { Root } from "applesauce-content/nast";
/**
* Helper to create a simple text tree for testing
*/
function createTextTree(content: string): Root {
return {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
value: content,
},
],
} as any, // Type assertion needed for test setup
],
};
}
/**
* Helper to extract text and mention nodes from a transformed tree
*/
function extractNodes(
tree: Root,
): Array<{ type: string; value?: string; decoded?: any }> {
const results: Array<{ type: string; value?: string; decoded?: any }> = [];
for (const child of tree.children as any[]) {
if (child.type === "paragraph" && "children" in child) {
for (const node of child.children) {
if (node.type === "text" && "value" in node) {
results.push({ type: "text", value: node.value as string });
} else if (node.type === "mention" && "decoded" in node) {
results.push({ type: "mention", decoded: node.decoded });
}
}
}
}
return results;
}
describe("nostrMentionReferences", () => {
describe("npub mentions", () => {
it("should parse npub mention with space after", () => {
const tree = createTextTree(
"Hello nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg world",
);
const transformer = nostrMentionReferences();
transformer(tree);
const nodes = extractNodes(tree);
expect(nodes).toHaveLength(3);
expect(nodes[0]).toEqual({ type: "text", value: "Hello " });
expect(nodes[1].type).toBe("mention");
expect(nodes[1].decoded?.type).toBe("npub");
expect(nodes[2]).toEqual({ type: "text", value: " world" });
});
it("should parse npub mention immediately followed by text without space", () => {
// This is the bug case from the issue - npub followed by "how" without space
const tree = createTextTree(
"nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxghow does the COUNT work?",
);
const transformer = nostrMentionReferences();
transformer(tree);
const nodes = extractNodes(tree);
expect(nodes).toHaveLength(2);
expect(nodes[0].type).toBe("mention");
expect(nodes[0].decoded?.type).toBe("npub");
// The "how does the COUNT work?" should remain as text
expect(nodes[1]).toEqual({
type: "text",
value: "how does the COUNT work?",
});
});
it("should parse npub mention followed by punctuation", () => {
const tree = createTextTree(
"Check out nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg!",
);
const transformer = nostrMentionReferences();
transformer(tree);
const nodes = extractNodes(tree);
expect(nodes).toHaveLength(3);
expect(nodes[0]).toEqual({ type: "text", value: "Check out " });
expect(nodes[1].type).toBe("mention");
expect(nodes[1].decoded?.type).toBe("npub");
expect(nodes[2]).toEqual({ type: "text", value: "!" });
});
it("should not match nostr: without valid prefix", () => {
const tree = createTextTree("nostr:invalid123");
const transformer = nostrMentionReferences();
transformer(tree);
const nodes = extractNodes(tree);
expect(nodes).toHaveLength(1);
expect(nodes[0]).toEqual({ type: "text", value: "nostr:invalid123" });
});
});
describe("note mentions", () => {
it("should fallback to text for invalid note mention (wrong checksum)", () => {
// Note with invalid checksum will be matched by pattern but fail decode, falling back to text
const tree = createTextTree(
"See nostr:note1q9m4f9qx3p2l8zq3xz9h3whxuc2h69k2xy6x5agdchp3upynhsxqqdhcxs test",
);
const transformer = nostrMentionReferences();
transformer(tree);
const nodes = extractNodes(tree);
// Pattern matches, but decode fails, so matched text + surrounding text remain
expect(nodes).toHaveLength(3);
expect(nodes[0]).toEqual({ type: "text", value: "See " });
expect(nodes[1].type).toBe("text"); // Failed mention becomes text
expect(nodes[1].value).toContain("nostr:note1");
expect(nodes[2]).toEqual({ type: "text", value: " test" });
});
});
describe("nevent mentions", () => {
it("should not parse nevent that is too short", () => {
// nevent that's less than 40 chars after prefix won't match our pattern
const tree = createTextTree("Check nostr:nevent1short");
const transformer = nostrMentionReferences();
transformer(tree);
const nodes = extractNodes(tree);
// Should remain as text because nevent is too short
expect(nodes).toHaveLength(1);
expect(nodes[0]).toEqual({
type: "text",
value: "Check nostr:nevent1short",
});
});
});
describe("multiple mentions", () => {
it("should parse multiple mentions in one string", () => {
const tree = createTextTree(
"nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg and nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg!",
);
const transformer = nostrMentionReferences();
transformer(tree);
const nodes = extractNodes(tree);
// Should have: mention, text(" and "), mention, text("!")
expect(nodes.length).toBe(4);
expect(nodes[0].type).toBe("mention");
expect(nodes[1]).toEqual({ type: "text", value: " and " });
expect(nodes[2].type).toBe("mention");
expect(nodes[3]).toEqual({ type: "text", value: "!" });
});
});
describe("edge cases", () => {
it("should not match nostr: in middle of word", () => {
const tree = createTextTree(
"testnostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg",
);
const transformer = nostrMentionReferences();
transformer(tree);
const nodes = extractNodes(tree);
// Should remain as text because no word boundary before nostr:
expect(nodes).toHaveLength(1);
expect(nodes[0]).toEqual({
type: "text",
value:
"testnostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg",
});
});
it("should handle mention at start of string", () => {
const tree = createTextTree(
"nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg",
);
const transformer = nostrMentionReferences();
transformer(tree);
const nodes = extractNodes(tree);
expect(nodes).toHaveLength(1);
expect(nodes[0].type).toBe("mention");
});
it("should handle mention at end of string", () => {
const tree = createTextTree(
"Check out nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg",
);
const transformer = nostrMentionReferences();
transformer(tree);
const nodes = extractNodes(tree);
expect(nodes).toHaveLength(2);
expect(nodes[0]).toEqual({ type: "text", value: "Check out " });
expect(nodes[1].type).toBe("mention");
});
});
});

View File

@@ -0,0 +1,81 @@
import { findAndReplace } from "applesauce-content/nast";
import type { Root, Content } from "applesauce-content/nast";
import { nip19 } from "nostr-tools";
/**
* Custom node type for nostr mentions
*/
export interface NostrMentionNode {
type: "mention";
/** The decoded nip19 entity */
decoded?: {
type: string;
data: any;
};
/** The original encoded string (without nostr: prefix) */
encoded?: string;
/** The raw matched text (including nostr: prefix) */
raw: string;
}
/**
* Match nostr: URIs with proper word boundaries and length constraints
*
* Pattern explanation:
* - \bnostr: - word boundary + nostr: prefix
* - Two alternatives:
* 1. (npub1|note1)[bech32]{58} - fixed-length identifiers (32-byte pubkey/event ID)
* 2. (nprofile1|nevent1|naddr1)[bech32]{40,300} - variable-length TLV-encoded identifiers
* - [023456789acdefghjklmnpqrstuvwxyz] - bech32 character set (excludes 1, b, i, o)
*
* Length constraints prevent matching too many characters when text immediately follows:
* - "nostr:npub1...{58 chars}how" will match only the 63-char npub, not including "how"
* - This works because npub/note are always exactly 63 chars (including prefix)
* - Other types are TLV-encoded and vary, but typically 100-200 chars
*/
const NOSTR_MENTION_PATTERN =
/\bnostr:((?:(?:npub1|note1)[023456789acdefghjklmnpqrstuvwxyz]{58})|(?:(?:nprofile1|nevent1|naddr1)[023456789acdefghjklmnpqrstuvwxyz]{40,300}))/gi;
/**
* Transformer that finds nostr: mentions and converts them to mention nodes.
* Compatible with applesauce-content's transformer pipeline.
*
* This transformer runs BEFORE the default textNoteTransformers to handle
* edge cases where mentions are immediately followed by text without whitespace.
*/
export function nostrMentionReferences() {
return (tree: Root) => {
findAndReplace(tree, [
[
NOSTR_MENTION_PATTERN,
(full, encoded) => {
try {
// Decode the nip19 identifier
const decoded = nip19.decode(encoded);
// Return a mention node with decoded data
return {
type: "mention",
decoded: {
type: decoded.type,
data: decoded.data,
},
encoded,
raw: full,
} as unknown as Content;
} catch (error) {
// If decode fails, return as plain text
console.warn(
`[nostr-mention-transformer] Failed to decode ${encoded}:`,
error,
);
return {
type: "text",
value: full,
} as unknown as Content;
}
},
],
]);
};
}