Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Details & summary tag support #1767

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion components/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child
return <Link id={props.id} target='_blank' rel={rel} href={href}>{children}</Link>
},
img: TextMediaOrLink,
embed: Embed
embed: Embed,
details: Details,
summary: Summary
}), [outlawed, rel, TextMediaOrLink, topLevel])

const carousel = useCarousel()
Expand Down Expand Up @@ -306,3 +308,19 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr
</div>
)
}

function Details ({ children, ...props }) {
return (
<details className={styles.details} {...props}>
{children}
</details>
)
}

function Summary ({ children, ...props }) {
return (
<summary className={styles.summary} {...props}>
{children}
</summary>
)
}
27 changes: 27 additions & 0 deletions components/text.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
211 changes: 207 additions & 4 deletions lib/rehype-sn.js
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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('<details>')) {
// Handle case where opening and closing tags are in same node
if (node.value.includes('</details>')) {
const [before, ...rest] = node.value.split('<details>')
const [content, ...after] = rest.join('<details>').split('</details>')

// 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('</details>').trim()) {
replacementNodes.push({
type: 'text',
value: after.join('</details>').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('<details>')
const afterTag = rest.join('<details>')
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('</details>')) {
const [beforeClose, ...rest] = node.value.split('</details>')

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('</details>').trim()) {
currentParent.children.splice(startIndex + 1, 0, {
type: 'text',
value: rest.join('</details>').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('<summary>')) {
// 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
Expand Down Expand Up @@ -135,7 +317,6 @@ export default function rehypeSN (options = {}) {
return index + newChildren.length
}
}

// Handle Nostr IDs
if (node.type === 'text') {
const newChildren = []
Expand All @@ -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) {
Expand All @@ -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]
Expand Down Expand Up @@ -226,7 +405,6 @@ export default function rehypeSN (options = {}) {

return tree
}

function isImageOnlyParagraph (node) {
return node &&
node.tagName === 'p' &&
Expand Down Expand Up @@ -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('<summary>')) return { summary: null, rest: content }

const [before, ...afterOpen] = content.split('<summary>')
const [summaryContent, ...afterClose] = afterOpen.join('<summary>').split('</summary>')

return {
summary: summaryContent.trim(),
rest: (before + afterClose.join('</summary>')).trim()
}
}
}