diff --git a/.fdignore b/.fdignore
index 691ae4c..603fd55 100644
--- a/.fdignore
+++ b/.fdignore
@@ -1,5 +1,5 @@
node_modules
-docs
+#docs
docs/reference
docs/.vitepress/cache
build
diff --git a/docs/content/index.md b/docs/content/index.md
index c32e5d8..f96e73b 100644
--- a/docs/content/index.md
+++ b/docs/content/index.md
@@ -2,67 +2,15 @@
[](https://npmjs.com/package/@welshman/content)
-`@welshman/content` is a comprehensive content processing library designed specifically for Nostr applications.
-It provides a robust system for parsing, processing, and rendering Nostr content while handling various special formats and entities common in the Nostr ecosystem.
+A content processing library designed specifically for Nostr applications. It provides a simple system for parsing, processing, and rendering Nostr content.
+## What's Included
-## Core Concepts
-
-The package is built around two main components:
-
-1. **Parser**: Transforms raw content into structured elements
- ```typescript
- const parsed = parse({
- content: "Hello #nostr, check nostr:npub1...",
- tags: [["p", "pubkey123"]]
- })
- ```
-
-2. **Renderer**: Converts parsed content into desired output format
- ```typescript
- const html = renderAsHtml(parsed).toString()
- const text = renderAsText(parsed).toString()
- ```
-
-## Common Use Cases
-
-- Rendering Nostr notes with proper entity linking
-- Processing and displaying user content safely
-- Handling rich text content in Nostr clients
-- Converting between different content formats
-- Creating customized content displays
-
-## Quick Example
-
-```typescript
-import { parse, renderAsHtml, truncate } from '@welshman/content'
-
-// Parse and process content
-const parsed = parse({
- content: `
- Hello #nostr!
- Check out this note: nostr:note1...
- https://example.com/image.jpg
- `,
- tags: [["p", "pubkey123"]]
-})
-
-// Truncate if needed
-const truncated = truncate(parsed, {
- maxLength: 500,
- mediaLength: 150
-})
-
-// Render as HTML
-const html = renderAsHtml(truncated, {
- entityBase: "https://your-app.com/"
-}).toString()
-```
+- **Parsing**: Utilities for parsing nostr event content, as well as truncating and building image grids.
+- **Rendering**: Basic rendering support for parsed nostr event content.
## Installation
-```bash
+```
npm install @welshman/content
```
-
-This package is essential for applications that need to handle Nostr content in a structured and safe way, providing all the necessary tools for parsing, processing, and rendering Nostr-specific content formats.
diff --git a/docs/content/parser.md b/docs/content/parser.md
index 009235b..27d282d 100644
--- a/docs/content/parser.md
+++ b/docs/content/parser.md
@@ -1,181 +1,98 @@
# Content Parser
-The content parser system in `@welshman/content` provides a powerful way to parse Nostr content into structured elements.
-It handles various types of content including Nostr entities, links, code blocks, and special formats.
+The content parser system in `@welshman/content` provides utilities for parsing Nostr content into structured elements.
-## Content Types
+## Core Types
+
+### ParsedType Enum
+
+Defines all supported content types:
+- `Address` - naddr references to parameterized replaceable events
+- `Cashu` - Cashu token strings
+- `Code` - Code blocks and inline code
+- `Ellipsis` - Truncation indicators
+- `Emoji` - Custom emoji references
+- `Event` - Event references (note/nevent)
+- `Invoice` - Lightning invoices
+- `Link` - HTTP/HTTPS URLs
+- `LinkGrid` - Collections of adjacent links
+- `Newline` - Line breaks
+- `Profile` - Profile references (npub/nprofile)
+- `Text` - Plain text content
+- `Topic` - Hashtags
+
+## Main Functions
+
+### parse(options)
+
+Main parsing function that processes content into structured elements:
-### Basic Types
```typescript
-enum ParsedType {
- Text = "text", // Plain text
- Newline = "newline", // Line breaks
- Topic = "topic", // Hashtags (#nostr)
- Code = "code", // Code blocks (inline and multi-line)
- Link = "link", // URLs
- LinkGrid = "link-grid" // Grid of media links
-}
+parse({content?: string, tags?: string[][]}) => Parsed[]
```
-### Nostr-specific Types
-```typescript
-enum ParsedType {
- Event = "event", // Nostr events (note1/nevent1)
- Profile = "profile", // Profiles (npub1/nprofile1)
- Address = "address", // Addresses (naddr1)
-}
-```
+Takes content string and optional tags array, returns array of parsed elements. Uses tags for emoji lookup and imeta information.
-### Special Format Types
-```typescript
-enum ParsedType {
- Cashu = "cashu", // Cashu tokens
- Invoice = "invoice", // Lightning invoices
- Ellipsis = "ellipsis" // Truncation marker
-}
-```
+### truncate(content, options)
-## Parsing Content
+Truncates parsed content to specified length limits:
-### Main Parser
```typescript
-const parse = ({
- content = "",
- tags = []
-}: {
- content?: string
- tags?: string[][]
+truncate(content: Parsed[], {
+ minLength?: number, // 500 - minimum before truncating
+ maxLength?: number, // 700 - maximum total length
+ mediaLength?: number, // 200 - assumed size for media
+ entityLength?: number // 30 - assumed size for entities
}) => Parsed[]
-
-// Example
-const parsed = parse({
- content: "Hello #nostr, check nostr:npub1...",
- tags: [["p", "pubkey123"]]
-})
```
-### Available Parsers
+### reduceLinks(content)
-The system includes specialized parsers for each content type:
+Combines adjacent links into `LinkGrid` elements for better presentation:
```typescript
-// Nostr Entities
-parseAddress(text: string, context: ParseContext): ParsedAddress | void
-parseEvent(text: string, context: ParseContext): ParsedEvent | void
-parseProfile(text: string, context: ParseContext): ParsedProfile | void
-
-// Code Blocks
-parseCodeBlock(text: string, context: ParseContext): ParsedCode | void
-parseCodeInline(text: string, context: ParseContext): ParsedCode | void
-
-// Special Formats
-parseCashu(text: string, context: ParseContext): ParsedCashu | void
-parseInvoice(text: string, context: ParseContext): ParsedInvoice | void
-
-// Basic Content
-parseLink(text: string, context: ParseContext): ParsedLink | void
-parseNewline(text: string, context: ParseContext): ParsedNewline | void
-parseTopic(text: string, context: ParseContext): ParsedTopic | void
-```
-
-## Content Processing
-
-### Truncation
-```typescript
-type TruncateOpts = {
- minLength?: number // Minimum content length (default: 500)
- maxLength?: number // Maximum content length (default: 700)
- mediaLength?: number // Length value for media items (default: 200)
- entityLength?: number // Length value for entities (default: 30)
-}
-
-const truncate = (
- content: Parsed[],
- options?: TruncateOpts
-) => Parsed[]
-
-// Example
-const truncated = truncate(parsed, {
- maxLength: 1000,
- mediaLength: 150
-})
-```
-
-### Link Processing
-```typescript
-// Consolidate consecutive image links into grids
-const reduceLinks = (content: Parsed[]) => Parsed[]
-
-// Example
-const processed = reduceLinks(parsed)
+reduceLinks(content: Parsed[]) => Parsed[]
```
## Type Guards
-```typescript
-// Basic content
-isText(parsed: Parsed): parsed is ParsedText
-isNewline(parsed: Parsed): parsed is ParsedNewline
-isCode(parsed: Parsed): parsed is ParsedCode
-isTopic(parsed: Parsed): parsed is ParsedTopic
+Utility functions to check parsed element types:
+- `isAddress(parsed)`, `isCashu(parsed)`, `isCode(parsed)`, etc.
+- `isImage(parsed)` - special check for image links
-// Links and media
-isLink(parsed: Parsed): parsed is ParsedLink
-isImage(parsed: Parsed): parsed is ParsedLink
-isLinkGrid(parsed: Parsed): parsed is ParsedLinkGrid
+## Utilities
-// Nostr entities
-isEvent(parsed: Parsed): parsed is ParsedEvent
-isProfile(parsed: Parsed): parsed is ParsedProfile
-isAddress(parsed: Parsed): parsed is ParsedAddress
+- `urlIsMedia(url)` - Checks if URL points to media file
+- `fromNostrURI(s)` - Removes nostr: protocol prefix
-// Special formats
-isCashu(parsed: Parsed): parsed is ParsedCashu
-isInvoice(parsed: Parsed): parsed is ParsedInvoice
-isEllipsis(parsed: Parsed): parsed is ParsedEllipsis
-```
-
-## Complete Example
+## Example Usage
```typescript
-// Parse content with tags
-const parsed = parse({
- content: `
- Hello #nostr!
+import { parse, truncate, reduceLinks } from '@welshman/content'
- Check out this note: nostr:note1...
- And this profile: nostr:npub1...
+const content = `Check out this cool #nostr client!
+https://github.com/coracle-social/welshman
+https://welshman.coracle.social
+Visit npub1jlrs53pkdfjnts29kveljul2sm0actt6n8dxrrzqcersttvcuv3qdjynqn for more info`
- Some code: \`console.log("hello")\`
+// Parse the content into structured elements
+const parsed = parse({ content })
- https://example.com/image.jpg
- https://example.com/image2.jpg
- `,
- tags: [
- ["p", "pubkey123"],
- ["e", "event456"]
- ]
+// Combine adjacent links into grids
+const withLinkGrids = reduceLinks(parsed)
+
+// Truncate to reasonable length for preview
+const truncated = truncate(withLinkGrids, {
+ minLength: 100,
+ maxLength: 200
})
-// Process the content
-const processed = reduceLinks(parsed)
-
-// Truncate if needed
-const final = truncate(processed, {
- maxLength: 500,
- mediaLength: 150
-})
-
-// Check types and handle accordingly
-final.forEach(item => {
- if (isImage(item)) {
- // Handle image
- } else if (isProfile(item)) {
- // Handle profile reference
- } else if (isCode(item)) {
- // Handle code block
- }
-})
+// Result contains structured elements:
+// - Text: "Check out this cool "
+// - Topic: "nostr"
+// - Text: " client!\n"
+// - LinkGrid: [github.com/..., welshman.coracle.social]
+// - Text: "Visit "
+// - Profile: npub1jlrs53pkdfjnts29kveljul2sm0actt6n8dxrrzqcersttvcuv3qdjynqn
+// - Text: " for more info"
```
-
-This parser system provides a robust foundation for handling Nostr content, with support for various content types and processing needs. The type-safe approach ensures reliable content handling while maintaining flexibility for different use cases.
diff --git a/docs/content/render.md b/docs/content/render.md
new file mode 100644
index 0000000..0892fe1
--- /dev/null
+++ b/docs/content/render.md
@@ -0,0 +1,60 @@
+# Content Renderer
+
+The renderer system in `@welshman/content` provides utilities for converting parsed content into text or HTML output. It includes customizable rendering options and specialized handlers for each content type.
+
+## Render Options
+
+```typescript
+type RenderOptions = {
+ // String to use for newlines
+ newline: string
+
+ // Base URL for Nostr entities
+ entityBase: string
+
+ // Custom link rendering
+ renderLink: (href: string, display: string) => string
+
+ // Custom entity rendering
+ renderEntity: (entity: string) => string
+
+ // Custom function for creating an element
+ createElement: (tag: string) => any
+}
+```
+
+## Built-in Renderers
+
+- `makeTextRenderer` - renders an array of `Parsed` elements as text
+- `makeHtmlRenderer` - renders an array of `Parsed` elements as HTML
+
+## Main Functions
+
+- `render(parsed, renderer)` - Renders single or multiple parsed elements
+- `renderAsText(parsed, options)` - Convenience function for text rendering
+- `renderAsHtml(parsed, options)` - Convenience function for HTML rendering
+
+## Example Usage
+
+```typescript
+import { parse, renderAsHtml } from '@welshman/content'
+
+const content = `Check out this cool #nostr client!
+Visit npub1jlrs53pkdfjnts29kveljul2sm0actt6n8dxrrzqcersttvcuv3qdjynqn for more info
+https://github.com/coracle-social/welshman`
+
+// Parse the content
+const parsed = parse({ content })
+
+// Render as HTML with custom options
+const html = renderAsHtml(parsed, {
+ entityBase: 'https://njump.me/',
+ renderEntity: (entity) => entity.slice(0, 12) + '...',
+ renderLink: (href, display) => `${display}`
+}).toString()
+
+// Result:
+// Check out this cool #nostr client!
+// Visit npub1jlrs53p... for more info
+// github.com/coracle-social/welshman
+```
diff --git a/docs/content/renderer.md b/docs/content/renderer.md
deleted file mode 100644
index d1249c7..0000000
--- a/docs/content/renderer.md
+++ /dev/null
@@ -1,195 +0,0 @@
-# Content Renderer
-
-The renderer system in `@welshman/content` provides flexible ways to convert parsed content into text or HTML output. It includes customizable rendering options and specialized handlers for each content type.
-
-## Renderer Class
-
-```typescript
-class Renderer {
- constructor(readonly options: RenderOptions)
-
- // Core methods
- toString(): string
- addText(value: string): void
- addNewlines(count: number): void
- addLink(href: string, display: string): void
- addEntityLink(entity: string): void
-}
-```
-
-## Render Options
-
-```typescript
-type RenderOptions = {
- // String to use for newlines
- newline: string
-
- // Base URL for Nostr entities
- entityBase: string
-
- // Custom link rendering
- renderLink: (href: string, display: string) => string
-
- // Custom entity rendering
- renderEntity: (entity: string) => string
-}
-```
-
-## Built-in Renderers
-
-### Text Renderer
-```typescript
-const textRenderOptions = {
- newline: "\n",
- entityBase: "",
- renderLink: (href, display) => href,
- renderEntity: (entity) => entity.slice(0, 16) + "…"
-}
-
-const textRenderer = makeTextRenderer({
- // Override default options if needed
-})
-```
-
-### HTML Renderer
-```typescript
-const htmlRenderOptions = {
- newline: "\n",
- entityBase: "https://njump.me/",
- renderLink: (href, display) => {
- const element = document.createElement("a")
- element.href = sanitizeUrl(href)
- element.target = "_blank"
- element.innerText = display
- return element.outerHTML
- },
- renderEntity: (entity) => entity.slice(0, 16) + "…"
-}
-
-const htmlRenderer = makeHtmlRenderer({
- // Override default options if needed
-})
-```
-
-## Content Type Renderers
-
-```typescript
-// Basic content
-renderText(p: ParsedText, r: Renderer): void
-renderNewline(p: ParsedNewline, r: Renderer): void
-renderCode(p: ParsedCode, r: Renderer): void
-renderTopic(p: ParsedTopic, r: Renderer): void
-
-// Links
-renderLink(p: ParsedLink, r: Renderer): void
-
-// Nostr entities
-renderEvent(p: ParsedEvent, r: Renderer): void
-renderProfile(p: ParsedProfile, r: Renderer): void
-renderAddress(p: ParsedAddress, r: Renderer): void
-
-// Special formats
-renderCashu(p: ParsedCashu, r: Renderer): void
-renderInvoice(p: ParsedInvoice, r: Renderer): void
-renderEllipsis(p: ParsedEllipsis, r: Renderer): void
-```
-
-## Usage Examples
-
-### Basic Text Rendering
-```typescript
-const parsed = parse({
- content: "Hello #nostr, check nostr:npub1...",
- tags: []
-})
-
-// Render as plain text
-const text = renderAsText(parsed).toString()
-
-// Render as HTML
-const html = renderAsHtml(parsed).toString()
-```
-
-### Custom Rendering Options
-```typescript
-// Custom text renderer
-const customText = renderAsText(parsed, {
- entityBase: "nostr:",
- renderEntity: (entity) => entity.slice(0, 8)
-}).toString()
-
-// Custom HTML renderer
-const customHtml = renderAsHtml(parsed, {
- entityBase: "https://example.com/",
- renderLink: (href, display) => `${display}`,
- renderEntity: (entity) => `${entity}`
-}).toString()
-```
-
-### Rendering Individual Elements
-```typescript
-const renderer = makeHtmlRenderer()
-
-// Render single element
-renderOne({
- type: ParsedType.Link,
- value: {
- url: new URL("https://example.com"),
- meta: {},
- isMedia: false
- },
- raw: "https://example.com"
-}, renderer)
-
-// Render multiple elements
-renderMany([
- {
- type: ParsedType.Text,
- value: "Hello ",
- raw: "Hello "
- },
- {
- type: ParsedType.Topic,
- value: "nostr",
- raw: "#nostr"
- }
-], renderer)
-```
-
-### Complete Example
-```typescript
-// Parse and process content
-const parsed = parse({
- content: `
- Check out this profile: nostr:npub1...
-
- Code example:
- \`console.log("hello")\`
-
- #nostr #bitcoin
-
- https://example.com/image.jpg
- `,
- tags: []
-})
-
-// Create custom renderer
-const renderer = makeHtmlRenderer({
- entityBase: "https://example.com/",
- renderLink: (href, display) => {
- if (href.endsWith('.jpg')) {
- return `
`
- }
- return `${display}`
- },
- renderEntity: (entity) => {
- return `${entity.slice(0, 8)}`
- }
-})
-
-// Render content
-const html = render(parsed, renderer).toString()
-```
-
-
-The renderer system provides a flexible way to output parsed content in various formats while maintaining control over the rendering process. Its modular design allows for easy customization and extension for specific application needs.
diff --git a/docs/util/index.md b/docs/util/index.md
index b31b61e..45ff303 100644
--- a/docs/util/index.md
+++ b/docs/util/index.md
@@ -1,6 +1,8 @@
# @welshman/util
-A comprehensive utility package for Nostr application development, providing essential tools and types for working with Nostr events, addresses, profiles, and more.
+[](https://npmjs.com/package/@welshman/util)
+
+A utility package for Nostr application development, providing essential tools and types for working with Nostr events, addresses, profiles, and more.
## What's Included
diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts
index 686c42f..895484e 100644
--- a/packages/content/src/index.ts
+++ b/packages/content/src/index.ts
@@ -1,651 +1,2 @@
-import {decode, neventEncode, nprofileEncode, naddrEncode} from "nostr-tools/nip19"
-import {sanitizeUrl} from "@braintree/sanitize-url"
-
-const last = (xs: T[], ...args: unknown[]) => xs[xs.length - 1]
-
-const fromNostrURI = (s: string) => s.replace(/^nostr:\/?\/?/, "")
-
-export const urlIsMedia = (url: string) =>
- Boolean(url.match(/\.(jpe?g|png|wav|mp3|mp4|mov|avi|webm|webp|gif|bmp|svg)$/))
-
-// Copy some types from nostr-tools because I can't import them
-
-export type AddressPointer = {
- identifier: string
- pubkey: string
- kind: number
- relays?: string[]
-}
-
-export type EventPointer = {
- id: string
- relays?: string[]
- author?: string
- kind?: number
-}
-
-export type ProfilePointer = {
- pubkey: string
- relays?: string[]
-}
-
-// Types
-
-export type ParseContext = {
- results: Parsed[]
- content: string
- tags: string[][]
-}
-
-export enum ParsedType {
- Address = "address",
- Cashu = "cashu",
- Code = "code",
- Ellipsis = "ellipsis",
- Emoji = "emoji",
- Event = "event",
- Invoice = "invoice",
- Link = "link",
- LinkGrid = "link-grid",
- Newline = "newline",
- Profile = "profile",
- Text = "text",
- Topic = "topic",
-}
-
-export type ParsedBase = {
- raw: string
-}
-
-export type ParsedCashu = ParsedBase & {
- type: ParsedType.Cashu
- value: string
-}
-
-export type ParsedCode = ParsedBase & {
- type: ParsedType.Code
- value: string
-}
-
-export type ParsedEllipsis = ParsedBase & {
- type: ParsedType.Ellipsis
- value: string
-}
-
-export type ParsedEmojiValue = {
- name: string
- url?: string
-}
-
-export type ParsedEmoji = ParsedBase & {
- type: ParsedType.Emoji
- value: ParsedEmojiValue
-}
-
-export type ParsedInvoice = ParsedBase & {
- type: ParsedType.Invoice
- value: string
-}
-
-export type ParsedLinkValue = {
- url: URL
- meta: Record
-}
-
-export type ParsedLinkGridValue = {
- links: ParsedLinkValue[]
-}
-
-export type ParsedLink = ParsedBase & {
- type: ParsedType.Link
- value: ParsedLinkValue
-}
-
-export type ParsedLinkGrid = ParsedBase & {
- type: ParsedType.LinkGrid
- value: ParsedLinkGridValue
-}
-
-export type ParsedNewline = ParsedBase & {
- type: ParsedType.Newline
- value: string
-}
-
-export type ParsedText = ParsedBase & {
- type: ParsedType.Text
- value: string
-}
-
-export type ParsedTopic = ParsedBase & {
- type: ParsedType.Topic
- value: string
-}
-
-export type ParsedEvent = ParsedBase & {
- type: ParsedType.Event
- value: EventPointer
-}
-
-export type ParsedProfile = ParsedBase & {
- type: ParsedType.Profile
- value: ProfilePointer
-}
-
-export type ParsedAddress = ParsedBase & {
- type: ParsedType.Address
- value: AddressPointer
-}
-
-export type Parsed =
- | ParsedAddress
- | ParsedCashu
- | ParsedCode
- | ParsedEllipsis
- | ParsedEmoji
- | ParsedEvent
- | ParsedInvoice
- | ParsedLink
- | ParsedLinkGrid
- | ParsedNewline
- | ParsedProfile
- | ParsedText
- | ParsedTopic
-
-// Matchers
-
-export const isAddress = (parsed: Parsed): parsed is ParsedAddress =>
- parsed.type === ParsedType.Address
-export const isCashu = (parsed: Parsed): parsed is ParsedCashu => parsed.type === ParsedType.Cashu
-export const isCode = (parsed: Parsed): parsed is ParsedCode => parsed.type === ParsedType.Code
-export const isEllipsis = (parsed: Parsed): parsed is ParsedEllipsis =>
- parsed.type === ParsedType.Ellipsis
-export const isEmoji = (parsed: Parsed): parsed is ParsedEmoji => parsed.type === ParsedType.Emoji
-export const isEvent = (parsed: Parsed): parsed is ParsedEvent => parsed.type === ParsedType.Event
-export const isInvoice = (parsed: Parsed): parsed is ParsedInvoice =>
- parsed.type === ParsedType.Invoice
-export const isLink = (parsed: Parsed): parsed is ParsedLink => parsed.type === ParsedType.Link
-export const isImage = (parsed: Parsed): parsed is ParsedLink =>
- isLink(parsed) && Boolean(parsed.value.url.toString().match(/\.(jpe?g|png|gif|webp)$/))
-export const isLinkGrid = (parsed: Parsed): parsed is ParsedLinkGrid =>
- parsed.type === ParsedType.LinkGrid
-export const isNewline = (parsed: Parsed): parsed is ParsedNewline =>
- parsed.type === ParsedType.Newline
-export const isProfile = (parsed: Parsed): parsed is ParsedProfile =>
- parsed.type === ParsedType.Profile
-export const isText = (parsed: Parsed): parsed is ParsedText => parsed.type === ParsedType.Text
-export const isTopic = (parsed: Parsed): parsed is ParsedTopic => parsed.type === ParsedType.Topic
-
-// Parsers for known formats
-
-export const parseAddress = (text: string, context: ParseContext): ParsedAddress | void => {
- const [naddr] = text.match(/^(web\+)?(nostr:)naddr1[\d\w]+/i) || []
-
- if (naddr) {
- try {
- const {data} = decode(fromNostrURI(naddr))
-
- return {type: ParsedType.Address, value: data as AddressPointer, raw: naddr}
- } catch (e) {
- // Pass
- }
- }
-}
-
-export const parseCashu = (text: string, context: ParseContext): ParsedCashu | void => {
- const [value] = text.match(/^cashu:cashu[-\d\w=]{50,5000}/i) || []
-
- if (value) {
- return {type: ParsedType.Cashu, value, raw: value}
- }
-}
-
-export const parseCodeBlock = (text: string, context: ParseContext): ParsedCode | void => {
- const [raw, value] = text.match(/^```([^]*?)```/i) || []
-
- if (raw) {
- return {type: ParsedType.Code, value, raw}
- }
-}
-
-export const parseCodeInline = (text: string, context: ParseContext): ParsedCode | void => {
- const [raw, value] = text.match(/^`(.*?)`/i) || []
-
- if (raw) {
- return {type: ParsedType.Code, value, raw}
- }
-}
-
-export const parseEmoji = (text: string, context: ParseContext): ParsedEmoji | void => {
- const [raw, name] = text.match(/^:(\w+):/i) || []
-
- if (raw) {
- const url = context.tags.find(t => t[0] === "emoji" && t[1] === name)?.[2]
-
- return {type: ParsedType.Emoji, value: {name, url}, raw}
- }
-}
-
-export const parseEvent = (text: string, context: ParseContext): ParsedEvent | void => {
- const [entity] = text.match(/^(web\+)?(nostr:)n(event|ote)1[\d\w]+/i) || []
-
- if (entity) {
- try {
- const {type, data} = decode(fromNostrURI(entity))
- const value = type === "note" ? {id: data as string, relays: []} : (data as EventPointer)
-
- return {type: ParsedType.Event, value, raw: entity}
- } catch (e) {
- // Pass
- }
- }
-}
-
-export const parseInvoice = (text: string, context: ParseContext): ParsedInvoice | void => {
- const [raw, _, value] = text.match(/^(lightning:)(ln(bc|url)[0-9a-z]{10,})/i) || []
-
- if (raw && value) {
- return {type: ParsedType.Invoice, value, raw}
- }
-}
-
-export const parseLink = (text: string, context: ParseContext): ParsedLink | void => {
- const prev = last(context.results)
- const link = text.match(
- /^([a-z\+:]{2,30}:\/\/)?[-\.~\w]+\.[\w]{2,6}([^\s]*[^<>"'\.!,:\s\)\(]+)?/gi,
- )?.[0]
-
- // Skip url if it's just the end of a filepath or an ellipse
- if (!link || (prev?.type === ParsedType.Text && prev.value.endsWith("/")) || link.match(/\.\./)) {
- return
- }
-
- // Skip it if it looks like an IP address but doesn't have a protocol
- if (link.match(/\d+\.\d+/) && !link.includes("://")) {
- return
- }
-
- // Parse using URL, make sure there's a protocol
- let url
- try {
- url = new URL(link.match(/^\w+:\/\//) ? link : "https://" + link)
- } catch (e) {
- return
- }
-
- const meta = Object.fromEntries(new URLSearchParams(url.hash.slice(1)).entries())
-
- for (const tag of context.tags) {
- if (tag[0] === "imeta" && tag.find(t => t.includes(`url ${link}`))) {
- Object.assign(meta, Object.fromEntries(tag.slice(1).map((m: string) => m.split(" "))))
- }
- }
-
- return {type: ParsedType.Link, value: {url, meta}, raw: link}
-}
-
-export const parseNewline = (text: string, context: ParseContext): ParsedNewline | void => {
- const [value] = text.match(/^\n+/) || []
-
- if (value) {
- return {type: ParsedType.Newline, value, raw: value}
- }
-}
-
-export const parseProfile = (text: string, context: ParseContext): ParsedProfile | void => {
- const [entity] = text.match(/^@?(web\+)?(nostr:)n(profile|pub)1[\d\w]+/i) || []
-
- if (entity) {
- try {
- const {type, data} = decode(fromNostrURI(entity.replace("@", "")))
- const value =
- type === "npub" ? {pubkey: data as string, relays: []} : (data as ProfilePointer)
-
- return {type: ParsedType.Profile, value, raw: entity}
- } catch (e) {
- // Pass
- }
- }
-}
-
-export const parseTopic = (text: string, context: ParseContext): ParsedTopic | void => {
- const [value] = text.match(/^#[^\s!\"#$%&'()*+,-.\/:;<=>?@[\\\]^_`{|}~]+/i) || []
-
- // Skip numeric topics
- if (value && !value.match(/^#\d+$/)) {
- return {type: ParsedType.Topic, value: value.slice(1), raw: value}
- }
-}
-
-// Parse other formats to known types
-
-export const parseLegacyMention = (
- text: string,
- context: ParseContext,
-): ParsedProfile | ParsedEvent | void => {
- const mentionMatch = text.match(/^#\[(\d+)\]/i) || []
-
- if (mentionMatch) {
- const [tag, value, url] = context.tags[parseInt(mentionMatch[1])] || []
- const relays = url ? [url] : []
-
- if (tag === "p") {
- return {type: ParsedType.Profile, value: {pubkey: value, relays}, raw: mentionMatch[0]!}
- }
-
- if (tag === "e") {
- return {type: ParsedType.Event, value: {id: value, relays}, raw: mentionMatch[0]!}
- }
- }
-}
-
-export const parsers = [
- parseNewline,
- parseLegacyMention,
- parseTopic,
- parseCodeBlock,
- parseCodeInline,
- parseAddress,
- parseProfile,
- parseEmoji,
- parseEvent,
- parseCashu,
- parseInvoice,
- parseLink,
-]
-
-export const parseNext = (raw: string, context: ParseContext): Parsed | void => {
- for (const parser of parsers) {
- const result = parser(raw, context)
-
- if (result) {
- return result
- }
- }
-}
-
-// Main exports
-
-export const parse = ({content = "", tags = []}: {content?: string; tags?: string[][]}) => {
- const context: ParseContext = {content, tags, results: []}
-
- let buffer = ""
- let remaining = content.trim() || tags.find(t => t[0] === "alt")?.[1] || ""
-
- while (remaining) {
- const parsed = parseNext(remaining, context)
-
- if (parsed) {
- if (buffer) {
- context.results.push({type: ParsedType.Text, value: buffer, raw: buffer})
- buffer = ""
- }
-
- context.results.push(parsed)
- remaining = remaining.slice(parsed.raw.length)
- } else {
- // Instead of going character by character and re-running all the above regular expressions
- // a million times, try to match the next word and add it to the buffer
- const [match] = remaining.match(/^[\w\d]+ ?/i) || remaining[0]
-
- buffer += match
- remaining = remaining.slice(match.length)
- }
- }
-
- if (buffer) {
- context.results.push({type: ParsedType.Text, value: buffer, raw: buffer})
- }
-
- return context.results
-}
-
-type TruncateOpts = {
- minLength?: number
- maxLength?: number
- mediaLength?: number
- entityLength?: number
-}
-
-export const truncate = (
- content: Parsed[],
- {minLength = 500, maxLength = 700, mediaLength = 200, entityLength = 30}: TruncateOpts = {},
-) => {
- // Get a list of content sizes so we know where to truncate
- // Non-plaintext things might take up more or less room if rendered
- const sizes = content.map((parsed: Parsed) => {
- switch (parsed.type) {
- case ParsedType.Link:
- case ParsedType.LinkGrid:
- case ParsedType.Cashu:
- case ParsedType.Invoice:
- return mediaLength
- case ParsedType.Event:
- case ParsedType.Address:
- case ParsedType.Profile:
- return entityLength
- case ParsedType.Emoji:
- return parsed.value.name.length
- default:
- return parsed.value.length
- }
- })
-
- // If total size fits inside our max, we're done
- if (sizes.reduce((r, x) => r + x, 0) < maxLength) {
- return content
- }
-
- let currentSize = 0
-
- // Otherwise, truncate more then necessary so that when the user expands the note
- // they have more than just a tiny bit to look at. Truncating a single word is annoying.
- sizes.every((size, i) => {
- currentSize += size
-
- if (currentSize > minLength) {
- content = content
- .slice(0, Math.max(1, i + 1))
- .concat({type: ParsedType.Ellipsis, value: "…", raw: ""})
-
- return false
- }
-
- return true
- })
-
- return content
-}
-
-export const reduceLinks = (content: Parsed[]): Parsed[] => {
- const result: Parsed[] = []
- const buffer: ParsedLinkValue[] = []
-
- for (const parsed of content) {
- const prev = last(result)
-
- // If we have a link and we're in our own block, start a grid
- if (isLink(parsed) && (!prev || isNewline(prev))) {
- buffer.push(parsed.value)
- continue
- }
-
- // Ignore newlines and empty space if we're building a grid
- if (isNewline(parsed) && buffer.length > 0) continue
- if (isText(parsed) && !parsed.value.trim() && buffer.length > 0) continue
-
- if (buffer.length > 0) {
- result.push({type: ParsedType.LinkGrid, value: {links: buffer.splice(0)}, raw: ""})
- }
-
- result.push(parsed)
- }
-
- if (buffer.length > 0) {
- result.push({type: ParsedType.LinkGrid, value: {links: buffer.splice(0)}, raw: ""})
- }
-
- return result
-}
-
-// Renderer
-
-export class Renderer {
- private value = ""
-
- constructor(readonly options: RenderOptions) {}
-
- toString = () => this.value
-
- addText = (value: string) => {
- const element = this.options.createElement("div")
-
- element.innerText = value
-
- this.value += element.innerHTML
- }
-
- addNewlines = (count: number) => {
- for (let i = 0; i < count; i++) {
- this.value += this.options.newline
- }
- }
-
- addLink = (href: string, display: string) => {
- this.value += this.options.renderLink(href, display)
- }
-
- addEntityLink = (entity: string) => {
- this.addLink(this.options.entityBase + entity, this.options.renderEntity(entity))
- }
-}
-
-export type RenderOptions = {
- newline: string
- entityBase: string
- renderLink: (href: string, display: string) => string
- renderEntity: (entity: string) => string
- createElement: (tag: string) => any
-}
-
-const createElement = (tag: string) => document.createElement(tag) as any
-
-export const textRenderOptions = {
- newline: "\n",
- entityBase: "",
- createElement,
- renderLink: (href: string, display: string) => href,
- renderEntity: (entity: string) => entity.slice(0, 16) + "…",
-}
-
-export const htmlRenderOptions = {
- newline: "\n",
- entityBase: "https://njump.me/",
- createElement,
- renderLink(href: string, display: string) {
- const element = this.createElement("a")
-
- element.href = sanitizeUrl(href)
- element.target = "_blank"
- element.innerText = display
-
- return element.outerHTML
- },
- renderEntity: (entity: string) => entity.slice(0, 16) + "…",
-}
-
-export const makeTextRenderer = (options: Partial = {}) =>
- new Renderer({...textRenderOptions, ...options})
-
-export const makeHtmlRenderer = (options: Partial = {}) =>
- new Renderer({...htmlRenderOptions, ...options})
-
-// Top level render methods
-
-export const renderCashu = (p: ParsedCashu, r: Renderer) => r.addText(p.value)
-
-export const renderCode = (p: ParsedCode, r: Renderer) => r.addText(p.value)
-
-export const renderEllipsis = (p: ParsedEllipsis, r: Renderer) => r.addText("…")
-
-export const renderEmoji = (p: ParsedEmoji, r: Renderer) => r.addText(p.raw)
-
-export const renderInvoice = (p: ParsedInvoice, r: Renderer) =>
- r.addLink("lightning:" + p.value, p.value.slice(0, 16) + "…")
-
-export const renderLink = (p: ParsedLink, r: Renderer) =>
- r.addLink(p.value.url.toString(), p.value.url.host + p.value.url.pathname)
-
-export const renderNewline = (p: ParsedNewline, r: Renderer) =>
- r.addNewlines(Array.from(p.value).length)
-
-export const renderText = (p: ParsedText, r: Renderer) => r.addText(p.value)
-
-export const renderTopic = (p: ParsedTopic, r: Renderer) => r.addText(p.value)
-
-export const renderEvent = (p: ParsedEvent, r: Renderer) => r.addEntityLink(neventEncode(p.value))
-
-export const renderProfile = (p: ParsedProfile, r: Renderer) =>
- r.addEntityLink(nprofileEncode(p.value))
-
-export const renderAddress = (p: ParsedAddress, r: Renderer) =>
- r.addEntityLink(naddrEncode(p.value))
-
-export const renderOne = (parsed: Parsed, renderer: Renderer) => {
- switch (parsed.type) {
- case ParsedType.Address:
- renderAddress(parsed as ParsedAddress, renderer)
- break
- case ParsedType.Cashu:
- renderCashu(parsed as ParsedCashu, renderer)
- break
- case ParsedType.Code:
- renderCode(parsed as ParsedCode, renderer)
- break
- case ParsedType.Ellipsis:
- renderEllipsis(parsed as ParsedEllipsis, renderer)
- break
- case ParsedType.Emoji:
- renderEmoji(parsed as ParsedEmoji, renderer)
- break
- case ParsedType.Event:
- renderEvent(parsed as ParsedEvent, renderer)
- break
- case ParsedType.Invoice:
- renderInvoice(parsed as ParsedInvoice, renderer)
- break
- case ParsedType.Link:
- renderLink(parsed as ParsedLink, renderer)
- break
- case ParsedType.Newline:
- renderNewline(parsed as ParsedNewline, renderer)
- break
- case ParsedType.Profile:
- renderProfile(parsed as ParsedProfile, renderer)
- break
- case ParsedType.Text:
- renderText(parsed as ParsedText, renderer)
- break
- case ParsedType.Topic:
- renderTopic(parsed as ParsedTopic, renderer)
- break
- }
-
- return renderer
-}
-
-export const renderMany = (parsed: Parsed[], renderer: Renderer) => {
- for (const p of parsed) {
- renderOne(p, renderer)
- }
-
- return renderer
-}
-
-export const render = (parsed: Parsed | Parsed[], renderer: Renderer) =>
- Array.isArray(parsed) ? renderMany(parsed, renderer) : renderOne(parsed, renderer)
-
-export const renderAsText = (parsed: Parsed | Parsed[], options: Partial = {}) =>
- render(parsed, makeTextRenderer(options))
-
-export const renderAsHtml = (parsed: Parsed | Parsed[], options: Partial = {}) =>
- render(parsed, makeHtmlRenderer(options))
+export * from "./parser.js"
+export * from "./render.js"
diff --git a/packages/content/src/parser.ts b/packages/content/src/parser.ts
new file mode 100644
index 0000000..3db82dc
--- /dev/null
+++ b/packages/content/src/parser.ts
@@ -0,0 +1,487 @@
+import {decode} from "nostr-tools/nip19"
+
+const last = (xs: T[], ...args: unknown[]) => xs[xs.length - 1]
+
+const fromNostrURI = (s: string) => s.replace(/^nostr:\/?\/?/, "")
+
+export const urlIsMedia = (url: string) =>
+ Boolean(url.match(/\.(jpe?g|png|wav|mp3|mp4|mov|avi|webm|webp|gif|bmp|svg)$/))
+
+// Copy some types from nostr-tools because I can't import them
+
+export type AddressPointer = {
+ identifier: string
+ pubkey: string
+ kind: number
+ relays?: string[]
+}
+
+export type EventPointer = {
+ id: string
+ relays?: string[]
+ author?: string
+ kind?: number
+}
+
+export type ProfilePointer = {
+ pubkey: string
+ relays?: string[]
+}
+
+// Types
+
+export type ParseContext = {
+ results: Parsed[]
+ content: string
+ tags: string[][]
+}
+
+export enum ParsedType {
+ Address = "address",
+ Cashu = "cashu",
+ Code = "code",
+ Ellipsis = "ellipsis",
+ Emoji = "emoji",
+ Event = "event",
+ Invoice = "invoice",
+ Link = "link",
+ LinkGrid = "link-grid",
+ Newline = "newline",
+ Profile = "profile",
+ Text = "text",
+ Topic = "topic",
+}
+
+export type ParsedBase = {
+ raw: string
+}
+
+export type ParsedCashu = ParsedBase & {
+ type: ParsedType.Cashu
+ value: string
+}
+
+export type ParsedCode = ParsedBase & {
+ type: ParsedType.Code
+ value: string
+}
+
+export type ParsedEllipsis = ParsedBase & {
+ type: ParsedType.Ellipsis
+ value: string
+}
+
+export type ParsedEmojiValue = {
+ name: string
+ url?: string
+}
+
+export type ParsedEmoji = ParsedBase & {
+ type: ParsedType.Emoji
+ value: ParsedEmojiValue
+}
+
+export type ParsedInvoice = ParsedBase & {
+ type: ParsedType.Invoice
+ value: string
+}
+
+export type ParsedLinkValue = {
+ url: URL
+ meta: Record
+}
+
+export type ParsedLinkGridValue = {
+ links: ParsedLinkValue[]
+}
+
+export type ParsedLink = ParsedBase & {
+ type: ParsedType.Link
+ value: ParsedLinkValue
+}
+
+export type ParsedLinkGrid = ParsedBase & {
+ type: ParsedType.LinkGrid
+ value: ParsedLinkGridValue
+}
+
+export type ParsedNewline = ParsedBase & {
+ type: ParsedType.Newline
+ value: string
+}
+
+export type ParsedText = ParsedBase & {
+ type: ParsedType.Text
+ value: string
+}
+
+export type ParsedTopic = ParsedBase & {
+ type: ParsedType.Topic
+ value: string
+}
+
+export type ParsedEvent = ParsedBase & {
+ type: ParsedType.Event
+ value: EventPointer
+}
+
+export type ParsedProfile = ParsedBase & {
+ type: ParsedType.Profile
+ value: ProfilePointer
+}
+
+export type ParsedAddress = ParsedBase & {
+ type: ParsedType.Address
+ value: AddressPointer
+}
+
+export type Parsed =
+ | ParsedAddress
+ | ParsedCashu
+ | ParsedCode
+ | ParsedEllipsis
+ | ParsedEmoji
+ | ParsedEvent
+ | ParsedInvoice
+ | ParsedLink
+ | ParsedLinkGrid
+ | ParsedNewline
+ | ParsedProfile
+ | ParsedText
+ | ParsedTopic
+
+// Matchers
+
+export const isAddress = (parsed: Parsed): parsed is ParsedAddress =>
+ parsed.type === ParsedType.Address
+export const isCashu = (parsed: Parsed): parsed is ParsedCashu => parsed.type === ParsedType.Cashu
+export const isCode = (parsed: Parsed): parsed is ParsedCode => parsed.type === ParsedType.Code
+export const isEllipsis = (parsed: Parsed): parsed is ParsedEllipsis =>
+ parsed.type === ParsedType.Ellipsis
+export const isEmoji = (parsed: Parsed): parsed is ParsedEmoji => parsed.type === ParsedType.Emoji
+export const isEvent = (parsed: Parsed): parsed is ParsedEvent => parsed.type === ParsedType.Event
+export const isInvoice = (parsed: Parsed): parsed is ParsedInvoice =>
+ parsed.type === ParsedType.Invoice
+export const isLink = (parsed: Parsed): parsed is ParsedLink => parsed.type === ParsedType.Link
+export const isImage = (parsed: Parsed): parsed is ParsedLink =>
+ isLink(parsed) && Boolean(parsed.value.url.toString().match(/\.(jpe?g|png|gif|webp)$/))
+export const isLinkGrid = (parsed: Parsed): parsed is ParsedLinkGrid =>
+ parsed.type === ParsedType.LinkGrid
+export const isNewline = (parsed: Parsed): parsed is ParsedNewline =>
+ parsed.type === ParsedType.Newline
+export const isProfile = (parsed: Parsed): parsed is ParsedProfile =>
+ parsed.type === ParsedType.Profile
+export const isText = (parsed: Parsed): parsed is ParsedText => parsed.type === ParsedType.Text
+export const isTopic = (parsed: Parsed): parsed is ParsedTopic => parsed.type === ParsedType.Topic
+
+// Parsers for known formats
+
+export const parseAddress = (text: string, context: ParseContext): ParsedAddress | void => {
+ const [naddr] = text.match(/^(web\+)?(nostr:)naddr1[\d\w]+/i) || []
+
+ if (naddr) {
+ try {
+ const {data} = decode(fromNostrURI(naddr))
+
+ return {type: ParsedType.Address, value: data as AddressPointer, raw: naddr}
+ } catch (e) {
+ // Pass
+ }
+ }
+}
+
+export const parseCashu = (text: string, context: ParseContext): ParsedCashu | void => {
+ const [value] = text.match(/^cashu:cashu[-\d\w=]{50,5000}/i) || []
+
+ if (value) {
+ return {type: ParsedType.Cashu, value, raw: value}
+ }
+}
+
+export const parseCodeBlock = (text: string, context: ParseContext): ParsedCode | void => {
+ const [raw, value] = text.match(/^```([^]*?)```/i) || []
+
+ if (raw) {
+ return {type: ParsedType.Code, value, raw}
+ }
+}
+
+export const parseCodeInline = (text: string, context: ParseContext): ParsedCode | void => {
+ const [raw, value] = text.match(/^`(.*?)`/i) || []
+
+ if (raw) {
+ return {type: ParsedType.Code, value, raw}
+ }
+}
+
+export const parseEmoji = (text: string, context: ParseContext): ParsedEmoji | void => {
+ const [raw, name] = text.match(/^:(\w+):/i) || []
+
+ if (raw) {
+ const url = context.tags.find(t => t[0] === "emoji" && t[1] === name)?.[2]
+
+ return {type: ParsedType.Emoji, value: {name, url}, raw}
+ }
+}
+
+export const parseEvent = (text: string, context: ParseContext): ParsedEvent | void => {
+ const [entity] = text.match(/^(web\+)?(nostr:)n(event|ote)1[\d\w]+/i) || []
+
+ if (entity) {
+ try {
+ const {type, data} = decode(fromNostrURI(entity))
+ const value = type === "note" ? {id: data as string, relays: []} : (data as EventPointer)
+
+ return {type: ParsedType.Event, value, raw: entity}
+ } catch (e) {
+ // Pass
+ }
+ }
+}
+
+export const parseInvoice = (text: string, context: ParseContext): ParsedInvoice | void => {
+ const [raw, _, value] = text.match(/^(lightning:)(ln(bc|url)[0-9a-z]{10,})/i) || []
+
+ if (raw && value) {
+ return {type: ParsedType.Invoice, value, raw}
+ }
+}
+
+export const parseLink = (text: string, context: ParseContext): ParsedLink | void => {
+ const prev = last(context.results)
+ const link = text.match(
+ /^([a-z\+:]{2,30}:\/\/)?[-\.~\w]+\.[\w]{2,6}([^\s]*[^<>"'\.!,:\s\)\(]+)?/gi,
+ )?.[0]
+
+ // Skip url if it's just the end of a filepath or an ellipse
+ if (!link || (prev?.type === ParsedType.Text && prev.value.endsWith("/")) || link.match(/\.\./)) {
+ return
+ }
+
+ // Skip it if it looks like an IP address but doesn't have a protocol
+ if (link.match(/\d+\.\d+/) && !link.includes("://")) {
+ return
+ }
+
+ // Parse using URL, make sure there's a protocol
+ let url
+ try {
+ url = new URL(link.match(/^\w+:\/\//) ? link : "https://" + link)
+ } catch (e) {
+ return
+ }
+
+ const meta = Object.fromEntries(new URLSearchParams(url.hash.slice(1)).entries())
+
+ for (const tag of context.tags) {
+ if (tag[0] === "imeta" && tag.find(t => t.includes(`url ${link}`))) {
+ Object.assign(meta, Object.fromEntries(tag.slice(1).map((m: string) => m.split(" "))))
+ }
+ }
+
+ return {type: ParsedType.Link, value: {url, meta}, raw: link}
+}
+
+export const parseNewline = (text: string, context: ParseContext): ParsedNewline | void => {
+ const [value] = text.match(/^\n+/) || []
+
+ if (value) {
+ return {type: ParsedType.Newline, value, raw: value}
+ }
+}
+
+export const parseProfile = (text: string, context: ParseContext): ParsedProfile | void => {
+ const [entity] = text.match(/^@?(web\+)?(nostr:)n(profile|pub)1[\d\w]+/i) || []
+
+ if (entity) {
+ try {
+ const {type, data} = decode(fromNostrURI(entity.replace("@", "")))
+ const value =
+ type === "npub" ? {pubkey: data as string, relays: []} : (data as ProfilePointer)
+
+ return {type: ParsedType.Profile, value, raw: entity}
+ } catch (e) {
+ // Pass
+ }
+ }
+}
+
+export const parseTopic = (text: string, context: ParseContext): ParsedTopic | void => {
+ const [value] = text.match(/^#[^\s!\"#$%&'()*+,-.\/:;<=>?@[\\\]^_`{|}~]+/i) || []
+
+ // Skip numeric topics
+ if (value && !value.match(/^#\d+$/)) {
+ return {type: ParsedType.Topic, value: value.slice(1), raw: value}
+ }
+}
+
+// Parse other formats to known types
+
+export const parseLegacyMention = (
+ text: string,
+ context: ParseContext,
+): ParsedProfile | ParsedEvent | void => {
+ const mentionMatch = text.match(/^#\[(\d+)\]/i) || []
+
+ if (mentionMatch) {
+ const [tag, value, url] = context.tags[parseInt(mentionMatch[1])] || []
+ const relays = url ? [url] : []
+
+ if (tag === "p") {
+ return {type: ParsedType.Profile, value: {pubkey: value, relays}, raw: mentionMatch[0]!}
+ }
+
+ if (tag === "e") {
+ return {type: ParsedType.Event, value: {id: value, relays}, raw: mentionMatch[0]!}
+ }
+ }
+}
+
+export const parsers = [
+ parseNewline,
+ parseLegacyMention,
+ parseTopic,
+ parseCodeBlock,
+ parseCodeInline,
+ parseAddress,
+ parseProfile,
+ parseEmoji,
+ parseEvent,
+ parseCashu,
+ parseInvoice,
+ parseLink,
+]
+
+export const parseNext = (raw: string, context: ParseContext): Parsed | void => {
+ for (const parser of parsers) {
+ const result = parser(raw, context)
+
+ if (result) {
+ return result
+ }
+ }
+}
+
+// Main exports
+
+export const parse = ({content = "", tags = []}: {content?: string; tags?: string[][]}) => {
+ const context: ParseContext = {content, tags, results: []}
+
+ let buffer = ""
+ let remaining = content.trim() || tags.find(t => t[0] === "alt")?.[1] || ""
+
+ while (remaining) {
+ const parsed = parseNext(remaining, context)
+
+ if (parsed) {
+ if (buffer) {
+ context.results.push({type: ParsedType.Text, value: buffer, raw: buffer})
+ buffer = ""
+ }
+
+ context.results.push(parsed)
+ remaining = remaining.slice(parsed.raw.length)
+ } else {
+ // Instead of going character by character and re-running all the above regular expressions
+ // a million times, try to match the next word and add it to the buffer
+ const [match] = remaining.match(/^[\w\d]+ ?/i) || remaining[0]
+
+ buffer += match
+ remaining = remaining.slice(match.length)
+ }
+ }
+
+ if (buffer) {
+ context.results.push({type: ParsedType.Text, value: buffer, raw: buffer})
+ }
+
+ return context.results
+}
+
+type TruncateOpts = {
+ minLength?: number
+ maxLength?: number
+ mediaLength?: number
+ entityLength?: number
+}
+
+export const truncate = (
+ content: Parsed[],
+ {minLength = 500, maxLength = 700, mediaLength = 200, entityLength = 30}: TruncateOpts = {},
+) => {
+ // Get a list of content sizes so we know where to truncate
+ // Non-plaintext things might take up more or less room if rendered
+ const sizes = content.map((parsed: Parsed) => {
+ switch (parsed.type) {
+ case ParsedType.Link:
+ case ParsedType.LinkGrid:
+ case ParsedType.Cashu:
+ case ParsedType.Invoice:
+ return mediaLength
+ case ParsedType.Event:
+ case ParsedType.Address:
+ case ParsedType.Profile:
+ return entityLength
+ case ParsedType.Emoji:
+ return parsed.value.name.length
+ default:
+ return parsed.value.length
+ }
+ })
+
+ // If total size fits inside our max, we're done
+ if (sizes.reduce((r, x) => r + x, 0) < maxLength) {
+ return content
+ }
+
+ let currentSize = 0
+
+ // Otherwise, truncate more then necessary so that when the user expands the note
+ // they have more than just a tiny bit to look at. Truncating a single word is annoying.
+ sizes.every((size, i) => {
+ currentSize += size
+
+ if (currentSize > minLength) {
+ content = content
+ .slice(0, Math.max(1, i + 1))
+ .concat({type: ParsedType.Ellipsis, value: "…", raw: ""})
+
+ return false
+ }
+
+ return true
+ })
+
+ return content
+}
+
+export const reduceLinks = (content: Parsed[]): Parsed[] => {
+ const result: Parsed[] = []
+ const buffer: ParsedLinkValue[] = []
+
+ for (const parsed of content) {
+ const prev = last(result)
+
+ // If we have a link and we're in our own block, start a grid
+ if (isLink(parsed) && (!prev || isNewline(prev))) {
+ buffer.push(parsed.value)
+ continue
+ }
+
+ // Ignore newlines and empty space if we're building a grid
+ if (isNewline(parsed) && buffer.length > 0) continue
+ if (isText(parsed) && !parsed.value.trim() && buffer.length > 0) continue
+
+ if (buffer.length > 0) {
+ result.push({type: ParsedType.LinkGrid, value: {links: buffer.splice(0)}, raw: ""})
+ }
+
+ result.push(parsed)
+ }
+
+ if (buffer.length > 0) {
+ result.push({type: ParsedType.LinkGrid, value: {links: buffer.splice(0)}, raw: ""})
+ }
+
+ return result
+}
diff --git a/packages/content/src/render.ts b/packages/content/src/render.ts
new file mode 100644
index 0000000..7357577
--- /dev/null
+++ b/packages/content/src/render.ts
@@ -0,0 +1,179 @@
+import {neventEncode, nprofileEncode, naddrEncode} from "nostr-tools/nip19"
+import {sanitizeUrl} from "@braintree/sanitize-url"
+import {
+ Parsed,
+ ParsedType,
+ ParsedTopic,
+ ParsedProfile,
+ ParsedNewline,
+ ParsedLink,
+ ParsedInvoice,
+ ParsedEvent,
+ ParsedEmoji,
+ ParsedEllipsis,
+ ParsedCode,
+ ParsedCashu,
+ ParsedAddress,
+ ParsedText,
+} from "./parser.js"
+
+export class Renderer {
+ private value = ""
+
+ constructor(readonly options: RenderOptions) {}
+
+ toString = () => this.value
+
+ addText = (value: string) => {
+ const element = this.options.createElement("div")
+
+ element.innerText = value
+
+ this.value += element.innerHTML
+ }
+
+ addNewlines = (count: number) => {
+ for (let i = 0; i < count; i++) {
+ this.value += this.options.newline
+ }
+ }
+
+ addLink = (href: string, display: string) => {
+ this.value += this.options.renderLink(href, display)
+ }
+
+ addEntityLink = (entity: string) => {
+ this.addLink(this.options.entityBase + entity, this.options.renderEntity(entity))
+ }
+}
+
+export type RenderOptions = {
+ newline: string
+ entityBase: string
+ renderLink: (href: string, display: string) => string
+ renderEntity: (entity: string) => string
+ createElement: (tag: string) => any
+}
+
+const createElement = (tag: string) => document.createElement(tag) as any
+
+export const textRenderOptions = {
+ newline: "\n",
+ entityBase: "",
+ createElement,
+ renderLink: (href: string, display: string) => href,
+ renderEntity: (entity: string) => entity.slice(0, 16) + "…",
+}
+
+export const htmlRenderOptions = {
+ newline: "\n",
+ entityBase: "https://njump.me/",
+ createElement,
+ renderLink(href: string, display: string) {
+ const element = this.createElement("a")
+
+ element.href = sanitizeUrl(href)
+ element.target = "_blank"
+ element.innerText = display
+
+ return element.outerHTML
+ },
+ renderEntity: (entity: string) => entity.slice(0, 16) + "…",
+}
+
+export const makeTextRenderer = (options: Partial = {}) =>
+ new Renderer({...textRenderOptions, ...options})
+
+export const makeHtmlRenderer = (options: Partial = {}) =>
+ new Renderer({...htmlRenderOptions, ...options})
+
+// Top level render methods
+
+export const renderCashu = (p: ParsedCashu, r: Renderer) => r.addText(p.value)
+
+export const renderCode = (p: ParsedCode, r: Renderer) => r.addText(p.value)
+
+export const renderEllipsis = (p: ParsedEllipsis, r: Renderer) => r.addText("…")
+
+export const renderEmoji = (p: ParsedEmoji, r: Renderer) => r.addText(p.raw)
+
+export const renderInvoice = (p: ParsedInvoice, r: Renderer) =>
+ r.addLink("lightning:" + p.value, p.value.slice(0, 16) + "…")
+
+export const renderLink = (p: ParsedLink, r: Renderer) =>
+ r.addLink(p.value.url.toString(), p.value.url.host + p.value.url.pathname)
+
+export const renderNewline = (p: ParsedNewline, r: Renderer) =>
+ r.addNewlines(Array.from(p.value).length)
+
+export const renderText = (p: ParsedText, r: Renderer) => r.addText(p.value)
+
+export const renderTopic = (p: ParsedTopic, r: Renderer) => r.addText(p.value)
+
+export const renderEvent = (p: ParsedEvent, r: Renderer) => r.addEntityLink(neventEncode(p.value))
+
+export const renderProfile = (p: ParsedProfile, r: Renderer) =>
+ r.addEntityLink(nprofileEncode(p.value))
+
+export const renderAddress = (p: ParsedAddress, r: Renderer) =>
+ r.addEntityLink(naddrEncode(p.value))
+
+export const renderOne = (parsed: Parsed, renderer: Renderer) => {
+ switch (parsed.type) {
+ case ParsedType.Address:
+ renderAddress(parsed as ParsedAddress, renderer)
+ break
+ case ParsedType.Cashu:
+ renderCashu(parsed as ParsedCashu, renderer)
+ break
+ case ParsedType.Code:
+ renderCode(parsed as ParsedCode, renderer)
+ break
+ case ParsedType.Ellipsis:
+ renderEllipsis(parsed as ParsedEllipsis, renderer)
+ break
+ case ParsedType.Emoji:
+ renderEmoji(parsed as ParsedEmoji, renderer)
+ break
+ case ParsedType.Event:
+ renderEvent(parsed as ParsedEvent, renderer)
+ break
+ case ParsedType.Invoice:
+ renderInvoice(parsed as ParsedInvoice, renderer)
+ break
+ case ParsedType.Link:
+ renderLink(parsed as ParsedLink, renderer)
+ break
+ case ParsedType.Newline:
+ renderNewline(parsed as ParsedNewline, renderer)
+ break
+ case ParsedType.Profile:
+ renderProfile(parsed as ParsedProfile, renderer)
+ break
+ case ParsedType.Text:
+ renderText(parsed as ParsedText, renderer)
+ break
+ case ParsedType.Topic:
+ renderTopic(parsed as ParsedTopic, renderer)
+ break
+ }
+
+ return renderer
+}
+
+export const renderMany = (parsed: Parsed[], renderer: Renderer) => {
+ for (const p of parsed) {
+ renderOne(p, renderer)
+ }
+
+ return renderer
+}
+
+export const render = (parsed: Parsed | Parsed[], renderer: Renderer) =>
+ Array.isArray(parsed) ? renderMany(parsed, renderer) : renderOne(parsed, renderer)
+
+export const renderAsText = (parsed: Parsed | Parsed[], options: Partial = {}) =>
+ render(parsed, makeTextRenderer(options))
+
+export const renderAsHtml = (parsed: Parsed | Parsed[], options: Partial = {}) =>
+ render(parsed, makeHtmlRenderer(options))