mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
refactor(markdown): replace react-markdown with custom imageboard-style text parser
This commit is contained in:
@@ -18,25 +18,6 @@
|
||||
text-decoration: var(--post-quotelink-text-decoration-hover);
|
||||
}
|
||||
|
||||
.hrWrapper {
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
|
||||
.hrWrapper hr {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
all: revert;
|
||||
border: none;
|
||||
border-top: 1px solid var(--body-font-color);
|
||||
}
|
||||
|
||||
.markdown ol, .markdown ul {
|
||||
padding-left: 1em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.embedButton {
|
||||
color: var(--button-desktop-text-color);
|
||||
text-transform: capitalize;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Placement } from '@floating-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown, { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import supersub from 'remark-supersub';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, shift, size, autoUpdate, FloatingPortal } from '@floating-ui/react';
|
||||
import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
||||
import { isCatalogView } from '../../lib/utils/view-utils';
|
||||
@@ -14,7 +9,7 @@ import CommentMedia from '../comment-media';
|
||||
import styles from './markdown.module.css';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { canEmbed } from '../embed';
|
||||
import { is5chanLink, transform5chanLinkToInternal, preprocess5chanPatterns } from '../../lib/utils/url-utils';
|
||||
import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } from '../../lib/utils/url-utils';
|
||||
import usePostNumberStore from '../../stores/use-post-number-store';
|
||||
import useSubplebbitsPagesStore from '@bitsocialhq/pkc-react-hooks/dist/stores/subplebbits-pages';
|
||||
import { useComment } from '@bitsocialhq/pkc-react-hooks';
|
||||
@@ -34,10 +29,6 @@ interface ContentLinkEmbedProps {
|
||||
linkMediaInfo: any;
|
||||
}
|
||||
|
||||
interface ExtendedComponents extends Components {
|
||||
spoiler: React.ComponentType<{ children: React.ReactNode }>;
|
||||
}
|
||||
|
||||
const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -139,48 +130,137 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
|
||||
);
|
||||
};
|
||||
|
||||
const MAX_LENGTH_FOR_GFM = 10000; // remarkGfm lags with large content
|
||||
const normalizeContent = (content: string): string => {
|
||||
if (!content) return '';
|
||||
let normalized = content.replace(/\n \n/g, '\n\n');
|
||||
normalized = normalized.replace(/\n{3,}/g, '\n\n');
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const blockquoteToGreentext = () => (tree: any) => {
|
||||
tree.children.forEach((node: any) => {
|
||||
if (node.type === 'blockquote') {
|
||||
node.children.forEach((child: any) => {
|
||||
if (child.type === 'paragraph' && child.children.length > 0) {
|
||||
const prefix = {
|
||||
type: 'text',
|
||||
value: '>',
|
||||
};
|
||||
child.children.unshift(prefix);
|
||||
type Token =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'url'; href: string }
|
||||
| { type: 'quoteLink'; number: number }
|
||||
| { type: 'crossBoardLink'; display: string; route: string }
|
||||
| { type: 'spoiler'; tokens: Token[] };
|
||||
|
||||
const SPOILER_REGEX = /<spoiler>([\s\S]*?)<\/spoiler>/;
|
||||
const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?))[.,:;!?]*/;
|
||||
const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
|
||||
const URL_REGEX = /https?:\/\/[^\s<\[\]]*[^\s<\[\].,;:!?\"'\)\]>]/;
|
||||
|
||||
const COMBINED_REGEX = new RegExp(`(${SPOILER_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`, 'g');
|
||||
|
||||
function getCrossboardRoute(fullPattern: string): string | null {
|
||||
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
|
||||
if (!isValidCrossboardPattern(`>>>/${pathPart}`)) {
|
||||
return null;
|
||||
}
|
||||
if (/^[a-zA-Z0-9]{1,10}\/$/.test(pathPart)) {
|
||||
return `/${pathPart.slice(0, -1)}`;
|
||||
}
|
||||
if (/^[a-zA-Z0-9]{1,10}\/[a-zA-Z0-9]{46}$/.test(pathPart)) {
|
||||
const [code, cid] = pathPart.split('/');
|
||||
return `/${code}/thread/${cid}`;
|
||||
}
|
||||
if (/^[^/]+\/[a-zA-Z0-9]{46}$/.test(pathPart)) {
|
||||
const [address, cid] = pathPart.split('/');
|
||||
return `/${address}/thread/${cid}`;
|
||||
}
|
||||
return `/${pathPart}`;
|
||||
}
|
||||
|
||||
function tokenize(text: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
const regex = new RegExp(COMBINED_REGEX.source, 'g');
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const matchStart = match.index;
|
||||
|
||||
if (matchStart > lastIndex) {
|
||||
tokens.push({ type: 'text', value: text.slice(lastIndex, matchStart) });
|
||||
}
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
const innerContent = match[2];
|
||||
tokens.push({ type: 'spoiler', tokens: tokenize(innerContent) });
|
||||
} else if (match[3] !== undefined) {
|
||||
const pathPart = match[4];
|
||||
const fullPattern = `>>>/${pathPart}`;
|
||||
const route = getCrossboardRoute(fullPattern);
|
||||
if (route) {
|
||||
tokens.push({ type: 'crossBoardLink', display: fullPattern, route });
|
||||
} else {
|
||||
tokens.push({ type: 'text', value: fullMatch });
|
||||
}
|
||||
} else if (match[5] !== undefined) {
|
||||
const number = parseInt(match[6], 10);
|
||||
tokens.push({ type: 'quoteLink', number });
|
||||
} else if (match[7] !== undefined) {
|
||||
tokens.push({ type: 'url', href: fullMatch });
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
interface RenderContext {
|
||||
isInCatalogView: boolean;
|
||||
postCid?: string;
|
||||
subplebbitAddress?: string;
|
||||
}
|
||||
|
||||
function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[] {
|
||||
const { isInCatalogView, postCid, subplebbitAddress } = context;
|
||||
|
||||
return tokens.map((token, i) => {
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
return <React.Fragment key={i}>{token.value}</React.Fragment>;
|
||||
case 'url': {
|
||||
const href = token.href;
|
||||
const linkMediaInfo = getLinkMediaInfo(href);
|
||||
const embedUrl = safeParseUrl(href);
|
||||
if (!isInCatalogView && ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href))) {
|
||||
return (
|
||||
<ContentLinkEmbed key={i} href={href} linkMediaInfo={linkMediaInfo}>
|
||||
{href}
|
||||
</ContentLinkEmbed>
|
||||
);
|
||||
}
|
||||
});
|
||||
node.type = 'div';
|
||||
node.data = {
|
||||
hName: 'div',
|
||||
hProperties: {
|
||||
className: 'greentext',
|
||||
},
|
||||
};
|
||||
return <React.Fragment key={i}>{renderAnchorLink(href, href, postCid, subplebbitAddress)}</React.Fragment>;
|
||||
}
|
||||
case 'quoteLink':
|
||||
return (
|
||||
<span key={i} className={styles.inlineQuoteLink}>
|
||||
<NumberQuoteLink number={token.number} threadPostCid={postCid} subplebbitAddress={subplebbitAddress} />
|
||||
</span>
|
||||
);
|
||||
case 'crossBoardLink':
|
||||
return (
|
||||
<Link key={i} to={token.route}>
|
||||
{token.display}
|
||||
</Link>
|
||||
);
|
||||
case 'spoiler':
|
||||
return (
|
||||
<span key={i} className='spoilertext'>
|
||||
{renderTokens(token.tokens, context)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const spoilerTransform = () => (tree: any) => {
|
||||
const visit = (node: any) => {
|
||||
if (node.tagName === 'spoiler') {
|
||||
node.tagName = 'span';
|
||||
node.properties = node.properties || {};
|
||||
node.properties.className = 'spoilertext';
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
node.children.forEach(visit);
|
||||
}
|
||||
};
|
||||
|
||||
if (tree.children) {
|
||||
tree.children.forEach(visit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
@@ -189,8 +269,6 @@ interface MarkdownProps {
|
||||
subplebbitAddress?: string;
|
||||
}
|
||||
|
||||
const NUMBER_QUOTE_HREF_REGEX = /^#q-(\d+)$/;
|
||||
|
||||
const NumberQuoteLink = ({ number, threadPostCid, subplebbitAddress }: { number: number; threadPostCid?: string; subplebbitAddress?: string }) => {
|
||||
const cid = usePostNumberStore((state) => (subplebbitAddress ? state.numberToCid[subplebbitAddress]?.[number] : undefined));
|
||||
const commentFromStore = useSubplebbitsPagesStore((state) => (cid ? state.comments[cid] : undefined));
|
||||
@@ -210,21 +288,9 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
|
||||
const numberQuoteMatch = href.match(NUMBER_QUOTE_HREF_REGEX);
|
||||
if (numberQuoteMatch) {
|
||||
const number = parseInt(numberQuoteMatch[1], 10);
|
||||
return (
|
||||
<span className={styles.inlineQuoteLink}>
|
||||
<NumberQuoteLink number={number} threadPostCid={threadPostCid} subplebbitAddress={subplebbitAddress} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a valid 5chan link that should be handled internally
|
||||
if (is5chanLink(href)) {
|
||||
const internalPath = transform5chanLinkToInternal(href);
|
||||
if (internalPath) {
|
||||
// Check if the link text should be replaced with the internal path
|
||||
let shouldReplaceText = false;
|
||||
|
||||
if (typeof children === 'string') {
|
||||
@@ -238,7 +304,7 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
|
||||
const isAutolinkedUrl = shouldReplaceText && typeof childrenText === 'string' && childrenText.startsWith('http');
|
||||
|
||||
if (isAutolinkedUrl) {
|
||||
// Keep the full URL as display text for autolinked URLs (e.g. https://5chan.app/pass → "https://5chan.app/pass")
|
||||
displayText = children;
|
||||
} else if (shouldReplaceText && internalPath.match(/^\/[^/]+$/)) {
|
||||
displayText = internalPath.substring(1);
|
||||
} else if (shouldReplaceText) {
|
||||
@@ -252,8 +318,6 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
|
||||
}
|
||||
}
|
||||
|
||||
// Handle hash routes and internal patterns (including routes that start with /#/)
|
||||
// Support both old format (/p/...) for backward compatibility and new format (/{boardIdentifier}/...)
|
||||
if (
|
||||
href.startsWith('#/') ||
|
||||
href.startsWith('/#/') ||
|
||||
@@ -265,7 +329,6 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
|
||||
return <Link to={href}>{children}</Link>;
|
||||
}
|
||||
|
||||
// External links
|
||||
return (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer'>
|
||||
{children}
|
||||
@@ -274,83 +337,40 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
|
||||
};
|
||||
|
||||
const Markdown = ({ content, title, postCid, subplebbitAddress }: MarkdownProps) => {
|
||||
const remarkPlugins = useMemo(() => {
|
||||
const plugins: any[] = [[supersub]];
|
||||
|
||||
if (content && content.length <= MAX_LENGTH_FOR_GFM) {
|
||||
plugins.push([remarkGfm, { singleTilde: false }]);
|
||||
}
|
||||
|
||||
plugins.push([blockquoteToGreentext]);
|
||||
plugins.push([spoilerTransform]);
|
||||
|
||||
return plugins;
|
||||
}, [content]);
|
||||
|
||||
const customSchema = useMemo(
|
||||
() => ({
|
||||
...defaultSchema,
|
||||
tagNames: [...(defaultSchema.tagNames || []), 'div', 'span', 'spoiler'],
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
div: ['className'],
|
||||
span: ['className'],
|
||||
spoiler: [],
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const isInCatalogView = isCatalogView(location.pathname, params);
|
||||
|
||||
const rehypePlugins = useMemo(() => [[rehypeRaw as any], [rehypeSanitize, customSchema]] as any[], [customSchema]);
|
||||
const rendered = useMemo(() => {
|
||||
const normalized = normalizeContent(content || '');
|
||||
const lines = normalized.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
// Preprocess content to convert plain text 5chan patterns to markdown links
|
||||
const processedContent = useMemo(() => preprocess5chanPatterns(content || ''), [content]);
|
||||
lines.forEach((line, lineIndex) => {
|
||||
if (lineIndex > 0) {
|
||||
elements.push(<br key={`br-${lineIndex}`} />);
|
||||
}
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
({
|
||||
p: ({ children }) => <p className={isInCatalogView ? styles.inline : ''}>{children}</p>,
|
||||
h1: ({ children }) => <p className={styles.header}>{children}</p>,
|
||||
h2: ({ children }) => <p className={styles.header}>{children}</p>,
|
||||
h3: ({ children }) => <p className={styles.header}>{children}</p>,
|
||||
h4: ({ children }) => <p className={styles.header}>{children}</p>,
|
||||
h5: ({ children }) => <p className={styles.header}>{children}</p>,
|
||||
h6: ({ children }) => <p className={styles.header}>{children}</p>,
|
||||
img: ({ src, alt }) => {
|
||||
const displayText = src || alt || 'image';
|
||||
return <span>{displayText}</span>;
|
||||
},
|
||||
video: ({ src }) => <span>{src}</span>,
|
||||
iframe: ({ src }) => <span>{src}</span>,
|
||||
source: ({ src }) => <span>{src}</span>,
|
||||
spoiler: ({ children }) => <span className='spoilertext'>{children}</span>,
|
||||
a: ({ href, children }) => {
|
||||
if (href && !isInCatalogView) {
|
||||
const linkMediaInfo = getLinkMediaInfo(href);
|
||||
const embedUrl = safeParseUrl(href);
|
||||
if ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href)) {
|
||||
return (
|
||||
<ContentLinkEmbed href={href} linkMediaInfo={linkMediaInfo}>
|
||||
{children}
|
||||
</ContentLinkEmbed>
|
||||
);
|
||||
}
|
||||
if (!embedUrl && href.startsWith('http')) {
|
||||
console.debug('Invalid URL:', href);
|
||||
}
|
||||
if (line.length === 0) return;
|
||||
|
||||
return renderAnchorLink(children, href, postCid, subplebbitAddress);
|
||||
}
|
||||
const isGreentext = /^>[^>]/.test(line) || line === '>';
|
||||
|
||||
return renderAnchorLink(children, href || '', postCid, subplebbitAddress);
|
||||
},
|
||||
}) as ExtendedComponents,
|
||||
[isInCatalogView, postCid, subplebbitAddress],
|
||||
);
|
||||
const tokens = tokenize(line);
|
||||
const lineElements = renderTokens(tokens, { isInCatalogView, postCid, subplebbitAddress });
|
||||
|
||||
if (isGreentext) {
|
||||
elements.push(
|
||||
<span key={`line-${lineIndex}`} className='greentext'>
|
||||
{lineElements}
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
elements.push(<React.Fragment key={`line-${lineIndex}`}>{lineElements}</React.Fragment>);
|
||||
}
|
||||
});
|
||||
|
||||
return elements;
|
||||
}, [content, isInCatalogView, postCid, subplebbitAddress]);
|
||||
|
||||
return (
|
||||
<span className={styles.markdown}>
|
||||
@@ -360,9 +380,7 @@ const Markdown = ({ content, title, postCid, subplebbitAddress }: MarkdownProps)
|
||||
{content ? ': ' : ''}
|
||||
</span>
|
||||
)}
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components}>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
{rendered}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import getShortAddress from '../../lib/get-short-address';
|
||||
import useSubplebbitsStore from '@bitsocialhq/pkc-react-hooks/dist/stores/subplebbits';
|
||||
import useSubplebbitsPagesStore from '@bitsocialhq/pkc-react-hooks/dist/stores/subplebbits-pages';
|
||||
import { getLinkMediaInfo } from '../../lib/utils/media-utils';
|
||||
import { formatMarkdown } from '../../lib/utils/post-utils';
|
||||
import { isValidURL } from '../../lib/utils/url-utils';
|
||||
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
@@ -401,9 +400,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
const { setPublishReplyOptions, resetPublishReplyOptions, replyIndex, publishReply } = usePublishReply({ cid, subplebbitAddress });
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const formattedContent = formatMarkdown(e.target.value);
|
||||
isInPostView ? setPublishReplyOptions({ content: formattedContent }) : setPublishPostOptions({ content: formattedContent });
|
||||
checkContentLength(formattedContent, t);
|
||||
const content = e.target.value;
|
||||
isInPostView ? setPublishReplyOptions({ content }) : setPublishPostOptions({ content });
|
||||
checkContentLength(content, t);
|
||||
};
|
||||
|
||||
const onPublishReply = () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { setAccount, useAccount } from '@bitsocialhq/pkc-react-hooks';
|
||||
import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
|
||||
import { formatMarkdown } from '../../lib/utils/post-utils';
|
||||
import { getFormattedTimeAgo } from '../../lib/utils/time-utils';
|
||||
import { isValidURL } from '../../lib/utils/url-utils';
|
||||
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
@@ -203,9 +202,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
const len = textRef.current.value.length;
|
||||
lastSelectionStartRef.current = len;
|
||||
lastSelectionEndRef.current = len;
|
||||
const formattedContent = formatMarkdown(textRef.current.value);
|
||||
setPublishReplyOptions({ content: formattedContent });
|
||||
checkContentLengthRef.current(formattedContent, t);
|
||||
const content = textRef.current.value;
|
||||
setPublishReplyOptions({ content });
|
||||
checkContentLengthRef.current(content, t);
|
||||
|
||||
setTimeout(() => {
|
||||
if (textRef.current) {
|
||||
@@ -221,9 +220,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
};
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const formattedContent = formatMarkdown(e.target.value);
|
||||
setPublishReplyOptions({ content: formattedContent });
|
||||
checkContentLengthRef.current(formattedContent, t);
|
||||
const content = e.target.value;
|
||||
setPublishReplyOptions({ content });
|
||||
checkContentLengthRef.current(content, t);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -265,9 +264,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
lastSelectionStartRef.current = nextCursor;
|
||||
lastSelectionEndRef.current = nextCursor;
|
||||
|
||||
const formattedContent = formatMarkdown(nextValue);
|
||||
setPublishReplyOptions({ content: formattedContent });
|
||||
checkContentLengthRef.current(formattedContent, t);
|
||||
setPublishReplyOptions({ content: nextValue });
|
||||
checkContentLengthRef.current(nextValue, t);
|
||||
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]);
|
||||
|
||||
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
|
||||
|
||||
Reference in New Issue
Block a user