From 71cdf5582b5e09cdcd3c66906db9520c73fa58f9 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 4 Jun 2024 08:57:29 -0700 Subject: [PATCH] Fix content parsing, add default rendering --- package-lock.json | 30 +++++ packages/content/index.ts | 201 ++++++++++++++++++++++++---------- packages/content/package.json | 4 +- 3 files changed, 176 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec6b6e2..9f2322e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -323,6 +323,12 @@ "version": "3.0.3", "license": "MIT" }, + "node_modules/@types/insane": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/insane/-/insane-1.0.0.tgz", + "integrity": "sha512-9FNbmwdaQezEszc5B/w4kRSpMJMOVj+gX7CKSbBCFO4WPiUqKO3HJlUNXzjtus0w5tF2BOJoKTbyps/Envlg/Q==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "dev": true, @@ -666,6 +672,11 @@ "node": ">=0.10.0" } }, + "node_modules/assignment": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/assignment/-/assignment-2.0.0.tgz", + "integrity": "sha512-naMULXjtgCs9SVUEtyvJNt68aF18em7/W+dhbR59kbz9cXWPEvUkCun2tqlgqRPSqZaKPpqLc5ZnwL8jVmJRvw==" + }, "node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -1542,6 +1553,14 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/he/-/he-0.5.0.tgz", + "integrity": "sha512-DoufbNNOFzwRPy8uecq+j+VCPQ+JyDelHTmSgygrA5TsR8Cbw4Qcir5sGtWiusB4BdT89nmlaVDhSJOqC/33vw==", + "bin": { + "he": "bin/he" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "dev": true, @@ -1648,6 +1667,15 @@ "node": ">=8.0.0" } }, + "node_modules/insane": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/insane/-/insane-2.6.2.tgz", + "integrity": "sha512-BqEL1CJsjJi+/C/zKZxv31zs3r6zkLH5Nz1WMFb7UBX2KHY2yXDpbFTSEmNHzomBbGDysIfkTX55A0mQZ2CQiw==", + "dependencies": { + "assignment": "2.0.0", + "he": "0.5.0" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "dev": true, @@ -3067,9 +3095,11 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "insane": "^2.6.2", "nostr-tools": "^2.7.0" }, "devDependencies": { + "@types/insane": "^1.0.0", "gts": "^5.0.1", "tsc-multi": "^1.1.0", "typescript": "~5.1.6" diff --git a/packages/content/index.ts b/packages/content/index.ts index 610daf4..8d944cc 100644 --- a/packages/content/index.ts +++ b/packages/content/index.ts @@ -1,4 +1,5 @@ import {nip19} from "nostr-tools" +import insane from 'insane' const last = (xs: T[], ...args: unknown[]) => xs[xs.length - 1] @@ -79,8 +80,7 @@ export type ParsedInvoice = { } export type ParsedLinkValue = { - url: string - hash: string + url: URL meta: Record isMedia: boolean } @@ -143,46 +143,46 @@ export type Parsed = // Parsers for known formats -export const parseAddress = (raw: string, context: ParseContext): ParsedAddress | void => { - const [naddr] = raw.match(/^(web\+)?(nostr:)?\/?\/?naddr1[\d\w]+/i) || [] +export const parseAddress = (text: string, context: ParseContext): ParsedAddress | void => { + const [naddr] = text.match(/^(web\+)?(nostr:)?\/?\/?naddr1[\d\w]+/i) || [] if (naddr) { try { const {data} = nip19.decode(fromNostrURI(naddr)) - return {type: ParsedType.Address, value: data as AddressPointer, raw} + return {type: ParsedType.Address, value: data as AddressPointer, raw: naddr} } catch (e) { // Pass } } } -export const parseCashu = (raw: string, context: ParseContext): ParsedCashu | void => { - const [value] = raw.match(/^(cashu)[\d\w=]{50,5000}/i) || [] +export const parseCashu = (text: string, context: ParseContext): ParsedCashu | void => { + const [value] = text.match(/^(cashu)[\d\w=]{50,5000}/i) || [] if (value) { - return {type: ParsedType.Cashu, value, raw} + return {type: ParsedType.Cashu, value, raw: value} } } -export const parseCodeBlock = (raw: string, context: ParseContext): ParsedCodeBlock | void => { - const [code, value] = raw.match(/^```([^]*?)```/i) || [] +export const parseCodeBlock = (text: string, context: ParseContext): ParsedCodeBlock | void => { + const [code, value] = text.match(/^```([^]*?)```/i) || [] if (code) { - return {type: ParsedType.CodeBlock, value, raw} + return {type: ParsedType.CodeBlock, value, raw: code} } } -export const parseCodeInline = (raw: string, context: ParseContext): ParsedCodeInline | void => { - const [code, value] = raw.match(/^`(.*?)`/i) || [] +export const parseCodeInline = (text: string, context: ParseContext): ParsedCodeInline | void => { + const [code, value] = text.match(/^`(.*?)`/i) || [] if (code) { - return {type: ParsedType.CodeInline, value, raw} + return {type: ParsedType.CodeInline, value, raw: code} } } -export const parseEvent = (raw: string, context: ParseContext): ParsedEvent | void => { - const [entity] = raw.match(/^(web\+)?(nostr:)?\/?\/?n(event|ote)1[\d\w]+/i) || [] +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 { @@ -191,23 +191,23 @@ export const parseEvent = (raw: string, context: ParseContext): ParsedEvent | vo ? {id: data as string, relays: []} : data as EventPointer - return {type: ParsedType.Event, value, raw} + return {type: ParsedType.Event, value, raw: entity} } catch (e) { // Pass } } } -export const parseInvoice = (raw: string, context: ParseContext): ParsedInvoice | void => { - const [value] = raw.match(/^ln(lnbc|lnurl)[\d\w]{50,1000}/i) || [] +export const parseInvoice = (text: string, context: ParseContext): ParsedInvoice | void => { + const [value] = text.match(/^ln(lnbc|lnurl)[\d\w]{50,1000}/i) || [] if (value) { - return {type: ParsedType.Invoice, value, raw} + return {type: ParsedType.Invoice, value, raw: value} } } -export const parseLink = (raw: string, context: ParseContext): ParsedLink | void => { - const [link] = raw.match(/^([a-z\+:]{2,30}:\/\/)?[^<>\(\)\s]+\.[a-z]{2,6}[^\s]*[^<>"'\.!?,:\s\)\(]/gi) || [] +export const parseLink = (text: string, context: ParseContext): ParsedLink | void => { + let [link] = text.match(/^([a-z\+:]{2,30}:\/\/)?[^<>\(\)\s]+\.[a-z]{2,6}[^\s]*[^<>"'\.!?,:\s\)\(]/gi) || [] if (!link) { return @@ -215,52 +215,51 @@ export const parseLink = (raw: string, context: ParseContext): ParsedLink | void const prev = last(context.results) - // Skip url if it's just the end of a filepath - if (prev?.type === ParsedType.Text && prev.value.endsWith("/")) { - return - } - - // Strip hash component - let [url, hash] = link.split("#") - - // Skip ellipses and very short non-urls - if (url.match(/\.\./)) { + // Skip url if it's just the end of a filepath or an ellipse + if (prev?.type === ParsedType.Text && prev.value.endsWith("/") || link.match(/\.\./)) { return } // Make sure there's a protocol - if (!url.match("^\w+://")) { - url = "https://" + url + if (!link.match(/^\w+:\/\//)) { + link = "https://" + link } - const meta = Object.fromEntries(new URLSearchParams(hash).entries()) + // Parse using URL + let url + try { + url = new URL(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 ${raw}`))) { + if (tag[0] === 'imeta' && tag.find(t => t.includes(`url ${link}`))) { Object.assign(meta, Object.fromEntries(tag.slice(1).map((m: string) => m.split(" ")))) } } const isMedia = Boolean( - url.match(/\.(jpe?g|png|wav|mp3|mp4|mov|avi|webm|webp|gif|bmp|svg)$/) && - last(url.replace(/\/$/, "").split("://"))?.includes("/") + url.pathname.match(/\.(jpe?g|png|wav|mp3|mp4|mov|avi|webm|webp|gif|bmp|svg)$/) ) - const value = {url, hash, meta, isMedia} + const value = {url, meta, isMedia} - return {type: ParsedType.Link, value, raw} + return {type: ParsedType.Link, value, raw: link} } -export const parseNewline = (raw: string, context: ParseContext): ParsedNewline | void => { - const [value] = raw.match(/^\n+/) || [] +export const parseNewline = (text: string, context: ParseContext): ParsedNewline | void => { + const [value] = text.match(/^\n+/) || [] if (value) { - return {type: ParsedType.Newline, raw, value} + return {type: ParsedType.Newline, value, raw: value} } } -export const parseProfile = (raw: string, context: ParseContext): ParsedProfile | void => { - const [entity] = raw.match(/^(web\+)?(nostr:)?\/?\/?n(profile|pub)1[\d\w]+/i) || [] +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 { @@ -269,38 +268,38 @@ export const parseProfile = (raw: string, context: ParseContext): ParsedProfile ? {pubkey: data as string, relays: []} : data as ProfilePointer - return {type: ParsedType.Profile, value, raw} + return {type: ParsedType.Profile, value, raw: entity} } catch (e) { // Pass } } } -export const parseTopic = (raw: string, context: ParseContext): ParsedTopic | void => { - const [value] = raw.match(/^#[^\s!\"#$%&'()*+,-.\/:;<=>?@[\\\]^_`{|}~]+/i) || [] +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, raw, value} + return {type: ParsedType.Topic, value, raw: value} } } // Parse other formats to known types -export const parseLegacyMention = (raw: string, context: ParseContext): ParsedProfile | ParsedEvent | void => { - const mentionMatch = raw.match(/^#\[(\d+)\]/i) || [] +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} + return {type: ParsedType.Profile, value: {pubkey: value, relays}, raw: mentionMatch[0]!} } if (tag === "e") { - return {type: ParsedType.Event, value: {id: value, relays}, raw} + return {type: ParsedType.Event, value: {id: value, relays}, raw: mentionMatch[0]!} } } } @@ -366,10 +365,10 @@ export const parse = ({content = "", tags = []}: {content?: string; tags?: strin } type TruncateOpts = { - minLength: number - maxLength: number - mediaLength: number - entityLength: number + minLength?: number + maxLength?: number + mediaLength?: number + entityLength?: number } export const truncate = ( @@ -379,7 +378,7 @@ export const truncate = ( maxLength = 600, mediaLength = 200, entityLength = 30, - }: TruncateOpts, + }: 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 @@ -423,3 +422,89 @@ export const truncate = ( return content } + +// Renderers + +export type RenderOptions = { + entityBaseUrl?: string +} + +export const defaultRenderOptions = { + entityBaseUrl: 'https://njump.me/' +} + +export class HTML { + constructor(readonly value: string) { + this.value = value + } + + toString = () => this.value + + static useSafely = (value: string) => new HTML(insane(value)) + + static useDangerously = (value: string) => new HTML(value) + + static buildLink = (href: string, display: string) => + HTML.useSafely(`${display}`) + + static buildEntityLink = (entity: string, options: RenderOptions) => + HTML.buildLink(options.entityBaseUrl + entity, entity.slice(0, 16)) +} + +export const renderCashu = (parsed: ParsedCashu, options: RenderOptions) => + HTML.useSafely(parsed.value) + +export const renderCodeBlock = (parsed: ParsedCodeBlock, options: RenderOptions) => + HTML.useSafely(parsed.value) + +export const renderCodeInline = (parsed: ParsedCodeInline, options: RenderOptions) => + HTML.useSafely(parsed.value) + +export const renderEllipsis = (parsed: ParsedEllipsis, options: RenderOptions) => "..." + +export const renderInvoice = (parsed: ParsedInvoice, options: RenderOptions) => + HTML.useSafely(parsed.value) + +export const renderLink = (parsed: ParsedLink, options: RenderOptions) => { + const href = parsed.value.url.toString() + const display = parsed.value.url.host + parsed.value.url.pathname + + return HTML.buildLink(href, display) +} + +export const renderNewline = (parsed: ParsedNewline, options: RenderOptions) => + HTML.useSafely(Array.from(parsed.value).map(() => '
').join('')) + +export const renderText = (parsed: ParsedText, options: RenderOptions) => + HTML.useSafely(parsed.value) + +export const renderTopic = (parsed: ParsedTopic, options: RenderOptions) => + HTML.useSafely(parsed.value) + +export const renderEvent = (parsed: ParsedEvent, options: RenderOptions) => + HTML.buildEntityLink(nip19.neventEncode(parsed.value), options) + +export const renderProfile = (parsed: ParsedProfile, options: RenderOptions) => + HTML.buildEntityLink(nip19.nprofileEncode(parsed.value), options) + +export const renderAddress = (parsed: ParsedAddress, options: RenderOptions) => + HTML.buildEntityLink(nip19.naddrEncode(parsed.value), options) + +export const render = (parsed: Parsed, options: RenderOptions = {}) => { + options = {...defaultRenderOptions, ...options} + + switch (parsed.type) { + case ParsedType.Address: return renderAddress(parsed as ParsedAddress, options) + case ParsedType.Cashu: return renderCashu(parsed as ParsedCashu, options) + case ParsedType.CodeBlock: return renderCodeBlock(parsed as ParsedCodeBlock, options) + case ParsedType.CodeInline: return renderCodeInline(parsed as ParsedCodeInline, options) + case ParsedType.Ellipsis: return renderEllipsis(parsed as ParsedEllipsis, options) + case ParsedType.Event: return renderEvent(parsed as ParsedEvent, options) + case ParsedType.Invoice: return renderInvoice(parsed as ParsedInvoice, options) + case ParsedType.Link: return renderLink(parsed as ParsedLink, options) + case ParsedType.Newline: return renderNewline(parsed as ParsedNewline, options) + case ParsedType.Profile: return renderProfile(parsed as ParsedProfile, options) + case ParsedType.Text: return renderText(parsed as ParsedText, options) + case ParsedType.Topic: return renderTopic(parsed as ParsedTopic, options) + } +} diff --git a/packages/content/package.json b/packages/content/package.json index f83c621..de5945f 100644 --- a/packages/content/package.json +++ b/packages/content/package.json @@ -1,6 +1,6 @@ { "name": "@welshman/content", - "version": "0.0.1", + "version": "0.0.2", "author": "hodlbod", "license": "MIT", "description": "A collection of utilities for parsing nostr note content.", @@ -26,11 +26,13 @@ "fix": "gts fix" }, "devDependencies": { + "@types/insane": "^1.0.0", "gts": "^5.0.1", "tsc-multi": "^1.1.0", "typescript": "~5.1.6" }, "dependencies": { + "insane": "^2.6.2", "nostr-tools": "^2.7.0" } }