feat(RichText): extend parser with custom options and NIP reference support

- Add nipReferences transformer to parse NIP-xx patterns (e.g., NIP-01, nip-19)
- Create Nip component that renders NIP references as clickable links opening Grimoire's NIP viewer
- Expose parserOptions prop for customizing transformers, maxLength, and cacheKey
- Add expand/collapse functionality for truncated content with "Show more" button
- Include NIP transformer in default transformer pipeline
- Add comprehensive tests for NIP pattern matching and normalization
This commit is contained in:
Claude
2026-01-11 09:53:38 +00:00
parent 84b5ac88aa
commit 8789011e45
4 changed files with 330 additions and 3 deletions

View File

@@ -1,16 +1,29 @@
import { cn } from "@/lib/utils";
import { Hooks } from "applesauce-react";
import { createContext, useContext } from "react";
import { textNoteTransformers } from "applesauce-content/text";
import { createContext, useContext, useState, useMemo } from "react";
import { Text } from "./RichText/Text";
import { Hashtag } from "./RichText/Hashtag";
import { Mention } from "./RichText/Mention";
import { Link } from "./RichText/Link";
import { Emoji } from "./RichText/Emoji";
import { Gallery } from "./RichText/Gallery";
import { Nip } from "./RichText/Nip";
import { nipReferences } from "@/lib/nip-transformer";
import type { NostrEvent } from "@/types/nostr";
import type { Root } from "applesauce-content/nast";
/** Transformer function type compatible with applesauce-content */
type ContentTransformer = () => (tree: Root) => void;
const { useRenderedContent } = Hooks;
// Custom cache key for our extended transformers
const GrimoireContentSymbol = Symbol.for("grimoire-content");
// Default transformers including our custom NIP transformer
const defaultTransformers = [...textNoteTransformers, nipReferences];
// Context for passing depth through RichText rendering
const DepthContext = createContext<number>(1);
@@ -50,28 +63,44 @@ export function useRichTextOptions() {
return useContext(OptionsContext);
}
/**
* Parser options for customizing content parsing
*/
export interface ParserOptions {
/** Custom transformers to use instead of defaults */
transformers?: ContentTransformer[];
/** Maximum content length before truncation (characters) */
maxLength?: number;
/** Custom cache key (pass null to disable caching) */
cacheKey?: symbol | null;
}
interface RichTextProps {
event?: NostrEvent;
content?: string;
className?: string;
depth?: number;
options?: RichTextOptions;
/** Parser options for customizing content parsing */
parserOptions?: ParserOptions;
children?: React.ReactNode;
}
// Content node component types for rendering
const contentComponents = {
// Using 'any' for node type since we extend with custom node types (like 'nip')
const contentComponents: Record<string, React.ComponentType<{ node: any }>> = {
text: Text,
hashtag: Hashtag,
mention: Mention,
link: Link,
emoji: Emoji,
gallery: Gallery,
nip: Nip,
};
/**
* RichText component that renders Nostr event content with rich formatting
* Supports mentions, hashtags, links, emojis, and galleries
* Supports mentions, hashtags, links, emojis, galleries, and NIP references
* Can also render plain text without requiring a full event
*/
export function RichText({
@@ -80,14 +109,47 @@ export function RichText({
className = "",
depth = 1,
options = {},
parserOptions = {},
children,
}: RichTextProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Merge provided options with defaults
const mergedOptions: Required<RichTextOptions> = {
...defaultOptions,
...options,
};
// Get content string for length checking
const contentString = content ?? event?.content ?? "";
// Determine if content might need truncation
const maxLength = parserOptions.maxLength;
const mightBeTruncated = maxLength && contentString.length > maxLength;
// Use effective maxLength based on expansion state
const effectiveMaxLength =
isExpanded || !mightBeTruncated ? undefined : maxLength;
// Prepare transformers - use provided or defaults
const transformers = parserOptions.transformers ?? defaultTransformers;
// Prepare cache key - use provided, or default, or null to disable
const cacheKey =
parserOptions.cacheKey === null
? null
: (parserOptions.cacheKey ?? GrimoireContentSymbol);
// Memoize hook options to prevent unnecessary re-renders
const hookOptions = useMemo(
() => ({
transformers,
maxLength: effectiveMaxLength,
cacheKey,
}),
[transformers, effectiveMaxLength, cacheKey],
);
// Call hook unconditionally - it will handle undefined/null
const trimmedEvent = event
? {
@@ -95,6 +157,7 @@ export function RichText({
content: event.content.trim(),
}
: undefined;
const renderedContent = useRenderedContent(
content
? ({
@@ -102,8 +165,12 @@ export function RichText({
} as NostrEvent)
: trimmedEvent,
contentComponents,
hookOptions,
);
// Show expand button only when content is truncated and not expanded
const showExpandButton = mightBeTruncated && !isExpanded;
return (
<DepthContext.Provider value={depth}>
<OptionsContext.Provider value={mergedOptions}>
@@ -113,6 +180,14 @@ export function RichText({
>
{children}
{renderedContent}
{showExpandButton && (
<button
onClick={() => setIsExpanded(true)}
className="text-primary hover:underline text-sm mt-1 block"
>
Show more
</button>
)}
</div>
</OptionsContext.Provider>
</DepthContext.Provider>

View File

@@ -0,0 +1,34 @@
import type { NipNode } from "@/lib/nip-transformer";
import { useGrimoire } from "@/core/state";
import { getNIPInfo } from "@/lib/nip-icons";
interface NipNodeProps {
node: NipNode;
}
/**
* Renders a NIP reference as a clickable link that opens the NIP viewer
*/
export function Nip({ node }: NipNodeProps) {
const { addWindow } = useGrimoire();
const { number, raw } = node;
const nipInfo = getNIPInfo(number);
const openNIP = () => {
addWindow(
"nip",
{ number },
nipInfo ? `NIP ${number} - ${nipInfo.name}` : `NIP ${number}`,
);
};
return (
<button
onClick={openNIP}
className="text-primary hover:underline"
title={nipInfo?.description ?? `View NIP-${number} specification`}
>
{raw}
</button>
);
}

View File

@@ -0,0 +1,177 @@
import { describe, it, expect } from "vitest";
import { nipReferences, NipNode } from "./nip-transformer";
import type { Root, Text } from "applesauce-content/nast";
// Helper to create a basic tree with text content
function createTree(content: string): Root {
return {
type: "root",
children: [{ type: "text", value: content }],
};
}
// Helper to get all nodes of a specific type from the tree
function getNodesOfType<T>(tree: Root, type: string): T[] {
return tree.children.filter((node) => node.type === type) as T[];
}
describe("nipReferences transformer", () => {
describe("basic NIP patterns", () => {
it("should parse NIP-01 format", () => {
const tree = createTree("Check out NIP-01 for the basics");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(1);
expect(nips[0].number).toBe("01");
expect(nips[0].raw).toBe("NIP-01");
});
it("should parse lowercase nip-01 format", () => {
const tree = createTree("See nip-01 for details");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(1);
expect(nips[0].number).toBe("01");
expect(nips[0].raw).toBe("nip-01");
});
it("should parse single digit NIP-1", () => {
const tree = createTree("NIP-1 is important");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(1);
expect(nips[0].number).toBe("01"); // Normalized to 2 digits
expect(nips[0].raw).toBe("NIP-1");
});
it("should parse three digit NIP-100", () => {
const tree = createTree("Future NIP-100 might exist");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(1);
expect(nips[0].number).toBe("100");
expect(nips[0].raw).toBe("NIP-100");
});
});
describe("multiple NIPs in content", () => {
it("should parse multiple NIP references", () => {
const tree = createTree("NIP-01, NIP-19, and NIP-30 are related");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(3);
expect(nips[0].number).toBe("01");
expect(nips[1].number).toBe("19");
expect(nips[2].number).toBe("30");
});
it("should preserve text between NIP references", () => {
const tree = createTree("See NIP-01 and NIP-02");
const transformer = nipReferences();
transformer(tree);
const texts = getNodesOfType<Text>(tree, "text");
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(2);
expect(texts.some((t) => t.value.includes("See "))).toBe(true);
expect(texts.some((t) => t.value.includes(" and "))).toBe(true);
});
});
describe("edge cases", () => {
it("should not match NIP without number", () => {
const tree = createTree("NIP- is not valid");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(0);
});
it("should not match partial word like SNIP-01", () => {
const tree = createTree("SNIP-01 is not a NIP");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(0);
});
it("should handle NIP at start of content", () => {
const tree = createTree("NIP-01 defines the protocol");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(1);
expect(nips[0].number).toBe("01");
});
it("should handle NIP at end of content", () => {
const tree = createTree("Check NIP-01");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(1);
expect(nips[0].number).toBe("01");
});
it("should handle content with no NIPs", () => {
const tree = createTree("Just some regular text");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(0);
const texts = getNodesOfType<Text>(tree, "text");
expect(texts).toHaveLength(1);
expect(texts[0].value).toBe("Just some regular text");
});
});
describe("number normalization", () => {
it("should normalize single digit to 2 digits", () => {
const tree = createTree("NIP-1 NIP-2 NIP-9");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips[0].number).toBe("01");
expect(nips[1].number).toBe("02");
expect(nips[2].number).toBe("09");
});
it("should preserve two digit numbers", () => {
const tree = createTree("NIP-19 NIP-50");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips[0].number).toBe("19");
expect(nips[1].number).toBe("50");
});
it("should preserve three digit numbers", () => {
const tree = createTree("NIP-100 NIP-999");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips[0].number).toBe("100");
expect(nips[1].number).toBe("999");
});
});
});

View File

@@ -0,0 +1,41 @@
import { findAndReplace } from "applesauce-content/nast";
import type { Root, Content } from "applesauce-content/nast";
/**
* Custom node type for NIP references
*/
export interface NipNode {
type: "nip";
/** The NIP number (e.g., "01", "19", "30") */
number: string;
/** The raw matched text (e.g., "NIP-01", "nip-19") */
raw: string;
}
// Match NIP-xx patterns (case insensitive, 1-3 digits)
// Supports: NIP-01, NIP-1, nip-19, NIP-100, etc.
const NIP_PATTERN = /\bNIP-(\d{1,3})\b/gi;
/**
* Transformer that finds NIP-xx references and converts them to nip nodes.
* Compatible with applesauce-content's transformer pipeline.
*/
export function nipReferences() {
return (tree: Root) => {
findAndReplace(tree, [
[
NIP_PATTERN,
(full, number) => {
// Normalize to 2 digits with leading zero for consistency
const normalized = number.padStart(2, "0");
// Cast to Content since we're extending with a custom node type
return {
type: "nip",
number: normalized,
raw: full,
} as unknown as Content;
},
],
]);
};
}