10 KiB
name, description
| name | description |
|---|---|
| welshman-content | Use this skill when working with @welshman/content: parsing nostr note content, extracting mentions/links/media/topics, or rendering parsed content to HTML or custom formats. |
welshman/content — Note Content Parsing
@welshman/content parses raw nostr event content strings into structured typed elements (links, profiles, events, topics, media, etc.) and renders them back to text or HTML. It is a standalone package with no welshman sibling dependencies — it sits at the bottom of the stack and can be used independently or alongside @welshman/app, @welshman/net, etc.
Installation
npm install @welshman/content
# or
pnpm add @welshman/content
yarn add @welshman/content
Key Exports
Parsing
| Export | Description |
|---|---|
parse({ content?, tags? }) |
Main entry point. Parses a content string (and optional event tags) into a Parsed[] array. Falls back to the alt tag if content is empty. |
truncate(content, opts?) |
Truncates a Parsed[] array, appending an Ellipsis element. Leaves content unchanged if it fits within maxLength. |
reduceLinks(content) |
Collapses consecutive block-level links (each on its own line) into a single LinkGrid element for gallery rendering. |
urlIsMedia(url) |
Returns true if the URL has a media file extension (jpg, png, gif, webp, mp4, etc.). |
Parsed Types
ParsedType enum values and their corresponding type shapes:
ParsedType |
value type |
Notes |
|---|---|---|
Text |
string |
Plain text |
Newline |
string |
One or more \n characters |
Topic |
string |
Hashtag text without the #; numeric-only tags are skipped |
Link |
{ url: URL, meta: Record<string, string> } |
URLs with any scheme (http, https, ftp, ws, wss, etc.) and bare domains without a protocol; meta is populated from imeta tags or URL hash params |
LinkGrid |
{ links: ParsedLinkValue[] } |
Produced by reduceLinks; a collection of adjacent block links |
Profile |
ProfilePointer ({ pubkey, relays? }) |
nostr:npub / nostr:nprofile / @nostr:npub / @nostr:nprofile references (the nostr: prefix is required) |
Event |
EventPointer ({ id, relays?, author?, kind? }) |
note / nevent references |
Address |
AddressPointer ({ identifier, pubkey, kind, relays? }) |
naddr references |
Emoji |
{ name: string, url?: string } |
:shortcode: — url resolved from emoji tags |
Code |
string |
Backtick inline code or triple-backtick blocks |
Cashu |
string |
cashu: token strings |
Invoice |
string |
Bare lightning invoice string (without lightning: prefix); the lightning: prefix is in raw |
Email |
string |
Email addresses (with or without mailto:) |
Ellipsis |
string |
Appended by truncate to indicate truncated content |
Every Parsed element also has a raw: string field holding the original matched text (empty string for synthetic elements like LinkGrid and Ellipsis).
Type Guards
All guards narrow the union type:
isAddress isCashu isCode isEllipsis isEmail
isEmoji isEvent isImage isInvoice isLink
isLinkGrid isNewline isProfile isText isTopic
isImage(parsed) — special guard: true only for ParsedLink elements whose URL ends in .jpg/.jpeg/.png/.gif/.webp.
Rendering
| Export | Description |
|---|---|
renderAsText(parsed, options?) |
Renders Parsed | Parsed[] to a Renderer; call .toString() to get a string. Text rendering shows links as full raw URLs and entities as their full bech32 string (the renderEntity truncation is discarded in text mode since renderLink returns the href). |
renderAsHtml(parsed, options?) |
Same, but produces sanitized HTML with <a> tags. Default entityBase is https://njump.me/. |
render(parsed, renderer) |
Low-level: renders into an existing Renderer instance. |
makeTextRenderer(options?) |
Creates a Renderer pre-configured for text output. |
makeHtmlRenderer(options?) |
Creates a Renderer pre-configured for HTML output. |
Renderer |
Class with addText, addLink, addEntityLink, addNewlines, toString. |
RenderOptions fields (all optional when using the convenience functions):
| Field | Default (HTML) | Description |
|---|---|---|
newline |
"\n" |
String emitted for each newline character |
entityBase |
"https://njump.me/" |
Base URL prepended to bech32 entity strings |
renderLink(href, display) |
<a href=... target=_blank>display</a> |
Custom link HTML/text |
renderEntity(entity) |
entity.slice(0, 16) + "…" |
Display text for entity links |
createElement(tag) |
document.createElement(tag) |
DOM element factory; override for SSR/non-browser |
Individual per-type render helpers are also exported (renderText, renderLink, renderProfile, renderEvent, renderAddress, renderTopic, renderEmoji, renderCode, renderCashu, renderInvoice, renderEmail, renderNewline, renderEllipsis, renderOne, renderMany).
Common Patterns
Parse and render a note to HTML
import { parse, renderAsHtml } from '@welshman/content'
const event = {
content: "Hello #nostr! Check out nostr:npub1jlrs53pkdfjnts29kveljul2sm0actt6n8dxrrzqcersttvcuv3qdjynqn",
tags: []
}
const parsed = parse({ content: event.content, tags: event.tags })
const html = renderAsHtml(parsed, {
entityBase: 'https://njump.me/',
renderEntity: (entity) => entity.slice(0, 12) + '…',
}).toString()
// → 'Hello nostr! Check out <a href="https://njump.me/nprofile1..." target="_blank">nprofile1qqsj…</a>'
Truncate long notes for a feed preview
import { parse, truncate, reduceLinks, renderAsHtml } from '@welshman/content'
const parsed = parse({ content: event.content, tags: event.tags })
const withGrids = reduceLinks(parsed)
const preview = truncate(withGrids, { minLength: 300, maxLength: 500 })
const html = renderAsHtml(preview).toString()
Extract all mentioned pubkeys
import { parse, isProfile, isAddress } from '@welshman/content'
const parsed = parse({ content: event.content, tags: event.tags })
const pubkeys = parsed
.filter(isProfile)
.map(p => p.value.pubkey)
const addressPubkeys = parsed
.filter(isAddress)
.map(p => p.value.pubkey)
Extract all links and check for images
import { parse, isLink, isImage, isLinkGrid } from '@welshman/content'
const parsed = parse({ content: event.content, tags: event.tags })
const images = parsed.filter(isImage)
// images[0].value.url → URL object
// images[0].value.meta → Record<string, string> from imeta tags
const allLinks = parsed.filter(isLink)
Render with a custom link handler (e.g. Svelte/React)
import { parse, renderAsHtml } from '@welshman/content'
const html = renderAsHtml(parse({ content }), {
renderLink: (href, display) =>
`<a href="${href}" class="text-blue-500 underline" rel="noopener">${display}</a>`,
renderEntity: (entity) => entity.slice(0, 16) + '…',
}).toString()
Server-side rendering (no DOM)
The default createElement calls document.createElement, which fails in Node/SSR environments. Override it:
import { parse, renderAsHtml } from '@welshman/content'
import { JSDOM } from 'jsdom'
const dom = new JSDOM('')
const html = renderAsHtml(parse({ content }), {
createElement: (tag: string) => dom.window.document.createElement(tag),
}).toString()
Using custom emoji tags
import { parse, isEmoji } from '@welshman/content'
const tags = [
['emoji', 'parrot', 'https://example.com/parrot.gif'],
]
const parsed = parse({ content: 'Hello :parrot:!', tags })
const emojiElements = parsed.filter(isEmoji)
// emojiElements[0].value → { name: 'parrot', url: 'https://example.com/parrot.gif' }
Integration Notes
@welshman/contenthas no dependencies on other welshman packages. It depends on@braintree/sanitize-urlas a direct dependency and requiresnostr-tools^2.x as a peer dependency (consumers must install it).- In
@welshman/app, content parsing is typically done at the component layer. Theparsefunction is called withevent.contentandevent.tagstogether so thatimetaandemojitags are resolved. ParsedLinkValue.metais populated fromimetatags (NIP-92). When an event carries rich media metadata, the parsed link'smetaobject will include fields likeurl,m(MIME type),blurhash,dim, etc.reduceLinksshould be called afterparseand beforetruncateif you want link grids to count as single media units for truncation purposes.
Gotchas & Tips
parsetrims content before processing. Leading/trailing whitespace in the raw content string is dropped.parsefallback: ifcontentis empty or whitespace,parsewill use the firstalttag value instead. This is useful for kind-1 reposts and other events with alternative text.truncateis non-destructive when content is short: it returns the original array unchanged if the total estimated size is undermaxLength.reduceLinksrequires block-level links: a link is only pulled into a grid if it appears at the start of a block (i.e. preceded by a newline or at the very beginning). Inline links in the middle of a sentence are left asParsedLink.isImageis stricter thanurlIsMedia:isImageonly matches.jpg/.jpeg/.png/.gif/.webp— it will not match.mp4or.webm. UseurlIsMediadirectly if you need to detect video; note thaturlIsMediatakes a URL string, not aParsedelement — usage would be:urlIsMedia(parsed.value.url.toString()).Renderer.toString()is how you get the final string out.renderAsHtmlandrenderAsTextboth return aRendererinstance, not a string.LinkGridis not rendered by default renderers:renderOnehas no case forParsedType.LinkGrid. You must handle it yourself when building a custom UI (e.g. render eachvalue.linksentry as an image or card grid).- Legacy mentions (
#[0],#[1]) are parsed automatically from thetagsarray and emitted asParsedProfileorParsedEventelements. - Numeric hashtags are skipped:
#42will not produce aTopicelement. - Email matching strips a leading
mailto:— the resultingParsedEmail.valueis always the bare address string.