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;
+ });
+};