stacker.news/lib/rehype-sn.js

278 lines
9.2 KiB
JavaScript

import { SKIP, visit } from 'unist-util-visit'
import { parseEmbedUrl, parseInternalLinks } from './url'
import { slug } from 'github-slugger'
import { toString } from 'mdast-util-to-string'
const userGroup = '[\\w_]+'
const subGroup = '[A-Za-z][\\w_]+'
const mentionRegex = new RegExp('@(' + userGroup + '(?:\\/' + userGroup + ')?)', 'gi')
const subRegex = new RegExp('~(' + subGroup + '(?:\\/' + subGroup + ')?)', 'gi')
const nostrIdRegex = /\b((npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)\b/g
export default function rehypeSN (options = {}) {
const { stylers = [] } = options
return function transformer (tree) {
try {
visit(tree, (node, index, parent) => {
// Handle inline code property
if (node.tagName === 'code') {
node.properties.inline = !(parent && parent.tagName === 'pre')
}
// handle headings
if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) {
const nodeText = toString(node)
const headingId = slug(nodeText.replace(/[^\w\-\s]+/gi, ''))
node.properties.id = headingId
// Create a new link element
const linkElement = {
type: 'element',
tagName: 'headlink',
properties: {
href: `#${headingId}`
},
children: [{ type: 'text', value: nodeText }]
}
// Replace the heading's children with the new link element
node.children = [linkElement]
return [SKIP]
}
// if img is wrapped in a link, remove the link
if (node.tagName === 'a' && node.children.length === 1 && node.children[0].tagName === 'img') {
parent.children[index] = node.children[0]
return index
}
// handle internal links
if (node.tagName === 'a') {
try {
if (node.properties.href.includes('#itemfn-')) {
node.tagName = 'footnote'
} else {
const { itemId, linkText } = parseInternalLinks(node.properties.href)
if (itemId) {
node.tagName = 'item'
node.properties.id = itemId
if (node.properties.href === toString(node)) {
node.children[0].value = linkText
}
}
}
} catch {
// ignore errors like invalid URLs
}
}
// only show a link as an embed if it doesn't have text siblings
if (node.tagName === 'a' &&
!parent.children.some(s => s.type === 'text' && s.value.trim()) &&
toString(node) === node.properties.href) {
const embed = parseEmbedUrl(node.properties.href)
if (embed) {
node.tagName = 'embed'
node.properties = { ...embed, src: node.properties.href }
} else {
node.tagName = 'autolink'
}
}
// if the link text is a URL, just show the URL
if (node.tagName === 'a' && isMisleadingLink(toString(node), node.properties.href)) {
node.children = [{ type: 'text', value: node.properties.href }]
return [SKIP]
}
// Handle @mentions and ~subs
if (node.type === 'text') {
const newChildren = []
let lastIndex = 0
let match
let childrenConsumed = 1
let text = toString(node)
const combinedRegex = new RegExp(mentionRegex.source + '|' + subRegex.source, 'gi')
// handle @__username__ or ~__sub__
if (['@', '~'].includes(node.value) &&
parent.children[index + 1]?.tagName === 'strong' &&
parent.children[index + 1].children[0]?.type === 'text') {
childrenConsumed = 2
text = node.value + '__' + toString(parent.children[index + 1]) + '__'
}
while ((match = combinedRegex.exec(text)) !== null) {
if (lastIndex < match.index) {
newChildren.push({ type: 'text', value: text.slice(lastIndex, match.index) })
}
const [fullMatch, mentionMatch, subMatch] = match
const replacement = mentionMatch ? replaceMention(fullMatch, mentionMatch) : replaceSub(fullMatch, subMatch)
if (replacement) {
newChildren.push(replacement)
} else {
newChildren.push({ type: 'text', value: fullMatch })
}
lastIndex = combinedRegex.lastIndex
}
if (newChildren.length > 0) {
if (lastIndex < text.length) {
newChildren.push({ type: 'text', value: text.slice(lastIndex) })
}
parent.children.splice(index, childrenConsumed, ...newChildren)
return index + newChildren.length
}
}
// Handle Nostr IDs
if (node.type === 'text') {
const newChildren = []
let lastIndex = 0
let match
while ((match = nostrIdRegex.exec(node.value)) !== null) {
if (lastIndex < match.index) {
newChildren.push({ type: 'text', value: node.value.slice(lastIndex, match.index) })
}
newChildren.push(replaceNostrId(match[0], match[0]))
lastIndex = nostrIdRegex.lastIndex
}
if (lastIndex < node.value.length) {
newChildren.push({ type: 'text', value: node.value.slice(lastIndex) })
}
if (newChildren.length > 0) {
parent.children.splice(index, 1, ...newChildren)
return index + newChildren.length
}
}
// handle custom tags
if (node.type === 'element') {
for (const { startTag, endTag, className } of stylers) {
for (let i = 0; i < node.children.length - 2; i++) {
const [start, text, end] = node.children.slice(i, i + 3)
if (start?.type === 'raw' && start?.value === startTag &&
text?.type === 'text' &&
end?.type === 'raw' && end?.value === endTag) {
const newChild = {
type: 'element',
tagName: 'span',
properties: { className: [className] },
children: [{ type: 'text', value: text.value }]
}
node.children.splice(i, 3, newChild)
}
}
}
}
// merge adjacent images and empty paragraphs into a single image collage
if ((node.tagName === 'img' || isImageOnlyParagraph(node)) && Array.isArray(parent.children)) {
const adjacentNodes = [node]
let nextIndex = index + 1
const siblings = parent.children
const somethingBefore = parent.children[index - 1] && parent.children[index - 1].tagName !== 'p'
let somethingAfter = false
while (nextIndex < siblings.length) {
const nextNode = siblings[nextIndex]
if (!nextNode) break
if (nextNode.tagName === 'img' || isImageOnlyParagraph(nextNode)) {
adjacentNodes.push(nextNode)
nextIndex++
} else if (nextNode.type === 'text' && typeof nextNode.value === 'string' && !nextNode.value.trim()) {
nextIndex++
} else {
somethingAfter = true
break
}
}
if (adjacentNodes.length > 0) {
const allImages = adjacentNodes.flatMap(n =>
n.tagName === 'img' ? [n] : (Array.isArray(n.children) ? n.children.filter(child => child.tagName === 'img') : [])
)
const collageNode = {
type: 'element',
tagName: 'p',
children: allImages,
properties: { onlyImages: true, somethingBefore, somethingAfter }
}
parent.children.splice(index, nextIndex - index, collageNode)
return index + 1
}
}
})
} catch (error) {
console.error('Error in rehypeSN transformer:', error)
}
return tree
}
function isImageOnlyParagraph (node) {
return node &&
node.tagName === 'p' &&
Array.isArray(node.children) &&
node.children.every(child =>
(child.tagName === 'img') ||
(child.type === 'text' && typeof child.value === 'string' && !child.value.trim())
)
}
function replaceMention (value, username) {
return {
type: 'element',
tagName: 'mention',
properties: { href: '/' + username, name: username },
children: [{ type: 'text', value }]
}
}
function replaceSub (value, sub) {
return {
type: 'element',
tagName: 'sub',
properties: { href: '/~' + sub, name: sub },
children: [{ type: 'text', value }]
}
}
function isMisleadingLink (text, href) {
let misleading = false
if (/^\s*(\w+\.)+\w+/.test(text)) {
try {
const hrefUrl = new URL(href)
if (new URL(hrefUrl.protocol + text).origin !== hrefUrl.origin) {
misleading = true
}
} catch {}
}
return misleading
}
function replaceNostrId (value, id) {
return {
type: 'element',
tagName: 'a',
properties: { href: `https://njump.me/${id}` },
children: [{ type: 'text', value }]
}
}
}