mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Improve React Doctor score and badge (#1127)
* fix(react doctor): improve quality score and badge * fix(react doctor): address review feedback * fix(review): address final bot feedback
This commit is contained in:
@@ -230,6 +230,44 @@ describe('Markdown', () => {
|
||||
expect(links.find((link) => link.getAttribute('href') === '/fit')?.textContent).toBe('>>>/fit/');
|
||||
});
|
||||
|
||||
it('preserves trailing punctuation outside cross-board links', async () => {
|
||||
await renderMarkdown({
|
||||
content: 'see >>>/fit/, next',
|
||||
});
|
||||
|
||||
const link = container.querySelector('a');
|
||||
expect(link?.getAttribute('href')).toBe('/fit');
|
||||
expect(link?.textContent).toBe('>>>/fit/');
|
||||
expect(container.textContent).toBe('see >>>/fit/, next');
|
||||
});
|
||||
|
||||
it('normalizes hash-routed 5chan links before passing them to React Router', async () => {
|
||||
testState.internalPathByHref = {
|
||||
'https://5chan.local/#/mu': '#/mu',
|
||||
};
|
||||
|
||||
await renderMarkdown({
|
||||
content: 'https://5chan.local/#/mu',
|
||||
});
|
||||
|
||||
const link = container.querySelector('a');
|
||||
expect(link?.getAttribute('href')).toBe('/mu');
|
||||
expect(link?.textContent).toBe('https://5chan.local/#/mu');
|
||||
});
|
||||
|
||||
it('preserves balanced URL parentheses and leaves unmatched trailing punctuation outside links', async () => {
|
||||
await renderMarkdown({
|
||||
content: 'https://en.wikipedia.org/wiki/Function_(mathematics) https://example.com/path),',
|
||||
});
|
||||
|
||||
const links = Array.from(container.querySelectorAll('a'));
|
||||
expect(links[0]?.textContent).toBe('https://en.wikipedia.org/wiki/Function_(mathematics)');
|
||||
expect(links[0]?.getAttribute('href')).toBe('https://en.wikipedia.org/wiki/Function_(mathematics)');
|
||||
expect(links[1]?.textContent).toBe('https://example.com/path');
|
||||
expect(links[1]?.getAttribute('href')).toBe('https://example.com/path');
|
||||
expect(container.textContent).toBe('https://en.wikipedia.org/wiki/Function_(mathematics) https://example.com/path),');
|
||||
});
|
||||
|
||||
it('renders number quote links with op and unavailable state derived from cached comments', async () => {
|
||||
testState.comments = {
|
||||
'comment-42': { cid: 'comment-42', number: 42 },
|
||||
|
||||
@@ -206,7 +206,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
|
||||
setPreviewPosition(null);
|
||||
};
|
||||
|
||||
const handleClick = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
const openExternalQuote = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isResolving) {
|
||||
@@ -273,7 +273,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
|
||||
aria-busy={isResolving || undefined}
|
||||
className={isResolving ? styles.inlineQuoteLinkResolving : undefined}
|
||||
href={`#/${boardLabel}`}
|
||||
onClick={handleClick}
|
||||
onClick={openExternalQuote}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={anchorRef}
|
||||
@@ -289,7 +289,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
|
||||
className={previewClassName}
|
||||
data-thread-scroll-preview='true'
|
||||
ref={previewRef}
|
||||
style={{ left: previewPosition.left, position: 'fixed', top: previewPosition.top, zIndex: 1000 }}
|
||||
style={{ left: previewPosition.left, position: 'fixed', top: previewPosition.top, zIndex: 30 }}
|
||||
>
|
||||
{previewContent}
|
||||
</div>,
|
||||
|
||||
@@ -141,23 +141,63 @@ const normalizeContent = (content: string): string => {
|
||||
};
|
||||
|
||||
type Token =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'url'; href: string }
|
||||
| { type: 'quoteLink'; number: number }
|
||||
| { type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
|
||||
| { type: 'crossBoardLink'; display: string; route: string }
|
||||
| { type: 'spoiler'; tokens: Token[] };
|
||||
| { key: string; type: 'text'; value: string }
|
||||
| { key: string; type: 'url'; href: string }
|
||||
| { key: string; type: 'quoteLink'; number: number }
|
||||
| { key: string; type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
|
||||
| { key: string; type: 'crossBoardLink'; display: string; route: string }
|
||||
| { key: string; type: 'spoiler'; tokens: Token[] };
|
||||
|
||||
const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO][iI][lL][eE][rR]\]/;
|
||||
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 URL_REGEX = /https?:\/\/[^\s<>[\]]+/;
|
||||
|
||||
const COMBINED_REGEX = new RegExp(
|
||||
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
|
||||
'g',
|
||||
);
|
||||
|
||||
const makeTokenKey = (prefix: string, type: Token['type'], start: number, end: number): string => `${prefix}${type}:${start}:${end}`;
|
||||
|
||||
function normalizeInternalRouteHref(href: string): string {
|
||||
if (href.startsWith('/#/')) {
|
||||
return href.slice(2);
|
||||
}
|
||||
if (href.startsWith('#/')) {
|
||||
return href.slice(1);
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
function splitUrlTrailingText(rawHref: string): { href: string; trailingText: string } {
|
||||
let href = rawHref;
|
||||
let trailingText = '';
|
||||
|
||||
while (href) {
|
||||
const trailingPunctuationMatch = href.match(/[.,;:!?"']+$/);
|
||||
if (trailingPunctuationMatch) {
|
||||
trailingText = `${trailingPunctuationMatch[0]}${trailingText}`;
|
||||
href = href.slice(0, -trailingPunctuationMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (href.endsWith(')')) {
|
||||
const openingParens = (href.match(/\(/g) || []).length;
|
||||
const closingParens = (href.match(/\)/g) || []).length;
|
||||
if (closingParens > openingParens) {
|
||||
trailingText = `)${trailingText}`;
|
||||
href = href.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return { href, trailingText };
|
||||
}
|
||||
|
||||
function getCrossboardRoute(fullPattern: string): string | null {
|
||||
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
|
||||
if (!isValidCrossboardPattern(`>>>/${pathPart}`)) {
|
||||
@@ -177,7 +217,7 @@ function getCrossboardRoute(fullPattern: string): string | null {
|
||||
return `/${pathPart}`;
|
||||
}
|
||||
|
||||
function tokenize(text: string): Token[] {
|
||||
function tokenize(text: string, keyPrefix = ''): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
@@ -187,19 +227,26 @@ function tokenize(text: string): Token[] {
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const matchStart = match.index;
|
||||
const matchEnd = regex.lastIndex;
|
||||
|
||||
if (matchStart > lastIndex) {
|
||||
tokens.push({ type: 'text', value: text.slice(lastIndex, matchStart) });
|
||||
tokens.push({
|
||||
key: makeTokenKey(keyPrefix, 'text', lastIndex, matchStart),
|
||||
type: 'text',
|
||||
value: text.slice(lastIndex, matchStart),
|
||||
});
|
||||
}
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
const innerContent = match[2];
|
||||
tokens.push({ type: 'spoiler', tokens: tokenize(innerContent) });
|
||||
const key = makeTokenKey(keyPrefix, 'spoiler', matchStart, matchEnd);
|
||||
tokens.push({ key, type: 'spoiler', tokens: tokenize(innerContent, `${key}/`) });
|
||||
} else if (match[3] !== undefined) {
|
||||
const boardIdentifier = match[4];
|
||||
const number = parseInt(match[5], 10);
|
||||
if (boardIdentifier && !Number.isNaN(number)) {
|
||||
tokens.push({
|
||||
key: makeTokenKey(keyPrefix, 'crossBoardNumberQuoteLink', matchStart, matchEnd),
|
||||
type: 'crossBoardNumberQuoteLink',
|
||||
reference: {
|
||||
boardIdentifier,
|
||||
@@ -209,29 +256,43 @@ function tokenize(text: string): Token[] {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
tokens.push({ type: 'text', value: fullMatch });
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
|
||||
}
|
||||
} else if (match[6] !== undefined) {
|
||||
const pathPart = match[7];
|
||||
const fullPattern = `>>>/${pathPart}`;
|
||||
const route = getCrossboardRoute(fullPattern);
|
||||
if (route) {
|
||||
tokens.push({ type: 'crossBoardLink', display: fullPattern, route });
|
||||
const trailingText = fullMatch.startsWith(fullPattern) ? fullMatch.slice(fullPattern.length) : '';
|
||||
const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd;
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'crossBoardLink', matchStart, linkEnd), type: 'crossBoardLink', display: fullPattern, route });
|
||||
if (trailingText) {
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'text', linkEnd, matchEnd), type: 'text', value: trailingText });
|
||||
}
|
||||
} else {
|
||||
tokens.push({ type: 'text', value: fullMatch });
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
|
||||
}
|
||||
} else if (match[8] !== undefined) {
|
||||
const number = parseInt(match[9], 10);
|
||||
tokens.push({ type: 'quoteLink', number });
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'quoteLink', matchStart, matchEnd), type: 'quoteLink', number });
|
||||
} else if (match[10] !== undefined) {
|
||||
tokens.push({ type: 'url', href: fullMatch });
|
||||
const { href, trailingText } = splitUrlTrailingText(fullMatch);
|
||||
const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd;
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'url', matchStart, linkEnd), type: 'url', href });
|
||||
if (trailingText) {
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'text', linkEnd, matchEnd), type: 'text', value: trailingText });
|
||||
}
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
tokens.push({
|
||||
key: makeTokenKey(keyPrefix, 'text', lastIndex, text.length),
|
||||
type: 'text',
|
||||
value: text.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
@@ -243,54 +304,6 @@ interface RenderContext {
|
||||
communityAddress?: string;
|
||||
}
|
||||
|
||||
function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[] {
|
||||
const { isInCatalogView, postCid, communityAddress } = 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>
|
||||
);
|
||||
}
|
||||
return <React.Fragment key={i}>{renderAnchorLink(href, href, postCid, communityAddress)}</React.Fragment>;
|
||||
}
|
||||
case 'quoteLink':
|
||||
return (
|
||||
<span key={i} className={styles.inlineQuoteLink}>
|
||||
<NumberQuoteLink number={token.number} threadPostCid={postCid} communityAddress={communityAddress} />
|
||||
</span>
|
||||
);
|
||||
case 'crossBoardNumberQuoteLink':
|
||||
return (
|
||||
<span key={i} className={styles.inlineQuoteLink}>
|
||||
<ExternalNumberQuoteLink reference={token.reference} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
title?: string;
|
||||
@@ -329,38 +342,30 @@ const NumberQuoteLink = ({ number, threadPostCid, communityAddress }: { number:
|
||||
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isOP={isOP} showTrailingBreak={false} />;
|
||||
};
|
||||
|
||||
const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid?: string, communityAddress?: string) => {
|
||||
const AnchorLink = ({ href, text }: { href: string; text: string }) => {
|
||||
if (!href) {
|
||||
return <span>{children}</span>;
|
||||
return <span>{text}</span>;
|
||||
}
|
||||
|
||||
if (is5chanLink(href)) {
|
||||
const internalPath = transform5chanLinkToInternal(href);
|
||||
if (internalPath) {
|
||||
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();
|
||||
}
|
||||
|
||||
let displayText: React.ReactNode = children;
|
||||
const childrenText = typeof children === 'string' ? children : Array.isArray(children) ? children[0] : '';
|
||||
const isAutolinkedUrl = shouldReplaceText && typeof childrenText === 'string' && childrenText.startsWith('http');
|
||||
const internalRoute = normalizeInternalRouteHref(internalPath);
|
||||
let displayText: React.ReactNode = text;
|
||||
const isAutolinkedUrl = text.startsWith('http');
|
||||
|
||||
if (isAutolinkedUrl) {
|
||||
displayText = children;
|
||||
} else if (shouldReplaceText && internalPath.match(/^\/[^/]+$/)) {
|
||||
displayText = internalPath.substring(1);
|
||||
} else if (shouldReplaceText) {
|
||||
displayText = internalPath;
|
||||
displayText = text;
|
||||
} else if (internalRoute.match(/^\/[^/]+$/)) {
|
||||
displayText = internalRoute.substring(1);
|
||||
} else {
|
||||
displayText = internalRoute;
|
||||
}
|
||||
|
||||
return <Link to={internalPath}>{displayText}</Link>;
|
||||
return <Link to={internalRoute}>{displayText}</Link>;
|
||||
} else {
|
||||
console.warn('Failed to transform 5chan link to internal path:', href);
|
||||
return <Link to={href}>{children}</Link>;
|
||||
return <Link to={href}>{text}</Link>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,16 +377,68 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
|
||||
href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) ||
|
||||
href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/)
|
||||
) {
|
||||
return <Link to={href}>{children}</Link>;
|
||||
return <Link to={normalizeInternalRouteHref(href)}>{text}</Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer'>
|
||||
{children}
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const TokenNode = ({ token, context }: { token: Token; context: RenderContext }) => {
|
||||
const { isInCatalogView, postCid, communityAddress } = context;
|
||||
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
return <>{token.value}</>;
|
||||
case 'url': {
|
||||
const href = token.href;
|
||||
const linkMediaInfo = getLinkMediaInfo(href);
|
||||
const embedUrl = safeParseUrl(href);
|
||||
if (!isInCatalogView && ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href))) {
|
||||
return (
|
||||
<ContentLinkEmbed href={href} linkMediaInfo={linkMediaInfo}>
|
||||
{href}
|
||||
</ContentLinkEmbed>
|
||||
);
|
||||
}
|
||||
return <AnchorLink href={href} text={href} />;
|
||||
}
|
||||
case 'quoteLink':
|
||||
return (
|
||||
<span className={styles.inlineQuoteLink}>
|
||||
<NumberQuoteLink number={token.number} threadPostCid={postCid} communityAddress={communityAddress} />
|
||||
</span>
|
||||
);
|
||||
case 'crossBoardNumberQuoteLink':
|
||||
return (
|
||||
<span className={styles.inlineQuoteLink}>
|
||||
<ExternalNumberQuoteLink reference={token.reference} />
|
||||
</span>
|
||||
);
|
||||
case 'crossBoardLink':
|
||||
return <Link to={token.route}>{token.display}</Link>;
|
||||
case 'spoiler':
|
||||
return (
|
||||
<span className='spoilertext'>
|
||||
<TokenList tokens={token.tokens} context={context} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const TokenList = ({ tokens, context }: { tokens: Token[]; context: RenderContext }) => {
|
||||
return (
|
||||
<>
|
||||
{tokens.map((token) => (
|
||||
<TokenNode key={token.key} token={token} context={context} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) => {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
@@ -392,6 +449,8 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
|
||||
const lines = normalized.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
const context = { isInCatalogView, postCid, communityAddress };
|
||||
|
||||
lines.forEach((line, lineIndex) => {
|
||||
if (lineIndex > 0) {
|
||||
elements.push(<br key={`br-${lineIndex}`} />);
|
||||
@@ -402,7 +461,7 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
|
||||
const isGreentext = /^>[^>]/.test(line) || line === '>';
|
||||
|
||||
const tokens = tokenize(line);
|
||||
const lineElements = renderTokens(tokens, { isInCatalogView, postCid, communityAddress });
|
||||
const lineElements = <TokenList tokens={tokens} context={context} />;
|
||||
|
||||
if (isGreentext) {
|
||||
elements.push(
|
||||
|
||||
Reference in New Issue
Block a user