refactor(markdown): replace react-markdown with custom imageboard-style text parser

This commit is contained in:
plebeius
2026-03-04 15:20:06 +08:00
parent 1aabe7a6b2
commit e6afb041ae
8 changed files with 191 additions and 1039 deletions
@@ -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;
+156 -138
View File
@@ -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&nbsp;\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>
);
};
+3 -4
View File
@@ -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 = () => {
+8 -10
View File
@@ -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({
+7 -29
View File
@@ -21,36 +21,14 @@ export function getTextColorForBackground(rgb: string): string {
return brightness > 125 ? 'black' : 'white';
}
export const formatMarkdown = (content: string): string => {
let md = content;
if (md) {
// Check if the content already contains valid Markdown patterns
const alreadyFormattedPattern = /\n&nbsp;\n/;
// help the user by formatting the content if it's not already formatted
if (!alreadyFormattedPattern.test(md)) {
// Replace single newline with "/n&nbsp;/n" if followed by a newline
md = md.replace(/\n(?=\n)/g, '\n&nbsp;\n');
// Replace single newline with double newline if between two characters
md = md.replace(/\n(?=\S)/g, '\n\n');
}
}
return md;
};
export function removeMarkdown(md: string): string {
return md
.replace(/\[([^\]]*?)][[(].*?[)\]]/g, '$1') // Remove links
.replace(/[*_~`]/g, '') // Remove emphasis and code markers
.replace(/^#+\s*(.+)$/gm, '$1') // Remove headers
.replace(/^\s*[-*+]\s+/gm, '') // Remove list markers
.replace(/^\s*>\s+/gm, '') // Remove blockquotes
.replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, '') // Remove reference-style links
.replace(/^(\n)?\s{0,}#{1,6}\s*( (.+))? +#+$|^(\n)?\s{0,}#{1,6}\s*( (.+))?$/gm, '$1$3$4$6') // Remove atx-style headers
.replace(/([*]+)(\S)(.*?\S)?\1/g, '$2$3') // Remove * emphasis
.replace(/(^|\W)([_]+)(\S)(.*?\S)??\2($|\W)/g, '$1$3$4$5') // Remove _ emphasis
.replace(/(`{3,})(.*?)\1/gm, '$2') // Remove code blocks
.replace(/`(.+?)`/g, '$1') // Remove inline code
.replace(/~(.*?)~/g, '$1') // Remove strike through
.replace(/<spoiler>(.*?)<\/spoiler>/gs, '$1') // spoiler tags - keep inner text
.replace(/\[([^\]]*?)\]\([^)]*\)/g, '$1') // [text](url) -> text
.replace(/&nbsp;/g, ' ') // &nbsp; -> space
.replace(/^>\s*/gm, '') // greentext at line start
.replace(/[*_]/g, '') // bold/italic markers
.replace(/```[\s\S]*?```/g, (m) => m.slice(3, -3)) // code blocks - keep content
.replace(/`([^`]*)`/g, '$1') // inline code - keep content
.trim();
}
-51
View File
@@ -181,54 +181,3 @@ export const isValidCrossboardPattern = (pattern: string): boolean => {
// Check if it's just a full address pattern: >>>/board.eth
return isValidDomain(pathPart) || isValidIPNSKey(pathPart);
};
// Transform >>{number} post number patterns to markdown links with special anchor
const preprocessPostNumberPatterns = (content: string): string => {
// Match >> followed by digits, avoid overlap with greentext (>>>), cross-board (>>>/), URLs, CID-like patterns
return content.replace(QUOTE_NUMBER_REGEX, (_, num) => `[>>${num}](#q-${num})`);
};
// Preprocess content to convert plain text 5chan cross-board patterns to markdown links
export const preprocess5chanPatterns = (content: string): string => {
const withPostNumbers = preprocessPostNumberPatterns(content);
// Pattern to match ">>>/something" or ">>>/something/cid"
// Negative lookbehind prevents matching patterns that are already part of URLs
// Matches: >>>/directory/, >>>/directory/cid (46 chars), >>>/address, >>>/address/cid (46 chars)
const pattern = /(?<!https?:\/\/[^\s]*)>>>\/([a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?)[.,:;!?]*/g;
return withPostNumbers.replace(pattern, (match, capturedPath) => {
// Remove any trailing punctuation from the captured path
const cleanPath = capturedPath.replace(/[.,:;!?]+$/, '');
const fullPattern = `>>>/${cleanPath}`;
if (isValidCrossboardPattern(fullPattern)) {
// Generate internal route based on pattern type
let internalRoute: string;
// Directory with trailing slash: >>>/biz/ → /biz
if (/^[a-zA-Z0-9]{1,10}\/$/.test(cleanPath)) {
internalRoute = `/${cleanPath.slice(0, -1)}`;
}
// Directory + CID: >>>/biz/cid → /biz/thread/cid (CID is 46 chars)
else if (/^[a-zA-Z0-9]{1,10}\/[a-zA-Z0-9]{46}$/.test(cleanPath)) {
const [code, cid] = cleanPath.split('/');
internalRoute = `/${code}/thread/${cid}`;
}
// Full address + CID: >>>/board.eth/cid → /board.eth/thread/cid (CID is 46 chars)
else if (/^[^/]+\/[a-zA-Z0-9]{46}$/.test(cleanPath)) {
const [address, cid] = cleanPath.split('/');
internalRoute = `/${address}/thread/${cid}`;
}
// Just a full address: >>>/board.eth → /board.eth
else {
internalRoute = `/${cleanPath}`;
}
// Preserve trailing punctuation outside the link
const trailingPunctuation = match.slice(fullPattern.length);
return `[${fullPattern}](${internalRoute})${trailingPunctuation}`;
}
return match;
});
};