Update docs for content

This commit is contained in:
Jon Staab
2025-06-10 08:38:41 -07:00
parent a4255ea61a
commit 90b2ab2974
9 changed files with 803 additions and 1054 deletions
+2 -651
View File
@@ -1,651 +1,2 @@
import {decode, neventEncode, nprofileEncode, naddrEncode} from "nostr-tools/nip19"
import {sanitizeUrl} from "@braintree/sanitize-url"
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))
export * from "./parser.js"
export * from "./render.js"
+487
View File
@@ -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
}
+179
View File
@@ -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))