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({
|
||||
|
||||
@@ -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 \n/;
|
||||
|
||||
// help the user by formatting the content if it's not already formatted
|
||||
if (!alreadyFormattedPattern.test(md)) {
|
||||
// Replace single newline with "/n /n" if followed by a newline
|
||||
md = md.replace(/\n(?=\n)/g, '\n \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(/ /g, ' ') // -> 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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user