From 2d874af911f19eb299563e34bc7eecb91cb99c0a Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Sun, 27 Oct 2024 14:31:41 -0400 Subject: [PATCH 01/23] Working summary-details, todo cleanup, newline support & generallayout fixes --- components/text.module.css | 68 ++++++++++++++++++- lib/rehype-sn.js | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) diff --git a/components/text.module.css b/components/text.module.css index ff99b8a90..dac615533 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -432,4 +432,70 @@ max-width: 480px; border-radius: 13px; overflow: hidden; -} \ No newline at end of file +} + +/* Details/summary styling */ + +.text details { + background-color: var(--theme-bg); + border: 1px solid var(--theme-borderColor); + border-radius: 0.4rem; + margin: calc(var(--grid-gap) * 0.5) 0; + overflow: hidden; +} + +.text details[open] { + border-color: var(--bs-primary); +} + +.text details > summary { + padding: 0.75rem 1rem; + cursor: pointer; + user-select: none; + font-weight: 500; + position: relative; +} + +.text details > summary:hover { + background-color: var(--theme-commentBg); +} + +.text details > summary::before { + content: 'ā¶'; + font-size: 0.8em; + margin-right: 0.75rem; + color: var(--bs-primary); + transition: transform 0.2s ease; +} + +.text details[open] > summary::before { + transform: rotate(90deg); +} + +.text details > *:not(summary) { + padding: 0.75rem 1rem; + color: var(--theme-grey); +} + +.text details > summary + * { + padding-top: 0; +} + +.text details > *:last-child { + padding-bottom: 1rem; +} + +.text details .details-content { + padding: 0.75rem 1rem; + color: var(--theme-grey); +} + +.text details[open] > .details-content { + border-top: 1px solid rgba(var(--bs-primary-rgb), 0.2); +} + +.text details .details-content > br { + display: block; + content: ""; + margin-top: 0.5rem; +} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index fb35bf4bd..b7f1dbd94 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -16,6 +16,62 @@ export default function rehypeSN (options = {}) { return function transformer (tree) { try { visit(tree, (node, index, parent) => { + + // Handle raw HTML content that might be details/summary tags + if (node.type === 'raw') { + const value = node.value.trim() + + // Check if this is a details tag (single-line or multi-line) + if (value.includes('<details>')) { + const detailsNode = { + type: 'element', + tagName: 'details', + properties: { + className: ['collapsable-details'] + }, + children: [] + } + + // Extract content between details tags + const detailsContent = value.replace(/<\/?details>/g, '').trim() + + // Check for summary tag + const summaryMatch = detailsContent.match(/<summary>([\s\S]*?)<\/summary>([\s\S]*)/) + if (summaryMatch) { + // Add summary element + detailsNode.children.push({ + type: 'element', + tagName: 'summary', + properties: { + className: ['collapsable-summary'] + }, + children: [{ + type: 'text', + value: summaryMatch[1].trim() + }] + }) + + // Add remaining content + if (summaryMatch[2].trim()) { + detailsNode.children.push({ + type: 'text', + value: summaryMatch[2].trim() + }) + } + } else { + // No summary found, add all content + detailsNode.children.push({ + type: 'text', + value: detailsContent + }) + } + + // Replace the raw node with our new details element + parent.children[index] = detailsNode + return [SKIP] + } + } + // Handle inline code property if (node.tagName === 'code') { node.properties.inline = !(parent && parent.tagName === 'pre') @@ -159,6 +215,85 @@ export default function rehypeSN (options = {}) { // handle custom tags if (node.type === 'element') { + // Handle details/summary tags + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i] + + console.log('Checking child:', child?.type, child?.value?.trim()) + + // Convert raw <details> tags into elements + if (child?.type === 'raw' && child.value.trim() === '<details>') { + console.log('Found details tag at index:', i) + const detailsNode = { + type: 'element', + tagName: 'details', + properties: { + className: [] // Initialize with empty array + }, + children: [] + } + + // Collect children until we find the closing tag + let j = i + 1 + while (j < node.children.length) { + const current = node.children[j] + if (current?.type === 'raw' && current.value.trim() === '</details>') { + break + } + detailsNode.children.push(current) + j++ + } + + // Add default summary if none exists + if (!detailsNode.children.some(c => + (c.type === 'raw' && c.value.includes('<summary>')) || + (c.type === 'element' && c.tagName === 'summary') + )) { + detailsNode.children.unshift({ + type: 'element', + tagName: 'summary', + properties: { + className: [] // Initialize with empty array + }, + children: [{ type: 'text', value: 'Details' }] + }) + } + + // Convert any raw summary tags in the children + for (let k = 0; k < detailsNode.children.length; k++) { + const child = detailsNode.children[k] + if (child?.type === 'raw' && child.value.trim() === '<summary>') { + const summaryNode = { + type: 'element', + tagName: 'summary', + properties: { + className: [] // Initialize with empty array + }, + children: [] + } + + // Collect summary content + let l = k + 1 + while (l < detailsNode.children.length) { + const current = detailsNode.children[l] + if (current?.type === 'raw' && current.value.trim() === '</summary>') { + break + } + summaryNode.children.push(current) + l++ + } + + // Replace the raw tags and content with the summary element + detailsNode.children.splice(k, l - k + 1, summaryNode) + } + } + + // Replace the original content with our new details element + node.children.splice(i, j - i + 1, detailsNode) + } + } + + // 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) From dcb4e063d4d2f3bdcf0cc53686fbaa5899a1127d Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Fri, 1 Nov 2024 18:58:13 -0400 Subject: [PATCH 02/23] pre-unist-visit review and tweaks --- components/text.js | 3 +++ lib/rehype-sn.js | 45 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/components/text.js b/components/text.js index fe70f0a22..913d8f364 100644 --- a/components/text.js +++ b/components/text.js @@ -246,3 +246,6 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr </div> ) } + + + diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index b7f1dbd94..618351740 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -1,3 +1,32 @@ +/* Example of details/summary tags structures that must work as expected: + +<details> + <summary>like this</summary> + +lorem ipsum placeholder text +blahblahblah +</details> + +<details><summary>THE SUMMARY</summary>TESTING INSIDE DETAILS</details> + +<details> +<summary>THE SUMMARY +summary second line</summary> +1. first things first +2. second thing to note +3. third thing +</details> + +<details> + <summary> + THE SUMMARY + </summary> + TESTING INSIDE DETAILS +</details> +*/ + + + import { SKIP, visit } from 'unist-util-visit' import { parseEmbedUrl, parseInternalLinks } from './url' import { slug } from 'github-slugger' @@ -18,11 +47,18 @@ export default function rehypeSN (options = {}) { visit(tree, (node, index, parent) => { // Handle raw HTML content that might be details/summary tags + console.log("tree:", tree) + console.log("node:", node) + console.log("index:", index) + console.log("parent:", parent) + if (node.type === 'raw') { + console.log('Checking raw node (trimmed):', node.value.trim()) const value = node.value.trim() // Check if this is a details tag (single-line or multi-line) if (value.includes('<details>')) { + console.log('Found details tag') const detailsNode = { type: 'element', tagName: 'details', @@ -34,10 +70,12 @@ export default function rehypeSN (options = {}) { // Extract content between details tags const detailsContent = value.replace(/<\/?details>/g, '').trim() + console.log('Details content:', detailsContent) // Check for summary tag const summaryMatch = detailsContent.match(/<summary>([\s\S]*?)<\/summary>([\s\S]*)/) if (summaryMatch) { + console.log('Found summary tag') // Add summary element detailsNode.children.push({ type: 'element', @@ -50,8 +88,9 @@ export default function rehypeSN (options = {}) { value: summaryMatch[1].trim() }] }) - + // Add remaining content + console.log('Adding remaining content:', summaryMatch[2].trim()) if (summaryMatch[2].trim()) { detailsNode.children.push({ type: 'text', @@ -60,6 +99,7 @@ export default function rehypeSN (options = {}) { } } else { // No summary found, add all content + console.log('Adding all content (no summary found, adding rest of content):', detailsContent) detailsNode.children.push({ type: 'text', value: detailsContent @@ -67,6 +107,9 @@ export default function rehypeSN (options = {}) { } // Replace the raw node with our new details element + console.log('Replacing raw node with details element') + console.log("raw now:", parent.children[index]) + console.log("detailsNode (raw node will be replaced with this):", detailsNode) parent.children[index] = detailsNode return [SKIP] } From b3547d1cd7156f9046d2f8c6fd46ebe09ca204ae Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Sat, 9 Nov 2024 20:52:39 -0500 Subject: [PATCH 03/23] details & summary tags w functional inner markdown --- components/text.js | 19 ++- components/text.module.css | 66 ----------- lib/rehype-sn.js | 233 +++++++++---------------------------- 3 files changed, 74 insertions(+), 244 deletions(-) diff --git a/components/text.js b/components/text.js index 913d8f364..7d2c135bc 100644 --- a/components/text.js +++ b/components/text.js @@ -123,7 +123,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() @@ -247,5 +249,18 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr ) } +function Summary({ children }) { + return ( + <summary className={styles.summary}> + {children} + </summary> + ) +} - +function Details({ children }) { + return ( + <details className={styles.details}> + {children} + </details> + ) +} \ No newline at end of file diff --git a/components/text.module.css b/components/text.module.css index dac615533..a8a3c7bcd 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -433,69 +433,3 @@ border-radius: 13px; overflow: hidden; } - -/* Details/summary styling */ - -.text details { - background-color: var(--theme-bg); - border: 1px solid var(--theme-borderColor); - border-radius: 0.4rem; - margin: calc(var(--grid-gap) * 0.5) 0; - overflow: hidden; -} - -.text details[open] { - border-color: var(--bs-primary); -} - -.text details > summary { - padding: 0.75rem 1rem; - cursor: pointer; - user-select: none; - font-weight: 500; - position: relative; -} - -.text details > summary:hover { - background-color: var(--theme-commentBg); -} - -.text details > summary::before { - content: 'ā¶'; - font-size: 0.8em; - margin-right: 0.75rem; - color: var(--bs-primary); - transition: transform 0.2s ease; -} - -.text details[open] > summary::before { - transform: rotate(90deg); -} - -.text details > *:not(summary) { - padding: 0.75rem 1rem; - color: var(--theme-grey); -} - -.text details > summary + * { - padding-top: 0; -} - -.text details > *:last-child { - padding-bottom: 1rem; -} - -.text details .details-content { - padding: 0.75rem 1rem; - color: var(--theme-grey); -} - -.text details[open] > .details-content { - border-top: 1px solid rgba(var(--bs-primary-rgb), 0.2); -} - -.text details .details-content > br { - display: block; - content: ""; - margin-top: 0.5rem; -} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 618351740..3f248a63b 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -1,36 +1,9 @@ -/* Example of details/summary tags structures that must work as expected: - -<details> - <summary>like this</summary> - -lorem ipsum placeholder text -blahblahblah -</details> - -<details><summary>THE SUMMARY</summary>TESTING INSIDE DETAILS</details> - -<details> -<summary>THE SUMMARY -summary second line</summary> -1. first things first -2. second thing to note -3. third thing -</details> - -<details> - <summary> - THE SUMMARY - </summary> - TESTING INSIDE DETAILS -</details> -*/ - - - 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' const userGroup = '[\\w_]+' const subGroup = '[A-Za-z][\\w_]+' @@ -45,75 +18,6 @@ export default function rehypeSN (options = {}) { return function transformer (tree) { try { visit(tree, (node, index, parent) => { - - // Handle raw HTML content that might be details/summary tags - console.log("tree:", tree) - console.log("node:", node) - console.log("index:", index) - console.log("parent:", parent) - - if (node.type === 'raw') { - console.log('Checking raw node (trimmed):', node.value.trim()) - const value = node.value.trim() - - // Check if this is a details tag (single-line or multi-line) - if (value.includes('<details>')) { - console.log('Found details tag') - const detailsNode = { - type: 'element', - tagName: 'details', - properties: { - className: ['collapsable-details'] - }, - children: [] - } - - // Extract content between details tags - const detailsContent = value.replace(/<\/?details>/g, '').trim() - console.log('Details content:', detailsContent) - - // Check for summary tag - const summaryMatch = detailsContent.match(/<summary>([\s\S]*?)<\/summary>([\s\S]*)/) - if (summaryMatch) { - console.log('Found summary tag') - // Add summary element - detailsNode.children.push({ - type: 'element', - tagName: 'summary', - properties: { - className: ['collapsable-summary'] - }, - children: [{ - type: 'text', - value: summaryMatch[1].trim() - }] - }) - - // Add remaining content - console.log('Adding remaining content:', summaryMatch[2].trim()) - if (summaryMatch[2].trim()) { - detailsNode.children.push({ - type: 'text', - value: summaryMatch[2].trim() - }) - } - } else { - // No summary found, add all content - console.log('Adding all content (no summary found, adding rest of content):', detailsContent) - detailsNode.children.push({ - type: 'text', - value: detailsContent - }) - } - - // Replace the raw node with our new details element - console.log('Replacing raw node with details element') - console.log("raw now:", parent.children[index]) - console.log("detailsNode (raw node will be replaced with this):", detailsNode) - parent.children[index] = detailsNode - return [SKIP] - } - } // Handle inline code property if (node.tagName === 'code') { @@ -258,84 +162,6 @@ export default function rehypeSN (options = {}) { // handle custom tags if (node.type === 'element') { - // Handle details/summary tags - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i] - - console.log('Checking child:', child?.type, child?.value?.trim()) - - // Convert raw <details> tags into elements - if (child?.type === 'raw' && child.value.trim() === '<details>') { - console.log('Found details tag at index:', i) - const detailsNode = { - type: 'element', - tagName: 'details', - properties: { - className: [] // Initialize with empty array - }, - children: [] - } - - // Collect children until we find the closing tag - let j = i + 1 - while (j < node.children.length) { - const current = node.children[j] - if (current?.type === 'raw' && current.value.trim() === '</details>') { - break - } - detailsNode.children.push(current) - j++ - } - - // Add default summary if none exists - if (!detailsNode.children.some(c => - (c.type === 'raw' && c.value.includes('<summary>')) || - (c.type === 'element' && c.tagName === 'summary') - )) { - detailsNode.children.unshift({ - type: 'element', - tagName: 'summary', - properties: { - className: [] // Initialize with empty array - }, - children: [{ type: 'text', value: 'Details' }] - }) - } - - // Convert any raw summary tags in the children - for (let k = 0; k < detailsNode.children.length; k++) { - const child = detailsNode.children[k] - if (child?.type === 'raw' && child.value.trim() === '<summary>') { - const summaryNode = { - type: 'element', - tagName: 'summary', - properties: { - className: [] // Initialize with empty array - }, - children: [] - } - - // Collect summary content - let l = k + 1 - while (l < detailsNode.children.length) { - const current = detailsNode.children[l] - if (current?.type === 'raw' && current.value.trim() === '</summary>') { - break - } - summaryNode.children.push(current) - l++ - } - - // Replace the raw tags and content with the summary element - detailsNode.children.splice(k, l - k + 1, summaryNode) - } - } - - // Replace the original content with our new details element - node.children.splice(i, j - i + 1, detailsNode) - } - } - // Existing stylers handling for (const { startTag, endTag, className } of stylers) { for (let i = 0; i < node.children.length - 2; i++) { @@ -392,6 +218,61 @@ export default function rehypeSN (options = {}) { return index + 1 } } + + // Handle raw HTML content for details/summary tags + if (node.type === 'raw') { + const value = node.value.trim() + + if (value.includes('<details>')) { + // Step 1: Extract everything between details tags + const detailsRegex = /<details>([\s\S]*?)<\/details>/ + const detailsMatch = value.match(detailsRegex) + if (!detailsMatch) return + + const fullContent = detailsMatch[1] + + // Step 2: Extract summary content + const summaryRegex = /<summary>([\s\S]*?)<\/summary>/ + const summaryMatch = fullContent.match(summaryRegex) + if (!summaryMatch) return + + const summaryContent = summaryMatch[1].trim() + + // Step 3: Get remaining content + const remainingContent = fullContent + .replace(summaryMatch[0], '') // Remove summary tag and its content + .trim() + + // Step 4: Process remaining content as markdown + const mdast = fromMarkdown(remainingContent) + const contentHast = toHast(mdast) + + // Step 5: Construct new node structure with proper HAST nodes + const detailsNode = { + type: 'element', + tagName: 'details', + properties: {}, + children: [ + // Summary node + { + type: 'element', + tagName: 'summary', + properties: {}, + children: [{ + type: 'text', + value: summaryContent + }] + }, + // Content node(s) - spread all children from processed markdown + ...(contentHast?.children || []) + ] + } + + // Step 6: Replace original node with our new structure + parent.children[index] = detailsNode + return [SKIP] + } + } }) } catch (error) { console.error('Error in rehypeSN transformer:', error) From 004c9477d50e6dbfcecee29e8562b510ab180043 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Sat, 9 Nov 2024 21:25:11 -0500 Subject: [PATCH 04/23] Clean up comments and improve readability --- lib/rehype-sn.js | 55 ++++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 3f248a63b..5e4fd503f 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -11,6 +11,8 @@ 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 +const detailsRegex = /<details>([\s\S]*?)<\/details>/ +const summaryRegex = /<summary>([\s\S]*?)<\/summary>/ export default function rehypeSN (options = {}) { const { stylers = [] } = options @@ -219,56 +221,55 @@ export default function rehypeSN (options = {}) { } } - // Handle raw HTML content for details/summary tags + // Handle details/summary tags if (node.type === 'raw') { const value = node.value.trim() if (value.includes('<details>')) { - // Step 1: Extract everything between details tags - const detailsRegex = /<details>([\s\S]*?)<\/details>/ + // Extract content between details tags const detailsMatch = value.match(detailsRegex) if (!detailsMatch) return const fullContent = detailsMatch[1] - // Step 2: Extract summary content - const summaryRegex = /<summary>([\s\S]*?)<\/summary>/ + // Try to extract summary content if it exists const summaryMatch = fullContent.match(summaryRegex) - if (!summaryMatch) return + const summaryContent = summaryMatch + ? summaryMatch[1].trim() + : 'Details' // Default summary text if none provided - const summaryContent = summaryMatch[1].trim() + // Get remaining content, accounting for optional summary + const remainingContent = summaryMatch + ? fullContent.replace(summaryMatch[0], '').trim() + : fullContent.trim() - // Step 3: Get remaining content - const remainingContent = fullContent - .replace(summaryMatch[0], '') // Remove summary tag and its content - .trim() - - // Step 4: Process remaining content as markdown + // Convert markdown content to HTML AST nodes + // This allows proper rendering of markdown syntax like + // **bold**, *italic*, lists, etc. within details tags const mdast = fromMarkdown(remainingContent) const contentHast = toHast(mdast) - // Step 5: Construct new node structure with proper HAST nodes + const summaryNode = { + type: 'element', + tagName: 'summary', + properties: {}, + children: [{ + type: 'text', + value: summaryContent + }] + } + const detailsNode = { type: 'element', tagName: 'details', properties: {}, children: [ - // Summary node - { - type: 'element', - tagName: 'summary', - properties: {}, - children: [{ - type: 'text', - value: summaryContent - }] - }, - // Content node(s) - spread all children from processed markdown - ...(contentHast?.children || []) + summaryNode, + ...(contentHast?.children ?? []) ] } - // Step 6: Replace original node with our new structure + // Replace original node with new structure parent.children[index] = detailsNode return [SKIP] } From 71db2f9b9f48c96d8682696e652adabac3d0ec3b Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Sat, 9 Nov 2024 22:53:38 -0500 Subject: [PATCH 05/23] Lint fixes --- components/text.js | 12 ++++++------ lib/rehype-sn.js | 24 ++++++++++-------------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/components/text.js b/components/text.js index 7d2c135bc..b4683ad76 100644 --- a/components/text.js +++ b/components/text.js @@ -249,7 +249,7 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr ) } -function Summary({ children }) { +function Summary ({ children }) { return ( <summary className={styles.summary}> {children} @@ -257,10 +257,10 @@ function Summary({ children }) { ) } -function Details({ children }) { +function Details ({ children }) { return ( - <details className={styles.details}> - {children} - </details> + <details className={styles.details}> + {children} + </details> ) -} \ No newline at end of file +} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 5e4fd503f..73afc5445 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -20,18 +20,15 @@ export default function rehypeSN (options = {}) { return function transformer (tree) { try { visit(tree, (node, index, parent) => { - // Handle inline code property if (node.tagName === 'code') { node.properties.inline = !(parent && parent.tagName === 'pre') } - // handle headings if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) { 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', @@ -41,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] @@ -224,31 +220,31 @@ export default function rehypeSN (options = {}) { // Handle details/summary tags if (node.type === 'raw') { const value = node.value.trim() - + if (value.includes('<details>')) { - // Extract content between details tags + // Extract content between details tags const detailsMatch = value.match(detailsRegex) if (!detailsMatch) return - + const fullContent = detailsMatch[1] - + // Try to extract summary content if it exists const summaryMatch = fullContent.match(summaryRegex) - const summaryContent = summaryMatch + const summaryContent = summaryMatch ? summaryMatch[1].trim() : 'Details' // Default summary text if none provided - + // Get remaining content, accounting for optional summary const remainingContent = summaryMatch ? fullContent.replace(summaryMatch[0], '').trim() : fullContent.trim() - + // Convert markdown content to HTML AST nodes // This allows proper rendering of markdown syntax like // **bold**, *italic*, lists, etc. within details tags const mdast = fromMarkdown(remainingContent) const contentHast = toHast(mdast) - + const summaryNode = { type: 'element', tagName: 'summary', @@ -258,7 +254,7 @@ export default function rehypeSN (options = {}) { value: summaryContent }] } - + const detailsNode = { type: 'element', tagName: 'details', @@ -268,7 +264,7 @@ export default function rehypeSN (options = {}) { ...(contentHast?.children ?? []) ] } - + // Replace original node with new structure parent.children[index] = detailsNode return [SKIP] From 8d068428e71851497462e409504038cc3bcd119e Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Sun, 17 Nov 2024 02:30:12 -0500 Subject: [PATCH 06/23] Handle blank lines, prevent some nodes breaking details tag --- lib/rehype-sn.js | 320 +++++++++++++---------------------------------- 1 file changed, 86 insertions(+), 234 deletions(-) diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 73afc5445..e1a6bfec6 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -11,7 +11,7 @@ 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 -const detailsRegex = /<details>([\s\S]*?)<\/details>/ +const detailsRegex = /<details>\s*([\s\S]*?)\s*<\/details>/ const summaryRegex = /<summary>([\s\S]*?)<\/summary>/ export default function rehypeSN (options = {}) { @@ -19,254 +19,99 @@ export default function rehypeSN (options = {}) { return function transformer (tree) { try { + // First pass: find and combine split details tags + // This handles cases where blank lines cause the parser to split nodes visit(tree, (node, index, parent) => { - // Handle inline code property - if (node.tagName === 'code') { - node.properties.inline = !(parent && parent.tagName === 'pre') - } - // handle headings - if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) { - 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', - tagName: 'headlink', - properties: { - href: `#${headingId}` - }, - children: [{ type: 'text', value: nodeText }] - } - // Replace the heading's children with the new link element - node.children = [linkElement] - return [SKIP] - } - - // if img is wrapped in a link, remove the link - if (node.tagName === 'a' && node.children.length === 1 && node.children[0].tagName === 'img') { - parent.children[index] = node.children[0] - return index - } - - // handle internal links - if (node.tagName === 'a') { - try { - if (node.properties.href.includes('#itemfn-')) { - node.tagName = 'footnote' - } else { - const { itemId, linkText } = parseInternalLinks(node.properties.href) - if (itemId) { - node.tagName = 'item' - node.properties.id = itemId - if (node.properties.href === toString(node)) { - node.children[0].value = linkText - } - } - } - } catch { - // ignore errors like invalid URLs - } - } - - // 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) { - const embed = parseEmbedUrl(node.properties.href) - if (embed) { - node.tagName = 'embed' - node.properties = { ...embed, src: node.properties.href } - } else { - node.tagName = 'autolink' - } - } - - // if the link text is a URL, just show the URL - if (node.tagName === 'a' && isMisleadingLink(toString(node), node.properties.href)) { - node.children = [{ type: 'text', value: node.properties.href }] - return [SKIP] - } - - // Handle @mentions and ~subs - if (node.type === 'text') { - const newChildren = [] - let lastIndex = 0 - let match - let childrenConsumed = 1 - let text = toString(node) - - const combinedRegex = new RegExp(mentionRegex.source + '|' + subRegex.source, 'gi') - - // handle @__username__ or ~__sub__ - if (['@', '~'].includes(node.value) && - parent.children[index + 1]?.tagName === 'strong' && - parent.children[index + 1].children[0]?.type === 'text') { - childrenConsumed = 2 - text = node.value + '__' + toString(parent.children[index + 1]) + '__' - } - - while ((match = combinedRegex.exec(text)) !== null) { - if (lastIndex < match.index) { - newChildren.push({ type: 'text', value: text.slice(lastIndex, match.index) }) - } - - const [fullMatch, mentionMatch, subMatch] = match - const replacement = mentionMatch ? replaceMention(fullMatch, mentionMatch) : replaceSub(fullMatch, subMatch) - - if (replacement) { - newChildren.push(replacement) - } else { - newChildren.push({ type: 'text', value: fullMatch }) - } - - lastIndex = combinedRegex.lastIndex - } - - if (newChildren.length > 0) { - if (lastIndex < text.length) { - newChildren.push({ type: 'text', value: text.slice(lastIndex) }) - } - parent.children.splice(index, childrenConsumed, ...newChildren) - return index + newChildren.length - } - } - - // Handle Nostr IDs - if (node.type === 'text') { - const newChildren = [] - let lastIndex = 0 - let match - - while ((match = nostrIdRegex.exec(node.value)) !== null) { - if (lastIndex < match.index) { - newChildren.push({ type: 'text', value: node.value.slice(lastIndex, match.index) }) - } - - newChildren.push(replaceNostrId(match[0], match[0])) - - lastIndex = nostrIdRegex.lastIndex - } - - if (lastIndex < node.value.length) { - newChildren.push({ type: 'text', value: node.value.slice(lastIndex) }) - } - - if (newChildren.length > 0) { - parent.children.splice(index, 1, ...newChildren) - return index + newChildren.length - } - } - - // 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) - - if (start?.type === 'raw' && start?.value === startTag && - text?.type === 'text' && - end?.type === 'raw' && end?.value === endTag) { - const newChild = { - type: 'element', - tagName: 'span', - properties: { className: [className] }, - children: [{ type: 'text', value: text.value }] + if (node.type === 'raw' && parent?.children) { + // Look for details tags that are incomplete (no closing tag) + if (node.value.includes('<details') && !node.value.includes('</details>')) { + let nextIndex = index + 1 + let content = node.value + + // Keep looking through subsequent nodes until we find the closing tag + while (nextIndex < parent.children.length) { + const nextNode = parent.children[nextIndex] + + // Handle different types of content nodes + if (nextNode.type === 'raw') { + content += nextNode.value + // Found the closing tag - combine all nodes and stop + if (nextNode.value.includes('</details>')) { + node.value = content + // Remove the now-combined nodes + parent.children.splice(index + 1, nextIndex - index) + return [SKIP] } - node.children.splice(i, 3, newChild) + } else if (nextNode.type === 'text' || nextNode.type === 'element') { + // Preserve content from text nodes and elements + content += getTextContent(nextNode) } - } - } - } - - // merge adjacent images and empty paragraphs into a single image collage - if ((node.tagName === 'img' || isImageOnlyParagraph(node)) && Array.isArray(parent.children)) { - const adjacentNodes = [node] - let nextIndex = index + 1 - const siblings = parent.children - const somethingBefore = parent.children[index - 1] && parent.children[index - 1].tagName !== 'p' - let somethingAfter = false - - while (nextIndex < siblings.length) { - const nextNode = siblings[nextIndex] - if (!nextNode) break - if (nextNode.tagName === 'img' || isImageOnlyParagraph(nextNode)) { - adjacentNodes.push(nextNode) - nextIndex++ - } else if (nextNode.type === 'text' && typeof nextNode.value === 'string' && !nextNode.value.trim()) { nextIndex++ - } else { - somethingAfter = true - break - } - } - - if (adjacentNodes.length > 0) { - const allImages = adjacentNodes.flatMap(n => - n.tagName === 'img' ? [n] : (Array.isArray(n.children) ? n.children.filter(child => child.tagName === 'img') : []) - ) - const collageNode = { - type: 'element', - tagName: 'p', - children: allImages, - properties: { onlyImages: true, somethingBefore, somethingAfter } } - parent.children.splice(index, nextIndex - index, collageNode) - return index + 1 } } + }) - // Handle details/summary tags + // Second pass: process details tags into proper HTML structure + visit(tree, (node) => { if (node.type === 'raw') { const value = node.value.trim() + // Only process nodes containing complete details tags if (value.includes('<details>')) { - // Extract content between details tags - const detailsMatch = value.match(detailsRegex) - if (!detailsMatch) return - - const fullContent = detailsMatch[1] - - // Try to extract summary content if it exists - const summaryMatch = fullContent.match(summaryRegex) - const summaryContent = summaryMatch - ? summaryMatch[1].trim() - : 'Details' // Default summary text if none provided - - // Get remaining content, accounting for optional summary - const remainingContent = summaryMatch - ? fullContent.replace(summaryMatch[0], '').trim() - : fullContent.trim() - - // Convert markdown content to HTML AST nodes - // This allows proper rendering of markdown syntax like - // **bold**, *italic*, lists, etc. within details tags - const mdast = fromMarkdown(remainingContent) - const contentHast = toHast(mdast) + // Find all details blocks in the content + const detailsMatches = Array.from(value.matchAll(/<details>\s*([\s\S]*?)\s*<\/details>/g)) + if (!detailsMatches.length) return + + detailsMatches.forEach(match => { + const content = match[1] + + // Extract summary content if present, or use default + const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/) + const summaryContent = summaryMatch + ? summaryMatch[1].trim() + : 'Details' + + // Get remaining content after summary + let remainingContent = summaryMatch + ? content.replace(summaryMatch[0], '').trim() + : content.trim() + + // Normalize newlines for consistent rendering + remainingContent = remainingContent + .replace(/\r\n/g, '\n') + .replace(/\n\s*\n/g, '\n\n') + .trim() + + // Convert markdown content to HTML structure + const mdast = fromMarkdown(remainingContent) + const contentHast = toHast(mdast) + + // Create the details node structure + const summaryNode = { + type: 'element', + tagName: 'summary', + properties: {}, + children: [{ + type: 'text', + value: summaryContent + }] + } - const summaryNode = { - type: 'element', - tagName: 'summary', - properties: {}, - children: [{ - type: 'text', - value: summaryContent - }] - } + const detailsNode = { + type: 'element', + tagName: 'details', + properties: {}, + children: [ + summaryNode, + ...(contentHast?.children ?? []) + ] + } - const detailsNode = { - type: 'element', - tagName: 'details', - properties: {}, - children: [ - summaryNode, - ...(contentHast?.children ?? []) - ] - } + // Replace the original node with our structured version + Object.assign(node, detailsNode) + }) - // Replace original node with new structure - parent.children[index] = detailsNode return [SKIP] } } @@ -331,3 +176,10 @@ export default function rehypeSN (options = {}) { } } } + +// Helper function to extract text content from nodes +function getTextContent(node) { + if (node.value) return node.value + if (!node.children) return '' + return node.children.map(child => getTextContent(child)).join('') +} From 8ff93734abf351d5612fa2ac8e79f067a5ce3242 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Sun, 17 Nov 2024 02:33:03 -0500 Subject: [PATCH 07/23] Revert "Handle blank lines, prevent some nodes breaking details tag" This reverts commit 8d068428e71851497462e409504038cc3bcd119e. --- lib/rehype-sn.js | 320 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 234 insertions(+), 86 deletions(-) diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index e1a6bfec6..73afc5445 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -11,7 +11,7 @@ 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 -const detailsRegex = /<details>\s*([\s\S]*?)\s*<\/details>/ +const detailsRegex = /<details>([\s\S]*?)<\/details>/ const summaryRegex = /<summary>([\s\S]*?)<\/summary>/ export default function rehypeSN (options = {}) { @@ -19,99 +19,254 @@ export default function rehypeSN (options = {}) { return function transformer (tree) { try { - // First pass: find and combine split details tags - // This handles cases where blank lines cause the parser to split nodes visit(tree, (node, index, parent) => { - if (node.type === 'raw' && parent?.children) { - // Look for details tags that are incomplete (no closing tag) - if (node.value.includes('<details') && !node.value.includes('</details>')) { - let nextIndex = index + 1 - let content = node.value - - // Keep looking through subsequent nodes until we find the closing tag - while (nextIndex < parent.children.length) { - const nextNode = parent.children[nextIndex] - - // Handle different types of content nodes - if (nextNode.type === 'raw') { - content += nextNode.value - // Found the closing tag - combine all nodes and stop - if (nextNode.value.includes('</details>')) { - node.value = content - // Remove the now-combined nodes - parent.children.splice(index + 1, nextIndex - index) - return [SKIP] + // Handle inline code property + if (node.tagName === 'code') { + node.properties.inline = !(parent && parent.tagName === 'pre') + } + // handle headings + if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) { + 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', + tagName: 'headlink', + properties: { + href: `#${headingId}` + }, + children: [{ type: 'text', value: nodeText }] + } + // Replace the heading's children with the new link element + node.children = [linkElement] + return [SKIP] + } + + // if img is wrapped in a link, remove the link + if (node.tagName === 'a' && node.children.length === 1 && node.children[0].tagName === 'img') { + parent.children[index] = node.children[0] + return index + } + + // handle internal links + if (node.tagName === 'a') { + try { + if (node.properties.href.includes('#itemfn-')) { + node.tagName = 'footnote' + } else { + const { itemId, linkText } = parseInternalLinks(node.properties.href) + if (itemId) { + node.tagName = 'item' + node.properties.id = itemId + if (node.properties.href === toString(node)) { + node.children[0].value = linkText + } + } + } + } catch { + // ignore errors like invalid URLs + } + } + + // 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) { + const embed = parseEmbedUrl(node.properties.href) + if (embed) { + node.tagName = 'embed' + node.properties = { ...embed, src: node.properties.href } + } else { + node.tagName = 'autolink' + } + } + + // if the link text is a URL, just show the URL + if (node.tagName === 'a' && isMisleadingLink(toString(node), node.properties.href)) { + node.children = [{ type: 'text', value: node.properties.href }] + return [SKIP] + } + + // Handle @mentions and ~subs + if (node.type === 'text') { + const newChildren = [] + let lastIndex = 0 + let match + let childrenConsumed = 1 + let text = toString(node) + + const combinedRegex = new RegExp(mentionRegex.source + '|' + subRegex.source, 'gi') + + // handle @__username__ or ~__sub__ + if (['@', '~'].includes(node.value) && + parent.children[index + 1]?.tagName === 'strong' && + parent.children[index + 1].children[0]?.type === 'text') { + childrenConsumed = 2 + text = node.value + '__' + toString(parent.children[index + 1]) + '__' + } + + while ((match = combinedRegex.exec(text)) !== null) { + if (lastIndex < match.index) { + newChildren.push({ type: 'text', value: text.slice(lastIndex, match.index) }) + } + + const [fullMatch, mentionMatch, subMatch] = match + const replacement = mentionMatch ? replaceMention(fullMatch, mentionMatch) : replaceSub(fullMatch, subMatch) + + if (replacement) { + newChildren.push(replacement) + } else { + newChildren.push({ type: 'text', value: fullMatch }) + } + + lastIndex = combinedRegex.lastIndex + } + + if (newChildren.length > 0) { + if (lastIndex < text.length) { + newChildren.push({ type: 'text', value: text.slice(lastIndex) }) + } + parent.children.splice(index, childrenConsumed, ...newChildren) + return index + newChildren.length + } + } + + // Handle Nostr IDs + if (node.type === 'text') { + const newChildren = [] + let lastIndex = 0 + let match + + while ((match = nostrIdRegex.exec(node.value)) !== null) { + if (lastIndex < match.index) { + newChildren.push({ type: 'text', value: node.value.slice(lastIndex, match.index) }) + } + + newChildren.push(replaceNostrId(match[0], match[0])) + + lastIndex = nostrIdRegex.lastIndex + } + + if (lastIndex < node.value.length) { + newChildren.push({ type: 'text', value: node.value.slice(lastIndex) }) + } + + if (newChildren.length > 0) { + parent.children.splice(index, 1, ...newChildren) + return index + newChildren.length + } + } + + // 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) + + if (start?.type === 'raw' && start?.value === startTag && + text?.type === 'text' && + end?.type === 'raw' && end?.value === endTag) { + const newChild = { + type: 'element', + tagName: 'span', + properties: { className: [className] }, + children: [{ type: 'text', value: text.value }] } - } else if (nextNode.type === 'text' || nextNode.type === 'element') { - // Preserve content from text nodes and elements - content += getTextContent(nextNode) + node.children.splice(i, 3, newChild) } + } + } + } + + // merge adjacent images and empty paragraphs into a single image collage + if ((node.tagName === 'img' || isImageOnlyParagraph(node)) && Array.isArray(parent.children)) { + const adjacentNodes = [node] + let nextIndex = index + 1 + const siblings = parent.children + const somethingBefore = parent.children[index - 1] && parent.children[index - 1].tagName !== 'p' + let somethingAfter = false + + while (nextIndex < siblings.length) { + const nextNode = siblings[nextIndex] + if (!nextNode) break + if (nextNode.tagName === 'img' || isImageOnlyParagraph(nextNode)) { + adjacentNodes.push(nextNode) + nextIndex++ + } else if (nextNode.type === 'text' && typeof nextNode.value === 'string' && !nextNode.value.trim()) { nextIndex++ + } else { + somethingAfter = true + break + } + } + + if (adjacentNodes.length > 0) { + const allImages = adjacentNodes.flatMap(n => + n.tagName === 'img' ? [n] : (Array.isArray(n.children) ? n.children.filter(child => child.tagName === 'img') : []) + ) + const collageNode = { + type: 'element', + tagName: 'p', + children: allImages, + properties: { onlyImages: true, somethingBefore, somethingAfter } } + parent.children.splice(index, nextIndex - index, collageNode) + return index + 1 } } - }) - // Second pass: process details tags into proper HTML structure - visit(tree, (node) => { + // Handle details/summary tags if (node.type === 'raw') { const value = node.value.trim() - // Only process nodes containing complete details tags if (value.includes('<details>')) { - // Find all details blocks in the content - const detailsMatches = Array.from(value.matchAll(/<details>\s*([\s\S]*?)\s*<\/details>/g)) - if (!detailsMatches.length) return - - detailsMatches.forEach(match => { - const content = match[1] - - // Extract summary content if present, or use default - const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/) - const summaryContent = summaryMatch - ? summaryMatch[1].trim() - : 'Details' - - // Get remaining content after summary - let remainingContent = summaryMatch - ? content.replace(summaryMatch[0], '').trim() - : content.trim() - - // Normalize newlines for consistent rendering - remainingContent = remainingContent - .replace(/\r\n/g, '\n') - .replace(/\n\s*\n/g, '\n\n') - .trim() - - // Convert markdown content to HTML structure - const mdast = fromMarkdown(remainingContent) - const contentHast = toHast(mdast) - - // Create the details node structure - const summaryNode = { - type: 'element', - tagName: 'summary', - properties: {}, - children: [{ - type: 'text', - value: summaryContent - }] - } + // Extract content between details tags + const detailsMatch = value.match(detailsRegex) + if (!detailsMatch) return - const detailsNode = { - type: 'element', - tagName: 'details', - properties: {}, - children: [ - summaryNode, - ...(contentHast?.children ?? []) - ] - } + const fullContent = detailsMatch[1] - // Replace the original node with our structured version - Object.assign(node, detailsNode) - }) + // Try to extract summary content if it exists + const summaryMatch = fullContent.match(summaryRegex) + const summaryContent = summaryMatch + ? summaryMatch[1].trim() + : 'Details' // Default summary text if none provided + // Get remaining content, accounting for optional summary + const remainingContent = summaryMatch + ? fullContent.replace(summaryMatch[0], '').trim() + : fullContent.trim() + + // Convert markdown content to HTML AST nodes + // This allows proper rendering of markdown syntax like + // **bold**, *italic*, lists, etc. within details tags + const mdast = fromMarkdown(remainingContent) + const contentHast = toHast(mdast) + + const summaryNode = { + type: 'element', + tagName: 'summary', + properties: {}, + children: [{ + type: 'text', + value: summaryContent + }] + } + + const detailsNode = { + type: 'element', + tagName: 'details', + properties: {}, + children: [ + summaryNode, + ...(contentHast?.children ?? []) + ] + } + + // Replace original node with new structure + parent.children[index] = detailsNode return [SKIP] } } @@ -176,10 +331,3 @@ export default function rehypeSN (options = {}) { } } } - -// Helper function to extract text content from nodes -function getTextContent(node) { - if (node.value) return node.value - if (!node.children) return '' - return node.children.map(child => getTextContent(child)).join('') -} From 9c75beb9521bf020c0973f4f7f44cc8a2af9d365 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Sun, 17 Nov 2024 03:12:27 -0500 Subject: [PATCH 08/23] cleanup --- components/text.js | 20 +++++++++++- components/text.module.css | 2 +- lib/rehype-sn.js | 62 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/components/text.js b/components/text.js index 43b9a387f..52b8b959b 100644 --- a/components/text.js +++ b/components/text.js @@ -124,7 +124,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() @@ -247,3 +249,19 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr </div> ) } + +function Summary ({ children }) { + return ( + <summary className={styles.summary}> + {children} + </summary> + ) +} + +function Details ({ children }) { + return ( + <details className={styles.details}> + {children} + </details> + ) +} diff --git a/components/text.module.css b/components/text.module.css index ff99b8a90..a8a3c7bcd 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -432,4 +432,4 @@ max-width: 480px; border-radius: 13px; overflow: hidden; -} \ No newline at end of file +} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index fb35bf4bd..73afc5445 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -2,6 +2,8 @@ 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' const userGroup = '[\\w_]+' const subGroup = '[A-Za-z][\\w_]+' @@ -9,6 +11,8 @@ 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 +const detailsRegex = /<details>([\s\S]*?)<\/details>/ +const summaryRegex = /<summary>([\s\S]*?)<\/summary>/ export default function rehypeSN (options = {}) { const { stylers = [] } = options @@ -20,13 +24,11 @@ export default function rehypeSN (options = {}) { if (node.tagName === 'code') { node.properties.inline = !(parent && parent.tagName === 'pre') } - // handle headings if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) { 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] @@ -159,6 +160,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,6 +216,60 @@ export default function rehypeSN (options = {}) { return index + 1 } } + + // Handle details/summary tags + if (node.type === 'raw') { + const value = node.value.trim() + + if (value.includes('<details>')) { + // Extract content between details tags + const detailsMatch = value.match(detailsRegex) + if (!detailsMatch) return + + const fullContent = detailsMatch[1] + + // Try to extract summary content if it exists + const summaryMatch = fullContent.match(summaryRegex) + const summaryContent = summaryMatch + ? summaryMatch[1].trim() + : 'Details' // Default summary text if none provided + + // Get remaining content, accounting for optional summary + const remainingContent = summaryMatch + ? fullContent.replace(summaryMatch[0], '').trim() + : fullContent.trim() + + // Convert markdown content to HTML AST nodes + // This allows proper rendering of markdown syntax like + // **bold**, *italic*, lists, etc. within details tags + const mdast = fromMarkdown(remainingContent) + const contentHast = toHast(mdast) + + const summaryNode = { + type: 'element', + tagName: 'summary', + properties: {}, + children: [{ + type: 'text', + value: summaryContent + }] + } + + const detailsNode = { + type: 'element', + tagName: 'details', + properties: {}, + children: [ + summaryNode, + ...(contentHast?.children ?? []) + ] + } + + // Replace original node with new structure + parent.children[index] = detailsNode + return [SKIP] + } + } }) } catch (error) { console.error('Error in rehypeSN transformer:', error) From 5156da69e84e4255acc5b92b14b0898484b9e8c4 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Sun, 17 Nov 2024 03:15:22 -0500 Subject: [PATCH 09/23] support details/summary tags, allow markdown inside --- lib/rehype-sn.js | 159 +++++++++++++++++++++++++++++++---------------- 1 file changed, 105 insertions(+), 54 deletions(-) diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 73afc5445..fb88b0102 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -20,10 +20,73 @@ export default function rehypeSN (options = {}) { return function transformer (tree) { try { visit(tree, (node, index, parent) => { + // Process details/summary tags first to ensure proper node structure + if (node.type === 'raw' && parent?.children) { + // Check for incomplete details tags that might be split across nodes + if (node.value.includes('<details') && !node.value.includes('</details>')) { + let nextIndex = index + 1 + let content = node.value + + // Scan subsequent nodes until we find the closing details tag + while (nextIndex < parent.children.length) { + const nextNode = parent.children[nextIndex] + + if (nextNode.type === 'raw') { + content += nextNode.value + // Found closing tag - combine all nodes into one + if (nextNode.value.includes('</details>')) { + node.value = content + // Remove the now-combined nodes from parent + parent.children.splice(index + 1, nextIndex - index) + // Don't skip - we still need to process the combined content + break + } + } else if (nextNode.type === 'text' || nextNode.type === 'element') { + // Preserve content from text nodes and elements (like paragraphs) + content += getTextContent(nextNode) + } + nextIndex++ + } + } + + // Process details tags (both complete and newly-combined ones) + const value = node.value.trim() + if (value.includes('<details>')) { + // Find all details blocks in this node + const detailsMatches = Array.from(value.matchAll(/<details>\s*([\s\S]*?)\s*<\/details>/g)) + if (detailsMatches.length) { + detailsMatches.forEach(match => { + const content = match[1] + // Extract summary content if present + const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/) + const summaryContent = summaryMatch + ? summaryMatch[1].trim() + : 'Details' // Default summary text + + // Get content after summary tag, or all content if no summary + let remainingContent = summaryMatch + ? content.replace(summaryMatch[0], '').trim() + : content.trim() + + // Normalize markdown content + remainingContent = normalizeMarkdown(remainingContent) + + // Convert markdown content to HTML structure + const mdast = fromMarkdown(remainingContent) + const contentHast = toHast(mdast) + + Object.assign(node, createDetailsNode(summaryContent, contentHast?.children ?? [])) + }) + return [SKIP] + } + } + } + // Handle inline code property if (node.tagName === 'code') { node.properties.inline = !(parent && parent.tagName === 'pre') } + // handle headings if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) { const nodeText = toString(node) @@ -216,60 +279,6 @@ export default function rehypeSN (options = {}) { return index + 1 } } - - // Handle details/summary tags - if (node.type === 'raw') { - const value = node.value.trim() - - if (value.includes('<details>')) { - // Extract content between details tags - const detailsMatch = value.match(detailsRegex) - if (!detailsMatch) return - - const fullContent = detailsMatch[1] - - // Try to extract summary content if it exists - const summaryMatch = fullContent.match(summaryRegex) - const summaryContent = summaryMatch - ? summaryMatch[1].trim() - : 'Details' // Default summary text if none provided - - // Get remaining content, accounting for optional summary - const remainingContent = summaryMatch - ? fullContent.replace(summaryMatch[0], '').trim() - : fullContent.trim() - - // Convert markdown content to HTML AST nodes - // This allows proper rendering of markdown syntax like - // **bold**, *italic*, lists, etc. within details tags - const mdast = fromMarkdown(remainingContent) - const contentHast = toHast(mdast) - - const summaryNode = { - type: 'element', - tagName: 'summary', - properties: {}, - children: [{ - type: 'text', - value: summaryContent - }] - } - - const detailsNode = { - type: 'element', - tagName: 'details', - properties: {}, - children: [ - summaryNode, - ...(contentHast?.children ?? []) - ] - } - - // Replace original node with new structure - parent.children[index] = detailsNode - return [SKIP] - } - } }) } catch (error) { console.error('Error in rehypeSN transformer:', error) @@ -330,4 +339,46 @@ export default function rehypeSN (options = {}) { children: [{ type: 'text', value }] } } + + /** + * Extracts text content from any node type + * Handles both direct text nodes and nested element structures + */ + function getTextContent (node) { + if (node.value) return node.value + if (!node.children) return '' + return node.children.map(child => getTextContent(child)).join('') + } + + // Helper function to normalize markdown content + function normalizeMarkdown(content) { + return content + .replace(/\r\n/g, '\n') // Normalize line endings + .split('\n') // Split into lines + .map(line => line.trim()) // Trim each line + .join('\n\n') // Add blank line between all content + .replace(/\n{3,}/g, '\n\n') // Normalize multiple blank lines to two + .trim() // Trim final result + } + + // Helper function to create details node structure + function createDetailsNode(summaryContent, children) { + return { + type: 'element', + tagName: 'details', + properties: {}, + children: [ + { + type: 'element', + tagName: 'summary', + properties: {}, + children: [{ + type: 'text', + value: summaryContent + }] + }, + ...children + ] + } + } } From a5a90b8923d87303a6315ad1726f3317f1d245eb Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Sun, 17 Nov 2024 03:22:09 -0500 Subject: [PATCH 10/23] fix: linting --- lib/rehype-sn.js | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index fb88b0102..873674c49 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -11,8 +11,6 @@ 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 -const detailsRegex = /<details>([\s\S]*?)<\/details>/ -const summaryRegex = /<summary>([\s\S]*?)<\/summary>/ export default function rehypeSN (options = {}) { const { stylers = [] } = options @@ -20,9 +18,7 @@ export default function rehypeSN (options = {}) { return function transformer (tree) { try { visit(tree, (node, index, parent) => { - // Process details/summary tags first to ensure proper node structure if (node.type === 'raw' && parent?.children) { - // Check for incomplete details tags that might be split across nodes if (node.value.includes('<details') && !node.value.includes('</details>')) { let nextIndex = index + 1 let content = node.value @@ -30,15 +26,11 @@ export default function rehypeSN (options = {}) { // Scan subsequent nodes until we find the closing details tag while (nextIndex < parent.children.length) { const nextNode = parent.children[nextIndex] - if (nextNode.type === 'raw') { content += nextNode.value - // Found closing tag - combine all nodes into one if (nextNode.value.includes('</details>')) { node.value = content - // Remove the now-combined nodes from parent parent.children.splice(index + 1, nextIndex - index) - // Don't skip - we still need to process the combined content break } } else if (nextNode.type === 'text' || nextNode.type === 'element') { @@ -52,16 +44,13 @@ export default function rehypeSN (options = {}) { // Process details tags (both complete and newly-combined ones) const value = node.value.trim() if (value.includes('<details>')) { - // Find all details blocks in this node const detailsMatches = Array.from(value.matchAll(/<details>\s*([\s\S]*?)\s*<\/details>/g)) if (detailsMatches.length) { detailsMatches.forEach(match => { const content = match[1] // Extract summary content if present const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/) - const summaryContent = summaryMatch - ? summaryMatch[1].trim() - : 'Details' // Default summary text + const summaryContent = summaryMatch ? summaryMatch[1].trim() : 'Details' // Get content after summary tag, or all content if no summary let remainingContent = summaryMatch @@ -223,7 +212,6 @@ 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) @@ -350,19 +338,17 @@ export default function rehypeSN (options = {}) { return node.children.map(child => getTextContent(child)).join('') } - // Helper function to normalize markdown content - function normalizeMarkdown(content) { + function normalizeMarkdown (content) { return content - .replace(/\r\n/g, '\n') // Normalize line endings - .split('\n') // Split into lines - .map(line => line.trim()) // Trim each line - .join('\n\n') // Add blank line between all content - .replace(/\n{3,}/g, '\n\n') // Normalize multiple blank lines to two - .trim() // Trim final result + .replace(/\r\n/g, '\n') + .split('\n') + .map(line => line.trim()) + .join('\n\n') + .replace(/\n{3,}/g, '\n\n') + .trim() } - // Helper function to create details node structure - function createDetailsNode(summaryContent, children) { + function createDetailsNode (summaryContent, children) { return { type: 'element', tagName: 'details', From 1015cb2b3c5e5c55d972d028600d0591d30a8c53 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Mon, 18 Nov 2024 01:14:39 -0500 Subject: [PATCH 11/23] Working structure 2, with or without blank lines --- components/text.js | 21 +----- lib/rehype-sn.js | 171 +++++++++++++++++++++------------------------ 2 files changed, 82 insertions(+), 110 deletions(-) diff --git a/components/text.js b/components/text.js index 52b8b959b..7f5f214a2 100644 --- a/components/text.js +++ b/components/text.js @@ -125,8 +125,8 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child }, img: TextMediaOrLink, embed: Embed, - details: Details, - summary: Summary + details: ({ node, children, ...props }) => <details {...props}>{children}</details>, + summary: ({ node, children, ...props }) => <summary {...props}>{children}</summary> }), [outlawed, rel, TextMediaOrLink, topLevel]) const carousel = useCarousel() @@ -225,6 +225,7 @@ function Table ({ node, ...props }) { ) } + function Code ({ node, inline, className, children, style, ...props }) { return inline ? ( @@ -249,19 +250,3 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr </div> ) } - -function Summary ({ children }) { - return ( - <summary className={styles.summary}> - {children} - </summary> - ) -} - -function Details ({ children }) { - return ( - <details className={styles.details}> - {children} - </details> - ) -} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 873674c49..fe70d5bfe 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -3,6 +3,8 @@ 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 { gfm } from 'micromark-extension-gfm' +import { gfmFromMarkdown } from 'mdast-util-gfm' import { toHast } from 'mdast-util-to-hast' const userGroup = '[\\w_]+' @@ -18,59 +20,18 @@ export default function rehypeSN (options = {}) { return function transformer (tree) { try { visit(tree, (node, index, parent) => { - if (node.type === 'raw' && parent?.children) { - if (node.value.includes('<details') && !node.value.includes('</details>')) { - let nextIndex = index + 1 - let content = node.value - - // Scan subsequent nodes until we find the closing details tag - while (nextIndex < parent.children.length) { - const nextNode = parent.children[nextIndex] - if (nextNode.type === 'raw') { - content += nextNode.value - if (nextNode.value.includes('</details>')) { - node.value = content - parent.children.splice(index + 1, nextIndex - index) - break - } - } else if (nextNode.type === 'text' || nextNode.type === 'element') { - // Preserve content from text nodes and elements (like paragraphs) - content += getTextContent(nextNode) - } - nextIndex++ - } - } - - // Process details tags (both complete and newly-combined ones) - const value = node.value.trim() - if (value.includes('<details>')) { - const detailsMatches = Array.from(value.matchAll(/<details>\s*([\s\S]*?)\s*<\/details>/g)) - if (detailsMatches.length) { - detailsMatches.forEach(match => { - const content = match[1] - // Extract summary content if present - const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/) - const summaryContent = summaryMatch ? summaryMatch[1].trim() : 'Details' - - // Get content after summary tag, or all content if no summary - let remainingContent = summaryMatch - ? content.replace(summaryMatch[0], '').trim() - : content.trim() - - // Normalize markdown content - remainingContent = normalizeMarkdown(remainingContent) - - // Convert markdown content to HTML structure - const mdast = fromMarkdown(remainingContent) - const contentHast = toHast(mdast) - - Object.assign(node, createDetailsNode(summaryContent, contentHast?.children ?? [])) - }) - return [SKIP] - } - } + // Log node info for debugging + const nodeInfo = { + ...(node.type && { type: node.type }), + ...(node.tagName && { tagName: node.tagName }), + ...(node.properties && { properties: node.properties }), + ...(node.value && { value: node.value }), + ...(node.children && { children: `${node.children.length} children` }), + ...(parent?.type && { parentType: parent.type }), + ...(parent?.tagName && { parentTagName: parent.tagName }), + index } - + console.log('Node:', nodeInfo) // Handle inline code property if (node.tagName === 'code') { node.properties.inline = !(parent && parent.tagName === 'pre') @@ -267,6 +228,72 @@ export default function rehypeSN (options = {}) { return index + 1 } } + // Handle details tags + if (node.type === 'raw' && node.value?.includes('<details>')) { + // Find the start of our details block + const detailsMatch = node.value.match(/<details>\s*<summary>(.*?)<\/summary>(.*?)$/s) + if (detailsMatch) { + const [_, summary, initialContent] = detailsMatch + + // Collect all content until we find the closing details tag + let content = initialContent || '' // Include any content after summary + let currentIndex = index + 1 + const contentNodes = [] + + while (currentIndex < parent.children.length) { + const currentNode = parent.children[currentIndex] + if (currentNode.type === 'raw' && currentNode.value === '</details>') { + break + } + contentNodes.push(currentNode) + currentIndex++ + } + + // Convert content nodes to text, preserving markdown syntax + content += contentNodes + .map(node => { + if (node.type === 'text') return node.value + if (node.type === 'element' && node.tagName === 'ul') { + return node.children + .filter(child => child.tagName === 'li') + .map(li => `- ${li.children[0].value}`) + .join('\n') + } + return '' + }) + .join('') + + // Parse content to markdown AST + const mdast = fromMarkdown(content, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }) + + // Convert markdown AST to HTML AST + const contentHast = toHast(mdast) + + const detailsNode = { + type: 'element', + tagName: 'details', + properties: {}, + children: [ + { + type: 'element', + tagName: 'summary', + properties: {}, + children: [ + { type: 'text', value: summary } + ] + }, + ...contentHast.children + ] + } + + // Replace all nodes from details start to end with our new node + parent.children.splice(index, currentIndex - index + 1, detailsNode) + return index + 1 + } + } }) } catch (error) { console.error('Error in rehypeSN transformer:', error) @@ -327,44 +354,4 @@ export default function rehypeSN (options = {}) { children: [{ type: 'text', value }] } } - - /** - * Extracts text content from any node type - * Handles both direct text nodes and nested element structures - */ - function getTextContent (node) { - if (node.value) return node.value - if (!node.children) return '' - return node.children.map(child => getTextContent(child)).join('') - } - - function normalizeMarkdown (content) { - return content - .replace(/\r\n/g, '\n') - .split('\n') - .map(line => line.trim()) - .join('\n\n') - .replace(/\n{3,}/g, '\n\n') - .trim() - } - - function createDetailsNode (summaryContent, children) { - return { - type: 'element', - tagName: 'details', - properties: {}, - children: [ - { - type: 'element', - tagName: 'summary', - properties: {}, - children: [{ - type: 'text', - value: summaryContent - }] - }, - ...children - ] - } - } } From b8b02ced9ae8faf73f8958e0142137c75fe1ffec Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Sun, 24 Nov 2024 00:37:06 -0500 Subject: [PATCH 12/23] Makeover checkpoint: close to working with newlines breaking tags or markdown --- components/text.js | 30 ++++- lib/rehype-sn.js | 302 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 264 insertions(+), 68 deletions(-) diff --git a/components/text.js b/components/text.js index 7f5f214a2..748111e1f 100644 --- a/components/text.js +++ b/components/text.js @@ -125,8 +125,8 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child }, img: TextMediaOrLink, embed: Embed, - details: ({ node, children, ...props }) => <details {...props}>{children}</details>, - summary: ({ node, children, ...props }) => <summary {...props}>{children}</summary> + details: Details, + summary: Summary, }), [outlawed, rel, TextMediaOrLink, topLevel]) const carousel = useCarousel() @@ -250,3 +250,29 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr </div> ) } + +function Details({ children, node, ...props }) { + const [isOpen, setIsOpen] = useState(false) + + const handleToggle = (e) => { + setIsOpen(e.target.open) + } + + return ( + <details + className={styles.details} + {...props} + onToggle={handleToggle} + > + {children} + </details> + ) +} + +function Summary({ children, node, ...props }) { + return ( + <summary className={styles.summary} {...props}> + {children} + </summary> + ) +} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index fe70d5bfe..0be0737c9 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -32,6 +32,134 @@ export default function rehypeSN (options = {}) { index } console.log('Node:', nodeInfo) + // Handle details tags + if (node.type === 'raw' && node.value.includes('<details>')) { + console.log('Details tag found') + + // Find all content between opening and closing details tags + let detailsContent = [] + let summaryText = '' + let i = index + let foundClosingTag = false + let inSummary = false + + // First check if opening and closing tags are in the same node + if (node.value.includes('</details>')) { + // Extract content between tags from single node + const content = node.value.slice( + node.value.indexOf('<details>') + 9, + node.value.indexOf('</details>') + ) + + // Check for summary tag + if (content.includes('<summary>')) { + const summaryStart = content.indexOf('<summary>') + 9 + const summaryEnd = content.indexOf('</summary>') + summaryText = content.slice(summaryStart, summaryEnd).trim() + + // Get content after summary + const afterSummary = content.slice(summaryEnd + 10).trim() + if (afterSummary) { + detailsContent.push({ + type: 'text', + value: afterSummary + }) + } + } else { + // No summary tag, use content as-is + detailsContent.push({ + type: 'text', + value: content.trim() + }) + } + + foundClosingTag = true + i = index + + } else { + // Need to traverse nodes to find closing tag + while (i < parent.children.length) { + const currentNode = parent.children[i] + + // Check if we've hit the closing tag + if (currentNode.type === 'raw' && currentNode.value.includes('</details>')) { + // Get any content before closing tag + const beforeClosing = currentNode.value.slice(0, currentNode.value.indexOf('</details>')).trim() + if (beforeClosing) { + detailsContent.push({ + type: 'text', + value: beforeClosing + }) + } + foundClosingTag = true + break + } + + // Handle summary tags + if (currentNode.type === 'raw' && currentNode.value.includes('<summary>')) { + inSummary = true + // Get content after summary opening tag + const afterOpening = currentNode.value.slice(currentNode.value.indexOf('<summary>') + 9).trim() + if (afterOpening) { + summaryText += afterOpening + } + } else if (currentNode.type === 'raw' && currentNode.value.includes('</summary>')) { + inSummary = false + // Get content before summary closing tag + const beforeClosing = currentNode.value.slice(0, currentNode.value.indexOf('</summary>')).trim() + if (beforeClosing) { + summaryText += beforeClosing + } + } else if (inSummary) { + // Collect summary text + if (currentNode.type === 'text') { + summaryText += currentNode.value + } else if (currentNode.type === 'element') { + summaryText += toString(currentNode) + } + } else if (!inSummary && i > index) { + // Skip the opening details node content + if (currentNode.type === 'text' || currentNode.type === 'element') { + detailsContent.push(currentNode) + } else if (currentNode.type === 'raw' && !currentNode.value.includes('<details>')) { + detailsContent.push({ + type: 'text', + value: currentNode.value + }) + } + } + + i++ + } + } + + // Only process if we found a proper closing tag + if (foundClosingTag) { + // Convert collected content nodes to markdown string + const markdownContent = detailsContent + .map(node => toString(node)) + .join('\n') + .trim() + + // Use helper to create new details structure + const newDetailsNode = alternateCreateDetails( + markdownContent, + summaryText.trim() || 'Details' + ) + + console.log('Details content:', { + markdownContent, + summaryText: summaryText.trim(), + detailsContent, + newDetailsNode + }) + + // Replace original nodes with new details structure + parent.children.splice(index, i - index + 1, newDetailsNode) + + return index + } + } // Handle inline code property if (node.tagName === 'code') { node.properties.inline = !(parent && parent.tagName === 'pre') @@ -228,72 +356,6 @@ export default function rehypeSN (options = {}) { return index + 1 } } - // Handle details tags - if (node.type === 'raw' && node.value?.includes('<details>')) { - // Find the start of our details block - const detailsMatch = node.value.match(/<details>\s*<summary>(.*?)<\/summary>(.*?)$/s) - if (detailsMatch) { - const [_, summary, initialContent] = detailsMatch - - // Collect all content until we find the closing details tag - let content = initialContent || '' // Include any content after summary - let currentIndex = index + 1 - const contentNodes = [] - - while (currentIndex < parent.children.length) { - const currentNode = parent.children[currentIndex] - if (currentNode.type === 'raw' && currentNode.value === '</details>') { - break - } - contentNodes.push(currentNode) - currentIndex++ - } - - // Convert content nodes to text, preserving markdown syntax - content += contentNodes - .map(node => { - if (node.type === 'text') return node.value - if (node.type === 'element' && node.tagName === 'ul') { - return node.children - .filter(child => child.tagName === 'li') - .map(li => `- ${li.children[0].value}`) - .join('\n') - } - return '' - }) - .join('') - - // Parse content to markdown AST - const mdast = fromMarkdown(content, { - extensions: [gfm()], - mdastExtensions: [gfmFromMarkdown()] - }) - - // Convert markdown AST to HTML AST - const contentHast = toHast(mdast) - - const detailsNode = { - type: 'element', - tagName: 'details', - properties: {}, - children: [ - { - type: 'element', - tagName: 'summary', - properties: {}, - children: [ - { type: 'text', value: summary } - ] - }, - ...contentHast.children - ] - } - - // Replace all nodes from details start to end with our new node - parent.children.splice(index, currentIndex - index + 1, detailsNode) - return index + 1 - } - } }) } catch (error) { console.error('Error in rehypeSN transformer:', error) @@ -354,4 +416,112 @@ export default function rehypeSN (options = {}) { children: [{ type: 'text', value }] } } + function createDetails (markdownContent, summaryText) { + return { + type: 'element', + tagName: 'details', + properties: {}, + children: [ + { + type: 'element', + tagName: 'summary', + properties: {}, + children: [{ type: 'text', value: summaryText }] + }, + { + type: 'element', + tagName: 'div', + properties: {}, + children: markdownContent.split('\n').map(line => ({ + type: 'element', + tagName: 'p', + properties: {}, + children: [{ type: 'text', value: line }] + })) + } + ] + } + } + function alternateCreateDetails(markdownContent, summaryText) { + // Parse markdown content into mdast (markdown abstract syntax tree) + const mdastContent = fromMarkdown(markdownContent, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }) + + // Convert mdast to hast (HTML abstract syntax tree) + const hastContent = toHast(mdastContent) + + return { + type: 'element', + tagName: 'details', + properties: {}, + children: [ + { + type: 'element', + tagName: 'summary', + properties: {}, + children: [{ type: 'text', value: summaryText }] + }, + { + type: 'element', + tagName: 'div', + properties: {}, + children: hastContent.children + } + ] + } + } } + + +// Different structures that must be handled properly and not break details/summary + +// Structure 1: + +// <details>no summary tags +// lorem ipsum +// and text on a new line</details> + +// ---------- + +// Structure 2: + +// <details> +// <summary>text inside summary</summary> + +// lorem ipsum +// </details> + +// ---------- + +// Structure 3: + +// <details> +// <summary>summary indentation single line</summary> +// 1. first thing +// 2. second thing +// 3. third thing +// </details> + +// ---------- + +// Structure 4: + +// <details> +// <summary> +// summary text here +// </summary> +// text inside details with *markdown* working **properly** +// </details> + +// ---------- + +// Structure 5: + +// <details> +// <summary>Shopping list</summary> +// - Vegetables +// - Fruits +// - Fish +// </details> From cde3f12e14ee4b67149e2c06e5f30a8e2a047282 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Mon, 2 Dec 2024 01:07:53 -0500 Subject: [PATCH 13/23] Handle blanklines (extra newlines) that break formatting --- lib/rehype-sn.js | 346 +++++++++++++++++++++++++---------------------- 1 file changed, 184 insertions(+), 162 deletions(-) diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 0be0737c9..4f6e57e26 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -14,11 +14,68 @@ const mentionRegex = new RegExp('@(' + userGroup + '(?:\\/' + userGroup + ')?)', const subRegex = new RegExp('~(' + subGroup + '(?:\\/' + subGroup + ')?)', 'gi') const nostrIdRegex = /\b((npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)\b/g +function alternateCreateDetails(markdownContent, summaryText) { + // Parse both summary and content as markdown + const mdastSummary = fromMarkdown(summaryText, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }) + + const mdastContent = fromMarkdown(markdownContent, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }) + + // Convert both to hast + const hastSummary = toHast(mdastSummary) + const hastContent = toHast(mdastContent) + + // For summary, we want to ensure the content stays inline + // If hastSummary has block elements, we need to extract their text content + const flattenBlockElements = (node) => { + if (node.type === 'text') return [node] + if (!node.children) return [] + + return node.children.flatMap(child => { + if (child.type === 'text') return [child] + if (child.type === 'element') { + // Convert block elements to spans to keep them inline + if (['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(child.tagName)) { + return child.children.flatMap(flattenBlockElements) + } + return [child] + } + return flattenBlockElements(child) + }) + } + + const summaryChildren = hastSummary.children.flatMap(flattenBlockElements) + + // Create the details structure + return { + type: 'element', + tagName: 'details', + properties: {}, + children: [ + { + type: 'element', + tagName: 'summary', + properties: {}, + children: summaryChildren + }, + // For the content, we want to preserve block formatting + ...hastContent.children + ] + } +} + export default function rehypeSN (options = {}) { const { stylers = [] } = options return function transformer (tree) { try { + let detailsStack = [] // Track nested details processing + visit(tree, (node, index, parent) => { // Log node info for debugging const nodeInfo = { @@ -32,134 +89,154 @@ export default function rehypeSN (options = {}) { index } console.log('Node:', nodeInfo) + // Handle details tags - if (node.type === 'raw' && node.value.includes('<details>')) { - console.log('Details tag found') + if (node.type === 'raw' && ( + node.value.includes('<details>') || + node.value.includes('<details >') + )) { + console.log('Details opening tag found') - // Find all content between opening and closing details tags - let detailsContent = [] - let summaryText = '' - let i = index - let foundClosingTag = false - let inSummary = false - - // First check if opening and closing tags are in the same node + // Check if this is a single-line details tag if (node.value.includes('</details>')) { - // Extract content between tags from single node - const content = node.value.slice( - node.value.indexOf('<details>') + 9, - node.value.indexOf('</details>') - ) + // Extract content between tags + const fullContent = node.value + let content = fullContent + .replace(/<details>|<details >/, '') + .replace('</details>', '') - // Check for summary tag - if (content.includes('<summary>')) { + let summaryText = 'Details' + + // Check for summary tags + if (content.includes('<summary>') && content.includes('</summary>')) { const summaryStart = content.indexOf('<summary>') + 9 const summaryEnd = content.indexOf('</summary>') - summaryText = content.slice(summaryStart, summaryEnd).trim() + summaryText = content.slice(summaryStart, summaryEnd) - // Get content after summary + // Get content before and after summary + const beforeSummary = content.slice(0, content.indexOf('<summary>')).trim() const afterSummary = content.slice(summaryEnd + 10).trim() - if (afterSummary) { - detailsContent.push({ - type: 'text', - value: afterSummary - }) - } - } else { - // No summary tag, use content as-is - detailsContent.push({ - type: 'text', - value: content.trim() - }) + + content = [beforeSummary, afterSummary].filter(Boolean).join('\n') } - foundClosingTag = true - i = index + // Create the details node directly + const newDetailsNode = alternateCreateDetails( + content.trim(), + summaryText + ) - } else { - // Need to traverse nodes to find closing tag - while (i < parent.children.length) { - const currentNode = parent.children[i] - - // Check if we've hit the closing tag - if (currentNode.type === 'raw' && currentNode.value.includes('</details>')) { - // Get any content before closing tag - const beforeClosing = currentNode.value.slice(0, currentNode.value.indexOf('</details>')).trim() - if (beforeClosing) { - detailsContent.push({ - type: 'text', - value: beforeClosing - }) - } - foundClosingTag = true - break - } + // Replace the current node + parent.children[index] = newDetailsNode + return index + } + + // Initialize details processing state for multi-line case + detailsStack.push({ + startIndex: index, + contentBuffer: [], + summaryBuffer: [], + inSummary: false, + foundSummary: false, + summaryText: '', + detailsContent: [], + processedNodes: new Set() // Track which nodes we've processed + }) + + return + } - // Handle summary tags - if (currentNode.type === 'raw' && currentNode.value.includes('<summary>')) { - inSummary = true - // Get content after summary opening tag - const afterOpening = currentNode.value.slice(currentNode.value.indexOf('<summary>') + 9).trim() - if (afterOpening) { - summaryText += afterOpening - } - } else if (currentNode.type === 'raw' && currentNode.value.includes('</summary>')) { - inSummary = false - // Get content before summary closing tag - const beforeClosing = currentNode.value.slice(0, currentNode.value.indexOf('</summary>')).trim() - if (beforeClosing) { - summaryText += beforeClosing - } - } else if (inSummary) { - // Collect summary text - if (currentNode.type === 'text') { - summaryText += currentNode.value - } else if (currentNode.type === 'element') { - summaryText += toString(currentNode) - } - } else if (!inSummary && i > index) { - // Skip the opening details node content - if (currentNode.type === 'text' || currentNode.type === 'element') { - detailsContent.push(currentNode) - } else if (currentNode.type === 'raw' && !currentNode.value.includes('<details>')) { - detailsContent.push({ - type: 'text', - value: currentNode.value - }) - } - } + // Process details content if we're inside a details tag + if (detailsStack.length > 0) { + const state = detailsStack[detailsStack.length - 1] + + // Skip if we've already processed this node + if (state.processedNodes.has(index)) { + return + } + state.processedNodes.add(index) - i++ + // Check for closing details tag + if (node.type === 'raw' && node.value.includes('</details>')) { + console.log('Details closing tag found') + detailsStack.pop() + + // Get any content before the closing tag + const beforeClosing = node.value.split('</details>')[0] + if (beforeClosing) { + state.contentBuffer.push(beforeClosing) } - } - // Only process if we found a proper closing tag - if (foundClosingTag) { - // Convert collected content nodes to markdown string - const markdownContent = detailsContent - .map(node => toString(node)) + // Process all collected content + const content = state.contentBuffer + .filter(Boolean) // Remove empty strings .join('\n') + .replace(/\n{3,}/g, '\n\n') // Replace 3 or more newlines with 2 .trim() - // Use helper to create new details structure + // Create the details node + const markdownContent = state.detailsContent + .map(node => toString(node)) + .join('\n') + const newDetailsNode = alternateCreateDetails( - markdownContent, - summaryText.trim() || 'Details' + content || markdownContent, + state.summaryText ? state.summaryText.trim() : 'Details' ) - console.log('Details content:', { - markdownContent, - summaryText: summaryText.trim(), - detailsContent, - newDetailsNode - }) - - // Replace original nodes with new details structure - parent.children.splice(index, i - index + 1, newDetailsNode) + // Replace all nodes from start to current with new details node + parent.children.splice(state.startIndex, index - state.startIndex + 1, newDetailsNode) + + return state.startIndex + } - return index + // Handle summary tags + if (node.type === 'raw' && node.value.includes('<summary>')) { + state.inSummary = true + state.foundSummary = true + const parts = node.value.split('<summary>') + if (parts[0]) { + state.contentBuffer.push(parts[0]) + } + if (parts[1]) { + state.summaryBuffer.push(parts[1]) + } + } else if (node.type === 'raw' && node.value.includes('</summary>')) { + state.inSummary = false + const parts = node.value.split('</summary>') + if (parts[0]) { + state.summaryBuffer.push(parts[0]) + } + if (parts[1]) { + state.contentBuffer.push(parts[1]) + } + state.summaryText = state.summaryBuffer.join('\n') + } else if (state.inSummary) { + // Collect summary text + if (node.type === 'text' || node.type === 'raw') { + state.summaryBuffer.push(node.value) + } else if (node.type === 'element') { + state.summaryBuffer.push(toString(node)) + } + } else { + // Handle content collection + if (node.type === 'text') { + // Only add non-empty text nodes or preserve single newlines + if (node.value.trim() || node.value === '\n') { + state.contentBuffer.push(node.value) + } + } else if (node.type === 'element') { + state.contentBuffer.push(toString(node)) + } else if (node.type === 'raw' && + !node.value.includes('<details>') && + !node.value.includes('</details>')) { + state.contentBuffer.push(node.value) + } } + + return } + // Handle inline code property if (node.tagName === 'code') { node.properties.inline = !(parent && parent.tagName === 'pre') @@ -357,11 +434,12 @@ export default function rehypeSN (options = {}) { } } }) + + return tree } catch (error) { console.error('Error in rehypeSN transformer:', error) + return tree } - - return tree } function isImageOnlyParagraph (node) { @@ -416,62 +494,6 @@ export default function rehypeSN (options = {}) { children: [{ type: 'text', value }] } } - function createDetails (markdownContent, summaryText) { - return { - type: 'element', - tagName: 'details', - properties: {}, - children: [ - { - type: 'element', - tagName: 'summary', - properties: {}, - children: [{ type: 'text', value: summaryText }] - }, - { - type: 'element', - tagName: 'div', - properties: {}, - children: markdownContent.split('\n').map(line => ({ - type: 'element', - tagName: 'p', - properties: {}, - children: [{ type: 'text', value: line }] - })) - } - ] - } - } - function alternateCreateDetails(markdownContent, summaryText) { - // Parse markdown content into mdast (markdown abstract syntax tree) - const mdastContent = fromMarkdown(markdownContent, { - extensions: [gfm()], - mdastExtensions: [gfmFromMarkdown()] - }) - - // Convert mdast to hast (HTML abstract syntax tree) - const hastContent = toHast(mdastContent) - - return { - type: 'element', - tagName: 'details', - properties: {}, - children: [ - { - type: 'element', - tagName: 'summary', - properties: {}, - children: [{ type: 'text', value: summaryText }] - }, - { - type: 'element', - tagName: 'div', - properties: {}, - children: hastContent.children - } - ] - } - } } From 684e5aece020bff486984ae6cd066932551df1ea Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Mon, 2 Dec 2024 01:57:04 -0500 Subject: [PATCH 14/23] Refactoring and reorganize, cleanup --- lib/rehype-sn-OLD.js | 277 +++++++++++++++++++++++++++++++++++++++++++ lib/rehype-sn.js | 204 ++++++++++++++----------------- 2 files changed, 365 insertions(+), 116 deletions(-) create mode 100644 lib/rehype-sn-OLD.js diff --git a/lib/rehype-sn-OLD.js b/lib/rehype-sn-OLD.js new file mode 100644 index 000000000..fb35bf4bd --- /dev/null +++ b/lib/rehype-sn-OLD.js @@ -0,0 +1,277 @@ +import { SKIP, visit } from 'unist-util-visit' +import { parseEmbedUrl, parseInternalLinks } from './url' +import { slug } from 'github-slugger' +import { toString } from 'mdast-util-to-string' + +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 + +export default function rehypeSN (options = {}) { + const { stylers = [] } = options + + return function transformer (tree) { + try { + visit(tree, (node, index, parent) => { + // Handle inline code property + if (node.tagName === 'code') { + node.properties.inline = !(parent && parent.tagName === 'pre') + } + + // handle headings + if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) { + 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', + tagName: 'headlink', + properties: { + href: `#${headingId}` + }, + children: [{ type: 'text', value: nodeText }] + } + + // Replace the heading's children with the new link element + node.children = [linkElement] + return [SKIP] + } + + // if img is wrapped in a link, remove the link + if (node.tagName === 'a' && node.children.length === 1 && node.children[0].tagName === 'img') { + parent.children[index] = node.children[0] + return index + } + + // handle internal links + if (node.tagName === 'a') { + try { + if (node.properties.href.includes('#itemfn-')) { + node.tagName = 'footnote' + } else { + const { itemId, linkText } = parseInternalLinks(node.properties.href) + if (itemId) { + node.tagName = 'item' + node.properties.id = itemId + if (node.properties.href === toString(node)) { + node.children[0].value = linkText + } + } + } + } catch { + // ignore errors like invalid URLs + } + } + + // 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) { + const embed = parseEmbedUrl(node.properties.href) + if (embed) { + node.tagName = 'embed' + node.properties = { ...embed, src: node.properties.href } + } else { + node.tagName = 'autolink' + } + } + + // if the link text is a URL, just show the URL + if (node.tagName === 'a' && isMisleadingLink(toString(node), node.properties.href)) { + node.children = [{ type: 'text', value: node.properties.href }] + return [SKIP] + } + + // Handle @mentions and ~subs + if (node.type === 'text') { + const newChildren = [] + let lastIndex = 0 + let match + let childrenConsumed = 1 + let text = toString(node) + + const combinedRegex = new RegExp(mentionRegex.source + '|' + subRegex.source, 'gi') + + // handle @__username__ or ~__sub__ + if (['@', '~'].includes(node.value) && + parent.children[index + 1]?.tagName === 'strong' && + parent.children[index + 1].children[0]?.type === 'text') { + childrenConsumed = 2 + text = node.value + '__' + toString(parent.children[index + 1]) + '__' + } + + while ((match = combinedRegex.exec(text)) !== null) { + if (lastIndex < match.index) { + newChildren.push({ type: 'text', value: text.slice(lastIndex, match.index) }) + } + + const [fullMatch, mentionMatch, subMatch] = match + const replacement = mentionMatch ? replaceMention(fullMatch, mentionMatch) : replaceSub(fullMatch, subMatch) + + if (replacement) { + newChildren.push(replacement) + } else { + newChildren.push({ type: 'text', value: fullMatch }) + } + + lastIndex = combinedRegex.lastIndex + } + + if (newChildren.length > 0) { + if (lastIndex < text.length) { + newChildren.push({ type: 'text', value: text.slice(lastIndex) }) + } + parent.children.splice(index, childrenConsumed, ...newChildren) + return index + newChildren.length + } + } + + // Handle Nostr IDs + if (node.type === 'text') { + const newChildren = [] + let lastIndex = 0 + let match + + while ((match = nostrIdRegex.exec(node.value)) !== null) { + if (lastIndex < match.index) { + newChildren.push({ type: 'text', value: node.value.slice(lastIndex, match.index) }) + } + + newChildren.push(replaceNostrId(match[0], match[0])) + + lastIndex = nostrIdRegex.lastIndex + } + + if (lastIndex < node.value.length) { + newChildren.push({ type: 'text', value: node.value.slice(lastIndex) }) + } + + if (newChildren.length > 0) { + parent.children.splice(index, 1, ...newChildren) + return index + newChildren.length + } + } + + // handle custom tags + if (node.type === 'element') { + 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) + + if (start?.type === 'raw' && start?.value === startTag && + text?.type === 'text' && + end?.type === 'raw' && end?.value === endTag) { + const newChild = { + type: 'element', + tagName: 'span', + properties: { className: [className] }, + children: [{ type: 'text', value: text.value }] + } + node.children.splice(i, 3, newChild) + } + } + } + } + + // merge adjacent images and empty paragraphs into a single image collage + if ((node.tagName === 'img' || isImageOnlyParagraph(node)) && Array.isArray(parent.children)) { + const adjacentNodes = [node] + let nextIndex = index + 1 + const siblings = parent.children + const somethingBefore = parent.children[index - 1] && parent.children[index - 1].tagName !== 'p' + let somethingAfter = false + + while (nextIndex < siblings.length) { + const nextNode = siblings[nextIndex] + if (!nextNode) break + if (nextNode.tagName === 'img' || isImageOnlyParagraph(nextNode)) { + adjacentNodes.push(nextNode) + nextIndex++ + } else if (nextNode.type === 'text' && typeof nextNode.value === 'string' && !nextNode.value.trim()) { + nextIndex++ + } else { + somethingAfter = true + break + } + } + + if (adjacentNodes.length > 0) { + const allImages = adjacentNodes.flatMap(n => + n.tagName === 'img' ? [n] : (Array.isArray(n.children) ? n.children.filter(child => child.tagName === 'img') : []) + ) + const collageNode = { + type: 'element', + tagName: 'p', + children: allImages, + properties: { onlyImages: true, somethingBefore, somethingAfter } + } + parent.children.splice(index, nextIndex - index, collageNode) + return index + 1 + } + } + }) + } catch (error) { + console.error('Error in rehypeSN transformer:', error) + } + + 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 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 isMisleadingLink (text, href) { + let misleading = false + + if (/^\s*(\w+\.)+\w+/.test(text)) { + try { + const hrefUrl = new URL(href) + + if (new URL(hrefUrl.protocol + text).origin !== hrefUrl.origin) { + misleading = true + } + } catch {} + } + + return misleading + } + + function replaceNostrId (value, id) { + return { + type: 'element', + tagName: 'a', + properties: { href: `https://njump.me/${id}` }, + children: [{ type: 'text', value }] + } + } +} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 4f6e57e26..b358ea4ed 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -14,61 +14,6 @@ const mentionRegex = new RegExp('@(' + userGroup + '(?:\\/' + userGroup + ')?)', const subRegex = new RegExp('~(' + subGroup + '(?:\\/' + subGroup + ')?)', 'gi') const nostrIdRegex = /\b((npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)\b/g -function alternateCreateDetails(markdownContent, summaryText) { - // Parse both summary and content as markdown - const mdastSummary = fromMarkdown(summaryText, { - extensions: [gfm()], - mdastExtensions: [gfmFromMarkdown()] - }) - - const mdastContent = fromMarkdown(markdownContent, { - extensions: [gfm()], - mdastExtensions: [gfmFromMarkdown()] - }) - - // Convert both to hast - const hastSummary = toHast(mdastSummary) - const hastContent = toHast(mdastContent) - - // For summary, we want to ensure the content stays inline - // If hastSummary has block elements, we need to extract their text content - const flattenBlockElements = (node) => { - if (node.type === 'text') return [node] - if (!node.children) return [] - - return node.children.flatMap(child => { - if (child.type === 'text') return [child] - if (child.type === 'element') { - // Convert block elements to spans to keep them inline - if (['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(child.tagName)) { - return child.children.flatMap(flattenBlockElements) - } - return [child] - } - return flattenBlockElements(child) - }) - } - - const summaryChildren = hastSummary.children.flatMap(flattenBlockElements) - - // Create the details structure - return { - type: 'element', - tagName: 'details', - properties: {}, - children: [ - { - type: 'element', - tagName: 'summary', - properties: {}, - children: summaryChildren - }, - // For the content, we want to preserve block formatting - ...hastContent.children - ] - } -} - export default function rehypeSN (options = {}) { const { stylers = [] } = options @@ -77,29 +22,13 @@ export default function rehypeSN (options = {}) { let detailsStack = [] // Track nested details processing visit(tree, (node, index, parent) => { - // Log node info for debugging - const nodeInfo = { - ...(node.type && { type: node.type }), - ...(node.tagName && { tagName: node.tagName }), - ...(node.properties && { properties: node.properties }), - ...(node.value && { value: node.value }), - ...(node.children && { children: `${node.children.length} children` }), - ...(parent?.type && { parentType: parent.type }), - ...(parent?.tagName && { parentTagName: parent.tagName }), - index - } - console.log('Node:', nodeInfo) - - // Handle details tags + // Handle details tags - supports both single-line and multi-line formats if (node.type === 'raw' && ( node.value.includes('<details>') || node.value.includes('<details >') )) { - console.log('Details opening tag found') - - // Check if this is a single-line details tag + // Single-line details tag handling (e.g., <details>content</details>) if (node.value.includes('</details>')) { - // Extract content between tags const fullContent = node.value let content = fullContent .replace(/<details>|<details >/, '') @@ -107,31 +36,28 @@ export default function rehypeSN (options = {}) { let summaryText = 'Details' - // Check for summary tags + // Extract summary if present if (content.includes('<summary>') && content.includes('</summary>')) { const summaryStart = content.indexOf('<summary>') + 9 const summaryEnd = content.indexOf('</summary>') summaryText = content.slice(summaryStart, summaryEnd) - // Get content before and after summary + // Combine content before and after summary const beforeSummary = content.slice(0, content.indexOf('<summary>')).trim() const afterSummary = content.slice(summaryEnd + 10).trim() - content = [beforeSummary, afterSummary].filter(Boolean).join('\n') } - // Create the details node directly - const newDetailsNode = alternateCreateDetails( + // Create and replace with new details node + const newDetailsNode = createDetails( content.trim(), summaryText ) - - // Replace the current node parent.children[index] = newDetailsNode return index } - // Initialize details processing state for multi-line case + // Initialize state for multi-line details processing detailsStack.push({ startIndex: index, contentBuffer: [], @@ -140,53 +66,44 @@ export default function rehypeSN (options = {}) { foundSummary: false, summaryText: '', detailsContent: [], - processedNodes: new Set() // Track which nodes we've processed + processedNodes: new Set() }) - return } - // Process details content if we're inside a details tag + // Process multi-line details content if (detailsStack.length > 0) { const state = detailsStack[detailsStack.length - 1] - - // Skip if we've already processed this node - if (state.processedNodes.has(index)) { - return - } + if (state.processedNodes.has(index)) return state.processedNodes.add(index) - // Check for closing details tag + // Handle details closing tag if (node.type === 'raw' && node.value.includes('</details>')) { - console.log('Details closing tag found') detailsStack.pop() - // Get any content before the closing tag + // Collect any remaining content const beforeClosing = node.value.split('</details>')[0] if (beforeClosing) { state.contentBuffer.push(beforeClosing) } - // Process all collected content + // Process collected content const content = state.contentBuffer - .filter(Boolean) // Remove empty strings + .filter(Boolean) .join('\n') - .replace(/\n{3,}/g, '\n\n') // Replace 3 or more newlines with 2 + .replace(/\n{3,}/g, '\n\n') .trim() - // Create the details node const markdownContent = state.detailsContent .map(node => toString(node)) .join('\n') - const newDetailsNode = alternateCreateDetails( + // Create and replace with new details node + const newDetailsNode = createDetails( content || markdownContent, state.summaryText ? state.summaryText.trim() : 'Details' ) - - // Replace all nodes from start to current with new details node parent.children.splice(state.startIndex, index - state.startIndex + 1, newDetailsNode) - return state.startIndex } @@ -195,33 +112,24 @@ export default function rehypeSN (options = {}) { state.inSummary = true state.foundSummary = true const parts = node.value.split('<summary>') - if (parts[0]) { - state.contentBuffer.push(parts[0]) - } - if (parts[1]) { - state.summaryBuffer.push(parts[1]) - } + if (parts[0]) state.contentBuffer.push(parts[0]) + if (parts[1]) state.summaryBuffer.push(parts[1]) } else if (node.type === 'raw' && node.value.includes('</summary>')) { state.inSummary = false const parts = node.value.split('</summary>') - if (parts[0]) { - state.summaryBuffer.push(parts[0]) - } - if (parts[1]) { - state.contentBuffer.push(parts[1]) - } + if (parts[0]) state.summaryBuffer.push(parts[0]) + if (parts[1]) state.contentBuffer.push(parts[1]) state.summaryText = state.summaryBuffer.join('\n') } else if (state.inSummary) { - // Collect summary text + // Collect summary content if (node.type === 'text' || node.type === 'raw') { state.summaryBuffer.push(node.value) } else if (node.type === 'element') { state.summaryBuffer.push(toString(node)) } } else { - // Handle content collection + // Collect details content if (node.type === 'text') { - // Only add non-empty text nodes or preserve single newlines if (node.value.trim() || node.value === '\n') { state.contentBuffer.push(node.value) } @@ -233,7 +141,6 @@ export default function rehypeSN (options = {}) { state.contentBuffer.push(node.value) } } - return } @@ -494,6 +401,71 @@ export default function rehypeSN (options = {}) { children: [{ type: 'text', value }] } } + + // Creates a details node with proper markdown parsing for both summary and content + function createDetails(markdownContent, summaryText) { + // Parse both summary and content as markdown + const mdastSummary = fromMarkdown(summaryText, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }) + + const mdastContent = fromMarkdown(markdownContent, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }) + + // Convert both to hast + const hastSummary = toHast(mdastSummary) + const hastContent = toHast(mdastContent) + + // Ensure summary content stays inline by flattening block elements + const flattenBlockElements = (node) => { + if (node.type === 'text') return [node] + if (!node.children) return [] + + return node.children.flatMap(child => { + if (child.type === 'text') return [child] + if (child.type === 'element') { + // Convert block elements to spans to keep them inline + if (['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(child.tagName)) { + return child.children.flatMap(flattenBlockElements) + } + return [child] + } + return flattenBlockElements(child) + }) + } + + const summaryChildren = hastSummary.children.flatMap(flattenBlockElements) + + // Create the details structure + return { + type: 'element', + tagName: 'details', + properties: {}, + children: [ + { + type: 'element', + tagName: 'summary', + properties: {}, + children: summaryChildren + }, + // Preserve block formatting in content + ...hastContent.children + ] + } + } + + 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()) + ) + } } From 8cb58741c8a8b537dce6aa30cce4153f3d900046 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Mon, 2 Dec 2024 01:58:02 -0500 Subject: [PATCH 15/23] Add minimal styling to details and summary components --- components/text.module.css | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/components/text.module.css b/components/text.module.css index a8a3c7bcd..9292fb5c6 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -433,3 +433,41 @@ border-radius: 13px; overflow: hidden; } + +/* Details/Summary styling */ +.details { + border: 1px solid var(--theme-quoteBar); + border-radius: 6px; + padding: 0.75rem; + margin: calc(var(--grid-gap) * 0.5) 0; + background: var(--bs-body-bg); + transition: all 0.2s ease; +} + +.details[open] { + background: color-mix(in srgb, var(--bs-body-bg) 97%, white); +} + +.summary { + cursor: pointer; + user-select: none; + padding: 0.25rem 0; + margin: -0.25rem 0; + color: var(--bs-info); + font-weight: 500; +} + +.summary:hover { + color: color-mix(in srgb, var(--bs-info) 85%, white); +} + +.summary::marker, +.summary::-webkit-details-marker { + color: var(--bs-info); +} + +.details[open] > .summary { + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--theme-quoteBar); +} From 7bbadb97f67250b69dd54bef7696493ea8129eb2 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Mon, 2 Dec 2024 02:14:00 -0500 Subject: [PATCH 16/23] Remove old file as reference --- lib/rehype-sn-OLD.js | 277 ------------------------------------------- lib/rehype-sn.js | 4 - 2 files changed, 281 deletions(-) delete mode 100644 lib/rehype-sn-OLD.js diff --git a/lib/rehype-sn-OLD.js b/lib/rehype-sn-OLD.js deleted file mode 100644 index fb35bf4bd..000000000 --- a/lib/rehype-sn-OLD.js +++ /dev/null @@ -1,277 +0,0 @@ -import { SKIP, visit } from 'unist-util-visit' -import { parseEmbedUrl, parseInternalLinks } from './url' -import { slug } from 'github-slugger' -import { toString } from 'mdast-util-to-string' - -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 - -export default function rehypeSN (options = {}) { - const { stylers = [] } = options - - return function transformer (tree) { - try { - visit(tree, (node, index, parent) => { - // Handle inline code property - if (node.tagName === 'code') { - node.properties.inline = !(parent && parent.tagName === 'pre') - } - - // handle headings - if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) { - 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', - tagName: 'headlink', - properties: { - href: `#${headingId}` - }, - children: [{ type: 'text', value: nodeText }] - } - - // Replace the heading's children with the new link element - node.children = [linkElement] - return [SKIP] - } - - // if img is wrapped in a link, remove the link - if (node.tagName === 'a' && node.children.length === 1 && node.children[0].tagName === 'img') { - parent.children[index] = node.children[0] - return index - } - - // handle internal links - if (node.tagName === 'a') { - try { - if (node.properties.href.includes('#itemfn-')) { - node.tagName = 'footnote' - } else { - const { itemId, linkText } = parseInternalLinks(node.properties.href) - if (itemId) { - node.tagName = 'item' - node.properties.id = itemId - if (node.properties.href === toString(node)) { - node.children[0].value = linkText - } - } - } - } catch { - // ignore errors like invalid URLs - } - } - - // 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) { - const embed = parseEmbedUrl(node.properties.href) - if (embed) { - node.tagName = 'embed' - node.properties = { ...embed, src: node.properties.href } - } else { - node.tagName = 'autolink' - } - } - - // if the link text is a URL, just show the URL - if (node.tagName === 'a' && isMisleadingLink(toString(node), node.properties.href)) { - node.children = [{ type: 'text', value: node.properties.href }] - return [SKIP] - } - - // Handle @mentions and ~subs - if (node.type === 'text') { - const newChildren = [] - let lastIndex = 0 - let match - let childrenConsumed = 1 - let text = toString(node) - - const combinedRegex = new RegExp(mentionRegex.source + '|' + subRegex.source, 'gi') - - // handle @__username__ or ~__sub__ - if (['@', '~'].includes(node.value) && - parent.children[index + 1]?.tagName === 'strong' && - parent.children[index + 1].children[0]?.type === 'text') { - childrenConsumed = 2 - text = node.value + '__' + toString(parent.children[index + 1]) + '__' - } - - while ((match = combinedRegex.exec(text)) !== null) { - if (lastIndex < match.index) { - newChildren.push({ type: 'text', value: text.slice(lastIndex, match.index) }) - } - - const [fullMatch, mentionMatch, subMatch] = match - const replacement = mentionMatch ? replaceMention(fullMatch, mentionMatch) : replaceSub(fullMatch, subMatch) - - if (replacement) { - newChildren.push(replacement) - } else { - newChildren.push({ type: 'text', value: fullMatch }) - } - - lastIndex = combinedRegex.lastIndex - } - - if (newChildren.length > 0) { - if (lastIndex < text.length) { - newChildren.push({ type: 'text', value: text.slice(lastIndex) }) - } - parent.children.splice(index, childrenConsumed, ...newChildren) - return index + newChildren.length - } - } - - // Handle Nostr IDs - if (node.type === 'text') { - const newChildren = [] - let lastIndex = 0 - let match - - while ((match = nostrIdRegex.exec(node.value)) !== null) { - if (lastIndex < match.index) { - newChildren.push({ type: 'text', value: node.value.slice(lastIndex, match.index) }) - } - - newChildren.push(replaceNostrId(match[0], match[0])) - - lastIndex = nostrIdRegex.lastIndex - } - - if (lastIndex < node.value.length) { - newChildren.push({ type: 'text', value: node.value.slice(lastIndex) }) - } - - if (newChildren.length > 0) { - parent.children.splice(index, 1, ...newChildren) - return index + newChildren.length - } - } - - // handle custom tags - if (node.type === 'element') { - 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) - - if (start?.type === 'raw' && start?.value === startTag && - text?.type === 'text' && - end?.type === 'raw' && end?.value === endTag) { - const newChild = { - type: 'element', - tagName: 'span', - properties: { className: [className] }, - children: [{ type: 'text', value: text.value }] - } - node.children.splice(i, 3, newChild) - } - } - } - } - - // merge adjacent images and empty paragraphs into a single image collage - if ((node.tagName === 'img' || isImageOnlyParagraph(node)) && Array.isArray(parent.children)) { - const adjacentNodes = [node] - let nextIndex = index + 1 - const siblings = parent.children - const somethingBefore = parent.children[index - 1] && parent.children[index - 1].tagName !== 'p' - let somethingAfter = false - - while (nextIndex < siblings.length) { - const nextNode = siblings[nextIndex] - if (!nextNode) break - if (nextNode.tagName === 'img' || isImageOnlyParagraph(nextNode)) { - adjacentNodes.push(nextNode) - nextIndex++ - } else if (nextNode.type === 'text' && typeof nextNode.value === 'string' && !nextNode.value.trim()) { - nextIndex++ - } else { - somethingAfter = true - break - } - } - - if (adjacentNodes.length > 0) { - const allImages = adjacentNodes.flatMap(n => - n.tagName === 'img' ? [n] : (Array.isArray(n.children) ? n.children.filter(child => child.tagName === 'img') : []) - ) - const collageNode = { - type: 'element', - tagName: 'p', - children: allImages, - properties: { onlyImages: true, somethingBefore, somethingAfter } - } - parent.children.splice(index, nextIndex - index, collageNode) - return index + 1 - } - } - }) - } catch (error) { - console.error('Error in rehypeSN transformer:', error) - } - - 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 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 isMisleadingLink (text, href) { - let misleading = false - - if (/^\s*(\w+\.)+\w+/.test(text)) { - try { - const hrefUrl = new URL(href) - - if (new URL(hrefUrl.protocol + text).origin !== hrefUrl.origin) { - misleading = true - } - } catch {} - } - - return misleading - } - - function replaceNostrId (value, id) { - return { - type: 'element', - tagName: 'a', - properties: { href: `https://njump.me/${id}` }, - children: [{ type: 'text', value }] - } - } -} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index d7fbeffb5..f7d5baf38 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -4,10 +4,8 @@ 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 { fromMarkdown } from 'mdast-util-from-markdown' import { gfm } from 'micromark-extension-gfm' import { gfmFromMarkdown } from 'mdast-util-gfm' -import { toHast } from 'mdast-util-to-hast' const userGroup = '[\\w_]+' const subGroup = '[A-Za-z][\\w_]+' @@ -15,8 +13,6 @@ 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 -const detailsRegex = /<details>([\s\S]*?)<\/details>/ -const summaryRegex = /<summary>([\s\S]*?)<\/summary>/ export default function rehypeSN (options = {}) { const { stylers = [] } = options From 77428a9317da9fcc146daef52676e6a34deaa3c4 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Mon, 2 Dec 2024 02:27:21 -0500 Subject: [PATCH 17/23] Linting & remove isOpen state from Details component (already handled) --- components/text.js | 21 +++-------- lib/rehype-sn.js | 88 +++++++--------------------------------------- 2 files changed, 18 insertions(+), 91 deletions(-) diff --git a/components/text.js b/components/text.js index dfa0d9b3a..6e72095d4 100644 --- a/components/text.js +++ b/components/text.js @@ -127,9 +127,6 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child embed: Embed, details: Details, summary: Summary - embed: Embed, - details: Details, - summary: Summary, }), [outlawed, rel, TextMediaOrLink, topLevel]) const carousel = useCarousel() @@ -228,7 +225,6 @@ function Table ({ node, ...props }) { ) } - function Code ({ node, inline, className, children, style, ...props }) { return inline ? ( @@ -254,25 +250,18 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr ) } -function Details({ children, node, ...props }) { - const [isOpen, setIsOpen] = useState(false) - - const handleToggle = (e) => { - setIsOpen(e.target.open) - } - +function Details ({ children, node, ...props }) { return ( - <details - className={styles.details} - {...props} - onToggle={handleToggle} + <details + className={styles.details} + {...props} > {children} </details> ) } -function Summary({ children, node, ...props }) { +function Summary ({ children, node, ...props }) { return ( <summary className={styles.summary} {...props}> {children} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index f7d5baf38..ffe3a9f95 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -19,12 +19,12 @@ export default function rehypeSN (options = {}) { return function transformer (tree) { try { - let detailsStack = [] // Track nested details processing + const detailsStack = [] // Track nested details processing visit(tree, (node, index, parent) => { // Handle details tags - supports both single-line and multi-line formats if (node.type === 'raw' && ( - node.value.includes('<details>') || + node.value.includes('<details>') || node.value.includes('<details >') )) { // Single-line details tag handling (e.g., <details>content</details>) @@ -33,21 +33,21 @@ export default function rehypeSN (options = {}) { let content = fullContent .replace(/<details>|<details >/, '') .replace('</details>', '') - + let summaryText = 'Details' - + // Extract summary if present if (content.includes('<summary>') && content.includes('</summary>')) { const summaryStart = content.indexOf('<summary>') + 9 const summaryEnd = content.indexOf('</summary>') summaryText = content.slice(summaryStart, summaryEnd) - + // Combine content before and after summary const beforeSummary = content.slice(0, content.indexOf('<summary>')).trim() const afterSummary = content.slice(summaryEnd + 10).trim() content = [beforeSummary, afterSummary].filter(Boolean).join('\n') } - + // Create and replace with new details node const newDetailsNode = createDetails( content.trim(), @@ -56,7 +56,7 @@ export default function rehypeSN (options = {}) { parent.children[index] = newDetailsNode return index } - + // Initialize state for multi-line details processing detailsStack.push({ startIndex: index, @@ -80,7 +80,7 @@ export default function rehypeSN (options = {}) { // Handle details closing tag if (node.type === 'raw' && node.value.includes('</details>')) { detailsStack.pop() - + // Collect any remaining content const beforeClosing = node.value.split('</details>')[0] if (beforeClosing) { @@ -135,9 +135,9 @@ export default function rehypeSN (options = {}) { } } else if (node.type === 'element') { state.contentBuffer.push(toString(node)) - } else if (node.type === 'raw' && - !node.value.includes('<details>') && - !node.value.includes('</details>')) { + } else if (node.type === 'raw' && + !node.value.includes('<details>') && + !node.value.includes('</details>')) { state.contentBuffer.push(node.value) } } @@ -403,7 +403,7 @@ export default function rehypeSN (options = {}) { } // Creates a details node with proper markdown parsing for both summary and content - function createDetails(markdownContent, summaryText) { + function createDetails (markdownContent, summaryText) { // Parse both summary and content as markdown const mdastSummary = fromMarkdown(summaryText, { extensions: [gfm()], @@ -423,7 +423,7 @@ export default function rehypeSN (options = {}) { const flattenBlockElements = (node) => { if (node.type === 'text') return [node] if (!node.children) return [] - + return node.children.flatMap(child => { if (child.type === 'text') return [child] if (child.type === 'element') { @@ -456,66 +456,4 @@ export default function rehypeSN (options = {}) { ] } } - - 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()) - ) - } } - - -// Different structures that must be handled properly and not break details/summary - -// Structure 1: - -// <details>no summary tags -// lorem ipsum -// and text on a new line</details> - -// ---------- - -// Structure 2: - -// <details> -// <summary>text inside summary</summary> - -// lorem ipsum -// </details> - -// ---------- - -// Structure 3: - -// <details> -// <summary>summary indentation single line</summary> -// 1. first thing -// 2. second thing -// 3. third thing -// </details> - -// ---------- - -// Structure 4: - -// <details> -// <summary> -// summary text here -// </summary> -// text inside details with *markdown* working **properly** -// </details> - -// ---------- - -// Structure 5: - -// <details> -// <summary>Shopping list</summary> -// - Vegetables -// - Fruits -// - Fish -// </details> From f19fce26a7465ecdac7ae3c267f9ba85f27434c7 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Mon, 16 Dec 2024 00:23:31 -0500 Subject: [PATCH 18/23] Structure 1 & 1A perfect beheavior --- components/text.js | 9 +- components/text.module.css | 36 --- lib/rehype-sn.js | 510 ++++++++++++++++++++++--------------- 3 files changed, 304 insertions(+), 251 deletions(-) diff --git a/components/text.js b/components/text.js index d38431495..36b06cf0e 100644 --- a/components/text.js +++ b/components/text.js @@ -255,18 +255,15 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr ) } -function Details ({ children, node, ...props }) { +function Details({ children, node, ...props }) { return ( - <details - className={styles.details} - {...props} - > + <details className={styles.details} {...props}> {children} </details> ) } -function Summary ({ children, node, ...props }) { +function Summary({ children, node, ...props }) { return ( <summary className={styles.summary} {...props}> {children} diff --git a/components/text.module.css b/components/text.module.css index 82f6535de..b526da1ac 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -439,39 +439,3 @@ } /* Details/Summary styling */ -.details { - border: 1px solid var(--theme-quoteBar); - border-radius: 6px; - padding: 0.75rem; - margin: calc(var(--grid-gap) * 0.5) 0; - background: var(--bs-body-bg); - transition: all 0.2s ease; -} - -.details[open] { - background: color-mix(in srgb, var(--bs-body-bg) 97%, white); -} - -.summary { - cursor: pointer; - user-select: none; - padding: 0.25rem 0; - margin: -0.25rem 0; - color: var(--bs-info); - font-weight: 500; -} - -.summary:hover { - color: color-mix(in srgb, var(--bs-info) 85%, white); -} - -.summary::marker, -.summary::-webkit-details-marker { - color: var(--bs-info); -} - -.details[open] > .summary { - margin-bottom: 0.75rem; - padding-bottom: 0.75rem; - border-bottom: 1px solid var(--theme-quoteBar); -} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index ffe3a9f95..dcb791499 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -9,145 +9,254 @@ 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 -export default function rehypeSN (options = {}) { +// Helper to safely stringify node content +function safeStringify(obj, depth = 0) { + if (depth > 2) return '[Nested Object]' // Prevent infinite recursion + try { + return JSON.stringify(obj, (key, value) => { + if (key === 'parent') return '[Parent]' // Skip circular parent refs + if (typeof value === 'object' && value !== null && depth < 2) { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, safeStringify(v, depth + 1)]) + ) + } + return value + }, 2) + } catch (e) { + return String(obj) + } +} + +// Helper to print node info +function logNode(prefix, node, detailed = false) { + const nodeInfo = { + type: node.type, + tagName: node.tagName, + childCount: node.children?.length, + value: node.type === 'text' || node.type === 'raw' + ? node.value.substring(0, 100) + (node.value.length > 100 ? '...' : '') + : undefined, + properties: node.properties + } + + console.log(`${prefix} Node:`, safeStringify(nodeInfo)) + + if (detailed && node.children?.length > 0) { + console.log(`${prefix} Children:`) + node.children.forEach((child, i) => { + logNode(`${prefix} [${i}]`, child) + }) + } +} + +export default function rehypeSN(options = {}) { const { stylers = [] } = options - return function transformer (tree) { + return function transformer(tree) { try { - const detailsStack = [] // Track nested details processing - visit(tree, (node, index, parent) => { - // Handle details tags - supports both single-line and multi-line formats - if (node.type === 'raw' && ( - node.value.includes('<details>') || - node.value.includes('<details >') - )) { - // Single-line details tag handling (e.g., <details>content</details>) - if (node.value.includes('</details>')) { - const fullContent = node.value - let content = fullContent - .replace(/<details>|<details >/, '') - .replace('</details>', '') - - let summaryText = 'Details' + // Handle details/summary tags + if (node.type === 'raw' && node.value.includes('<details>')) { + console.log('\nš Found details tag at index:', index) + logNode('š Details', node, true) + + const detailsContent = { + summary: { + content: [], + found: false, + complete: false + }, + content: [], + startIndex: index, + endIndex: index + } + // Handle self-contained details block + if (node.value.includes('</details>')) { + console.log('š Found self-contained details block') + let content = node.value + // Extract summary if present - if (content.includes('<summary>') && content.includes('</summary>')) { - const summaryStart = content.indexOf('<summary>') + 9 - const summaryEnd = content.indexOf('</summary>') - summaryText = content.slice(summaryStart, summaryEnd) - - // Combine content before and after summary - const beforeSummary = content.slice(0, content.indexOf('<summary>')).trim() - const afterSummary = content.slice(summaryEnd + 10).trim() - content = [beforeSummary, afterSummary].filter(Boolean).join('\n') + const summaryMatch = content.match(/<summary>(.*?)<\/summary>/s) + if (summaryMatch) { + detailsContent.summary.content.push({ + type: 'text', + value: summaryMatch[1].trim() + }) + detailsContent.summary.complete = true + content = content.replace(/<summary>.*?<\/summary>/s, '') + } + + // Clean remaining content + const cleanedContent = content + .replace(/<details>/g, '') + .replace(/<\/details>/g, '') + .trim() + + if (cleanedContent) { + console.log('š Keeping content from self-contained block:', cleanedContent) + detailsContent.content.push({ + type: 'text', + value: cleanedContent + }) } - // Create and replace with new details node - const newDetailsNode = createDetails( - content.trim(), - summaryText - ) - parent.children[index] = newDetailsNode - return index + return createDetailsElement(detailsContent, parent, index) } - // Initialize state for multi-line details processing - detailsStack.push({ - startIndex: index, - contentBuffer: [], - summaryBuffer: [], - inSummary: false, - foundSummary: false, - summaryText: '', - detailsContent: [], - processedNodes: new Set() - }) - return - } - - // Process multi-line details content - if (detailsStack.length > 0) { - const state = detailsStack[detailsStack.length - 1] - if (state.processedNodes.has(index)) return - state.processedNodes.add(index) - - // Handle details closing tag - if (node.type === 'raw' && node.value.includes('</details>')) { - detailsStack.pop() + // Clean opening details tag and handle potential summary + let cleanedContent = node.value.replace(/<details>/g, '') + + // Check for summary in opening node + const summaryMatch = cleanedContent.match(/<summary>(.*?)<\/summary>/s) + if (summaryMatch) { + detailsContent.summary.content.push({ + type: 'text', + value: summaryMatch[1].trim() + }) + detailsContent.summary.complete = true + cleanedContent = cleanedContent.replace(/<summary>.*?<\/summary>/s, '') + } + + if (cleanedContent.trim()) { + console.log('š Keeping content from opening tag node:', cleanedContent) + detailsContent.content.push({ + type: 'text', + value: cleanedContent.trim() + }) + } - // Collect any remaining content - const beforeClosing = node.value.split('</details>')[0] - if (beforeClosing) { - state.contentBuffer.push(beforeClosing) + // Collect remaining content + console.log('\nš Starting content collection...') + let currentIndex = index + let foundClosing = false + + while (currentIndex < parent.children.length) { + const currentNode = parent.children[++currentIndex] + if (!currentNode) break + + console.log(`\nš Examining node at index ${currentIndex}:`) + logNode(' ', currentNode) + + // Handle summary tags if we haven't found a complete summary yet + if (!detailsContent.summary.complete) { + if (currentNode.type === 'raw' && currentNode.value.includes('<summary>')) { + detailsContent.summary.found = true + const summaryMatch = currentNode.value.match(/<summary>(.*?)<\/summary>/s) + if (summaryMatch) { + // Complete summary found in one node + detailsContent.summary.content.push({ + type: 'text', + value: summaryMatch[1].trim() + }) + detailsContent.summary.complete = true + continue + } + // If no match, it means the summary continues in next nodes + const afterOpen = currentNode.value.replace(/<summary>/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('</summary>')) { + 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 + } + } } - // Process collected content - const content = state.contentBuffer - .filter(Boolean) - .join('\n') - .replace(/\n{3,}/g, '\n\n') - .trim() + // Check for closing details tag + const hasClosingTag = (currentNode.type === 'raw' && currentNode.value.includes('</details>')) || + (currentNode.type === 'element' && toString(currentNode).includes('</details>')) + + if (hasClosingTag) { + let cleanedContent + if (currentNode.type === 'raw') { + const textBeforeClosing = currentNode.value.substring(0, currentNode.value.indexOf('</details>')) + 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) + } + } + } - const markdownContent = state.detailsContent - .map(node => toString(node)) - .join('\n') + console.log('ā Found closing details tag') + detailsContent.endIndex = currentIndex + foundClosing = true + break + } - // Create and replace with new details node - const newDetailsNode = createDetails( - content || markdownContent, - state.summaryText ? state.summaryText.trim() : 'Details' - ) - parent.children.splice(state.startIndex, index - state.startIndex + 1, newDetailsNode) - return state.startIndex + // Add to main content if not part of summary + if (currentNode.type === 'text' || currentNode.type === 'element') { + detailsContent.content.push(currentNode) + } } - // Handle summary tags - if (node.type === 'raw' && node.value.includes('<summary>')) { - state.inSummary = true - state.foundSummary = true - const parts = node.value.split('<summary>') - if (parts[0]) state.contentBuffer.push(parts[0]) - if (parts[1]) state.summaryBuffer.push(parts[1]) - } else if (node.type === 'raw' && node.value.includes('</summary>')) { - state.inSummary = false - const parts = node.value.split('</summary>') - if (parts[0]) state.summaryBuffer.push(parts[0]) - if (parts[1]) state.contentBuffer.push(parts[1]) - state.summaryText = state.summaryBuffer.join('\n') - } else if (state.inSummary) { - // Collect summary content - if (node.type === 'text' || node.type === 'raw') { - state.summaryBuffer.push(node.value) - } else if (node.type === 'element') { - state.summaryBuffer.push(toString(node)) - } - } else { - // Collect details content - if (node.type === 'text') { - if (node.value.trim() || node.value === '\n') { - state.contentBuffer.push(node.value) - } - } else if (node.type === 'element') { - state.contentBuffer.push(toString(node)) - } else if (node.type === 'raw' && - !node.value.includes('<details>') && - !node.value.includes('</details>')) { - state.contentBuffer.push(node.value) - } + if (!foundClosing) { + console.log('ā ļø No closing tag found, skipping...') + return SKIP } - return + + return createDetailsElement(detailsContent, parent, index) } - // Handle inline code property + // Leave all other existing handlers unchanged if (node.tagName === 'code') { node.properties.inline = !(parent && parent.tagName === 'pre') } + // handle headings if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) { const nodeText = toString(node) @@ -195,8 +304,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' @@ -224,8 +333,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]) + '__' } @@ -268,7 +377,6 @@ export default function rehypeSN (options = {}) { } newChildren.push(replaceNostrId(match[0], match[0])) - lastIndex = nostrIdRegex.lastIndex } @@ -344,116 +452,100 @@ export default function rehypeSN (options = {}) { return tree } catch (error) { - console.error('Error in rehypeSN transformer:', error) + console.error('ā Error in rehypeSN transformer:', error) 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) { + console.log('\nšØ Creating details element with:', { + hasSummary: detailsContent.summary.complete, + contentNodes: detailsContent.content.length + }) + + 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) + console.log('⨠Added summary element') } - // Creates a details node with proper markdown parsing for both summary and content - function createDetails (markdownContent, summaryText) { - // Parse both summary and content as markdown - const mdastSummary = fromMarkdown(summaryText, { - extensions: [gfm()], - mdastExtensions: [gfmFromMarkdown()] - }) + // Add main content + detailsElement.children.push(...detailsContent.content) + console.log('⨠Added content elements') - const mdastContent = fromMarkdown(markdownContent, { - extensions: [gfm()], - mdastExtensions: [gfmFromMarkdown()] - }) + // Replace nodes + parent.children.splice( + detailsContent.startIndex, + detailsContent.endIndex - detailsContent.startIndex + 1, + detailsElement + ) - // Convert both to hast - const hastSummary = toHast(mdastSummary) - const hastContent = toHast(mdastContent) - - // Ensure summary content stays inline by flattening block elements - const flattenBlockElements = (node) => { - if (node.type === 'text') return [node] - if (!node.children) return [] - - return node.children.flatMap(child => { - if (child.type === 'text') return [child] - if (child.type === 'element') { - // Convert block elements to spans to keep them inline - if (['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(child.tagName)) { - return child.children.flatMap(flattenBlockElements) - } - return [child] - } - return flattenBlockElements(child) - }) - } - - const summaryChildren = hastSummary.children.flatMap(flattenBlockElements) - - // Create the details structure - return { - type: 'element', - tagName: 'details', - properties: {}, - children: [ - { - type: 'element', - tagName: 'summary', - properties: {}, - children: summaryChildren - }, - // Preserve block formatting in content - ...hastContent.children - ] - } - } -} + return [SKIP, detailsContent.endIndex] +} \ No newline at end of file From 7415f7d20e1b86544aa6aeb5c7115629723a0485 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Mon, 16 Dec 2024 01:06:58 -0500 Subject: [PATCH 19/23] Structures 1, 1A, 2, 2A perfect behavior --- lib/rehype-sn.js | 67 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index dcb791499..52d8d1e9d 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -83,6 +83,10 @@ export default function rehypeSN(options = {}) { // Extract summary if present const summaryMatch = content.match(/<summary>(.*?)<\/summary>/s) if (summaryMatch) { + console.log('š Found summary in self-contained block:', { + fullMatch: summaryMatch[0], + content: summaryMatch[1].trim() + }) detailsContent.summary.content.push({ type: 'text', value: summaryMatch[1].trim() @@ -114,6 +118,10 @@ export default function rehypeSN(options = {}) { // Check for summary in opening node const summaryMatch = cleanedContent.match(/<summary>(.*?)<\/summary>/s) if (summaryMatch) { + console.log('\nš Found summary in opening details node:', { + fullMatch: summaryMatch[0], + content: summaryMatch[1].trim() + }) detailsContent.summary.content.push({ type: 'text', value: summaryMatch[1].trim() @@ -145,20 +153,49 @@ export default function rehypeSN(options = {}) { // Handle summary tags if we haven't found a complete summary yet if (!detailsContent.summary.complete) { if (currentNode.type === 'raw' && currentNode.value.includes('<summary>')) { + console.log('\nš Found summary tag in node:', { + type: currentNode.type, + value: currentNode.value + }) detailsContent.summary.found = true const summaryMatch = currentNode.value.match(/<summary>(.*?)<\/summary>/s) if (summaryMatch) { + // Keep any text that appears before the summary tag + const beforeSummary = currentNode.value.substring(0, currentNode.value.indexOf('<summary>')).trim() + if (beforeSummary) { + console.log('š Keeping content before summary tag:', beforeSummary) + detailsContent.content.push({ + type: 'text', + value: beforeSummary + }) + } + // Complete summary found in one node + console.log('š„ Extracted summary content:', summaryMatch[1].trim()) 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('</summary>') + '</summary>'.length + ).trim() + + if (afterSummary) { + console.log('š Keeping content after summary tag:', 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(/<summary>/g, '').trim() if (afterOpen) { + console.log('š Found partial summary content:', afterOpen) detailsContent.summary.content.push({ type: 'text', value: afterOpen @@ -172,6 +209,7 @@ export default function rehypeSN(options = {}) { if (currentNode.type === 'raw' && currentNode.value.includes('</summary>')) { const beforeClose = currentNode.value.replace(/<\/summary>/g, '').trim() if (beforeClose) { + console.log('š Found closing summary content:', beforeClose) detailsContent.summary.content.push({ type: 'text', value: beforeClose @@ -182,6 +220,10 @@ export default function rehypeSN(options = {}) { } // Add to summary content if (currentNode.type === 'text' || currentNode.type === 'element') { + console.log('š Adding summary node:', { + type: currentNode.type, + content: currentNode.type === 'text' ? currentNode.value : '[element]' + }) detailsContent.summary.content.push(currentNode) continue } @@ -249,6 +291,31 @@ export default function rehypeSN(options = {}) { return SKIP } + // Add comprehensive logging of collected content + console.log('\nš¦ Final collected content:', { + summary: { + complete: detailsContent.summary.complete, + nodeCount: detailsContent.summary.content.length, + nodes: detailsContent.summary.content.map(node => ({ + type: node.type, + tagName: node.tagName, + value: node.type === 'text' ? node.value : undefined, + childCount: node.children?.length, + properties: node.properties + })) + }, + content: { + nodeCount: detailsContent.content.length, + nodes: detailsContent.content.map(node => ({ + type: node.type, + tagName: node.tagName, + value: node.type === 'text' ? node.value : undefined, + childCount: node.children?.length, + properties: node.properties + })) + } + }) + return createDetailsElement(detailsContent, parent, index) } From 0ee8584266a8cde4c7db412fddbefabd3ca644dd Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Tue, 17 Dec 2024 23:48:23 -0500 Subject: [PATCH 20/23] Working implementation w/ useful logs to test --- lib/rehype-sn.js | 2 +- test-cases.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 test-cases.md diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 52d8d1e9d..5e59d5197 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -615,4 +615,4 @@ function createDetailsElement(detailsContent, parent, index) { ) return [SKIP, detailsContent.endIndex] -} \ No newline at end of file +} 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 From c96a4d24efbc0de477b4149ccda258d44412313b Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Wed, 18 Dec 2024 00:43:11 -0500 Subject: [PATCH 21/23] Cleanup and reorg new code to bottom --- lib/rehype-sn.js | 492 +++++++++++++++++++---------------------------- 1 file changed, 198 insertions(+), 294 deletions(-) diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 5e59d5197..43290478d 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -13,57 +13,212 @@ const mentionRegex = new RegExp('@(' + userGroup + '(?:\\/' + userGroup + ')?)', const subRegex = new RegExp('~(' + subGroup + '(?:\\/' + subGroup + ')?)', 'gi') const nostrIdRegex = /\b((npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)\b/g -// Helper to safely stringify node content -function safeStringify(obj, depth = 0) { - if (depth > 2) return '[Nested Object]' // Prevent infinite recursion - try { - return JSON.stringify(obj, (key, value) => { - if (key === 'parent') return '[Parent]' // Skip circular parent refs - if (typeof value === 'object' && value !== null && depth < 2) { - return Object.fromEntries( - Object.entries(value).map(([k, v]) => [k, safeStringify(v, depth + 1)]) - ) - } - return value - }, 2) - } catch (e) { - return String(obj) - } -} - -// Helper to print node info -function logNode(prefix, node, detailed = false) { - const nodeInfo = { - type: node.type, - tagName: node.tagName, - childCount: node.children?.length, - value: node.type === 'text' || node.type === 'raw' - ? node.value.substring(0, 100) + (node.value.length > 100 ? '...' : '') - : undefined, - properties: node.properties - } - - console.log(`${prefix} Node:`, safeStringify(nodeInfo)) - - if (detailed && node.children?.length > 0) { - console.log(`${prefix} Children:`) - node.children.forEach((child, i) => { - logNode(`${prefix} [${i}]`, child) - }) - } -} - export default function rehypeSN(options = {}) { const { stylers = [] } = options return function transformer(tree) { try { visit(tree, (node, index, parent) => { + + // Leave all other existing handlers unchanged + if (node.tagName === 'code') { + node.properties.inline = !(parent && parent.tagName === 'pre') + } + + // handle headings + if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) { + 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', + tagName: 'headlink', + properties: { + href: `#${headingId}` + }, + children: [{ type: 'text', value: nodeText }] + } + // Replace the heading's children with the new link element + node.children = [linkElement] + return [SKIP] + } + + // if img is wrapped in a link, remove the link + if (node.tagName === 'a' && node.children.length === 1 && node.children[0].tagName === 'img') { + parent.children[index] = node.children[0] + return index + } + + // handle internal links + if (node.tagName === 'a') { + try { + if (node.properties.href.includes('#itemfn-')) { + node.tagName = 'footnote' + } else { + const { itemId, linkText } = parseInternalLinks(node.properties.href) + if (itemId) { + node.tagName = 'item' + node.properties.id = itemId + if (node.properties.href === toString(node)) { + node.children[0].value = linkText + } + } + } + } catch { + // ignore errors like invalid URLs + } + } + + // 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) { + const embed = parseEmbedUrl(node.properties.href) + if (embed) { + node.tagName = 'embed' + node.properties = { ...embed, src: node.properties.href } + } else { + node.tagName = 'autolink' + } + } + + // if the link text is a URL, just show the URL + if (node.tagName === 'a' && isMisleadingLink(toString(node), node.properties.href)) { + node.children = [{ type: 'text', value: node.properties.href }] + return [SKIP] + } + + // Handle @mentions and ~subs + if (node.type === 'text') { + const newChildren = [] + let lastIndex = 0 + let match + let childrenConsumed = 1 + let text = toString(node) + + const combinedRegex = new RegExp(mentionRegex.source + '|' + subRegex.source, 'gi') + + // handle @__username__ or ~__sub__ + if (['@', '~'].includes(node.value) && + parent.children[index + 1]?.tagName === 'strong' && + parent.children[index + 1].children[0]?.type === 'text') { + childrenConsumed = 2 + text = node.value + '__' + toString(parent.children[index + 1]) + '__' + } + + while ((match = combinedRegex.exec(text)) !== null) { + if (lastIndex < match.index) { + newChildren.push({ type: 'text', value: text.slice(lastIndex, match.index) }) + } + + const [fullMatch, mentionMatch, subMatch] = match + const replacement = mentionMatch ? replaceMention(fullMatch, mentionMatch) : replaceSub(fullMatch, subMatch) + + if (replacement) { + newChildren.push(replacement) + } else { + newChildren.push({ type: 'text', value: fullMatch }) + } + + lastIndex = combinedRegex.lastIndex + } + + if (newChildren.length > 0) { + if (lastIndex < text.length) { + newChildren.push({ type: 'text', value: text.slice(lastIndex) }) + } + parent.children.splice(index, childrenConsumed, ...newChildren) + return index + newChildren.length + } + } + + // Handle Nostr IDs + if (node.type === 'text') { + const newChildren = [] + let lastIndex = 0 + let match + + while ((match = nostrIdRegex.exec(node.value)) !== null) { + if (lastIndex < match.index) { + newChildren.push({ type: 'text', value: node.value.slice(lastIndex, match.index) }) + } + + newChildren.push(replaceNostrId(match[0], match[0])) + lastIndex = nostrIdRegex.lastIndex + } + + if (lastIndex < node.value.length) { + newChildren.push({ type: 'text', value: node.value.slice(lastIndex) }) + } + + if (newChildren.length > 0) { + parent.children.splice(index, 1, ...newChildren) + return index + newChildren.length + } + } + + // 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) + + if (start?.type === 'raw' && start?.value === startTag && + text?.type === 'text' && + end?.type === 'raw' && end?.value === endTag) { + const newChild = { + type: 'element', + tagName: 'span', + properties: { className: [className] }, + children: [{ type: 'text', value: text.value }] + } + node.children.splice(i, 3, newChild) + } + } + } + } + + // merge adjacent images and empty paragraphs into a single image collage + if ((node.tagName === 'img' || isImageOnlyParagraph(node)) && Array.isArray(parent.children)) { + const adjacentNodes = [node] + let nextIndex = index + 1 + const siblings = parent.children + const somethingBefore = parent.children[index - 1] && parent.children[index - 1].tagName !== 'p' + let somethingAfter = false + + while (nextIndex < siblings.length) { + const nextNode = siblings[nextIndex] + if (!nextNode) break + if (nextNode.tagName === 'img' || isImageOnlyParagraph(nextNode)) { + adjacentNodes.push(nextNode) + nextIndex++ + } else if (nextNode.type === 'text' && typeof nextNode.value === 'string' && !nextNode.value.trim()) { + nextIndex++ + } else { + somethingAfter = true + break + } + } + + if (adjacentNodes.length > 0) { + const allImages = adjacentNodes.flatMap(n => + n.tagName === 'img' ? [n] : (Array.isArray(n.children) ? n.children.filter(child => child.tagName === 'img') : []) + ) + const collageNode = { + type: 'element', + tagName: 'p', + children: allImages, + properties: { onlyImages: true, somethingBefore, somethingAfter } + } + parent.children.splice(index, nextIndex - index, collageNode) + return index + 1 + } + } + // Handle details/summary tags if (node.type === 'raw' && node.value.includes('<details>')) { - console.log('\nš Found details tag at index:', index) - logNode('š Details', node, true) - const detailsContent = { summary: { content: [], @@ -77,16 +232,11 @@ export default function rehypeSN(options = {}) { // Handle self-contained details block if (node.value.includes('</details>')) { - console.log('š Found self-contained details block') let content = node.value // Extract summary if present const summaryMatch = content.match(/<summary>(.*?)<\/summary>/s) if (summaryMatch) { - console.log('š Found summary in self-contained block:', { - fullMatch: summaryMatch[0], - content: summaryMatch[1].trim() - }) detailsContent.summary.content.push({ type: 'text', value: summaryMatch[1].trim() @@ -102,7 +252,6 @@ export default function rehypeSN(options = {}) { .trim() if (cleanedContent) { - console.log('š Keeping content from self-contained block:', cleanedContent) detailsContent.content.push({ type: 'text', value: cleanedContent @@ -118,10 +267,6 @@ export default function rehypeSN(options = {}) { // Check for summary in opening node const summaryMatch = cleanedContent.match(/<summary>(.*?)<\/summary>/s) if (summaryMatch) { - console.log('\nš Found summary in opening details node:', { - fullMatch: summaryMatch[0], - content: summaryMatch[1].trim() - }) detailsContent.summary.content.push({ type: 'text', value: summaryMatch[1].trim() @@ -131,7 +276,6 @@ export default function rehypeSN(options = {}) { } if (cleanedContent.trim()) { - console.log('š Keeping content from opening tag node:', cleanedContent) detailsContent.content.push({ type: 'text', value: cleanedContent.trim() @@ -139,7 +283,6 @@ export default function rehypeSN(options = {}) { } // Collect remaining content - console.log('\nš Starting content collection...') let currentIndex = index let foundClosing = false @@ -147,23 +290,15 @@ export default function rehypeSN(options = {}) { const currentNode = parent.children[++currentIndex] if (!currentNode) break - console.log(`\nš Examining node at index ${currentIndex}:`) - logNode(' ', currentNode) - // Handle summary tags if we haven't found a complete summary yet if (!detailsContent.summary.complete) { if (currentNode.type === 'raw' && currentNode.value.includes('<summary>')) { - console.log('\nš Found summary tag in node:', { - type: currentNode.type, - value: currentNode.value - }) detailsContent.summary.found = true const summaryMatch = currentNode.value.match(/<summary>(.*?)<\/summary>/s) if (summaryMatch) { // Keep any text that appears before the summary tag const beforeSummary = currentNode.value.substring(0, currentNode.value.indexOf('<summary>')).trim() if (beforeSummary) { - console.log('š Keeping content before summary tag:', beforeSummary) detailsContent.content.push({ type: 'text', value: beforeSummary @@ -171,7 +306,6 @@ export default function rehypeSN(options = {}) { } // Complete summary found in one node - console.log('š„ Extracted summary content:', summaryMatch[1].trim()) detailsContent.summary.content.push({ type: 'text', value: summaryMatch[1].trim() @@ -184,7 +318,6 @@ export default function rehypeSN(options = {}) { ).trim() if (afterSummary) { - console.log('š Keeping content after summary tag:', afterSummary) detailsContent.content.push({ type: 'text', value: afterSummary @@ -195,7 +328,6 @@ export default function rehypeSN(options = {}) { // If no match, it means the summary continues in next nodes const afterOpen = currentNode.value.replace(/<summary>/g, '').trim() if (afterOpen) { - console.log('š Found partial summary content:', afterOpen) detailsContent.summary.content.push({ type: 'text', value: afterOpen @@ -209,7 +341,6 @@ export default function rehypeSN(options = {}) { if (currentNode.type === 'raw' && currentNode.value.includes('</summary>')) { const beforeClose = currentNode.value.replace(/<\/summary>/g, '').trim() if (beforeClose) { - console.log('š Found closing summary content:', beforeClose) detailsContent.summary.content.push({ type: 'text', value: beforeClose @@ -220,10 +351,6 @@ export default function rehypeSN(options = {}) { } // Add to summary content if (currentNode.type === 'text' || currentNode.type === 'element') { - console.log('š Adding summary node:', { - type: currentNode.type, - content: currentNode.type === 'text' ? currentNode.value : '[element]' - }) detailsContent.summary.content.push(currentNode) continue } @@ -274,7 +401,6 @@ export default function rehypeSN(options = {}) { } } - console.log('ā Found closing details tag') detailsContent.endIndex = currentIndex foundClosing = true break @@ -287,234 +413,12 @@ export default function rehypeSN(options = {}) { } if (!foundClosing) { - console.log('ā ļø No closing tag found, skipping...') return SKIP } - // Add comprehensive logging of collected content - console.log('\nš¦ Final collected content:', { - summary: { - complete: detailsContent.summary.complete, - nodeCount: detailsContent.summary.content.length, - nodes: detailsContent.summary.content.map(node => ({ - type: node.type, - tagName: node.tagName, - value: node.type === 'text' ? node.value : undefined, - childCount: node.children?.length, - properties: node.properties - })) - }, - content: { - nodeCount: detailsContent.content.length, - nodes: detailsContent.content.map(node => ({ - type: node.type, - tagName: node.tagName, - value: node.type === 'text' ? node.value : undefined, - childCount: node.children?.length, - properties: node.properties - })) - } - }) - return createDetailsElement(detailsContent, parent, index) } - // Leave all other existing handlers unchanged - if (node.tagName === 'code') { - node.properties.inline = !(parent && parent.tagName === 'pre') - } - - // handle headings - if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) { - 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', - tagName: 'headlink', - properties: { - href: `#${headingId}` - }, - children: [{ type: 'text', value: nodeText }] - } - // Replace the heading's children with the new link element - node.children = [linkElement] - return [SKIP] - } - - // if img is wrapped in a link, remove the link - if (node.tagName === 'a' && node.children.length === 1 && node.children[0].tagName === 'img') { - parent.children[index] = node.children[0] - return index - } - - // handle internal links - if (node.tagName === 'a') { - try { - if (node.properties.href.includes('#itemfn-')) { - node.tagName = 'footnote' - } else { - const { itemId, linkText } = parseInternalLinks(node.properties.href) - if (itemId) { - node.tagName = 'item' - node.properties.id = itemId - if (node.properties.href === toString(node)) { - node.children[0].value = linkText - } - } - } - } catch { - // ignore errors like invalid URLs - } - } - - // 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) { - const embed = parseEmbedUrl(node.properties.href) - if (embed) { - node.tagName = 'embed' - node.properties = { ...embed, src: node.properties.href } - } else { - node.tagName = 'autolink' - } - } - - // if the link text is a URL, just show the URL - if (node.tagName === 'a' && isMisleadingLink(toString(node), node.properties.href)) { - node.children = [{ type: 'text', value: node.properties.href }] - return [SKIP] - } - - // Handle @mentions and ~subs - if (node.type === 'text') { - const newChildren = [] - let lastIndex = 0 - let match - let childrenConsumed = 1 - let text = toString(node) - - const combinedRegex = new RegExp(mentionRegex.source + '|' + subRegex.source, 'gi') - - // handle @__username__ or ~__sub__ - if (['@', '~'].includes(node.value) && - parent.children[index + 1]?.tagName === 'strong' && - parent.children[index + 1].children[0]?.type === 'text') { - childrenConsumed = 2 - text = node.value + '__' + toString(parent.children[index + 1]) + '__' - } - - while ((match = combinedRegex.exec(text)) !== null) { - if (lastIndex < match.index) { - newChildren.push({ type: 'text', value: text.slice(lastIndex, match.index) }) - } - - const [fullMatch, mentionMatch, subMatch] = match - const replacement = mentionMatch ? replaceMention(fullMatch, mentionMatch) : replaceSub(fullMatch, subMatch) - - if (replacement) { - newChildren.push(replacement) - } else { - newChildren.push({ type: 'text', value: fullMatch }) - } - - lastIndex = combinedRegex.lastIndex - } - - if (newChildren.length > 0) { - if (lastIndex < text.length) { - newChildren.push({ type: 'text', value: text.slice(lastIndex) }) - } - parent.children.splice(index, childrenConsumed, ...newChildren) - return index + newChildren.length - } - } - - // Handle Nostr IDs - if (node.type === 'text') { - const newChildren = [] - let lastIndex = 0 - let match - - while ((match = nostrIdRegex.exec(node.value)) !== null) { - if (lastIndex < match.index) { - newChildren.push({ type: 'text', value: node.value.slice(lastIndex, match.index) }) - } - - newChildren.push(replaceNostrId(match[0], match[0])) - lastIndex = nostrIdRegex.lastIndex - } - - if (lastIndex < node.value.length) { - newChildren.push({ type: 'text', value: node.value.slice(lastIndex) }) - } - - if (newChildren.length > 0) { - parent.children.splice(index, 1, ...newChildren) - return index + newChildren.length - } - } - - // 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) - - if (start?.type === 'raw' && start?.value === startTag && - text?.type === 'text' && - end?.type === 'raw' && end?.value === endTag) { - const newChild = { - type: 'element', - tagName: 'span', - properties: { className: [className] }, - children: [{ type: 'text', value: text.value }] - } - node.children.splice(i, 3, newChild) - } - } - } - } - - // merge adjacent images and empty paragraphs into a single image collage - if ((node.tagName === 'img' || isImageOnlyParagraph(node)) && Array.isArray(parent.children)) { - const adjacentNodes = [node] - let nextIndex = index + 1 - const siblings = parent.children - const somethingBefore = parent.children[index - 1] && parent.children[index - 1].tagName !== 'p' - let somethingAfter = false - - while (nextIndex < siblings.length) { - const nextNode = siblings[nextIndex] - if (!nextNode) break - if (nextNode.tagName === 'img' || isImageOnlyParagraph(nextNode)) { - adjacentNodes.push(nextNode) - nextIndex++ - } else if (nextNode.type === 'text' && typeof nextNode.value === 'string' && !nextNode.value.trim()) { - nextIndex++ - } else { - somethingAfter = true - break - } - } - - if (adjacentNodes.length > 0) { - const allImages = adjacentNodes.flatMap(n => - n.tagName === 'img' ? [n] : (Array.isArray(n.children) ? n.children.filter(child => child.tagName === 'img') : []) - ) - const collageNode = { - type: 'element', - tagName: 'p', - children: allImages, - properties: { onlyImages: true, somethingBefore, somethingAfter } - } - parent.children.splice(index, nextIndex - index, collageNode) - return index + 1 - } - } }) return tree From 41e362e1e5f5f130955175d97b4fa25a83f61803 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Wed, 18 Dec 2024 01:18:26 -0500 Subject: [PATCH 22/23] linting --- components/text.js | 4 +-- components/text.module.css | 39 +++++++++++++++++++++++++++++ lib/rehype-sn.js | 50 +++++++++++++++----------------------- 3 files changed, 61 insertions(+), 32 deletions(-) diff --git a/components/text.js b/components/text.js index 36b06cf0e..757a568e5 100644 --- a/components/text.js +++ b/components/text.js @@ -255,7 +255,7 @@ function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...pr ) } -function Details({ children, node, ...props }) { +function Details ({ children, node, ...props }) { return ( <details className={styles.details} {...props}> {children} @@ -263,7 +263,7 @@ function Details({ children, node, ...props }) { ) } -function Summary({ children, node, ...props }) { +function Summary ({ children, node, ...props }) { return ( <summary className={styles.summary} {...props}> {children} diff --git a/components/text.module.css b/components/text.module.css index b526da1ac..7b5dc2fd8 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -439,3 +439,42 @@ } /* Details/Summary styling */ +.details { + border-left: 2px solid var(--theme-quoteBar); + padding-left: 0.75rem; + margin: calc(var(--grid-gap) * 0.5) 0; + transition: border-color 0.2s ease; +} + +.details[open] { + border-left-color: #f7931a; +} + +.summary { + cursor: pointer; + user-select: none; + color: #8b949e; + transition: color 0.2s ease; + padding: 0.25rem 0; +} + +.summary:hover { + color: #ffd700; +} + + +.summary::marker, +.summary::-webkit-details-marker { + color: #8b949e; +} + +.details[open] > .summary::marker, +.details[open] > .summary::-webkit-details-marker { + color: #f7931a; +} + + +.details > *:not(.summary) { + margin-top: calc(var(--grid-gap) * 0.5); + padding-bottom: calc(var(--grid-gap) * 0.25); +} diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 43290478d..6ee4241ab 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -13,14 +13,13 @@ const mentionRegex = new RegExp('@(' + userGroup + '(?:\\/' + userGroup + ')?)', const subRegex = new RegExp('~(' + subGroup + '(?:\\/' + subGroup + ')?)', 'gi') const nostrIdRegex = /\b((npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)\b/g -export default function rehypeSN(options = {}) { +export default function rehypeSN (options = {}) { const { stylers = [] } = options - return function transformer(tree) { + return function transformer (tree) { try { visit(tree, (node, index, parent) => { - - // Leave all other existing handlers unchanged + // Handle inline code property if (node.tagName === 'code') { node.properties.inline = !(parent && parent.tagName === 'pre') } @@ -233,7 +232,7 @@ export default function rehypeSN(options = {}) { // Handle self-contained details block if (node.value.includes('</details>')) { let content = node.value - + // Extract summary if present const summaryMatch = content.match(/<summary>(.*?)<\/summary>/s) if (summaryMatch) { @@ -244,13 +243,13 @@ export default function rehypeSN(options = {}) { detailsContent.summary.complete = true content = content.replace(/<summary>.*?<\/summary>/s, '') } - + // Clean remaining content const cleanedContent = content .replace(/<details>/g, '') .replace(/<\/details>/g, '') .trim() - + if (cleanedContent) { detailsContent.content.push({ type: 'text', @@ -263,7 +262,7 @@ export default function rehypeSN(options = {}) { // Clean opening details tag and handle potential summary let cleanedContent = node.value.replace(/<details>/g, '') - + // Check for summary in opening node const summaryMatch = cleanedContent.match(/<summary>(.*?)<\/summary>/s) if (summaryMatch) { @@ -274,7 +273,7 @@ export default function rehypeSN(options = {}) { detailsContent.summary.complete = true cleanedContent = cleanedContent.replace(/<summary>.*?<\/summary>/s, '') } - + if (cleanedContent.trim()) { detailsContent.content.push({ type: 'text', @@ -285,11 +284,11 @@ export default function rehypeSN(options = {}) { // 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('<summary>')) { @@ -316,7 +315,7 @@ export default function rehypeSN(options = {}) { const afterSummary = currentNode.value.substring( currentNode.value.indexOf('</summary>') + '</summary>'.length ).trim() - + if (afterSummary) { detailsContent.content.push({ type: 'text', @@ -335,7 +334,7 @@ export default function rehypeSN(options = {}) { } continue } - + // If we're collecting summary content if (detailsContent.summary.found) { if (currentNode.type === 'raw' && currentNode.value.includes('</summary>')) { @@ -360,9 +359,8 @@ export default function rehypeSN(options = {}) { // Check for closing details tag const hasClosingTag = (currentNode.type === 'raw' && currentNode.value.includes('</details>')) || (currentNode.type === 'element' && toString(currentNode).includes('</details>')) - + if (hasClosingTag) { - let cleanedContent if (currentNode.type === 'raw') { const textBeforeClosing = currentNode.value.substring(0, currentNode.value.indexOf('</details>')) if (textBeforeClosing.includes('\n')) { @@ -418,18 +416,17 @@ export default function rehypeSN(options = {}) { return createDetailsElement(detailsContent, parent, index) } - }) return tree } catch (error) { - console.error('ā Error in rehypeSN transformer:', error) + console.error('Error in rehypeSN transformer:', error) return tree } } } -function isImageOnlyParagraph(node) { +function isImageOnlyParagraph (node) { return node && node.tagName === 'p' && Array.isArray(node.children) && @@ -439,7 +436,7 @@ function isImageOnlyParagraph(node) { ) } -function replaceMention(value, username) { +function replaceMention (value, username) { return { type: 'element', tagName: 'mention', @@ -448,7 +445,7 @@ function replaceMention(value, username) { } } -function replaceSub(value, sub) { +function replaceSub (value, sub) { return { type: 'element', tagName: 'sub', @@ -457,7 +454,7 @@ function replaceSub(value, sub) { } } -function replaceNostrId(value, id) { +function replaceNostrId (value, id) { return { type: 'element', tagName: 'a', @@ -466,7 +463,7 @@ function replaceNostrId(value, id) { } } -function isMisleadingLink(text, href) { +function isMisleadingLink (text, href) { let misleading = false if (/^\s*(\w+\.)+\w+/.test(text)) { @@ -482,12 +479,7 @@ function isMisleadingLink(text, href) { } // Helper to create details element -function createDetailsElement(detailsContent, parent, index) { - console.log('\nšØ Creating details element with:', { - hasSummary: detailsContent.summary.complete, - contentNodes: detailsContent.content.length - }) - +function createDetailsElement (detailsContent, parent, index) { const detailsElement = { type: 'element', tagName: 'details', @@ -504,12 +496,10 @@ function createDetailsElement(detailsContent, parent, index) { children: detailsContent.summary.content } detailsElement.children.push(summaryElement) - console.log('⨠Added summary element') } // Add main content detailsElement.children.push(...detailsContent.content) - console.log('⨠Added content elements') // Replace nodes parent.children.splice( From f166ffd72ebe258c4418e7610625e4de3f980848 Mon Sep 17 00:00:00 2001 From: krav <kravhen@gmail.com> Date: Fri, 20 Dec 2024 00:08:55 -0500 Subject: [PATCH 23/23] styling touchup and cleanup --- components/text.module.css | 34 ++++++++++------------------------ 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/components/text.module.css b/components/text.module.css index 7b5dc2fd8..5ad30e23a 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -440,41 +440,27 @@ /* Details/Summary styling */ .details { - border-left: 2px solid var(--theme-quoteBar); - padding-left: 0.75rem; + border: 1px solid rgba(220, 220, 220, 0.5); + border-radius: 4px; + padding: 1rem; margin: calc(var(--grid-gap) * 0.5) 0; - transition: border-color 0.2s ease; + transition: all 0.2s ease; } .details[open] { - border-left-color: #f7931a; + border-color: rgba(249, 217, 94, 0.5); } .summary { cursor: pointer; - user-select: none; - color: #8b949e; + color: rgba(220, 220, 220, 0.5); transition: color 0.2s ease; - padding: 0.25rem 0; } -.summary:hover { - color: #ffd700; -} - - -.summary::marker, -.summary::-webkit-details-marker { - color: #8b949e; +.details[open] > .summary { + color: rgba(249, 217, 94, 0.5); } -.details[open] > .summary::marker, -.details[open] > .summary::-webkit-details-marker { - color: #f7931a; -} - - -.details > *:not(.summary) { - margin-top: calc(var(--grid-gap) * 0.5); - padding-bottom: calc(var(--grid-gap) * 0.25); +.summary:hover { + color: #f9d95e; }