diff --git a/components/text.js b/components/text.js index 48a17a3c8..c50924178 100644 --- a/components/text.js +++ b/components/text.js @@ -138,7 +138,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() @@ -306,3 +308,19 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr ) } + +function Details ({ children, ...props }) { + return ( +
+ {children} +
+ ) +} + +function Summary ({ children, ...props }) { + return ( + + {children} + + ) +} diff --git a/components/text.module.css b/components/text.module.css index 86e3f1e32..25eef347b 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -436,4 +436,31 @@ max-width: 480px; border-radius: 13px; overflow: hidden; +} + +/* Details/Summary styling */ +.details { + border: 1px solid var(--theme-borderColor); + border-radius: 4px; + padding: 1rem; + margin: calc(var(--grid-gap) * 0.5) 0; + transition: all 0.2s ease; +} + +.details[open] { + border-color: var(--bs-body-color); +} + +.summary { + cursor: pointer; + color: var(--theme-borderColor); + transition: color 0.2s ease; +} + +.details[open] > .summary { + color: var(--bs-body-color); +} + +.summary:hover { + color: var(--bs-body-color); } \ No newline at end of file diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index d65b3f7c0..8c2d6bb0b 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -1,5 +1,6 @@ import { SKIP, visit } from 'unist-util-visit' import { parseEmbedUrl, parseInternalLinks, isMisleadingLink } from './url' + import { slug } from 'github-slugger' import { toString } from 'mdast-util-to-string' @@ -15,7 +16,188 @@ export default function rehypeSN (options = {}) { return function transformer (tree) { try { + // details node state + let detailsNode = null + let nodesToRemove = [] + let startIndex = null + let currentParent = null + visit(tree, (node, index, parent) => { + // handle details and summary tags + if (parent && parent !== tree) { + // Only process top-level nodes: avoid picking up nodes multiple times due to visiting descendants + return + } + + // Start of details section + if (node.type === 'raw' && node.value.includes('
')) { + // Handle case where opening and closing tags are in same node + if (node.value.includes('
')) { + const [before, ...rest] = node.value.split('
') + const [content, ...after] = rest.join('
').split('
') + + // Create the details node + const newDetailsNode = { + type: 'element', + tagName: 'details', + properties: {}, + children: [] + } + + // Process content for summary and remaining content + const { summary, rest: remainingContent } = extractSummary(content) + + // Add summary if found + if (summary) { + newDetailsNode.children.push(createSummaryNode(summary)) + } + + // Add remaining content + if (remainingContent) { + newDetailsNode.children.push({ + type: 'text', + value: remainingContent + }) + } + + // Replace the current node with: before + details + after + const replacementNodes = [] + + if (before.trim()) { + replacementNodes.push({ + type: 'text', + value: before.trim() + }) + } + + replacementNodes.push(newDetailsNode) + + if (after.join('
').trim()) { + replacementNodes.push({ + type: 'text', + value: after.join('').trim() + }) + } + + parent.children.splice(index, 1, ...replacementNodes) + return SKIP + } + + // Start collecting nodes for a new details section + detailsNode = { + type: 'element', + tagName: 'details', + properties: {}, + children: [] + } + startIndex = index + currentParent = parent + nodesToRemove = [node] + + // Handle any content after the opening tag + const [, ...rest] = node.value.split('
') + const afterTag = rest.join('
') + if (afterTag.trim()) { + // Check for summary in the opening tag content + const { summary, rest: remainingContent } = extractSummary(afterTag) + + if (summary) { + detailsNode.children.push(createSummaryNode(summary)) + } + + if (remainingContent) { + detailsNode.children.push({ + type: 'text', + value: remainingContent + }) + } + } + + return + } + + // End of details section + if (detailsNode && node.type === 'raw' && node.value.includes('
')) { + const [beforeClose, ...rest] = node.value.split('
') + + if (beforeClose.trim()) { + // Check for summary in the closing content if no summary exists yet + if (!detailsNode.children.some(child => child.tagName === 'summary')) { + const { summary, rest: remainingContent } = extractSummary(beforeClose) + + if (summary) { + detailsNode.children.unshift(createSummaryNode(summary)) + } + + if (remainingContent) { + detailsNode.children.push({ + type: 'text', + value: remainingContent + }) + } + } else { + // No summary needed, just add content + detailsNode.children.push({ + type: 'text', + value: beforeClose.trim() + }) + } + } + + nodesToRemove.push(node) + + // Replace all collected nodes with the details node + currentParent.children.splice(startIndex, nodesToRemove.length, detailsNode) + + // Add any remaining content after the closing tag + if (rest.length && rest.join('').trim()) { + currentParent.children.splice(startIndex + 1, 0, { + type: 'text', + value: rest.join('').trim() + }) + } + + // Reset collection state + detailsNode = null + nodesToRemove = [] + startIndex = null + currentParent = null + + return SKIP + } + + // Collect nodes between details tags + if (detailsNode) { + if (node.type === 'raw' && node.value.includes('')) { + // Process summary node + const { summary, rest: remainingContent } = extractSummary(node.value) + + if (summary) { + // Add summary to start of details if none exists yet + if (!detailsNode.children.some(child => child.tagName === 'summary')) { + detailsNode.children.unshift(createSummaryNode(summary)) + } + + // Add remaining content if any + if (remainingContent) { + detailsNode.children.push({ + type: 'text', + value: remainingContent + }) + } + } else { + // No summary found, add as normal node + detailsNode.children.push(node) + } + } else { + // Not a summary node, add as normal + detailsNode.children.push(node) + } + + nodesToRemove.push(node) + return SKIP + } + if (parent?.tagName === 'code') { // don't process code blocks return @@ -135,7 +317,6 @@ export default function rehypeSN (options = {}) { return index + newChildren.length } } - // Handle Nostr IDs if (node.type === 'text') { const newChildren = [] @@ -161,7 +342,6 @@ export default function rehypeSN (options = {}) { return index + newChildren.length } } - // handle custom tags if (node.type === 'element') { for (const { startTag, endTag, className } of stylers) { @@ -182,7 +362,6 @@ export default function rehypeSN (options = {}) { } } } - // merge adjacent images and empty paragraphs into a single image collage if ((node.tagName === 'img' || isImageOnlyParagraph(node)) && Array.isArray(parent.children)) { const adjacentNodes = [node] @@ -226,7 +405,6 @@ export default function rehypeSN (options = {}) { return tree } - function isImageOnlyParagraph (node) { return node && node.tagName === 'p' && @@ -263,4 +441,29 @@ export default function rehypeSN (options = {}) { children: [{ type: 'text', value }] } } + // Helper function to create a summary node + function createSummaryNode (content) { + return { + type: 'element', + tagName: 'summary', + properties: {}, + children: [{ + type: 'text', + value: content.trim() + }] + } + } + + // Helper function to extract summary content + function extractSummary (content) { + if (!content.includes('')) return { summary: null, rest: content } + + const [before, ...afterOpen] = content.split('') + const [summaryContent, ...afterClose] = afterOpen.join('').split('') + + return { + summary: summaryContent.trim(), + rest: (before + afterClose.join('')).trim() + } + } }