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:
Tommaso Casaburi
2026-05-10 18:25:13 +07:00
committed by GitHub
parent e7a6c377c9
commit 556973a445
47 changed files with 771 additions and 442 deletions
@@ -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>,
+146 -87
View File
@@ -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(