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