Text Content
Text content rendering in applesauce uses a NAST (Nostr Abstract Syntax Tree) architecture, similar to how Markdown parsers work with AST. Event content is parsed into a tree structure, transformed through a pipeline, and then rendered using React components. This approach provides flexibility, extensibility, and type safety for rendering Nostr content.
Architecture Overview
The content rendering flow:
Event Content → Parser → NAST Tree → Transformers → React Components → UIKey Benefits:
- Separation of parsing from rendering
- Composable transformers for different content features
- Type-safe node structures
- Caching for performance
- Extensible for custom content types
Parsing content
The getParsedContent method parses event content into a NAST tree:
import { getParsedContent } from "applesauce-content/text";
const root = getParsedContent(event);
// root is a NAST tree with children nodesDefault Transformers:
The default transformer pipeline (in order) includes:
blossomURIs- Detect BUD-10blossom://URIslinks- Detect URLs and create link nodesnostrMentions- Parse NIP-19 mentions (npub, nevent, etc.)galleries- Group consecutive images into galleriesemojis- Replace:emoji_code:with NIP-30 custom emoji tagshashtags- Identify#hashtagseolMetadata- Attach end-of-line metadata for rendering
See Optional Transformers below for opt-in transformers like lightning and cashu.
Custom Transformers:
import { links, nostrMentions, hashtags } from "applesauce-content/text";
const root = getParsedContent(event, undefined, [links, nostrMentions, hashtags]);Caching
Because parsing and transforming content is an expensive operation getParsedContent method will cache the results on the event under a Symbol, by default this is the TextNoteContentSymbol
If your parsing or transforming different event kinds than kind 1, its recommended to create a new Symbol to and pass to getParsedContent to avoid cache collisions with the default kind 1 processor
const ArticleContentSymbol = Symbol("article-content");
const content = useRenderedContent(event, components, {
cacheKey: ArticleContentSymbol,
});Disable Caching:
import { getParsedContent } from "applesauce-content/text";
const content = getParsedContent(event, undefined, undefined, null);Transformers
Links
The links transformer detects URLs and creates Link nodes.
Detected patterns: https://example.com, http://example.com, example.com
Link node structure:
interface Link {
type: "link";
href: string; // Full URL
value: string; // Original text
}Mentions
The nostrMentions transformer detects NIP-19 and NIP-21 mentions and creates Mention nodes.
Detected patterns: nostr:npub1..., npub1..., nevent1..., naddr1... (all NIP-19 types)
Mention node structure:
interface Mention {
type: "mention";
encoded: string; // NIP-19 string (npub1..., note1...)
decoded: DecodeResult; // Decoded pointer object
}Hashtags
The hashtags transformer identifies hashtags and creates Hashtag nodes.
Important: Only hashtags with corresponding t tags in the event are parsed.
const event = {
content: "Check out #nostr and #bitcoin!",
tags: [
["t", "nostr"],
["t", "bitcoin"],
],
};
// Both #nostr and #bitcoin will be parsedHashtag node structure:
interface Hashtag {
type: "hashtag";
hashtag: string; // Normalized lowercase
name: string; // Original case
tag?: string[]; // The t-tag from event
}Emojis
The emojis transformer replaces :emoji_code: patterns with custom emoji from the event's emoji tags (NIP-30).
const event = {
content: "Hello :rocket: world!",
tags: [["emoji", "rocket", "https://example.com/rocket.png"]],
};
// :rocket: is replaced with the emoji imageEmoji node structure:
interface Emoji {
type: "emoji";
code: string; // emoji_code
url: string; // Image URL from tag
raw: string; // :emoji_code:
tag: string[]; // The emoji tag
}Galleries
The galleries transformer groups consecutive image URLs into Gallery nodes.
Grouping rules:
- Only consecutive images are grouped
- Minimum 2 images to create a gallery
- Text (except newlines) breaks the group
const event = {
content: "https://example.com/1.jpg\nhttps://example.com/2.png",
};
// Creates one gallery with 2 imagesGallery node structure:
interface Gallery {
type: "gallery";
links: string[]; // Array of image URLs
}Customizing image types:
import { galleries } from "applesauce-content/text";
const customGalleries = galleries([".jpg", ".png", ".svg"]);Gallery node structure:
interface Gallery {
type: "gallery";
links: string[]; // Array of image URLs
}Customizing image types:
import { galleries } from "applesauce-content/text";
const customGalleries = galleries([".jpg", ".png", ".webp", ".svg"]);
const content = useRenderedContent(event, components, {
transformers: [links, customGalleries, nostrMentions],
});Optional Transformers
Some transformers pull in extra dependencies and are kept out of the default pipeline to keep the bundle lean. Importing the module by its subpath registers the transformer in the default pipeline as a side effect — there's no need to pass it to getParsedContent manually.
// Adds bolt11 lightning invoice parsing
import "applesauce-content/text/lightning";
// Adds cashu token parsing
import "applesauce-content/text/cashu";
import { getParsedContent } from "applesauce-content/text";
// `getParsedContent` now produces `lightning` and `cashu` nodes
const root = getParsedContent(event);Lightning invoices
The lightningInvoices transformer detects bolt11 LNBC payment requests. The light-bolt11-decoder dependency ships with applesauce-common; the transformer itself is only registered when its module is imported.
import "applesauce-content/text/lightning";interface LightningInvoice {
type: "lightning";
invoice: string; // Full LNBC string
}Cashu tokens
The cashuTokens transformer detects Cashu ecash tokens. @cashu/cashu-ts is declared as an optional peer dependency — install it alongside the import to enable parsing.
import "applesauce-content/text/cashu";interface CashuToken {
type: "cashu";
raw: string; // Full token string (cashuA...)
}Extending the Parser
The parser is a unified pipeline of transformers. Each transformer walks the NAST tree, finds text nodes that match a pattern, and replaces them with a richer node type. You can add your own transformer to detect new tokens, swap out a built-in one, or modify nodes after they've been created.
Adding a New Token
Three steps: declare the node type, write the transformer, render it.
1. Declare the node type and augment the ContentMap so TypeScript knows about it:
import { Node } from "applesauce-content/nast";
export interface BitcoinAddress extends Node {
type: "bitcoin";
address: string;
}
declare module "applesauce-content/nast" {
interface ContentMap {
bitcoin: BitcoinAddress;
}
}2. Write the transformer using findAndReplace to swap matching text nodes for your new node:
import { findAndReplace } from "applesauce-content/nast";
import { Transformer } from "unified";
const BITCOIN = /\b(bc1[a-z0-9]{6,87}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})\b/g;
export function bitcoinAddresses(): Transformer<Root> {
return (tree) => {
findAndReplace(tree, [[BITCOIN, (match: string) => ({ type: "bitcoin", address: match })]]);
};
}Return false from the replace function to skip a match (e.g. validation failed) and leave the original text in place — see hashtags for an example.
3. Wire it into the pipeline either by passing it to getParsedContent or by appending to textNoteTransformers so it runs by default:
import { textNoteTransformers } from "applesauce-content/text";
if (!textNoteTransformers.includes(bitcoinAddresses)) {
textNoteTransformers.push(bitcoinAddresses);
}Side-effect registration is how the optional lightning and cashu modules opt themselves in — consumers enable parsing by importing your-package/bitcoin.
4. Render it by adding a component for the new node type:
const components: ComponentMap = {
bitcoin: ({ node }) => <code className="text-orange-500">{node.address}</code>,
};
const content = useRenderedContent(event, components);Modifying Existing Nodes
A transformer doesn't have to use findAndReplace — it can walk the tree and mutate nodes that earlier transformers produced. Useful for enriching link nodes, attaching metadata, or normalizing values:
import { visit } from "unist-util-visit";
function annotateLinks(): Transformer<Root> {
return (tree) => {
visit(tree, "link", (node) => {
node.href = node.href.replace(/^http:/, "https:");
});
};
}Order matters: list this after links in your transformer array so the link nodes exist by the time it runs.
Replacing a Built-in Transformer
Pass your own array to getParsedContent (or useRenderedContent) to fully control which transformers run and in what order:
import { links, nostrMentions, hashtags } from "applesauce-content/text";
const content = useRenderedContent(event, components, {
transformers: [links, customGalleries, nostrMentions, hashtags],
});Use a custom cacheKey (or null to disable caching) when overriding transformers — otherwise results may collide with the default kind-1 cache. See Caching above.
Media Detection
The applesauce-core/helpers package provides utilities for detecting media types:
import { isImageURL, isVideoURL, isAudioURL } from "applesauce-core/helpers";
if (isImageURL(url)) // Images: .svg, .gif, .png, .jpg, .jpeg, .webp, .avif
if (isVideoURL(url)) // Videos: .mp4, .mkv, .webm, .mov
if (isAudioURL(url)) // Audio: .mp3, .wav, .ogg, .aac, .m4aNAST Node Types
The parser creates different node types based on content:
interface Text {
type: "text";
value: string;
}
interface Link {
type: "link";
href: string;
value: string;
}
interface Mention {
type: "mention";
encoded: string;
decoded: DecodeResult;
}
interface Hashtag {
type: "hashtag";
hashtag: string;
name: string;
tag?: ["t", ...string[]];
}
interface Emoji {
type: "emoji";
code: string;
url: string;
raw: string;
tag: ["emoji", ...string[]];
}
interface Gallery {
type: "gallery";
links: string[];
}
interface BlossomURI {
type: "blossom";
raw: string;
sha256: string;
ext: string;
size?: number;
servers: string[];
authors: string[];
}
interface LightningInvoice {
type: "lightning";
invoice: string;
parsed: ParsedInvoice;
}
interface CashuToken {
type: "cashu";
raw: string;
token: Token;
}Using Parsed Content
Direct Tree Manipulation
const root = getParsedContent(event);
// Extract specific node types
const links = root.children.filter((node) => node.type === "link");
const mentions = root.children.filter((node) => node.type === "mention");
const hashtags = root.children.filter((node) => node.type === "hashtag").map((node) => node.hashtag);
// Get plain text only
const text = root.children
.filter((node) => node.type === "text")
.map((node) => node.value)
.join("");Extract Specific Content
// Get all hashtags
const root = getParsedContent(event);
const hashtags = root.children.filter((node) => node.type === "hashtag").map((node) => node.hashtag);
// Get all URLs
const urls = root.children.filter((node) => node.type === "link").map((node) => node.href);
// Get plain text only
const plainText = root.children
.filter((node) => node.type === "text")
.map((node) => node.value)
.join("");Check for Truncation
const root = getParsedContent(event, undefined, undefined, undefined);
if (root.truncated) {
console.log("Content was truncated");
console.log("Original length:", root.originalLength);
}Custom Content Override
Render custom content instead of event.content:
const content = useRenderedContent(event, components, {
content: customContent, // Override event.content
});Link Renderers
Use buildLinkRenderer for modular link handling:
import { buildLinkRenderer } from "applesauce-react/helpers";
import type { LinkRenderer } from "applesauce-react/helpers";
const imageRenderer: LinkRenderer = (url) => {
if (isImageURL(url)) {
return <img src={url.toString()} className="max-h-64" />;
}
return null; // Let next renderer handle it
};
const videoRenderer: LinkRenderer = (url) => {
if (isVideoURL(url)) {
return <video src={url.toString()} controls />;
}
return null;
};
const content = useRenderedContent(event, components, {
linkRenderers: [imageRenderer, videoRenderer],
});Best Practices
Memoize ComponentMap
Always memoize your ComponentMap to avoid recreating components:
const components = useMemo<ComponentMap>(
() => ({
text: ({ node }) => <span>{node.value}</span>,
link: LinkRenderer,
mention: MentionRenderer,
}),
[], // Or include dependencies if components use external values
);
const content = useRenderedContent(event, components);Handle Media Loading
Implement proper loading states and error handling for media:
link: ({ node }) => {
if (isImageURL(node.href)) {
return (
<img
src={node.href}
loading="lazy"
onError={(e) => {
e.currentTarget.src = "/placeholder.png";
}}
className="max-h-64 rounded"
/>
);
}
return <a href={node.href}>{node.value}</a>;
},Security Best Practices
Always use proper link attributes:
link: ({ node }) => (
<a
href={node.href}
target="_blank"
rel="noopener noreferrer" // Prevent window.opener access
className="link"
>
{node.value}
</a>
),Content Length Limits
For preview cards or feed items, truncate content:
const content = useRenderedContent(event, components, {
maxLength: 280,
});
const root = getParsedContent(event);
if (root.truncated) {
return (
<>
<div>{content}</div>
<button onClick={onShowFull}>Read more</button>
</>
);
}