diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index 92f97d2e..ecf424df 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -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 {children}; + } + + // 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 {displayText}; + } else { + console.warn('Failed to transform plebchan link to internal path:', href); + return {children}; + } + } + + // Handle hash routes and internal patterns (including routes that start with /#/) + if (href.startsWith('#/') || href.startsWith('/#/') || href.startsWith('/p/') || href.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/)) { + return {children}; + } + + // External links + return ( + + {children} + + ); +}; + 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 ( {isInCatalogView && title && ( @@ -188,7 +238,7 @@ const Markdown = ({ content, title }: MarkdownProps) => { )} { console.debug('Invalid URL:', href); } - return ( - - {children} - - ); + return renderAnchorLink(children, href); } + + return renderAnchorLink(children, href || ''); }, } as ExtendedComponents } diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts index b51b7e31..9edc727f 100644 --- a/src/lib/utils/url-utils.ts +++ b/src/lib/utils/url-utils.ts @@ -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 = /(? { + // 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; + }); +};