* fixes #1395 * rehype plugin for embeds * fix lint * replace many plugins with one rehype and improve image collage * remove unused css * handle more custom markdown behavior in rehype * refactor markdown rendering more + better footnotes * move more markdown logic to reyhpe plugin + better headers * fix #1397 * refactor embeds out of media-or-link
This commit is contained in:
parent
0f9b6f02f6
commit
cc4bbf99e4
|
@ -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 <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<div className={classNames(styles.tweetsSkeleton, className)}>
|
||||
<div className={styles.tweetSkeleton}>
|
||||
<div className={`${styles.img} clouds`} />
|
||||
<div className={styles.content1}>
|
||||
<div className={`${styles.line} clouds`} />
|
||||
<div className={`${styles.line} clouds`} />
|
||||
<div className={`${styles.line} clouds`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={classNames(styles.nostrContainer, !show && styles.twitterContained, className)}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
width={topLevel ? '550px' : '350px'}
|
||||
style={{ maxWidth: '100%' }}
|
||||
height={iframeRef.current?.height || (topLevel ? '200px' : '150px')}
|
||||
frameBorder='0'
|
||||
sandbox='allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox'
|
||||
allow=''
|
||||
/>
|
||||
{!show &&
|
||||
<Button size='md' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||
<div>show full note</div>
|
||||
<small className='fw-normal fst-italic'>or other stuff</small>
|
||||
</Button>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SpotifyEmbed = function SpotifyEmbed ({ src, className }) {
|
||||
const iframeRef = useRef(null)
|
||||
|
||||
// https://open.spotify.com/track/1KFxcj3MZrpBGiGA8ZWriv?si=f024c3aa52294aa1
|
||||
// Remove any additional path segments
|
||||
const url = new URL(src)
|
||||
url.pathname = url.pathname.replace(/\/intl-\w+\//, '/')
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return
|
||||
|
||||
const id = url.pathname.split('/').pop()
|
||||
|
||||
// https://developer.spotify.com/documentation/embeds/tutorials/using-the-iframe-api
|
||||
window.onSpotifyIframeApiReady = (IFrameAPI) => {
|
||||
const options = {
|
||||
uri: `spotify:episode:${id}`
|
||||
}
|
||||
const callback = (EmbedController) => {}
|
||||
IFrameAPI.createController(iframeRef.current, options, callback)
|
||||
}
|
||||
|
||||
return () => { window.onSpotifyIframeApiReady = null }
|
||||
}, [iframeRef.current, url.pathname])
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.spotifyWrapper, className)}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title='Spotify Web Player'
|
||||
src={`https://open.spotify.com/embed${url.pathname}`}
|
||||
width='100%'
|
||||
height='152'
|
||||
allowFullScreen
|
||||
frameBorder='0'
|
||||
allow='encrypted-media; clipboard-write;'
|
||||
style={{ borderRadius: '12px' }}
|
||||
sandbox='allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-presentation'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Embed = memo(function Embed ({ src, provider, id, meta, className, topLevel, onError }) {
|
||||
const [darkMode] = useDarkMode()
|
||||
const [overflowing, setOverflowing] = useState(true)
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
// This Twitter embed could use similar logic to the video embeds below
|
||||
if (provider === 'twitter') {
|
||||
return (
|
||||
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
|
||||
<TwitterTweetEmbed
|
||||
tweetId={id}
|
||||
options={{ theme: darkMode ? 'dark' : 'light', width: topLevel ? '550px' : '350px' }}
|
||||
key={darkMode ? '1' : '2'}
|
||||
placeholder={<TweetSkeleton className={className} />}
|
||||
onLoad={() => setOverflowing(true)}
|
||||
/>
|
||||
{overflowing && !show &&
|
||||
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||
show full tweet
|
||||
</Button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'nostr') {
|
||||
return (
|
||||
<NostrEmbed src={src} className={className} topLevel={topLevel} id={id} />
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'wavlake') {
|
||||
return (
|
||||
<div className={classNames(styles.wavlakeWrapper, className)}>
|
||||
<iframe
|
||||
src={`https://embed.wavlake.com/track/${id}`} width='100%' height='380' frameBorder='0'
|
||||
allow='encrypted-media'
|
||||
sandbox='allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-same-origin'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'spotify') {
|
||||
return (
|
||||
<SpotifyEmbed src={src} className={className} />
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'youtube') {
|
||||
return (
|
||||
<div className={classNames(styles.videoWrapper, className)}>
|
||||
<YouTube
|
||||
videoId={id} className={styles.videoContainer} opts={{
|
||||
playerVars: {
|
||||
start: meta?.start || 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'rumble') {
|
||||
return (
|
||||
<div className={classNames(styles.videoWrapper, className)}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='Rumble Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'peertube') {
|
||||
return (
|
||||
<div className={classNames(styles.videoWrapper, className)}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='PeerTube Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
export default Embed
|
|
@ -393,7 +393,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||
{tab !== 'write' &&
|
||||
<div className='form-group'>
|
||||
<div className={`${styles.text} form-control`}>
|
||||
<Text topLevel={topLevel} noFragments tab={tab}>{meta.value}</Text>
|
||||
<Text topLevel={topLevel} tab={tab}>{meta.value}</Text>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
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 { useState, useEffect, useMemo, useCallback, memo } from 'react'
|
||||
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'
|
||||
import classNames from 'classnames'
|
||||
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
||||
import YouTube from 'react-youtube'
|
||||
import useDarkMode from './dark-mode'
|
||||
import { useCarousel } from './carousel'
|
||||
|
||||
function LinkRaw ({ href, children, src, rel }) {
|
||||
|
@ -23,9 +19,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 (
|
||||
<div className={classNames(className, styles.mediaContainer)} style={style}>
|
||||
<div
|
||||
className={classNames(className, styles.mediaContainer)}
|
||||
style={style}
|
||||
>
|
||||
{video
|
||||
? <video
|
||||
src={src}
|
||||
|
@ -52,7 +54,7 @@ const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, heig
|
|||
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
|
||||
|
@ -64,8 +66,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
|
||||
|
||||
|
@ -77,14 +80,6 @@ export default function MediaOrLink ({ linkFallback = true, ...props }) {
|
|||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (media.embed) {
|
||||
return (
|
||||
<Embed
|
||||
{...media.embed} topLevel={props.topLevel} src={media.src} onError={handleError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (linkFallback) {
|
||||
|
@ -102,11 +97,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 <img>
|
||||
const img = new window.Image()
|
||||
img.onload = () => setIsImage(true)
|
||||
|
@ -121,7 +115,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
|
||||
|
@ -170,203 +164,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
function TweetSkeleton ({ className }) {
|
||||
return (
|
||||
<div className={classNames(styles.tweetsSkeleton, className)}>
|
||||
<div className={styles.tweetSkeleton}>
|
||||
<div className={`${styles.img} clouds`} />
|
||||
<div className={styles.content1}>
|
||||
<div className={`${styles.line} clouds`} />
|
||||
<div className={`${styles.line} clouds`} />
|
||||
<div className={`${styles.line} clouds`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={classNames(styles.nostrContainer, !show && styles.twitterContained, className)}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
width={topLevel ? '550px' : '350px'}
|
||||
style={{ maxWidth: '100%' }}
|
||||
height={iframeRef.current?.height || (topLevel ? '200px' : '150px')}
|
||||
frameBorder='0'
|
||||
sandbox='allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox'
|
||||
allow=''
|
||||
/>
|
||||
{!show &&
|
||||
<Button size='md' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||
<div>show full note</div>
|
||||
<small className='fw-normal fst-italic'>or other stuff</small>
|
||||
</Button>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SpotifyEmbed = function SpotifyEmbed ({ src, className }) {
|
||||
const iframeRef = useRef(null)
|
||||
|
||||
// https://open.spotify.com/track/1KFxcj3MZrpBGiGA8ZWriv?si=f024c3aa52294aa1
|
||||
// Remove any additional path segments
|
||||
const url = new URL(src)
|
||||
url.pathname = url.pathname.replace(/\/intl-\w+\//, '/')
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return
|
||||
|
||||
const id = url.pathname.split('/').pop()
|
||||
|
||||
// https://developer.spotify.com/documentation/embeds/tutorials/using-the-iframe-api
|
||||
window.onSpotifyIframeApiReady = (IFrameAPI) => {
|
||||
const options = {
|
||||
uri: `spotify:episode:${id}`
|
||||
}
|
||||
const callback = (EmbedController) => {}
|
||||
IFrameAPI.createController(iframeRef.current, options, callback)
|
||||
}
|
||||
|
||||
return () => { window.onSpotifyIframeApiReady = null }
|
||||
}, [iframeRef.current, url.pathname])
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.spotifyWrapper, className)}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title='Spotify Web Player'
|
||||
src={`https://open.spotify.com/embed${url.pathname}`}
|
||||
width='100%'
|
||||
height='152'
|
||||
allowFullScreen
|
||||
frameBorder='0'
|
||||
allow='encrypted-media; clipboard-write;'
|
||||
style={{ borderRadius: '12px' }}
|
||||
sandbox='allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-presentation'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Embed = memo(function Embed ({ src, provider, id, meta, className, topLevel, onError }) {
|
||||
const [darkMode] = useDarkMode()
|
||||
const [overflowing, setOverflowing] = useState(true)
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
// This Twitter embed could use similar logic to the video embeds below
|
||||
if (provider === 'twitter') {
|
||||
return (
|
||||
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
|
||||
<TwitterTweetEmbed
|
||||
tweetId={id}
|
||||
options={{ theme: darkMode ? 'dark' : 'light', width: topLevel ? '550px' : '350px' }}
|
||||
key={darkMode ? '1' : '2'}
|
||||
placeholder={<TweetSkeleton className={className} />}
|
||||
onLoad={() => setOverflowing(true)}
|
||||
/>
|
||||
{overflowing && !show &&
|
||||
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||
show full tweet
|
||||
</Button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'nostr') {
|
||||
return (
|
||||
<NostrEmbed src={src} className={className} topLevel={topLevel} id={id} />
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'wavlake') {
|
||||
return (
|
||||
<div className={classNames(styles.wavlakeWrapper, className)}>
|
||||
<iframe
|
||||
src={`https://embed.wavlake.com/track/${id}`} width='100%' height='380' frameBorder='0'
|
||||
allow='encrypted-media'
|
||||
sandbox='allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-same-origin'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'spotify') {
|
||||
return (
|
||||
<SpotifyEmbed src={src} className={className} />
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'youtube') {
|
||||
return (
|
||||
<div className={classNames(styles.videoWrapper, className)}>
|
||||
<YouTube
|
||||
videoId={id} className={styles.videoContainer} opts={{
|
||||
playerVars: {
|
||||
start: meta?.start || 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'rumble') {
|
||||
return (
|
||||
<div className={classNames(styles.videoWrapper, className)}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='Rumble Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'peertube') {
|
||||
return (
|
||||
<div className={classNames(styles.videoWrapper, className)}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='PeerTube Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
|
|
@ -3,18 +3,10 @@ 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 mention from '@/lib/remark-mention'
|
||||
import sub from '@/lib/remark-sub'
|
||||
import React, { useState, memo, useRef, useCallback, useMemo, useEffect } 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 MediaOrLink 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 { rehypeInlineCodeProperty, rehypeStyler } from '@/lib/md'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useRouter } from 'next/router'
|
||||
import Link from 'next/link'
|
||||
|
@ -24,10 +16,23 @@ import UserPopover from './user-popover'
|
|||
import ItemPopover from './item-popover'
|
||||
import classNames from 'classnames'
|
||||
import { CarouselProvider, useCarousel } from './carousel'
|
||||
import rehypeSN from '@/lib/rehype-sn'
|
||||
import Embed from './embed'
|
||||
|
||||
// Explicitely defined start/end tags & which CSS class from text.module.css to apply
|
||||
export const rehypeSuperscript = () => rehypeStyler('<sup>', '</sup>', styles.superscript)
|
||||
export const rehypeSubscript = () => rehypeStyler('<sub>', '</sub>', styles.subscript)
|
||||
const rehypeSNStyled = () => rehypeSN({
|
||||
stylers: [{
|
||||
startTag: '<sup>',
|
||||
endTag: '</sup>',
|
||||
className: styles.superscript
|
||||
}, {
|
||||
startTag: '<sub>',
|
||||
endTag: '</sub>',
|
||||
className: styles.subscript
|
||||
}]
|
||||
})
|
||||
|
||||
const remarkPlugins = [gfm]
|
||||
const rehypePlugins = [rehypeSNStyled]
|
||||
|
||||
export function SearchText ({ text }) {
|
||||
return (
|
||||
|
@ -42,16 +47,17 @@ 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)
|
||||
const containerRef = useRef(null)
|
||||
|
||||
// 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)
|
||||
|
@ -59,8 +65,9 @@ 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(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || overflowing) return
|
||||
|
@ -83,213 +90,157 @@ 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 (
|
||||
<span className={styles.heading}>
|
||||
{React.createElement(h || node?.tagName, { id, ...props }, children)}
|
||||
{!noFragments && topLevel &&
|
||||
<a className={`${styles.headingLink} ${copied ? styles.copied : ''}`} href={`#${id}`}>
|
||||
<Icon
|
||||
onClick={() => {
|
||||
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'
|
||||
/>
|
||||
</a>}
|
||||
</span>
|
||||
)
|
||||
}, [topLevel, noFragments])
|
||||
|
||||
const Table = useCallback(({ node, ...props }) =>
|
||||
<span className='table-responsive'>
|
||||
<table className='table table-bordered table-sm' {...props} />
|
||||
</span>, [])
|
||||
|
||||
const Code = useCallback(({ node, inline, className, children, style, ...props }) => {
|
||||
return inline
|
||||
? (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
: (
|
||||
<SyntaxHighlighter style={atomDark} language='text' PreTag='div' {...props}>
|
||||
{children}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
}, [])
|
||||
|
||||
const P = useCallback(({ children, node, ...props }) => <div className={styles.p} {...props}>{children}</div>, [])
|
||||
|
||||
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 <MediaOrLink srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} />
|
||||
}, [imgproxyUrls, topLevel, tab, outlawed, rel])
|
||||
const TextMediaOrLink = useCallback(props => {
|
||||
return <MediaLink {...props} outlawed={outlawed} imgproxyUrls={imgproxyUrls} topLevel={topLevel} rel={rel} />
|
||||
},
|
||||
[outlawed, imgproxyUrls, topLevel, rel])
|
||||
|
||||
const components = useMemo(() => ({
|
||||
h1: Heading,
|
||||
h2: Heading,
|
||||
h3: Heading,
|
||||
h4: Heading,
|
||||
h5: Heading,
|
||||
h6: Heading,
|
||||
h1: ({ node, id, ...props }) => <h1 id={topLevel ? id : undefined} {...props} />,
|
||||
h2: ({ node, id, ...props }) => <h2 id={topLevel ? id : undefined} {...props} />,
|
||||
h3: ({ node, id, ...props }) => <h3 id={topLevel ? id : undefined} {...props} />,
|
||||
h4: ({ node, id, ...props }) => <h4 id={topLevel ? id : undefined} {...props} />,
|
||||
h5: ({ node, id, ...props }) => <h5 id={topLevel ? id : undefined} {...props} />,
|
||||
h6: ({ node, id, ...props }) => <h6 id={topLevel ? id : undefined} {...props} />,
|
||||
table: Table,
|
||||
p: P,
|
||||
li: props => {
|
||||
return <li {...props} id={props.id && itemId ? `${props.id}-${itemId}` : props.id} />
|
||||
},
|
||||
code: Code,
|
||||
mention: Mention,
|
||||
sub: Sub,
|
||||
item: Item,
|
||||
footnote: Footnote,
|
||||
headlink: ({ node, href, ...props }) => <Link href={href} {...props} />,
|
||||
autolink: TextMediaOrLink,
|
||||
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 <a> 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.
|
||||
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 (
|
||||
<Link
|
||||
{...props}
|
||||
id={props.id && itemId ? `${props.id}-${itemId}` : props.id}
|
||||
href={itemId ? `${href}-${itemId}` : href}
|
||||
>{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
if (text.startsWith?.('@')) {
|
||||
// user mention might be within a markdown link like this: [@user foo bar](url)
|
||||
const name = text.replace('@', '').split(' ')[0]
|
||||
return (
|
||||
<UserPopover name={name}>
|
||||
<Link
|
||||
id={props.id}
|
||||
href={href}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
</UserPopover>
|
||||
)
|
||||
} else if (href.startsWith('/') || url?.origin === internalURL) {
|
||||
try {
|
||||
const { linkText } = parseInternalLinks(href)
|
||||
if (linkText) {
|
||||
return (
|
||||
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||
<Link href={href}>{text}</Link>
|
||||
</ItemPopover>
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore errors like invalid URLs
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
id={props.id}
|
||||
href={href}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line
|
||||
<a id={props.id} target='_blank' rel={rel ?? UNKNOWN_LINK_REL} href={href}>{text}</a>
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { linkText } = parseInternalLinks(href)
|
||||
if (linkText) {
|
||||
return (
|
||||
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||
<Link href={href}>{linkText}</Link>
|
||||
</ItemPopover>
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore errors like invalid URLs
|
||||
}
|
||||
|
||||
// assume the link is an image which will fallback to link if it's not
|
||||
return <TextMediaOrLink src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</TextMediaOrLink>
|
||||
// eslint-disable-next-line
|
||||
return <Link id={props.id} target='_blank' rel={rel} href={href}>{children}</Link>
|
||||
},
|
||||
img: TextMediaOrLink
|
||||
}), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink])
|
||||
img: TextMediaOrLink,
|
||||
embed: Embed
|
||||
}), [outlawed, rel, TextMediaOrLink, topLevel])
|
||||
|
||||
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
|
||||
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
|
||||
const carousel = useCarousel()
|
||||
|
||||
const markdownContent = useMemo(() => (
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkRehypeOptions={{ clobberPrefix: `itemfn-${itemId}-` }}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
), [components, remarkPlugins, rehypePlugins, children, itemId])
|
||||
|
||||
const showOverflow = useCallback(() => setShow(true), [setShow])
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.text, topLevel && styles.topLevel, show ? styles.textUncontained : overflowing && styles.textContained)} ref={containerRef}>
|
||||
{carousel && tab !== 'preview'
|
||||
? (
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>)
|
||||
: (
|
||||
<CarouselProvider>
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</CarouselProvider>)}
|
||||
{overflowing && !show &&
|
||||
<Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.text,
|
||||
topLevel && styles.topLevel,
|
||||
show ? styles.textUncontained : overflowing && styles.textContained
|
||||
)}
|
||||
ref={containerRef}
|
||||
>
|
||||
{
|
||||
carousel && tab !== 'preview'
|
||||
? markdownContent
|
||||
: <CarouselProvider>{markdownContent}</CarouselProvider>
|
||||
}
|
||||
{overflowing && !show && (
|
||||
<Button
|
||||
size='lg'
|
||||
variant='info'
|
||||
className={styles.textShowFull}
|
||||
onClick={showOverflow}
|
||||
>
|
||||
show full text
|
||||
</Button>}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}, isEqual)
|
||||
|
||||
function Mention ({ children, node, href, name, id }) {
|
||||
return (
|
||||
<UserPopover name={name}>
|
||||
<Link
|
||||
id={id}
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
</UserPopover>
|
||||
)
|
||||
}
|
||||
|
||||
function Sub ({ children, node, href, ...props }) {
|
||||
return <Link href={href}>{children}</Link>
|
||||
}
|
||||
|
||||
function Item ({ children, node, href, id }) {
|
||||
return (
|
||||
<ItemPopover id={id}>
|
||||
<Link href={href}>{children}</Link>
|
||||
</ItemPopover>
|
||||
)
|
||||
}
|
||||
|
||||
function Footnote ({ children, node, ...props }) {
|
||||
return (
|
||||
<Link {...props}>{children}</Link>
|
||||
)
|
||||
}
|
||||
|
||||
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 <MediaOrLink srcSet={srcSet} src={src} rel={rel} {...props} />
|
||||
}
|
||||
|
||||
function Table ({ node, ...props }) {
|
||||
return (
|
||||
<span className='table-responsive'>
|
||||
<table className='table table-bordered table-sm' {...props} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Code ({ node, inline, className, children, style, ...props }) {
|
||||
return inline
|
||||
? (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
: (
|
||||
<SyntaxHighlighter style={atomDark} language='text' PreTag='div' {...props}>
|
||||
{children}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
}
|
||||
|
||||
function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.p, onlyImages && styles.onlyImages,
|
||||
somethingBefore && styles.somethingBefore, somethingAfter && styles.somethingAfter)} {...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -9,11 +9,16 @@
|
|||
--grid-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.text.topLevel {
|
||||
--grid-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.text :global(.footnotes) {
|
||||
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. */
|
||||
|
@ -37,6 +42,12 @@
|
|||
content: ']';
|
||||
}
|
||||
|
||||
.text :global(sup:has([data-footnote-ref])) {
|
||||
top: 0;
|
||||
font-size: 100%;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.textUncontained {
|
||||
max-height: none;
|
||||
}
|
||||
|
@ -110,33 +121,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,50 +164,50 @@
|
|||
|
||||
.mediaContainer {
|
||||
display: block;
|
||||
width: calc(100% - var(--grid-gap));
|
||||
max-width: calc(100% - var(--grid-gap));
|
||||
width: 100%;
|
||||
max-width: 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;
|
||||
.mediaContainer.hasTextSiblingsAfter {
|
||||
margin-bottom: var(--grid-gap);
|
||||
}
|
||||
|
||||
.p:has(> .mediaContainer) .mediaContainer
|
||||
{
|
||||
display: flex;
|
||||
width: min-content;
|
||||
max-width: calc(100% - var(--grid-gap));
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mediaContainer ~ .mediaContainer, .mediaContainer:has(+ .mediaContainer) {
|
||||
display: inline-block;
|
||||
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,
|
||||
.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:has(> .mediaContainer) .mediaContainer img,
|
||||
.p:has(> .mediaContainer) .mediaContainer video
|
||||
{
|
||||
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 {
|
||||
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 {
|
||||
|
@ -226,6 +222,7 @@
|
|||
.mediaContainer img {
|
||||
cursor: zoom-in;
|
||||
min-width: 30%;
|
||||
max-width: 100%;
|
||||
object-position: left top;
|
||||
}
|
||||
|
||||
|
@ -268,30 +265,29 @@
|
|||
.text h1, .text h2, .text h3, .text h4, .text h5, .text h6 {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.text h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.text h2 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.text h3 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.text h4 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.text h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.text h6 {
|
||||
font-size: .85rem;
|
||||
.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;
|
||||
}
|
||||
|
||||
.topLevel.text h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.topLevel.text h2 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.topLevel.text h3 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.topLevel.text h4 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
|
||||
|
@ -306,17 +302,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 {
|
||||
|
|
39
lib/md.js
39
lib/md.js
|
@ -19,45 +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 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, {
|
||||
|
|
|
@ -0,0 +1,230 @@
|
|||
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')
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -940,6 +940,10 @@ div[contenteditable]:focus,
|
|||
animation: flipX 2s linear infinite;
|
||||
}
|
||||
|
||||
.topLevel {
|
||||
--grid-gap: 0.75rem;
|
||||
}
|
||||
|
||||
@keyframes flipY {
|
||||
from {
|
||||
transform: rotateY(0deg);
|
||||
|
|
Loading…
Reference in New Issue