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

* 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

* feat(nip-transformer): add support for hex NIPs like NIP-C7

- Extend pattern to match hex NIP identifiers (NIP-C7, NIP-C0, NIP-A0)
- Normalize hex NIPs to uppercase (nip-c7 -> C7)
- Add tests for hex NIP parsing and normalization

* refactor(RichText): remove maxLength for now, export transformer types

* style(Nip): add icon, dotted underline, muted text for NIP links

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-11 22:21:13 +01:00
committed by GitHub
parent 5233c57a1c
commit 938800c350
4 changed files with 391 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, 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 */
export 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
export const defaultTransformers = [...textNoteTransformers, nipReferences];
// Context for passing depth through RichText rendering
const DepthContext = createContext<number>(1);
@@ -50,28 +63,42 @@ export function useRichTextOptions() {
return useContext(OptionsContext);
}
/**
* Parser options for customizing content parsing
*/
export interface ParserOptions {
/** Custom transformers to use instead of defaults */
transformers?: ContentTransformer[];
/** 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,6 +107,7 @@ export function RichText({
className = "",
depth = 1,
options = {},
parserOptions = {},
children,
}: RichTextProps) {
// Merge provided options with defaults
@@ -88,6 +116,24 @@ export function RichText({
...options,
};
// 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,
cacheKey,
}),
[transformers, cacheKey],
);
// Call hook unconditionally - it will handle undefined/null
const trimmedEvent = event
? {
@@ -95,6 +141,7 @@ export function RichText({
content: event.content.trim(),
}
: undefined;
const renderedContent = useRenderedContent(
content
? ({
@@ -102,6 +149,7 @@ export function RichText({
} as NostrEvent)
: trimmedEvent,
contentComponents,
hookOptions,
);
return (

View File

@@ -0,0 +1,36 @@
import { FileText } from "lucide-react";
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="inline-flex items-center gap-0.5 text-muted-foreground underline decoration-dotted hover:text-foreground cursor-crosshair"
title={nipInfo?.description ?? `View NIP-${number} specification`}
>
<FileText className="size-3" />
<span>{raw}</span>
</button>
);
}

View File

@@ -0,0 +1,243 @@
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");
});
});
describe("hex NIP support", () => {
it("should parse hex NIP-C7", () => {
const tree = createTree("Code snippets are defined in NIP-C7");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(1);
expect(nips[0].number).toBe("C7");
expect(nips[0].raw).toBe("NIP-C7");
});
it("should parse lowercase hex nip-c7", () => {
const tree = createTree("See nip-c7 for code snippets");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(1);
expect(nips[0].number).toBe("C7"); // Normalized to uppercase
expect(nips[0].raw).toBe("nip-c7");
});
it("should parse NIP-C0", () => {
const tree = createTree("NIP-C0 defines something");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(1);
expect(nips[0].number).toBe("C0");
});
it("should parse NIP-A0", () => {
const tree = createTree("Check NIP-A0");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(1);
expect(nips[0].number).toBe("A0");
});
it("should handle mixed decimal and hex NIPs", () => {
const tree = createTree("NIP-01, NIP-C7, and NIP-19 together");
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("C7");
expect(nips[2].number).toBe("19");
});
it("should normalize mixed case hex to uppercase", () => {
const tree = createTree("nip-c7 NIP-C7 nip-C7 NIP-c7");
const transformer = nipReferences();
transformer(tree);
const nips = getNodesOfType<NipNode>(tree, "nip");
expect(nips).toHaveLength(4);
expect(nips.every((n) => n.number === "C7")).toBe(true);
});
});
});

View File

@@ -0,0 +1,61 @@
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/identifier (e.g., "01", "19", "C7") */
number: string;
/** The raw matched text (e.g., "NIP-01", "nip-19", "NIP-C7") */
raw: string;
}
// Match NIP-xx patterns (case insensitive)
// Supports both decimal (NIP-01, NIP-19, NIP-100) and hex (NIP-C7, NIP-C0, NIP-A0)
// Pattern: 1-3 hex characters (which includes pure decimal)
const NIP_PATTERN = /\bNIP-([0-9A-Fa-f]{1,3})\b/gi;
/**
* Check if a NIP identifier is purely decimal
*/
function isDecimalNip(nip: string): boolean {
return /^\d+$/.test(nip);
}
/**
* Normalize a NIP identifier:
* - Decimal NIPs: pad to 2 digits (1 -> 01, 19 -> 19)
* - Hex NIPs: uppercase (c7 -> C7)
*/
function normalizeNip(nip: string): string {
if (isDecimalNip(nip)) {
return nip.padStart(2, "0");
}
// Hex NIP - uppercase it
return nip.toUpperCase();
}
/**
* 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) => {
const normalized = normalizeNip(number);
// Cast to Content since we're extending with a custom node type
return {
type: "nip",
number: normalized,
raw: full,
} as unknown as Content;
},
],
]);
};
}