mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +02:00
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:
@@ -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>
|
||||
|
||||
34
src/components/nostr/RichText/Nip.tsx
Normal file
34
src/components/nostr/RichText/Nip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
src/lib/nip-transformer.test.ts
Normal file
177
src/lib/nip-transformer.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
41
src/lib/nip-transformer.ts
Normal file
41
src/lib/nip-transformer.ts
Normal 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;
|
||||
},
|
||||
],
|
||||
]);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user