Update docs for content
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
docs
|
#docs
|
||||||
docs/reference
|
docs/reference
|
||||||
docs/.vitepress/cache
|
docs/.vitepress/cache
|
||||||
build
|
build
|
||||||
|
|||||||
+5
-57
@@ -2,67 +2,15 @@
|
|||||||
|
|
||||||
[](https://npmjs.com/package/@welshman/content)
|
[](https://npmjs.com/package/@welshman/content)
|
||||||
|
|
||||||
`@welshman/content` is a comprehensive content processing library designed specifically for Nostr applications.
|
A content processing library designed specifically for Nostr applications. It provides a simple system for parsing, processing, and rendering Nostr content.
|
||||||
It provides a robust system for parsing, processing, and rendering Nostr content while handling various special formats and entities common in the Nostr ecosystem.
|
|
||||||
|
|
||||||
|
## What's Included
|
||||||
|
|
||||||
## Core Concepts
|
- **Parsing**: Utilities for parsing nostr event content, as well as truncating and building image grids.
|
||||||
|
- **Rendering**: Basic rendering support for parsed nostr event content.
|
||||||
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()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```
|
||||||
npm install @welshman/content
|
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.
|
|
||||||
|
|||||||
+66
-149
@@ -1,181 +1,98 @@
|
|||||||
# Content Parser
|
# Content Parser
|
||||||
|
|
||||||
The content parser system in `@welshman/content` provides a powerful way to parse Nostr content into structured elements.
|
The content parser system in `@welshman/content` provides utilities for parsing Nostr content into structured elements.
|
||||||
It handles various types of content including Nostr entities, links, code blocks, and special formats.
|
|
||||||
|
|
||||||
## 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
|
```typescript
|
||||||
enum ParsedType {
|
parse({content?: string, tags?: string[][]}) => Parsed[]
|
||||||
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
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Nostr-specific Types
|
Takes content string and optional tags array, returns array of parsed elements. Uses tags for emoji lookup and imeta information.
|
||||||
```typescript
|
|
||||||
enum ParsedType {
|
|
||||||
Event = "event", // Nostr events (note1/nevent1)
|
|
||||||
Profile = "profile", // Profiles (npub1/nprofile1)
|
|
||||||
Address = "address", // Addresses (naddr1)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Special Format Types
|
### truncate(content, options)
|
||||||
```typescript
|
|
||||||
enum ParsedType {
|
|
||||||
Cashu = "cashu", // Cashu tokens
|
|
||||||
Invoice = "invoice", // Lightning invoices
|
|
||||||
Ellipsis = "ellipsis" // Truncation marker
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parsing Content
|
Truncates parsed content to specified length limits:
|
||||||
|
|
||||||
### Main Parser
|
|
||||||
```typescript
|
```typescript
|
||||||
const parse = ({
|
truncate(content: Parsed[], {
|
||||||
content = "",
|
minLength?: number, // 500 - minimum before truncating
|
||||||
tags = []
|
maxLength?: number, // 700 - maximum total length
|
||||||
}: {
|
mediaLength?: number, // 200 - assumed size for media
|
||||||
content?: string
|
entityLength?: number // 30 - assumed size for entities
|
||||||
tags?: string[][]
|
|
||||||
}) => Parsed[]
|
}) => 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
|
```typescript
|
||||||
// Nostr Entities
|
reduceLinks(content: Parsed[]) => Parsed[]
|
||||||
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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Type Guards
|
## Type Guards
|
||||||
|
|
||||||
```typescript
|
Utility functions to check parsed element types:
|
||||||
// Basic content
|
- `isAddress(parsed)`, `isCashu(parsed)`, `isCode(parsed)`, etc.
|
||||||
isText(parsed: Parsed): parsed is ParsedText
|
- `isImage(parsed)` - special check for image links
|
||||||
isNewline(parsed: Parsed): parsed is ParsedNewline
|
|
||||||
isCode(parsed: Parsed): parsed is ParsedCode
|
|
||||||
isTopic(parsed: Parsed): parsed is ParsedTopic
|
|
||||||
|
|
||||||
// Links and media
|
## Utilities
|
||||||
isLink(parsed: Parsed): parsed is ParsedLink
|
|
||||||
isImage(parsed: Parsed): parsed is ParsedLink
|
|
||||||
isLinkGrid(parsed: Parsed): parsed is ParsedLinkGrid
|
|
||||||
|
|
||||||
// Nostr entities
|
- `urlIsMedia(url)` - Checks if URL points to media file
|
||||||
isEvent(parsed: Parsed): parsed is ParsedEvent
|
- `fromNostrURI(s)` - Removes nostr: protocol prefix
|
||||||
isProfile(parsed: Parsed): parsed is ParsedProfile
|
|
||||||
isAddress(parsed: Parsed): parsed is ParsedAddress
|
|
||||||
|
|
||||||
// Special formats
|
## Example Usage
|
||||||
isCashu(parsed: Parsed): parsed is ParsedCashu
|
|
||||||
isInvoice(parsed: Parsed): parsed is ParsedInvoice
|
|
||||||
isEllipsis(parsed: Parsed): parsed is ParsedEllipsis
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complete Example
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Parse content with tags
|
import { parse, truncate, reduceLinks } from '@welshman/content'
|
||||||
const parsed = parse({
|
|
||||||
content: `
|
|
||||||
Hello #nostr!
|
|
||||||
|
|
||||||
Check out this note: nostr:note1...
|
const content = `Check out this cool #nostr client!
|
||||||
And this profile: nostr:npub1...
|
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
|
// Combine adjacent links into grids
|
||||||
https://example.com/image2.jpg
|
const withLinkGrids = reduceLinks(parsed)
|
||||||
`,
|
|
||||||
tags: [
|
// Truncate to reasonable length for preview
|
||||||
["p", "pubkey123"],
|
const truncated = truncate(withLinkGrids, {
|
||||||
["e", "event456"]
|
minLength: 100,
|
||||||
]
|
maxLength: 200
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process the content
|
// Result contains structured elements:
|
||||||
const processed = reduceLinks(parsed)
|
// - Text: "Check out this cool "
|
||||||
|
// - Topic: "nostr"
|
||||||
// Truncate if needed
|
// - Text: " client!\n"
|
||||||
const final = truncate(processed, {
|
// - LinkGrid: [github.com/..., welshman.coracle.social]
|
||||||
maxLength: 500,
|
// - Text: "Visit "
|
||||||
mediaLength: 150
|
// - Profile: npub1jlrs53pkdfjnts29kveljul2sm0actt6n8dxrrzqcersttvcuv3qdjynqn
|
||||||
})
|
// - Text: " for more info"
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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) => `<a href="${href}" class="custom-link">${display}</a>`
|
||||||
|
}).toString()
|
||||||
|
|
||||||
|
// Result:
|
||||||
|
// Check out this cool #nostr client!<br>
|
||||||
|
// Visit <a href="https://njump.me/nprofile1...">npub1jlrs53p...</a> for more info<br>
|
||||||
|
// <a href="https://github.com/coracle-social/welshman" class="custom-link">github.com/coracle-social/welshman</a>
|
||||||
|
```
|
||||||
@@ -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) => `<a class="custom-link" href="${href}">${display}</a>`,
|
|
||||||
renderEntity: (entity) => `<span class="entity">${entity}</span>`
|
|
||||||
}).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 `<img src="${href}" alt="${display}">`
|
|
||||||
}
|
|
||||||
return `<a href="${href}">${display}</a>`
|
|
||||||
},
|
|
||||||
renderEntity: (entity) => {
|
|
||||||
return `<span class="entity">${entity.slice(0, 8)}</span>`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
# @welshman/util
|
# @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
|
## What's Included
|
||||||
|
|
||||||
|
|||||||
@@ -1,651 +1,2 @@
|
|||||||
import {decode, neventEncode, nprofileEncode, naddrEncode} from "nostr-tools/nip19"
|
export * from "./parser.js"
|
||||||
import {sanitizeUrl} from "@braintree/sanitize-url"
|
export * from "./render.js"
|
||||||
|
|
||||||
const last = <T>(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<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<RenderOptions> = {}) =>
|
|
||||||
new Renderer({...textRenderOptions, ...options})
|
|
||||||
|
|
||||||
export const makeHtmlRenderer = (options: Partial<RenderOptions> = {}) =>
|
|
||||||
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<RenderOptions> = {}) =>
|
|
||||||
render(parsed, makeTextRenderer(options))
|
|
||||||
|
|
||||||
export const renderAsHtml = (parsed: Parsed | Parsed[], options: Partial<RenderOptions> = {}) =>
|
|
||||||
render(parsed, makeHtmlRenderer(options))
|
|
||||||
|
|||||||
@@ -0,0 +1,487 @@
|
|||||||
|
import {decode} from "nostr-tools/nip19"
|
||||||
|
|
||||||
|
const last = <T>(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<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<RenderOptions> = {}) =>
|
||||||
|
new Renderer({...textRenderOptions, ...options})
|
||||||
|
|
||||||
|
export const makeHtmlRenderer = (options: Partial<RenderOptions> = {}) =>
|
||||||
|
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<RenderOptions> = {}) =>
|
||||||
|
render(parsed, makeTextRenderer(options))
|
||||||
|
|
||||||
|
export const renderAsHtml = (parsed: Parsed | Parsed[], options: Partial<RenderOptions> = {}) =>
|
||||||
|
render(parsed, makeHtmlRenderer(options))
|
||||||
Reference in New Issue
Block a user