diff --git a/components/text.js b/components/text.js index 96dd033c3..757a568e5 100644 --- a/components/text.js +++ b/components/text.js @@ -125,7 +125,9 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child return {children} }, img: TextMediaOrLink, - embed: Embed + embed: Embed, + details: Details, + summary: Summary }), [outlawed, rel, TextMediaOrLink, topLevel]) const carousel = useCarousel() @@ -252,3 +254,19 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr ) } + +function Details ({ children, node, ...props }) { + return ( +
+ {children} +
+ ) +} + +function Summary ({ children, node, ...props }) { + return ( + + {children} + + ) +} diff --git a/components/text.module.css b/components/text.module.css index faa1f1e3a..5ad30e23a 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -436,4 +436,31 @@ max-width: 480px; border-radius: 13px; overflow: hidden; -} \ No newline at end of file +} + +/* Details/Summary styling */ +.details { + border: 1px solid rgba(220, 220, 220, 0.5); + border-radius: 4px; + padding: 1rem; + margin: calc(var(--grid-gap) * 0.5) 0; + transition: all 0.2s ease; +} + +.details[open] { + border-color: rgba(249, 217, 94, 0.5); +} + +.summary { + cursor: pointer; + color: rgba(220, 220, 220, 0.5); + transition: color 0.2s ease; +} + +.details[open] > .summary { + color: rgba(249, 217, 94, 0.5); +} + +.summary:hover { + color: #f9d95e; +} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index fb35bf4bd..6ee4241ab 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -2,10 +2,13 @@ import { SKIP, visit } from 'unist-util-visit' import { parseEmbedUrl, parseInternalLinks } from './url' import { slug } from 'github-slugger' import { toString } from 'mdast-util-to-string' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { toHast } from 'mdast-util-to-hast' +import { gfm } from 'micromark-extension-gfm' +import { gfmFromMarkdown } from 'mdast-util-gfm' 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 @@ -26,7 +29,6 @@ export default function rehypeSN (options = {}) { 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', @@ -36,7 +38,6 @@ export default function rehypeSN (options = {}) { }, children: [{ type: 'text', value: nodeText }] } - // Replace the heading's children with the new link element node.children = [linkElement] return [SKIP] @@ -70,8 +71,8 @@ export default function rehypeSN (options = {}) { // 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) { + !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' @@ -99,8 +100,8 @@ export default function rehypeSN (options = {}) { // handle @__username__ or ~__sub__ if (['@', '~'].includes(node.value) && - parent.children[index + 1]?.tagName === 'strong' && - parent.children[index + 1].children[0]?.type === 'text') { + parent.children[index + 1]?.tagName === 'strong' && + parent.children[index + 1].children[0]?.type === 'text') { childrenConsumed = 2 text = node.value + '__' + toString(parent.children[index + 1]) + '__' } @@ -143,7 +144,6 @@ export default function rehypeSN (options = {}) { } newChildren.push(replaceNostrId(match[0], match[0])) - lastIndex = nostrIdRegex.lastIndex } @@ -159,6 +159,7 @@ export default function rehypeSN (options = {}) { // handle custom tags if (node.type === 'element') { + // Existing stylers handling 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) @@ -214,64 +215,298 @@ export default function rehypeSN (options = {}) { return index + 1 } } + + // Handle details/summary tags + if (node.type === 'raw' && node.value.includes('
')) { + const detailsContent = { + summary: { + content: [], + found: false, + complete: false + }, + content: [], + startIndex: index, + endIndex: index + } + + // Handle self-contained details block + if (node.value.includes('
')) { + let content = node.value + + // Extract summary if present + const summaryMatch = content.match(/(.*?)<\/summary>/s) + if (summaryMatch) { + detailsContent.summary.content.push({ + type: 'text', + value: summaryMatch[1].trim() + }) + detailsContent.summary.complete = true + content = content.replace(/.*?<\/summary>/s, '') + } + + // Clean remaining content + const cleanedContent = content + .replace(/
/g, '') + .replace(/<\/details>/g, '') + .trim() + + if (cleanedContent) { + detailsContent.content.push({ + type: 'text', + value: cleanedContent + }) + } + + return createDetailsElement(detailsContent, parent, index) + } + + // Clean opening details tag and handle potential summary + let cleanedContent = node.value.replace(/
/g, '') + + // Check for summary in opening node + const summaryMatch = cleanedContent.match(/(.*?)<\/summary>/s) + if (summaryMatch) { + detailsContent.summary.content.push({ + type: 'text', + value: summaryMatch[1].trim() + }) + detailsContent.summary.complete = true + cleanedContent = cleanedContent.replace(/.*?<\/summary>/s, '') + } + + if (cleanedContent.trim()) { + detailsContent.content.push({ + type: 'text', + value: cleanedContent.trim() + }) + } + + // Collect remaining content + let currentIndex = index + let foundClosing = false + + while (currentIndex < parent.children.length) { + const currentNode = parent.children[++currentIndex] + if (!currentNode) break + + // Handle summary tags if we haven't found a complete summary yet + if (!detailsContent.summary.complete) { + if (currentNode.type === 'raw' && currentNode.value.includes('')) { + detailsContent.summary.found = true + const summaryMatch = currentNode.value.match(/(.*?)<\/summary>/s) + if (summaryMatch) { + // Keep any text that appears before the summary tag + const beforeSummary = currentNode.value.substring(0, currentNode.value.indexOf('')).trim() + if (beforeSummary) { + detailsContent.content.push({ + type: 'text', + value: beforeSummary + }) + } + + // Complete summary found in one node + detailsContent.summary.content.push({ + type: 'text', + value: summaryMatch[1].trim() + }) + detailsContent.summary.complete = true + + // Preserve text after the closing summary tag + const afterSummary = currentNode.value.substring( + currentNode.value.indexOf('') + ''.length + ).trim() + + if (afterSummary) { + detailsContent.content.push({ + type: 'text', + value: afterSummary + }) + } + continue + } + // If no match, it means the summary continues in next nodes + const afterOpen = currentNode.value.replace(//g, '').trim() + if (afterOpen) { + detailsContent.summary.content.push({ + type: 'text', + value: afterOpen + }) + } + continue + } + + // If we're collecting summary content + if (detailsContent.summary.found) { + if (currentNode.type === 'raw' && currentNode.value.includes('')) { + const beforeClose = currentNode.value.replace(/<\/summary>/g, '').trim() + if (beforeClose) { + detailsContent.summary.content.push({ + type: 'text', + value: beforeClose + }) + } + detailsContent.summary.complete = true + continue + } + // Add to summary content + if (currentNode.type === 'text' || currentNode.type === 'element') { + detailsContent.summary.content.push(currentNode) + continue + } + } + } + + // Check for closing details tag + const hasClosingTag = (currentNode.type === 'raw' && currentNode.value.includes('
')) || + (currentNode.type === 'element' && toString(currentNode).includes('
')) + + if (hasClosingTag) { + if (currentNode.type === 'raw') { + const textBeforeClosing = currentNode.value.substring(0, currentNode.value.indexOf('')) + if (textBeforeClosing.includes('\n')) { + // Parse as markdown + const mdast = fromMarkdown(textBeforeClosing, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }) + // Convert to hast + const hast = toHast(mdast) + // Add all children from the parsed content + if (hast && hast.children) { + detailsContent.content.push(...hast.children) + } + } else { + // Single line, keep as text node + if (textBeforeClosing.trim()) { + detailsContent.content.push({ + type: 'text', + value: textBeforeClosing.trim() + }) + } + } + } else { + // Handle element nodes similarly + const content = toString(currentNode).replace(/<\/details>/g, '') + if (content.trim()) { + const mdast = fromMarkdown(content, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }) + const hast = toHast(mdast) + if (hast && hast.children) { + detailsContent.content.push(...hast.children) + } + } + } + + detailsContent.endIndex = currentIndex + foundClosing = true + break + } + + // Add to main content if not part of summary + if (currentNode.type === 'text' || currentNode.type === 'element') { + detailsContent.content.push(currentNode) + } + } + + if (!foundClosing) { + return SKIP + } + + return createDetailsElement(detailsContent, parent, index) + } }) + + return tree } catch (error) { console.error('Error in rehypeSN transformer:', error) + return tree } - - 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 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 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 replaceSub (value, sub) { - return { - type: 'element', - tagName: 'sub', - properties: { href: '/~' + sub, name: sub }, - children: [{ type: 'text', value }] - } +function replaceNostrId (value, id) { + return { + type: 'element', + tagName: 'a', + properties: { href: `https://njump.me/${id}` }, + children: [{ type: 'text', value }] } +} - function isMisleadingLink (text, href) { - let misleading = false +function isMisleadingLink (text, href) { + let misleading = false - if (/^\s*(\w+\.)+\w+/.test(text)) { - try { - const hrefUrl = new URL(href) + if (/^\s*(\w+\.)+\w+/.test(text)) { + try { + const hrefUrl = new URL(href) + if (new URL(hrefUrl.protocol + text).origin !== hrefUrl.origin) { + misleading = true + } + } catch {} + } - if (new URL(hrefUrl.protocol + text).origin !== hrefUrl.origin) { - misleading = true - } - } catch {} - } + return misleading +} - return misleading +// Helper to create details element +function createDetailsElement (detailsContent, parent, index) { + const detailsElement = { + type: 'element', + tagName: 'details', + properties: {}, + children: [] } - function replaceNostrId (value, id) { - return { + // Add summary if found + if (detailsContent.summary.complete) { + const summaryElement = { type: 'element', - tagName: 'a', - properties: { href: `https://njump.me/${id}` }, - children: [{ type: 'text', value }] + tagName: 'summary', + properties: {}, + children: detailsContent.summary.content } + detailsElement.children.push(summaryElement) } + + // Add main content + detailsElement.children.push(...detailsContent.content) + + // Replace nodes + parent.children.splice( + detailsContent.startIndex, + detailsContent.endIndex - detailsContent.startIndex + 1, + detailsElement + ) + + return [SKIP, detailsContent.endIndex] } diff --git a/test-cases.md b/test-cases.md new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/test-cases.md @@ -0,0 +1 @@ + \ No newline at end of file