mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 23:16:50 +02:00
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:
@@ -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,
|
||||
|
||||
207
src/lib/nostr-mention-transformer.test.ts
Normal file
207
src/lib/nostr-mention-transformer.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
81
src/lib/nostr-mention-transformer.ts
Normal file
81
src/lib/nostr-mention-transformer.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
],
|
||||
]);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user