Fix content parsing, add default rendering

This commit is contained in:
Jon Staab
2024-06-04 08:57:29 -07:00
parent d2a3f14567
commit 71cdf5582b
3 changed files with 176 additions and 59 deletions
+30
View File
@@ -323,6 +323,12 @@
"version": "3.0.3", "version": "3.0.3",
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"dev": true, "dev": true,
@@ -666,6 +672,11 @@
"node": ">=0.10.0" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"dev": true, "dev": true,
@@ -1542,6 +1553,14 @@
"node": ">= 0.4" "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": { "node_modules/hosted-git-info": {
"version": "4.1.0", "version": "4.1.0",
"dev": true, "dev": true,
@@ -1648,6 +1667,15 @@
"node": ">=8.0.0" "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": { "node_modules/is-arrayish": {
"version": "0.2.1", "version": "0.2.1",
"dev": true, "dev": true,
@@ -3067,9 +3095,11 @@
"version": "0.0.1", "version": "0.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"insane": "^2.6.2",
"nostr-tools": "^2.7.0" "nostr-tools": "^2.7.0"
}, },
"devDependencies": { "devDependencies": {
"@types/insane": "^1.0.0",
"gts": "^5.0.1", "gts": "^5.0.1",
"tsc-multi": "^1.1.0", "tsc-multi": "^1.1.0",
"typescript": "~5.1.6" "typescript": "~5.1.6"
+143 -58
View File
@@ -1,4 +1,5 @@
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import insane from 'insane'
const last = <T>(xs: T[], ...args: unknown[]) => xs[xs.length - 1] const last = <T>(xs: T[], ...args: unknown[]) => xs[xs.length - 1]
@@ -79,8 +80,7 @@ export type ParsedInvoice = {
} }
export type ParsedLinkValue = { export type ParsedLinkValue = {
url: string url: URL
hash: string
meta: Record<string, string> meta: Record<string, string>
isMedia: boolean isMedia: boolean
} }
@@ -143,46 +143,46 @@ export type Parsed =
// Parsers for known formats // Parsers for known formats
export const parseAddress = (raw: string, context: ParseContext): ParsedAddress | void => { export const parseAddress = (text: string, context: ParseContext): ParsedAddress | void => {
const [naddr] = raw.match(/^(web\+)?(nostr:)?\/?\/?naddr1[\d\w]+/i) || [] const [naddr] = text.match(/^(web\+)?(nostr:)?\/?\/?naddr1[\d\w]+/i) || []
if (naddr) { if (naddr) {
try { try {
const {data} = nip19.decode(fromNostrURI(naddr)) 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) { } catch (e) {
// Pass // Pass
} }
} }
} }
export const parseCashu = (raw: string, context: ParseContext): ParsedCashu | void => { export const parseCashu = (text: string, context: ParseContext): ParsedCashu | void => {
const [value] = raw.match(/^(cashu)[\d\w=]{50,5000}/i) || [] const [value] = text.match(/^(cashu)[\d\w=]{50,5000}/i) || []
if (value) { if (value) {
return {type: ParsedType.Cashu, value, raw} return {type: ParsedType.Cashu, value, raw: value}
} }
} }
export const parseCodeBlock = (raw: string, context: ParseContext): ParsedCodeBlock | void => { export const parseCodeBlock = (text: string, context: ParseContext): ParsedCodeBlock | void => {
const [code, value] = raw.match(/^```([^]*?)```/i) || [] const [code, value] = text.match(/^```([^]*?)```/i) || []
if (code) { if (code) {
return {type: ParsedType.CodeBlock, value, raw} return {type: ParsedType.CodeBlock, value, raw: code}
} }
} }
export const parseCodeInline = (raw: string, context: ParseContext): ParsedCodeInline | void => { export const parseCodeInline = (text: string, context: ParseContext): ParsedCodeInline | void => {
const [code, value] = raw.match(/^`(.*?)`/i) || [] const [code, value] = text.match(/^`(.*?)`/i) || []
if (code) { if (code) {
return {type: ParsedType.CodeInline, value, raw} return {type: ParsedType.CodeInline, value, raw: code}
} }
} }
export const parseEvent = (raw: string, context: ParseContext): ParsedEvent | void => { export const parseEvent = (text: string, context: ParseContext): ParsedEvent | void => {
const [entity] = raw.match(/^(web\+)?(nostr:)?\/?\/?n(event|ote)1[\d\w]+/i) || [] const [entity] = text.match(/^(web\+)?(nostr:)?\/?\/?n(event|ote)1[\d\w]+/i) || []
if (entity) { if (entity) {
try { try {
@@ -191,23 +191,23 @@ export const parseEvent = (raw: string, context: ParseContext): ParsedEvent | vo
? {id: data as string, relays: []} ? {id: data as string, relays: []}
: data as EventPointer : data as EventPointer
return {type: ParsedType.Event, value, raw} return {type: ParsedType.Event, value, raw: entity}
} catch (e) { } catch (e) {
// Pass // Pass
} }
} }
} }
export const parseInvoice = (raw: string, context: ParseContext): ParsedInvoice | void => { export const parseInvoice = (text: string, context: ParseContext): ParsedInvoice | void => {
const [value] = raw.match(/^ln(lnbc|lnurl)[\d\w]{50,1000}/i) || [] const [value] = text.match(/^ln(lnbc|lnurl)[\d\w]{50,1000}/i) || []
if (value) { if (value) {
return {type: ParsedType.Invoice, value, raw} return {type: ParsedType.Invoice, value, raw: value}
} }
} }
export const parseLink = (raw: string, context: ParseContext): ParsedLink | void => { export const parseLink = (text: string, context: ParseContext): ParsedLink | void => {
const [link] = raw.match(/^([a-z\+:]{2,30}:\/\/)?[^<>\(\)\s]+\.[a-z]{2,6}[^\s]*[^<>"'\.!?,:\s\)\(]/gi) || [] let [link] = text.match(/^([a-z\+:]{2,30}:\/\/)?[^<>\(\)\s]+\.[a-z]{2,6}[^\s]*[^<>"'\.!?,:\s\)\(]/gi) || []
if (!link) { if (!link) {
return return
@@ -215,52 +215,51 @@ export const parseLink = (raw: string, context: ParseContext): ParsedLink | void
const prev = last(context.results) const prev = last(context.results)
// Skip url if it's just the end of a filepath // Skip url if it's just the end of a filepath or an ellipse
if (prev?.type === ParsedType.Text && prev.value.endsWith("/")) { if (prev?.type === ParsedType.Text && prev.value.endsWith("/") || link.match(/\.\./)) {
return
}
// Strip hash component
let [url, hash] = link.split("#")
// Skip ellipses and very short non-urls
if (url.match(/\.\./)) {
return return
} }
// Make sure there's a protocol // Make sure there's a protocol
if (!url.match("^\w+://")) { if (!link.match(/^\w+:\/\//)) {
url = "https://" + url 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) { 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(" ")))) Object.assign(meta, Object.fromEntries(tag.slice(1).map((m: string) => m.split(" "))))
} }
} }
const isMedia = Boolean( const isMedia = Boolean(
url.match(/\.(jpe?g|png|wav|mp3|mp4|mov|avi|webm|webp|gif|bmp|svg)$/) && url.pathname.match(/\.(jpe?g|png|wav|mp3|mp4|mov|avi|webm|webp|gif|bmp|svg)$/)
last(url.replace(/\/$/, "").split("://"))?.includes("/")
) )
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 => { export const parseNewline = (text: string, context: ParseContext): ParsedNewline | void => {
const [value] = raw.match(/^\n+/) || [] const [value] = text.match(/^\n+/) || []
if (value) { if (value) {
return {type: ParsedType.Newline, raw, value} return {type: ParsedType.Newline, value, raw: value}
} }
} }
export const parseProfile = (raw: string, context: ParseContext): ParsedProfile | void => { export const parseProfile = (text: string, context: ParseContext): ParsedProfile | void => {
const [entity] = raw.match(/^(web\+)?(nostr:)?\/?\/?n(profile|pub)1[\d\w]+/i) || [] const [entity] = text.match(/^(web\+)?(nostr:)?\/?\/?n(profile|pub)1[\d\w]+/i) || []
if (entity) { if (entity) {
try { try {
@@ -269,38 +268,38 @@ export const parseProfile = (raw: string, context: ParseContext): ParsedProfile
? {pubkey: data as string, relays: []} ? {pubkey: data as string, relays: []}
: data as ProfilePointer : data as ProfilePointer
return {type: ParsedType.Profile, value, raw} return {type: ParsedType.Profile, value, raw: entity}
} catch (e) { } catch (e) {
// Pass // Pass
} }
} }
} }
export const parseTopic = (raw: string, context: ParseContext): ParsedTopic | void => { export const parseTopic = (text: string, context: ParseContext): ParsedTopic | void => {
const [value] = raw.match(/^#[^\s!\"#$%&'()*+,-.\/:;<=>?@[\\\]^_`{|}~]+/i) || [] const [value] = text.match(/^#[^\s!\"#$%&'()*+,-.\/:;<=>?@[\\\]^_`{|}~]+/i) || []
// Skip numeric topics // Skip numeric topics
if (value && !value.match(/^#\d+$/)) { if (value && !value.match(/^#\d+$/)) {
return {type: ParsedType.Topic, raw, value} return {type: ParsedType.Topic, value, raw: value}
} }
} }
// Parse other formats to known types // Parse other formats to known types
export const parseLegacyMention = (raw: string, context: ParseContext): ParsedProfile | ParsedEvent | void => { export const parseLegacyMention = (text: string, context: ParseContext): ParsedProfile | ParsedEvent | void => {
const mentionMatch = raw.match(/^#\[(\d+)\]/i) || [] const mentionMatch = text.match(/^#\[(\d+)\]/i) || []
if (mentionMatch) { if (mentionMatch) {
const [tag, value, url] = context.tags[parseInt(mentionMatch[1])] || [] const [tag, value, url] = context.tags[parseInt(mentionMatch[1])] || []
const relays = url ? [url] : [] const relays = url ? [url] : []
if (tag === "p") { 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") { 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 = { type TruncateOpts = {
minLength: number minLength?: number
maxLength: number maxLength?: number
mediaLength: number mediaLength?: number
entityLength: number entityLength?: number
} }
export const truncate = ( export const truncate = (
@@ -379,7 +378,7 @@ export const truncate = (
maxLength = 600, maxLength = 600,
mediaLength = 200, mediaLength = 200,
entityLength = 30, entityLength = 30,
}: TruncateOpts, }: TruncateOpts = {},
) => { ) => {
// Get a list of content sizes so we know where to truncate // Get a list of content sizes so we know where to truncate
// Non-plaintext things might take up more or less room if rendered // Non-plaintext things might take up more or less room if rendered
@@ -423,3 +422,89 @@ export const truncate = (
return content 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(`<a href=${href} target="_blank">${display}</a>`)
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(() => '<br />').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)
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@welshman/content", "name": "@welshman/content",
"version": "0.0.1", "version": "0.0.2",
"author": "hodlbod", "author": "hodlbod",
"license": "MIT", "license": "MIT",
"description": "A collection of utilities for parsing nostr note content.", "description": "A collection of utilities for parsing nostr note content.",
@@ -26,11 +26,13 @@
"fix": "gts fix" "fix": "gts fix"
}, },
"devDependencies": { "devDependencies": {
"@types/insane": "^1.0.0",
"gts": "^5.0.1", "gts": "^5.0.1",
"tsc-multi": "^1.1.0", "tsc-multi": "^1.1.0",
"typescript": "~5.1.6" "typescript": "~5.1.6"
}, },
"dependencies": { "dependencies": {
"insane": "^2.6.2",
"nostr-tools": "^2.7.0" "nostr-tools": "^2.7.0"
} }
} }