mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-17 19:07:06 +02:00
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:
@@ -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 (
|
||||
|
||||
36
src/components/nostr/RichText/Nip.tsx
Normal file
36
src/components/nostr/RichText/Nip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
src/lib/nip-transformer.test.ts
Normal file
243
src/lib/nip-transformer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
61
src/lib/nip-transformer.ts
Normal file
61
src/lib/nip-transformer.ts
Normal 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;
|
||||
},
|
||||
],
|
||||
]);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user