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 @@ [![version](https://badgen.net/npm/v/@welshman/content)](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 `${display}` - } - 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. +[![version](https://badgen.net/npm/v/@welshman/util)](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))