278 lines
9.2 KiB
JavaScript
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 }]
|
|
}
|
|
}
|
|
}
|