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()
+ }
+ }
}