mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(markdown): render plebchan links as internal links, so the user doesn't have to leave the app
This commit is contained in:
@@ -11,8 +11,9 @@ import { isCatalogView } from '../../lib/utils/view-utils';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import CommentMedia from '../comment-media';
|
||||
import styles from './markdown.module.css';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { canEmbed } from '../embed';
|
||||
import { isPlebchanLink, transformPlebchanLinkToInternal, preprocessPlebchanPatterns } from '../../lib/utils/url-utils';
|
||||
|
||||
interface ContentLinkEmbedProps {
|
||||
children: any;
|
||||
@@ -153,6 +154,52 @@ interface MarkdownProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const renderAnchorLink = (children: React.ReactNode, href: string) => {
|
||||
if (!href) {
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
|
||||
// Check if this is a valid plebchan link that should be handled internally
|
||||
if (isPlebchanLink(href)) {
|
||||
const internalPath = transformPlebchanLinkToInternal(href);
|
||||
if (internalPath) {
|
||||
// Check if the link text should be replaced with the internal path
|
||||
let shouldReplaceText = false;
|
||||
|
||||
if (typeof children === 'string') {
|
||||
shouldReplaceText = children === href || children.trim() === href.trim();
|
||||
} else if (Array.isArray(children) && children.length === 1 && typeof children[0] === 'string') {
|
||||
shouldReplaceText = children[0] === href || children[0].trim() === href.trim();
|
||||
}
|
||||
|
||||
// For display purposes, remove leading slash from paths like "/p/something"
|
||||
let displayText: React.ReactNode = children;
|
||||
if (shouldReplaceText && internalPath.startsWith('/p/')) {
|
||||
displayText = internalPath.substring(1); // Remove leading slash
|
||||
} else if (shouldReplaceText) {
|
||||
displayText = internalPath;
|
||||
}
|
||||
|
||||
return <Link to={internalPath}>{displayText}</Link>;
|
||||
} else {
|
||||
console.warn('Failed to transform plebchan link to internal path:', href);
|
||||
return <Link to={href}>{children}</Link>;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle hash routes and internal patterns (including routes that start with /#/)
|
||||
if (href.startsWith('#/') || href.startsWith('/#/') || href.startsWith('/p/') || href.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/)) {
|
||||
return <Link to={href}>{children}</Link>;
|
||||
}
|
||||
|
||||
// External links
|
||||
return (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer'>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const Markdown = ({ content, title }: MarkdownProps) => {
|
||||
const remarkPlugins: any[] = [[supersub]];
|
||||
|
||||
@@ -179,6 +226,9 @@ const Markdown = ({ content, title }: MarkdownProps) => {
|
||||
|
||||
const isInCatalogView = isCatalogView(useLocation().pathname, useParams());
|
||||
|
||||
// Preprocess content to convert plain text plebchan patterns to markdown links
|
||||
const processedContent = preprocessPlebchanPatterns(content || '');
|
||||
|
||||
return (
|
||||
<span className={styles.markdown}>
|
||||
{isInCatalogView && title && (
|
||||
@@ -188,7 +238,7 @@ const Markdown = ({ content, title }: MarkdownProps) => {
|
||||
</span>
|
||||
)}
|
||||
<ReactMarkdown
|
||||
children={content}
|
||||
children={processedContent}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={[[rehypeRaw as any], [rehypeSanitize, customSchema]]}
|
||||
components={
|
||||
@@ -220,12 +270,10 @@ const Markdown = ({ content, title }: MarkdownProps) => {
|
||||
console.debug('Invalid URL:', href);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer'>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
return renderAnchorLink(children, href);
|
||||
}
|
||||
|
||||
return renderAnchorLink(children, href || '');
|
||||
},
|
||||
} as ExtendedComponents
|
||||
}
|
||||
|
||||
@@ -21,3 +21,120 @@ export const copyShareLinkToClipboard = async (subplebbitAddress: string, cid: s
|
||||
const shareLink = `https://pleb.bz/p/${subplebbitAddress}/c/${cid}?redirect=plebchan.app`;
|
||||
await copyToClipboard(shareLink);
|
||||
};
|
||||
|
||||
const PLEBCHAN_HOSTNAMES = ['pleb.bz', 'plebchan.app', 'plebchan.eth.limo', 'plebchan.eth.link', 'plebchan.eth.sucks', 'plebchan.netlify.app'];
|
||||
|
||||
// Check if a URL is a valid plebchan link that should be handled internally
|
||||
export const isPlebchanLink = (url: string): boolean => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const hostname = parsedUrl.hostname.replace(/^www\./, '');
|
||||
|
||||
if (!PLEBCHAN_HOSTNAMES.includes(hostname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check both pathname and hash for the route pattern
|
||||
let routePath = parsedUrl.pathname;
|
||||
|
||||
// If there's a hash that starts with #/, use that as the route path
|
||||
if (parsedUrl.hash && parsedUrl.hash.startsWith('#/')) {
|
||||
routePath = parsedUrl.hash.substring(1); // Remove the # to get the path
|
||||
}
|
||||
|
||||
// For pleb.bz, only support the exact sharelink format
|
||||
if (hostname === 'pleb.bz') {
|
||||
// Must match exactly: /p/{subplebbitAddress}/c/{cid}
|
||||
// Allow redirect parameter since these are still valid internal links
|
||||
return /^\/p\/[^/]+\/c\/[^/]+$/.test(routePath);
|
||||
}
|
||||
|
||||
// For other plebchan hostnames, support:
|
||||
// - /p/{subplebbitAddress}
|
||||
// - /p/{subplebbitAddress}/c/{commentCid}
|
||||
return /^\/p\/[^/]+(\/c\/[^/]+)?$/.test(routePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Transform a valid plebchan URL to an internal route
|
||||
export const transformPlebchanLinkToInternal = (url: string): string | null => {
|
||||
if (!isPlebchanLink(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Check if this is a hash-based route
|
||||
if (parsedUrl.hash && parsedUrl.hash.startsWith('#/')) {
|
||||
// Extract the route from the hash, preserving any query params within the hash
|
||||
const hashPath = parsedUrl.hash.substring(1); // Remove the #
|
||||
return hashPath;
|
||||
}
|
||||
|
||||
// For regular pathname-based routes, remove redirect parameter from query string
|
||||
const searchParams = new URLSearchParams(parsedUrl.search);
|
||||
searchParams.delete('redirect'); // Remove redirect parameter for cleaner internal links
|
||||
|
||||
const cleanSearch = searchParams.toString();
|
||||
const searchString = cleanSearch ? `?${cleanSearch}` : '';
|
||||
|
||||
return parsedUrl.pathname + searchString + parsedUrl.hash;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if a string is a valid IPNS public key (52 chars starting with 12D3KooW)
|
||||
const isValidIPNSKey = (str: string): boolean => {
|
||||
return str.length === 52 && str.startsWith('12D3KooW');
|
||||
};
|
||||
|
||||
// Check if a string is a valid domain (contains a dot)
|
||||
const isValidDomain = (str: string): boolean => {
|
||||
return str.includes('.') && str.split('.').length >= 2 && str.split('.').every((part) => part.length > 0);
|
||||
};
|
||||
|
||||
// Check if a plain text pattern is a valid plebchan subplebbit reference
|
||||
export const isValidSubplebbitPattern = (pattern: string): boolean => {
|
||||
// Must start with "p/"
|
||||
if (!pattern.startsWith('p/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathPart = pattern.substring(2); // Remove "p/"
|
||||
|
||||
// Check if it's a post pattern: subplebbitAddress/c/cid
|
||||
const postMatch = pathPart.match(/^([^/]+)\/c\/([^/]+)$/);
|
||||
if (postMatch) {
|
||||
const [, subplebbitAddress, cid] = postMatch;
|
||||
// CID should be at least 10 characters (minimum reasonable CID length)
|
||||
return (isValidDomain(subplebbitAddress) || isValidIPNSKey(subplebbitAddress)) && cid.length >= 10;
|
||||
}
|
||||
|
||||
// Check if it's just a subplebbit pattern: subplebbitAddress
|
||||
return isValidDomain(pathPart) || isValidIPNSKey(pathPart);
|
||||
};
|
||||
|
||||
// Preprocess content to convert plain text plebchan patterns to markdown links
|
||||
export const preprocessPlebchanPatterns = (content: string): string => {
|
||||
// Pattern to match "p/something" or "p/something/c/something"
|
||||
// Negative lookbehind prevents matching patterns that are already part of URLs
|
||||
const pattern = /(?<!https?:\/\/[^\s]*)\bp\/([a-zA-Z0-9\-.]+(?:\/c\/[a-zA-Z0-9]{10,100})?)[.,:;!?]*/g;
|
||||
|
||||
return content.replace(pattern, (match, capturedPath) => {
|
||||
// Remove any trailing punctuation from the captured path
|
||||
const cleanPath = capturedPath.replace(/[.,:;!?]+$/, '');
|
||||
const fullPattern = `p/${cleanPath}`;
|
||||
|
||||
if (isValidSubplebbitPattern(fullPattern)) {
|
||||
// Preserve trailing punctuation outside the link
|
||||
const trailingPunctuation = match.slice(fullPattern.length);
|
||||
return `[${fullPattern}](/${fullPattern})${trailingPunctuation}`;
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user