From 78ee0422085240d478ce4626c8cd81c07305738d Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 24 Sep 2024 11:14:24 -0500 Subject: [PATCH 01/10] fixes #1395 --- components/text.js | 5 +++-- components/text.module.css | 13 +++++++------ lib/md.js | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/components/text.js b/components/text.js index ac5c2851f..c922e10b5 100644 --- a/components/text.js +++ b/components/text.js @@ -14,7 +14,7 @@ import copy from 'clipboard-copy' import MediaOrLink from './media-or-link' import { IMGPROXY_URL_REGEXP, parseInternalLinks, decodeProxyUrl } from '@/lib/url' import reactStringReplace from 'react-string-replace' -import { rehypeInlineCodeProperty, rehypeStyler } from '@/lib/md' +import { rehypeInlineCodeProperty, rehypeStyler, rehypeWrapText } from '@/lib/md' import { Button } from 'react-bootstrap' import { useRouter } from 'next/router' import Link from 'next/link' @@ -164,6 +164,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o return
  • }, code: Code, + span: ({ children, ...props }) => {children}, a: ({ node, href, children, ...props }) => { children = children ? Array.isArray(children) ? children : [children] : [] // don't allow zoomable images to be wrapped in links @@ -260,7 +261,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o }), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink]) const remarkPlugins = useMemo(() => [gfm, mention, sub], []) - const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], []) + const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript, rehypeWrapText], []) return (
    diff --git a/components/text.module.css b/components/text.module.css index 7985afb2d..5455d8c0b 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -188,7 +188,8 @@ max-width: calc(100% - var(--grid-gap)); } -.mediaContainer ~ .mediaContainer, .mediaContainer:has(+ .mediaContainer) { +.p:not(:has(> span)) .mediaContainer ~ .mediaContainer, +.p:not(:has(> span)) .mediaContainer:has(+ .mediaContainer) { display: inline-block; width: min-content; margin-right: var(--grid-gap); @@ -196,21 +197,21 @@ .p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) > .mediaContainer:only-child, .p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) > .mediaContainer:only-child, -.mediaContainer:first-child:has(+ .mediaContainer) { +.p:not(:has(> span)) .mediaContainer:first-child:has(+ .mediaContainer) { margin-right: var(--grid-gap); } .p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) > .mediaContainer:only-child img, .p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) > .mediaContainer:only-child img, -.mediaContainer ~ .mediaContainer img, -.mediaContainer:has(+ .mediaContainer) img { +.p:not(:has(> span)) .mediaContainer ~ .mediaContainer img, +.p:not(:has(> span)) .mediaContainer:has(+ .mediaContainer) img { block-size: revert-layer; max-width: stretch; } .p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) > .mediaContainer:only-child video, .p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) > .mediaContainer:only-child video, -.mediaContainer ~ .mediaContainer video, -.mediaContainer:has(+ .mediaContainer) video { +.p:not(:has(> span)) .mediaContainer ~ .mediaContainer video, +.p:not(:has(> span)) .mediaContainer:has(+ .mediaContainer) video { block-size: stretch; } diff --git a/lib/md.js b/lib/md.js index 2cab926e2..b8f5f81c6 100644 --- a/lib/md.js +++ b/lib/md.js @@ -2,6 +2,7 @@ import { gfmFromMarkdown } from 'mdast-util-gfm' import { visit } from 'unist-util-visit' import { gfm } from 'micromark-extension-gfm' import { fromMarkdown } from 'mdast-util-from-markdown' +import { visitParents } from 'unist-util-visit-parents' export function mdHas (md, test) { if (!md) return [] @@ -31,6 +32,24 @@ export function rehypeInlineCodeProperty () { } } +export function rehypeWrapText () { + return function wrapTextTransform (tree) { + visitParents(tree, 'text', (node, ancestors) => { + // if the text is not wrapped in a span, wrap it in a span + // if any of its parent's siblings are text, wrap it in a span + // unless it's an empty string + if (ancestors.at(-1).tagName !== 'span' && + ancestors.at(-2)?.children.some(s => s.type === 'text') && + node.value.trim()) { + node.children = [{ type: 'text', value: node.value }] + node.type = 'element' + node.tagName = 'span' + node.properties = { } + } + }) + } +} + export function rehypeStyler (startTag, endTag, className) { return function (tree) { visit(tree, 'element', (node) => { From 4c6294a6c934998841cbd1eae1f1055403edb459 Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 24 Sep 2024 19:32:43 -0500 Subject: [PATCH 02/10] rehype plugin for embeds --- components/media-or-link.js | 20 +++++--------------- components/text.js | 11 ++++++----- lib/md.js | 27 ++++++++++++++++++++++++--- lib/url.js | 2 +- 4 files changed, 36 insertions(+), 24 deletions(-) diff --git a/components/media-or-link.js b/components/media-or-link.js index 76ac52d77..c63392e8f 100644 --- a/components/media-or-link.js +++ b/components/media-or-link.js @@ -1,6 +1,6 @@ import styles from './text.module.css' import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react' -import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url' +import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url' import { useShowModal } from './modal' import { useMe } from './me' import { Button, Dropdown } from 'react-bootstrap' @@ -89,14 +89,6 @@ export default function MediaOrLink ({ linkFallback = true, ...props }) { /> ) } - - if (media.embed) { - return ( - - ) - } } if (linkFallback) { @@ -114,11 +106,10 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) => const [isImage, setIsImage] = useState(video === false && trusted) const [isVideo, setIsVideo] = useState(video) const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos]) - const embed = useMemo(() => parseEmbedUrl(src), [src]) useEffect(() => { // don't load the video at all if user doesn't want these - if (!showMedia || isVideo || isImage || embed) return + if (!showMedia || isVideo || isImage) return // make sure it's not a false negative by trying to load URL as const img = new window.Image() img.onload = () => setIsImage(true) @@ -133,7 +124,7 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) => video.onloadeddata = null video.src = '' } - }, [src, setIsImage, setIsVideo, showMedia, isVideo, embed]) + }, [src, setIsImage, setIsVideo, showMedia, isVideo]) const srcSet = useMemo(() => { if (Object.keys(srcSetObj).length === 0) return undefined @@ -182,9 +173,8 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) => style, width, height, - image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo && !embed, - video: !me?.privates?.imgproxyOnly && showMedia && isVideo && !embed, - embed: !me?.privates?.imgproxyOnly && showMedia && embed + image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo, + video: !me?.privates?.imgproxyOnly && showMedia && isVideo } } diff --git a/components/text.js b/components/text.js index c922e10b5..5aceaceb3 100644 --- a/components/text.js +++ b/components/text.js @@ -11,10 +11,10 @@ import LinkIcon from '@/svgs/link.svg' import Thumb from '@/svgs/thumb-up-fill.svg' import { toString } from 'mdast-util-to-string' import copy from 'clipboard-copy' -import MediaOrLink from './media-or-link' +import MediaOrLink, { Embed } from './media-or-link' import { IMGPROXY_URL_REGEXP, parseInternalLinks, decodeProxyUrl } from '@/lib/url' import reactStringReplace from 'react-string-replace' -import { rehypeInlineCodeProperty, rehypeStyler, rehypeWrapText } from '@/lib/md' +import { rehypeEmbed, rehypeInlineCodeProperty, rehypeStyler, rehypeWrapText } from '@/lib/md' import { Button } from 'react-bootstrap' import { useRouter } from 'next/router' import Link from 'next/link' @@ -257,11 +257,12 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o // assume the link is an image which will fallback to link if it's not return {children} }, - img: TextMediaOrLink - }), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink]) + img: TextMediaOrLink, + embed: Embed + }), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink, Embed]) const remarkPlugins = useMemo(() => [gfm, mention, sub], []) - const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript, rehypeWrapText], []) + const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript, rehypeEmbed, rehypeWrapText], []) return (
    diff --git a/lib/md.js b/lib/md.js index b8f5f81c6..fd6e3e338 100644 --- a/lib/md.js +++ b/lib/md.js @@ -3,6 +3,7 @@ import { visit } from 'unist-util-visit' import { gfm } from 'micromark-extension-gfm' import { fromMarkdown } from 'mdast-util-from-markdown' import { visitParents } from 'unist-util-visit-parents' +import { parseEmbedUrl } from './url' export function mdHas (md, test) { if (!md) return [] @@ -35,10 +36,10 @@ export function rehypeInlineCodeProperty () { export function rehypeWrapText () { return function wrapTextTransform (tree) { visitParents(tree, 'text', (node, ancestors) => { - // if the text is not wrapped in a span, wrap it in a span - // if any of its parent's siblings are text, wrap it in a span + // if the text is not a link or wrapped in a span + // and if any of its parent's siblings are text, wrap it in a span // unless it's an empty string - if (ancestors.at(-1).tagName !== 'span' && + if (!['span', 'a'].includes(ancestors.at(-1).tagName) && ancestors.at(-2)?.children.some(s => s.type === 'text') && node.value.trim()) { node.children = [{ type: 'text', value: node.value }] @@ -50,6 +51,26 @@ export function rehypeWrapText () { } } +export function rehypeEmbed () { + return function wrapTextTransform (tree) { + visitParents(tree, 'text', (node, ancestors) => { + // if this parent is a link and its parent doesn't have any text, embed + if (['a'].includes(ancestors.at(-1).tagName) && + !ancestors.at(-2)?.children?.some(s => s.type === 'text' && s.value.trim()) && + node.value.trim() && + ancestors.at(-1).properties?.href === node.value) { + const embed = parseEmbedUrl(node.value) + if (embed) { + node.children = [{ type: 'text', value: node.value }] + node.type = 'element' + node.tagName = 'embed' + node.properties = { ...embed, src: node.value } + } + } + }) + } +} + export function rehypeStyler (startTag, endTag, className) { return function (tree) { visit(tree, 'element', (node) => { diff --git a/lib/url.js b/lib/url.js index d06b1c15d..b98652b9f 100644 --- a/lib/url.js +++ b/lib/url.js @@ -177,7 +177,7 @@ export function parseEmbedUrl (href) { } } } catch (err) { - console.error('Error parsing embed URL:', err) + console.log('Error parsing embed URL:', href) } return null From 311e9b127334a3189de66006722bb4c685df5fdf Mon Sep 17 00:00:00 2001 From: k00b Date: Thu, 26 Sep 2024 19:31:18 -0500 Subject: [PATCH 03/10] fix lint --- components/media-or-link.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/media-or-link.js b/components/media-or-link.js index a5325d134..d8233b678 100644 --- a/components/media-or-link.js +++ b/components/media-or-link.js @@ -1,6 +1,6 @@ import styles from './text.module.css' import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react' -import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url' +import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url' import { useMe } from './me' import { Button } from 'react-bootstrap' import { UNKNOWN_LINK_REL } from '@/lib/constants' From 6cf6544be2651ff58f9bacc0362d827f8a477c71 Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 27 Sep 2024 19:13:44 -0500 Subject: [PATCH 04/10] replace many plugins with one rehype and improve image collage --- components/media-or-link.js | 10 ++- components/text.js | 35 +++++--- components/text.module.css | 106 +++++++++++------------- lib/md.js | 79 ------------------ lib/rehype-sn.js | 159 ++++++++++++++++++++++++++++++++++++ lib/remark-mention.js | 38 --------- lib/remark-sub.js | 38 --------- styles/globals.scss | 4 + 8 files changed, 241 insertions(+), 228 deletions(-) create mode 100644 lib/rehype-sn.js delete mode 100644 lib/remark-mention.js delete mode 100644 lib/remark-sub.js diff --git a/components/media-or-link.js b/components/media-or-link.js index d8233b678..2f9154d0c 100644 --- a/components/media-or-link.js +++ b/components/media-or-link.js @@ -23,9 +23,15 @@ function LinkRaw ({ href, children, src, rel }) { ) } -const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, height, onClick, onError, style, className, video }) { +const Media = memo(function Media ({ + src, bestResSrc, srcSet, sizes, width, + height, onClick, onError, style, className, video +}) { return ( -
    +
    {video ?
  • }, code: Code, - span: ({ children, ...props }) => {children}, + span: ({ children, className, ...props }) => {children}, a: ({ node, href, children, ...props }) => { children = children ? Array.isArray(children) ? children : [children] : [] // don't allow zoomable images to be wrapped in links @@ -263,9 +279,6 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o embed: Embed }), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink, Embed]) - const remarkPlugins = useMemo(() => [gfm, mention, sub], []) - - const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript, rehypeEmbed, rehypeWrapText], []) const carousel = useCarousel() return ( diff --git a/components/text.module.css b/components/text.module.css index 292d59692..c009ba2bb 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -9,6 +9,9 @@ --grid-gap: 0.5rem; } +.text.topLevel { + --grid-gap: 0.75rem; +} .text :global(.footnotes) { font-size: smaller; @@ -110,33 +113,18 @@ display: block; white-space: pre-wrap; word-break: break-word; - padding-top: .25rem; - padding-bottom: .25rem; -} - -.text.topLevel .p { - padding-top: .375rem; - padding-bottom: .375rem; + padding-top: calc(var(--grid-gap) * 0.5); + padding-bottom: calc(var(--grid-gap) * 0.5); } .text>*:not(.heading) { - padding-top: .25rem; - padding-bottom: .25rem; -} - -.text.topLevel>*:not(.heading) { - padding-top: .375rem; - padding-bottom: .375rem; + padding-top: calc(var(--grid-gap) * 0.5); + padding-bottom: calc(var(--grid-gap) * 0.5); } .text pre, .text blockquote { - margin-top: .25rem; - margin-bottom: .25rem; -} - -.text.topLevel pre, .text.topLevel blockquote { - margin-top: .375rem; - margin-bottom: .375rem; + margin-top: calc(var(--grid-gap) * 0.5); + margin-bottom: calc(var(--grid-gap) * 0.5); } .text pre>div { @@ -168,53 +156,58 @@ .mediaContainer { display: block; - width: calc(100% - var(--grid-gap)); - max-width: calc(100% - var(--grid-gap)); + width: calc(100%); + max-width: calc(100%); height: auto; overflow: hidden; max-height: 25vh; aspect-ratio: var(--aspect-ratio); + margin: 0; } -.p:has(> .mediaContainer) { - white-space: normal; - padding: 0 !important; +.mediaContainer.hasTextSiblingsBefore { + margin-top: var(--grid-gap); } -.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child), -.p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) { - display: inline-block; - width: min-content; - max-width: calc(100% - var(--grid-gap)); +.mediaContainer.hasTextSiblingsAfter { + margin-bottom: var(--grid-gap); } -.p:not(:has(> span)) .mediaContainer ~ .mediaContainer, -.p:not(:has(> span)) .mediaContainer:has(+ .mediaContainer) { - display: inline-block; +.p:has(> .mediaContainer) .mediaContainer +{ + display: flex; width: min-content; - margin-right: var(--grid-gap); -} - -.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) > .mediaContainer:only-child, -.p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) > .mediaContainer:only-child, -.p:not(:has(> span)) .mediaContainer:first-child:has(+ .mediaContainer) { - margin-right: var(--grid-gap); + max-width: 100%; } -.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) > .mediaContainer:only-child img, -.p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) > .mediaContainer:only-child img, -.p:not(:has(> span)) .mediaContainer ~ .mediaContainer img, -.p:not(:has(> span)) .mediaContainer:has(+ .mediaContainer) img { +.p:has(> .mediaContainer) .mediaContainer img, +.p:has(> .mediaContainer) .mediaContainer img +{ block-size: revert-layer; max-width: stretch; } -.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) > .mediaContainer:only-child video, -.p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) > .mediaContainer:only-child video, -.p:not(:has(> span)) .mediaContainer ~ .mediaContainer video, -.p:not(:has(> span)) .mediaContainer:has(+ .mediaContainer) video { + +.p:has(> .mediaContainer) + .p:has(> .mediaContainer):not(.hasTextSiblings) video, +.p:has(> .mediaContainer):has(+ .p > .mediaContainer):not(.hasTextSiblings) video +{ block-size: stretch; } +.p.onlyImages { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--grid-gap); +} + +.p.onlyImages:not(.somethingBefore) { + padding-top: 0; +} + +.p.onlyImages:not(.somethingAfter) { + padding-bottom: 0; +} + .mediaContainer img, .mediaContainer video { display: block; object-fit: contain; @@ -227,6 +220,7 @@ .mediaContainer img { cursor: zoom-in; min-width: 30%; + max-width: 100%; object-position: left top; } @@ -307,17 +301,9 @@ font-size: smaller; } -.twitterContainer, .nostrContainer, .videoWrapper, .wavlakeWrapper, .spotifyWrapper, .mediaContainer { - margin-top: 0.25rem; - margin-bottom: 0.25rem; -} - -.topLevel .twitterContainer, .topLevel .nostrContainer, .topLevel .videoWrapper, -.topLevel .wavlakeWrapper, .topLevel .spotifyWrapper, .topLevel .mediaContainer, -:global(.topLevel) .twitterContainer, :global(.topLevel) .nostrContainer, :global(.topLevel) .videoWrapper, -:global(.topLevel) .wavlakeWrapper, :global(.topLevel) .spotifyWrapper, :global(.topLevel) .mediaContainer { - margin-top: 0.375rem; - margin-bottom: 0.375rem; +.twitterContainer, .nostrContainer, .videoWrapper, .wavlakeWrapper, .spotifyWrapper { + margin-top: calc(var(--grid-gap) * 0.5); + margin-bottom: calc(var(--grid-gap) * 0.5); } .videoWrapper { diff --git a/lib/md.js b/lib/md.js index fd6e3e338..35dc48892 100644 --- a/lib/md.js +++ b/lib/md.js @@ -2,8 +2,6 @@ import { gfmFromMarkdown } from 'mdast-util-gfm' import { visit } from 'unist-util-visit' import { gfm } from 'micromark-extension-gfm' import { fromMarkdown } from 'mdast-util-from-markdown' -import { visitParents } from 'unist-util-visit-parents' -import { parseEmbedUrl } from './url' export function mdHas (md, test) { if (!md) return [] @@ -21,83 +19,6 @@ export function mdHas (md, test) { return found } -export function rehypeInlineCodeProperty () { - return function (tree) { - visit(tree, { tagName: 'code' }, function (node, index, parent) { - if (parent && parent.tagName === 'pre') { - node.properties.inline = false - } else { - node.properties.inline = true - } - }) - } -} - -export function rehypeWrapText () { - return function wrapTextTransform (tree) { - visitParents(tree, 'text', (node, ancestors) => { - // if the text is not a link or wrapped in a span - // and if any of its parent's siblings are text, wrap it in a span - // unless it's an empty string - if (!['span', 'a'].includes(ancestors.at(-1).tagName) && - ancestors.at(-2)?.children.some(s => s.type === 'text') && - node.value.trim()) { - node.children = [{ type: 'text', value: node.value }] - node.type = 'element' - node.tagName = 'span' - node.properties = { } - } - }) - } -} - -export function rehypeEmbed () { - return function wrapTextTransform (tree) { - visitParents(tree, 'text', (node, ancestors) => { - // if this parent is a link and its parent doesn't have any text, embed - if (['a'].includes(ancestors.at(-1).tagName) && - !ancestors.at(-2)?.children?.some(s => s.type === 'text' && s.value.trim()) && - node.value.trim() && - ancestors.at(-1).properties?.href === node.value) { - const embed = parseEmbedUrl(node.value) - if (embed) { - node.children = [{ type: 'text', value: node.value }] - node.type = 'element' - node.tagName = 'embed' - node.properties = { ...embed, src: node.value } - } - } - }) - } -} - -export function rehypeStyler (startTag, endTag, className) { - return function (tree) { - visit(tree, 'element', (node) => { - for (let i = 0; i < node.children.length; i += 1) { - const start = node.children[i] - const text = node.children[i + 1] - const end = node.children[i + 2] - - // is this a children slice wrapped with the tags we're looking for? - const isWrapped = - start?.type === 'raw' && start?.value === startTag && - text?.type === 'text' && - end?.type === 'raw' && end?.value === endTag - if (!isWrapped) continue - - const newChildren = { - type: 'element', - tagName: 'span', - properties: { className: [className] }, - children: [{ type: 'text', value: text.value }] - } - node.children.splice(i, 3, newChildren) - } - }) - } -} - export function extractUrls (md) { if (!md) return [] const tree = fromMarkdown(md, { diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js new file mode 100644 index 000000000..e19a0f657 --- /dev/null +++ b/lib/rehype-sn.js @@ -0,0 +1,159 @@ +import { visit } from 'unist-util-visit' +import { parseEmbedUrl } from './url' + +const userGroup = '[\\w_]+' +const subGroup = '[A-Za-z][\\w_]+' + +const mentionRegex = new RegExp('@(' + userGroup + '(?:\\/' + userGroup + ')?)', 'gi') +const subRegex = new RegExp('~(' + subGroup + '(?:\\/' + subGroup + ')?)', 'gi') + +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') + } + + // 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()) && + node.children[0].type === 'text' && + node.children[0].value === node.properties.href) { + const embed = parseEmbedUrl(node.properties.href) + if (embed) { + node.tagName = 'embed' + node.properties = { ...embed, src: node.properties.href } + } + } + + // Handle @mentions and ~subs + if (node.type === 'text') { + const newChildren = [] + let lastIndex = 0 + let match + + const combinedRegex = new RegExp(mentionRegex.source + '|' + subRegex.source, 'gi') + + while ((match = combinedRegex.exec(node.value)) !== null) { + if (lastIndex < match.index) { + newChildren.push({ type: 'text', value: node.value.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 (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) + } + } + } + } + + 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: 'a', + properties: { href: '/' + username }, + children: [{ type: 'text', value }] + } + } + + function replaceSub (value, sub) { + return { + type: 'element', + tagName: 'a', + properties: { href: '/~' + sub }, + children: [{ type: 'text', value }] + } + } +} diff --git a/lib/remark-mention.js b/lib/remark-mention.js deleted file mode 100644 index 4fcce8fce..000000000 --- a/lib/remark-mention.js +++ /dev/null @@ -1,38 +0,0 @@ -import { findAndReplace } from 'mdast-util-find-and-replace' - -const userGroup = '[\\w_]+' - -const mentionRegex = new RegExp( - '@(' + userGroup + '(?:\\/' + userGroup + ')?)', - 'gi' -) - -export default function mention (options) { - return function transformer (tree) { - findAndReplace( - tree, - [ - [mentionRegex, replaceMention] - ], - { ignore: ['link', 'linkReference'] } - ) - } - - function replaceMention (value, username, match) { - if ( - /[\w`]/.test(match.input.charAt(match.index - 1)) || - /[/\w`]/.test(match.input.charAt(match.index + value.length)) - ) { - return false - } - - const node = { type: 'text', value } - - return { - type: 'link', - title: null, - url: '/' + username, - children: [node] - } - } -} diff --git a/lib/remark-sub.js b/lib/remark-sub.js deleted file mode 100644 index 58957dd20..000000000 --- a/lib/remark-sub.js +++ /dev/null @@ -1,38 +0,0 @@ -import { findAndReplace } from 'mdast-util-find-and-replace' - -const subGroup = '[A-Za-z][\\w_]+' - -const subRegex = new RegExp( - '~(' + subGroup + '(?:\\/' + subGroup + ')?)', - 'gi' -) - -export default function sub (options) { - return function transformer (tree) { - findAndReplace( - tree, - [ - [subRegex, replaceSub] - ], - { ignore: ['link', 'linkReference'] } - ) - } - - function replaceSub (value, sub, match) { - if ( - /[\w`]/.test(match.input.charAt(match.index - 1)) || - /[/\w`]/.test(match.input.charAt(match.index + value.length)) - ) { - return false - } - - const node = { type: 'text', value } - - return { - type: 'link', - title: null, - url: '/~' + sub, - children: [node] - } - } -} diff --git a/styles/globals.scss b/styles/globals.scss index d19a8d505..15f0b5eec 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -940,6 +940,10 @@ div[contenteditable]:focus, animation: flipX 2s linear infinite; } +.topLevel { + --grid-gap: 0.75rem; +} + @keyframes flipY { from { transform: rotateY(0deg); From 98ca95625c50f094c70977187fc546579b4fd76f Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 27 Sep 2024 19:25:52 -0500 Subject: [PATCH 05/10] remove unused css --- components/text.module.css | 12 +++--------- lib/rehype-sn.js | 1 + 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/components/text.module.css b/components/text.module.css index c009ba2bb..58913beb1 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -156,8 +156,8 @@ .mediaContainer { display: block; - width: calc(100%); - max-width: calc(100%); + width: 100%; + max-width: 100%; height: auto; overflow: hidden; max-height: 25vh; @@ -181,18 +181,12 @@ } .p:has(> .mediaContainer) .mediaContainer img, -.p:has(> .mediaContainer) .mediaContainer img +.p:has(> .mediaContainer) .mediaContainer video { block-size: revert-layer; max-width: stretch; } -.p:has(> .mediaContainer) + .p:has(> .mediaContainer):not(.hasTextSiblings) video, -.p:has(> .mediaContainer):has(+ .p > .mediaContainer):not(.hasTextSiblings) video -{ - block-size: stretch; -} - .p.onlyImages { display: flex; flex-direction: row; diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index e19a0f657..0b843dc25 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -86,6 +86,7 @@ export default function rehypeSN (options = {}) { } } + // merge adjacent images and empty paragraphs into a single image collage if ((node.tagName === 'img' || isImageOnlyParagraph(node)) && Array.isArray(parent.children)) { const adjacentNodes = [node] let nextIndex = index + 1 From 3de63f0b497d02325d12fc9b80e781edb180fe4f Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 27 Sep 2024 20:05:27 -0500 Subject: [PATCH 06/10] handle more custom markdown behavior in rehype --- components/text.js | 143 +++++++++++++++++---------------------------- lib/rehype-sn.js | 32 ++++++++-- 2 files changed, 81 insertions(+), 94 deletions(-) diff --git a/components/text.js b/components/text.js index 950fb9d1c..2b32b0ce7 100644 --- a/components/text.js +++ b/components/text.js @@ -10,7 +10,7 @@ import Thumb from '@/svgs/thumb-up-fill.svg' import { toString } from 'mdast-util-to-string' import copy from 'clipboard-copy' import MediaOrLink, { Embed } from './media-or-link' -import { IMGPROXY_URL_REGEXP, parseInternalLinks, decodeProxyUrl } from '@/lib/url' +import { IMGPROXY_URL_REGEXP, decodeProxyUrl } from '@/lib/url' import reactStringReplace from 'react-string-replace' import { Button } from 'react-bootstrap' import { useRouter } from 'next/router' @@ -183,29 +183,36 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o }, code: Code, span: ({ children, className, ...props }) => {children}, + mention: ({ children, href, name, id }) => { + return ( + + + {children} + + + ) + }, + sub: ({ children, href, ...props }) => { + return {children} + }, + item: ({ children, href, id }) => { + return ( + + {children} + + ) + }, a: ({ node, href, children, ...props }) => { - children = children ? Array.isArray(children) ? children : [children] : [] - // don't allow zoomable images to be wrapped in links - if (children.some(e => e?.props?.node?.tagName === 'img')) { - return <>{children} - } - // if outlawed, render the link as text if (outlawed) { return href } - // If [text](url) was parsed as and text is not empty and not a link itself, - // we don't render it as an image since it was probably a conscious choice to include text. + // if the link has text, and it's not a URL, render it as an external link const text = children[0] - let url - try { - url = !href.startsWith('/') && new URL(href) - } catch { - // ignore invalid URLs - } - - const internalURL = process.env.NEXT_PUBLIC_URL if (!!text && !/^https?:\/\//.test(text)) { if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') { return ( @@ -217,61 +224,12 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o ) } - if (text.startsWith?.('@')) { - // user mention might be within a markdown link like this: [@user foo bar](url) - const name = text.replace('@', '').split(' ')[0] - return ( - - - {text} - - - ) - } else if (href.startsWith('/') || url?.origin === internalURL) { - try { - const { linkText } = parseInternalLinks(href) - if (linkText) { - return ( - - {text} - - ) - } - } catch { - // ignore errors like invalid URLs - } - - return ( - - {text} - - ) - } return ( // eslint-disable-next-line - {text} + {children} ) } - try { - const { linkText } = parseInternalLinks(href) - if (linkText) { - return ( - - {linkText} - - ) - } - } catch { - // ignore errors like invalid URLs - } - // assume the link is an image which will fallback to link if it's not return {children} }, @@ -281,31 +239,38 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o const carousel = useCarousel() + const markdownContent = useMemo(() => ( + + {children} + + ), [components, remarkPlugins, rehypePlugins, children]) + return ( -
    +
    {carousel && tab !== 'preview' - ? ( - - {children} - ) - : ( - - - {children} - - )} - {overflowing && !show && - } + + )}
    ) }, isEqual) diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index 0b843dc25..a23b3b452 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -1,5 +1,5 @@ import { visit } from 'unist-util-visit' -import { parseEmbedUrl } from './url' +import { parseEmbedUrl, parseInternalLinks } from './url' const userGroup = '[\\w_]+' const subGroup = '[A-Za-z][\\w_]+' @@ -30,6 +30,28 @@ export default function rehypeSN (options = {}) { } } + // handle internal links + if (node.tagName === 'a') { + try { + const { itemId, linkText } = parseInternalLinks(node.properties.href) + if (itemId) { + node.tagName = 'item' + node.properties.id = itemId + if (node.properties.href === node.children[0].value) { + node.children[0].value = linkText + } + } + } catch { + // ignore errors like invalid URLs + } + } + + // 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 @mentions and ~subs if (node.type === 'text') { const newChildren = [] @@ -143,8 +165,8 @@ export default function rehypeSN (options = {}) { function replaceMention (value, username) { return { type: 'element', - tagName: 'a', - properties: { href: '/' + username }, + tagName: 'mention', + properties: { href: '/' + username, name: username }, children: [{ type: 'text', value }] } } @@ -152,8 +174,8 @@ export default function rehypeSN (options = {}) { function replaceSub (value, sub) { return { type: 'element', - tagName: 'a', - properties: { href: '/~' + sub }, + tagName: 'sub', + properties: { href: '/~' + sub, name: sub }, children: [{ type: 'text', value }] } } From f15d9524e813f59b7b31e924baf53e1c3c8646ac Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 28 Sep 2024 13:06:34 -0500 Subject: [PATCH 07/10] refactor markdown rendering more + better footnotes --- components/text.js | 275 ++++++++++++++++++++----------------- components/text.module.css | 8 ++ lib/rehype-sn.js | 28 ++-- 3 files changed, 172 insertions(+), 139 deletions(-) diff --git a/components/text.js b/components/text.js index 2b32b0ce7..013892e3f 100644 --- a/components/text.js +++ b/components/text.js @@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown' import gfm from 'remark-gfm' import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter' import atomDark from 'react-syntax-highlighter/dist/cjs/styles/prism/atom-dark' -import React, { useState, memo, useRef, useCallback, useMemo, useEffect } from 'react' +import React, { useState, memo, useRef, useCallback, useMemo, useEffect, createElement } from 'react' import { slug } from 'github-slugger' import LinkIcon from '@/svgs/link.svg' import Thumb from '@/svgs/thumb-up-fill.svg' @@ -59,9 +59,9 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o // if we are navigating to a hash, show the full text useEffect(() => { - setShow(router.asPath.includes('#')) + setShow(router.asPath.includes('#') && !router.asPath.includes('#itemfn-')) const handleRouteChange = (url, { shallow }) => { - setShow(url.includes('#')) + setShow(url.includes('#') && !url.includes('#itemfn-')) } router.events.on('hashChangeStart', handleRouteChange) @@ -69,7 +69,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o return () => { router.events.off('hashChangeStart', handleRouteChange) } - }, [router]) + }, [router.asPath, router.events]) // clip item and give it a`show full text` button if we are overflowing useEffect(() => { @@ -94,117 +94,27 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o } }, [containerRef.current, setOverflowing]) - const Heading = useCallback(({ children, node, ...props }) => { - const [copied, setCopied] = useState(false) - const nodeText = toString(node) - const id = useMemo(() => noFragments ? undefined : slug(nodeText.replace(/[^\w\-\s]+/gi, '')), [nodeText, noFragments]) - const h = useMemo(() => { - if (topLevel) { - return node?.TagName - } - - const h = parseInt(node?.tagName?.replace('h', '') || 0) - if (h < 4) return `h${h + 3}` - - return 'h6' - }, [node, topLevel]) - const Icon = copied ? Thumb : LinkIcon - - return ( - - {React.createElement(h || node?.tagName, { id, ...props }, children)} - {!noFragments && topLevel && - - { - const location = new URL(window.location) - location.hash = `${id}` - copy(location.href) - setTimeout(() => setCopied(false), 1500) - setCopied(true) - }} - width={18} - height={18} - className='fill-grey' - /> - } - - ) - }, [topLevel, noFragments]) - - const Table = useCallback(({ node, ...props }) => - - - , []) - - const Code = useCallback(({ node, inline, className, children, style, ...props }) => { - return inline - ? ( - - {children} - - ) - : ( - - {children} - - ) - }, []) - - const P = useCallback(({ children, node, onlyImages, somethingBefore, somethingAfter, ...props }) => -
    {children} -
    , []) - - const TextMediaOrLink = useCallback(({ node, src, ...props }) => { - const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src - // if outlawed, render the media link as text - if (outlawed) { - return url - } - const srcSet = imgproxyUrls?.[url] - - return - }, [imgproxyUrls, topLevel, tab, outlawed, rel]) + const TextMediaOrLink = useCallback(props => { + return + }, + [outlawed, imgproxyUrls, topLevel, rel]) + const H = useCallback(props => , + [topLevel, noFragments]) const components = useMemo(() => ({ - h1: Heading, - h2: Heading, - h3: Heading, - h4: Heading, - h5: Heading, - h6: Heading, + h1: H, + h2: H, + h3: H, + h4: H, + h5: H, + h6: H, table: Table, p: P, - li: props => { - return
  • - }, code: Code, - span: ({ children, className, ...props }) => {children}, - mention: ({ children, href, name, id }) => { - return ( - - - {children} - - - ) - }, - sub: ({ children, href, ...props }) => { - return {children} - }, - item: ({ children, href, id }) => { - return ( - - {children} - - ) - }, + mention: Mention, + sub: Sub, + item: Item, + footnote: Footnote, a: ({ node, href, children, ...props }) => { // if outlawed, render the link as text if (outlawed) { @@ -214,16 +124,6 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o // if the link has text, and it's not a URL, render it as an external link const text = children[0] if (!!text && !/^https?:\/\//.test(text)) { - if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') { - return ( - {text} - - ) - } return ( // eslint-disable-next-line {children} @@ -231,11 +131,11 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o } // assume the link is an image which will fallback to link if it's not - return {children} + return {children} }, img: TextMediaOrLink, embed: Embed - }), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink, Embed]) + }), [outlawed, rel, itemId, H, TextMediaOrLink]) const carousel = useCarousel() @@ -244,10 +144,13 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o components={components} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} + remarkRehypeOptions={{ clobberPrefix: `itemfn-${itemId}-` }} > {children} - ), [components, remarkPlugins, rehypePlugins, children]) + ), [components, remarkPlugins, rehypePlugins, children, itemId]) + + const showOverflow = useCallback(() => setShow(true), [setShow]) return (
    - {carousel && tab !== 'preview' - ? markdownContent - : {markdownContent}} + { + carousel && tab !== 'preview' + ? markdownContent + : {markdownContent} + } {overflowing && !show && ( @@ -274,3 +179,119 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
    ) }, isEqual) + +function Mention ({ children, href, name, id }) { + return ( + + + {children} + + + ) +} + +function Sub ({ children, href, ...props }) { + return {children} +} + +function Item ({ children, href, id }) { + return ( + + {children} + + ) +} + +function Footnote ({ children, ...props }) { + return ( + {children} + ) +} + +function MediaLink ({ + node, src, outlawed, imgproxyUrls, rel = UNKNOWN_LINK_REL, ...props +}) { + const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src + // if outlawed, render the media link as text + if (outlawed) { + return url + } + + const srcSet = imgproxyUrls?.[url] + + return +} + +function Heading ({ children, node, topLevel, noFragments, ...props }) { + const [copied, setCopied] = useState(false) + const nodeText = toString(node) + const id = useMemo(() => noFragments ? undefined : slug(nodeText.replace(/[^\w\-\s]+/gi, '')), [nodeText, noFragments]) + const h = useMemo(() => { + if (topLevel) { + return node?.tagName + } + + const h = parseInt(node?.tagName?.replace('h', '') || 0) + if (h < 4) return `h${h + 3}` + + return 'h6' + }, [node?.tagName, topLevel]) + const onClick = useCallback(() => { + const location = new URL(window.location) + location.hash = id + copy(location.href) + setTimeout(() => setCopied(false), 1500) + setCopied(true) + }, [id]) + const Icon = copied ? Thumb : LinkIcon + + return ( + + {createElement(h, { id, ...props }, children)} + {!noFragments && topLevel && + + + } + + ) +} + +function Table ({ node, ...props }) { + return ( + +
  • + + ) +} + +function Code ({ node, inline, className, children, style, ...props }) { + return inline + ? ( + + {children} + + ) + : ( + + {children} + + ) +} + +function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...props }) { + return ( +
    + {children} +
    + ) +} diff --git a/components/text.module.css b/components/text.module.css index 58913beb1..1bcf95e42 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -17,6 +17,8 @@ font-size: smaller; color: #8b949e; border-top: 1px solid #30363d; + margin-top: calc(var(--grid-gap)* 0.5); + padding-top: 0 !important; } /* Hide the section label for visual users. */ @@ -40,6 +42,12 @@ content: ']'; } +.text :global(sup:has([data-footnote-ref])) { + top: 0; + font-size: 100%; + vertical-align: baseline; +} + .textUncontained { max-height: none; } diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index a23b3b452..fa719c6d3 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -18,6 +18,12 @@ export default function rehypeSN (options = {}) { node.properties.inline = !(parent && parent.tagName === 'pre') } + // 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 + } + // 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()) && @@ -33,12 +39,16 @@ export default function rehypeSN (options = {}) { // handle internal links if (node.tagName === 'a') { try { - const { itemId, linkText } = parseInternalLinks(node.properties.href) - if (itemId) { - node.tagName = 'item' - node.properties.id = itemId - if (node.properties.href === node.children[0].value) { - node.children[0].value = linkText + 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 === node.children[0].value) { + node.children[0].value = linkText + } } } } catch { @@ -46,12 +56,6 @@ export default function rehypeSN (options = {}) { } } - // 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 @mentions and ~subs if (node.type === 'text') { const newChildren = [] From 8223f2bba91726abc3815eb7d031d9f535c6deaf Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 28 Sep 2024 15:39:31 -0500 Subject: [PATCH 08/10] move more markdown logic to reyhpe plugin + better headers --- components/carousel.js | 6 ++- components/form.js | 2 +- components/media-or-link.js | 5 ++- components/text.js | 86 ++++++++----------------------------- components/text.module.css | 23 +++++----- lib/rehype-sn.js | 47 ++++++++++++++------ 6 files changed, 72 insertions(+), 97 deletions(-) diff --git a/components/carousel.js b/components/carousel.js index 666c895b8..1b5ed6dd0 100644 --- a/components/carousel.js +++ b/components/carousel.js @@ -120,7 +120,11 @@ export function CarouselProvider ({ children }) { media.current.set(src, { src, originalSrc, rel }) }, [media.current]) - const value = useMemo(() => ({ showCarousel, addMedia }), [showCarousel, addMedia]) + const removeMedia = useCallback((src) => { + media.current.delete(src) + }, [media.current]) + + const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia]) return {children} } diff --git a/components/form.js b/components/form.js index 350d1d48d..26a956afa 100644 --- a/components/form.js +++ b/components/form.js @@ -393,7 +393,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe {tab !== 'write' &&
    - {meta.value} + {meta.value}
    } diff --git a/components/media-or-link.js b/components/media-or-link.js index 2f9154d0c..9e4452224 100644 --- a/components/media-or-link.js +++ b/components/media-or-link.js @@ -58,7 +58,7 @@ const Media = memo(function Media ({ export default function MediaOrLink ({ linkFallback = true, ...props }) { const media = useMediaHelper(props) const [error, setError] = useState(false) - const { showCarousel, addMedia } = useCarousel() + const { showCarousel, addMedia, removeMedia } = useCarousel() useEffect(() => { if (!media.image) return @@ -70,8 +70,9 @@ export default function MediaOrLink ({ linkFallback = true, ...props }) { const handleError = useCallback((err) => { console.error('Error loading media', err) + removeMedia(media.bestResSrc) setError(true) - }, [setError]) + }, [setError, removeMedia, media.bestResSrc]) if (!media.src) return null diff --git a/components/text.js b/components/text.js index 013892e3f..35c0e9a7d 100644 --- a/components/text.js +++ b/components/text.js @@ -3,12 +3,7 @@ import ReactMarkdown from 'react-markdown' import gfm from 'remark-gfm' import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter' import atomDark from 'react-syntax-highlighter/dist/cjs/styles/prism/atom-dark' -import React, { useState, memo, useRef, useCallback, useMemo, useEffect, createElement } from 'react' -import { slug } from 'github-slugger' -import LinkIcon from '@/svgs/link.svg' -import Thumb from '@/svgs/thumb-up-fill.svg' -import { toString } from 'mdast-util-to-string' -import copy from 'clipboard-copy' +import React, { useState, memo, useRef, useCallback, useMemo, useEffect } from 'react' import MediaOrLink, { Embed } from './media-or-link' import { IMGPROXY_URL_REGEXP, decodeProxyUrl } from '@/lib/url' import reactStringReplace from 'react-string-replace' @@ -51,7 +46,7 @@ export function SearchText ({ text }) { } // this is one of the slowest components to render -export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, outlawed, topLevel, noFragments }) { +export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) { const [overflowing, setOverflowing] = useState(false) const router = useRouter() const [show, setShow] = useState(false) @@ -98,16 +93,14 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o return }, [outlawed, imgproxyUrls, topLevel, rel]) - const H = useCallback(props => , - [topLevel, noFragments]) const components = useMemo(() => ({ - h1: H, - h2: H, - h3: H, - h4: H, - h5: H, - h6: H, + h1: ({ node, id, ...props }) =>

    , + h2: ({ node, id, ...props }) =>

    , + h3: ({ node, id, ...props }) =>

    , + h4: ({ node, id, ...props }) =>

    , + h5: ({ node, id, ...props }) =>

    , + h6: ({ node, id, ...props }) =>
    , table: Table, p: P, code: Code, @@ -115,27 +108,20 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o sub: Sub, item: Item, footnote: Footnote, + headlink: ({ node, href, ...props }) => , + autolink: TextMediaOrLink, a: ({ node, href, children, ...props }) => { // if outlawed, render the link as text if (outlawed) { return href } - // if the link has text, and it's not a URL, render it as an external link - const text = children[0] - if (!!text && !/^https?:\/\//.test(text)) { - return ( - // eslint-disable-next-line - {children} - ) - } - - // assume the link is an image which will fallback to link if it's not - return {children} + // eslint-disable-next-line + return {children} }, img: TextMediaOrLink, embed: Embed - }), [outlawed, rel, itemId, H, TextMediaOrLink]) + }), [outlawed, rel, TextMediaOrLink, topLevel]) const carousel = useCarousel() @@ -180,7 +166,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o ) }, isEqual) -function Mention ({ children, href, name, id }) { +function Mention ({ children, node, href, name, id }) { return ( {children} } -function Item ({ children, href, id }) { +function Item ({ children, node, href, id }) { return ( {children} @@ -205,7 +191,7 @@ function Item ({ children, href, id }) { ) } -function Footnote ({ children, ...props }) { +function Footnote ({ children, node, ...props }) { return ( {children} ) @@ -225,44 +211,6 @@ function MediaLink ({ return } -function Heading ({ children, node, topLevel, noFragments, ...props }) { - const [copied, setCopied] = useState(false) - const nodeText = toString(node) - const id = useMemo(() => noFragments ? undefined : slug(nodeText.replace(/[^\w\-\s]+/gi, '')), [nodeText, noFragments]) - const h = useMemo(() => { - if (topLevel) { - return node?.tagName - } - - const h = parseInt(node?.tagName?.replace('h', '') || 0) - if (h < 4) return `h${h + 3}` - - return 'h6' - }, [node?.tagName, topLevel]) - const onClick = useCallback(() => { - const location = new URL(window.location) - location.hash = id - copy(location.href) - setTimeout(() => setCopied(false), 1500) - setCopied(true) - }, [id]) - const Icon = copied ? Thumb : LinkIcon - - return ( - - {createElement(h, { id, ...props }, children)} - {!noFragments && topLevel && - - - } - - ) -} - function Table ({ node, ...props }) { return ( diff --git a/components/text.module.css b/components/text.module.css index 1bcf95e42..9562cb93f 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -265,32 +265,31 @@ .text h1, .text h2, .text h3, .text h4, .text h5, .text h6 { margin-top: 0.75rem; margin-bottom: 0.5rem; + font-size: 1rem; +} + +.text h1 a, .text h2 a, .text h3 a, .text h4 a, .text h5 a, .text h6 a { + text-decoration: none; + --bs-text-opacity: 1; + color: inherit !important; } -.text h1 { +.topLevel.text h1 { font-size: 1.6rem; } -.text h2 { +.topLevel.text h2 { font-size: 1.45rem; } -.text h3 { +.topLevel.text h3 { font-size: 1.3rem; } -.text h4 { +.topLevel.text h4 { font-size: 1.15rem; } -.text h5 { - font-size: 1rem; -} - -.text h6 { - font-size: .85rem; -} - /* Utility classes used in rehype plugins in md.js */ .subscript { diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index fa719c6d3..ea11233f2 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -1,5 +1,7 @@ import { 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_]+' @@ -18,24 +20,31 @@ export default function rehypeSN (options = {}) { node.properties.inline = !(parent && parent.tagName === 'pre') } + 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: node.children + } + + // Replace the heading's children with the new link element + node.children = [linkElement] + } + // 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 } - // 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()) && - node.children[0].type === 'text' && - node.children[0].value === node.properties.href) { - const embed = parseEmbedUrl(node.properties.href) - if (embed) { - node.tagName = 'embed' - node.properties = { ...embed, src: node.properties.href } - } - } - // handle internal links if (node.tagName === 'a') { try { @@ -56,6 +65,20 @@ 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()) && + node.children[0].type === 'text' && + node.children[0].value === 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' + } + } + // Handle @mentions and ~subs if (node.type === 'text') { const newChildren = [] From 7c720f69eeea371806071f275ec728e1a5261353 Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 28 Sep 2024 16:22:25 -0500 Subject: [PATCH 09/10] fix #1397 --- lib/rehype-sn.js | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/rehype-sn.js b/lib/rehype-sn.js index ea11233f2..108fdb843 100644 --- a/lib/rehype-sn.js +++ b/lib/rehype-sn.js @@ -1,4 +1,4 @@ -import { visit } from 'unist-util-visit' +import { SKIP, visit } from 'unist-util-visit' import { parseEmbedUrl, parseInternalLinks } from './url' import { slug } from 'github-slugger' import { toString } from 'mdast-util-to-string' @@ -55,7 +55,7 @@ export default function rehypeSN (options = {}) { if (itemId) { node.tagName = 'item' node.properties.id = itemId - if (node.properties.href === node.children[0].value) { + if (node.properties.href === toString(node)) { node.children[0].value = linkText } } @@ -68,8 +68,7 @@ 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()) && - node.children[0].type === 'text' && - node.children[0].value === node.properties.href) { + toString(node) === node.properties.href) { const embed = parseEmbedUrl(node.properties.href) if (embed) { node.tagName = 'embed' @@ -79,6 +78,12 @@ export default function rehypeSN (options = {}) { } } + // 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 = [] @@ -206,4 +211,20 @@ export default function rehypeSN (options = {}) { 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 + } } From 1f67fd0bea138f5f98689eddcb09eaa6d49f8497 Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 28 Sep 2024 16:22:51 -0500 Subject: [PATCH 10/10] refactor embeds out of media-or-link --- components/embed.js | 204 ++++++++++++++++++++++++++++++++++++ components/media-or-link.js | 201 +---------------------------------- components/text.js | 3 +- 3 files changed, 207 insertions(+), 201 deletions(-) create mode 100644 components/embed.js diff --git a/components/embed.js b/components/embed.js new file mode 100644 index 000000000..fa24a660e --- /dev/null +++ b/components/embed.js @@ -0,0 +1,204 @@ +import { memo, useEffect, useRef, useState } from 'react' +import classNames from 'classnames' +import useDarkMode from './dark-mode' +import styles from './text.module.css' +import { Button } from 'react-bootstrap' +import { TwitterTweetEmbed } from 'react-twitter-embed' +import YouTube from 'react-youtube' + +function TweetSkeleton ({ className }) { + return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ) +} + +export const NostrEmbed = memo(function NostrEmbed ({ src, className, topLevel, id }) { + const [show, setShow] = useState(false) + const iframeRef = useRef(null) + + useEffect(() => { + if (!iframeRef.current) return + + const setHeightFromIframe = (e) => { + if (e.origin !== 'https://njump.me' || !e?.data?.height || e.source !== iframeRef.current.contentWindow) return + iframeRef.current.height = `${e.data.height}px` + } + + window?.addEventListener('message', setHeightFromIframe) + + // https://github.com/vercel/next.js/issues/39451 + iframeRef.current.src = `https://njump.me/${id}?embed=yes` + + return () => { + window?.removeEventListener('message', setHeightFromIframe) + } + }, [iframeRef.current]) + + return ( +
    +