fix(markdown): render moderation reason links

This commit is contained in:
Tommaso Casaburi
2026-06-04 15:54:15 +07:00
parent 608402b5c8
commit 54f2cf2b84
3 changed files with 93 additions and 10 deletions
@@ -321,6 +321,38 @@ describe('Markdown', () => {
expect(link?.textContent).toBe('https://5chan.local/#/mu');
});
it('renders inline Markdown links through the standard link renderer', async () => {
await renderMarkdown({
content: '[AI moderation](https://bitsocial.net/apps/ai-moderation-challenge) sent this reply to the mod queue because the fortune check is missing',
});
const link = container.querySelector<HTMLAnchorElement>('a[href="https://bitsocial.net/apps/ai-moderation-challenge"]');
expect(link?.textContent).toBe('AI moderation');
expect(link?.getAttribute('target')).toBe('_blank');
expect(container.textContent).toBe('AI moderation sent this reply to the mod queue because the fortune check is missing');
});
it('renders inline Markdown links for app routes and inert empty hrefs', async () => {
await renderMarkdown({
content: '[Rules](/rules/biz) [AI moderation]( )',
});
const links = Array.from(container.querySelectorAll('a'));
expect(links).toHaveLength(1);
expect(links[0]?.getAttribute('href')).toBe('/rules/biz');
expect(links[0]?.textContent).toBe('Rules');
expect(container.textContent).toBe('Rules AI moderation');
});
it('does not render unsafe inline Markdown hrefs', async () => {
await renderMarkdown({
content: '[bad](javascript:alert(1))',
});
expect(container.querySelector('a')).toBeNull();
expect(container.textContent).toBe('[bad](javascript:alert(1))');
});
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),',
+57 -10
View File
@@ -151,6 +151,7 @@ const normalizeContent = (content: string): string => {
type Token =
| { key: string; type: 'text'; value: string }
| { key: string; type: 'markdownLink'; text: string; href: string }
| { key: string; type: 'url'; href: string }
| { key: string; type: 'quoteLink'; number: number }
| { key: string; type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
@@ -158,6 +159,7 @@ type Token =
| { 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 MARKDOWN_LINK_REGEX = /(?<!!)\[([^\]\n]+)\]\(\s*([^\n)]*?)\s*\)/;
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<>[\]]+/;
@@ -203,12 +205,12 @@ const getActiveDirectoryCode = (pathname: string, communityAddress: string | und
};
const COMBINED_REGEX = new RegExp(
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
`(${SPOILER_REGEX.source})|(${MARKDOWN_LINK_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
'g',
);
const COMBINED_REGEX_WITHOUT_SPOILER = new RegExp(
`(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
`(${MARKDOWN_LINK_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
'g',
);
@@ -258,6 +260,9 @@ function splitUrlTrailingText(rawHref: string): { href: string; trailingText: st
return { href, trailingText };
}
const isAllowedMarkdownHref = (href: string): boolean =>
!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('#/') || href.startsWith('/#/') || href.startsWith('/');
function getCrossboardRoute(fullPattern: string): string | null {
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
if (!isValidCrossboardPattern(`>>>/${pathPart}`)) {
@@ -280,6 +285,33 @@ function getCrossboardRoute(fullPattern: string): string | null {
function tokenize(text: string, keyPrefix = '', parseSpoilers = true): Token[] {
const tokens: Token[] = [];
let lastIndex = 0;
const matchIndexes = parseSpoilers
? {
markdownLink: 3,
markdownLinkText: 4,
markdownLinkHref: 5,
crossBoardNumberQuoteLink: 6,
crossBoardNumberQuoteLinkBoard: 7,
crossBoardNumberQuoteLinkNumber: 8,
crossBoardLink: 9,
crossBoardLinkPath: 10,
quoteLink: 11,
quoteLinkNumber: 12,
url: 13,
}
: {
markdownLink: 1,
markdownLinkText: 2,
markdownLinkHref: 3,
crossBoardNumberQuoteLink: 4,
crossBoardNumberQuoteLinkBoard: 5,
crossBoardNumberQuoteLinkNumber: 6,
crossBoardLink: 7,
crossBoardLinkPath: 8,
quoteLink: 9,
quoteLinkNumber: 10,
url: 11,
};
const regex = new RegExp((parseSpoilers ? COMBINED_REGEX : COMBINED_REGEX_WITHOUT_SPOILER).source, 'g');
let match: RegExpExecArray | null;
@@ -301,9 +333,21 @@ function tokenize(text: string, keyPrefix = '', parseSpoilers = true): Token[] {
const innerContent = match[2];
const key = makeTokenKey(keyPrefix, 'spoiler', matchStart, matchEnd);
tokens.push({ key, type: 'spoiler', tokens: tokenize(innerContent, `${key}/`, parseSpoilers) });
} else if (match[parseSpoilers ? 3 : 1] !== undefined) {
const boardIdentifier = match[parseSpoilers ? 4 : 2];
const number = parseInt(match[parseSpoilers ? 5 : 3], 10);
} else if (match[matchIndexes.markdownLink] !== undefined) {
const href = match[matchIndexes.markdownLinkHref].trim();
if (isAllowedMarkdownHref(href)) {
tokens.push({
key: makeTokenKey(keyPrefix, 'markdownLink', matchStart, matchEnd),
type: 'markdownLink',
text: match[matchIndexes.markdownLinkText],
href,
});
} else {
tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
}
} else if (match[matchIndexes.crossBoardNumberQuoteLink] !== undefined) {
const boardIdentifier = match[matchIndexes.crossBoardNumberQuoteLinkBoard];
const number = parseInt(match[matchIndexes.crossBoardNumberQuoteLinkNumber], 10);
if (boardIdentifier && !Number.isNaN(number)) {
tokens.push({
key: makeTokenKey(keyPrefix, 'crossBoardNumberQuoteLink', matchStart, matchEnd),
@@ -318,8 +362,8 @@ function tokenize(text: string, keyPrefix = '', parseSpoilers = true): Token[] {
} else {
tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
}
} else if (match[parseSpoilers ? 6 : 4] !== undefined) {
const pathPart = match[parseSpoilers ? 7 : 5];
} else if (match[matchIndexes.crossBoardLink] !== undefined) {
const pathPart = match[matchIndexes.crossBoardLinkPath];
const fullPattern = `>>>/${pathPart}`;
const route = getCrossboardRoute(fullPattern);
if (route) {
@@ -332,10 +376,10 @@ function tokenize(text: string, keyPrefix = '', parseSpoilers = true): Token[] {
} else {
tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
}
} else if (match[parseSpoilers ? 8 : 6] !== undefined) {
const number = parseInt(match[parseSpoilers ? 9 : 7], 10);
} else if (match[matchIndexes.quoteLink] !== undefined) {
const number = parseInt(match[matchIndexes.quoteLinkNumber], 10);
tokens.push({ key: makeTokenKey(keyPrefix, 'quoteLink', matchStart, matchEnd), type: 'quoteLink', number });
} else if (match[parseSpoilers ? 10 : 8] !== undefined) {
} else if (match[matchIndexes.url] !== undefined) {
const { href, trailingText } = splitUrlTrailingText(fullMatch);
const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd;
tokens.push({ key: makeTokenKey(keyPrefix, 'url', matchStart, linkEnd), type: 'url', href });
@@ -439,6 +483,7 @@ const AnchorLink = ({ href, text }: { href: string; text: string }) => {
href.startsWith('/#/') ||
href.startsWith('/p/') ||
href.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) ||
href.match(/^\/rules\/[^/]+$/) ||
href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) ||
href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/)
) {
@@ -458,6 +503,8 @@ const TokenNode = ({ token, context }: { token: Token; context: RenderContext })
switch (token.type) {
case 'text':
return context.enableQstBbcode ? <QstBbcodeText text={token.value} tokenKey={token.key} /> : <>{token.value}</>;
case 'markdownLink':
return <AnchorLink href={token.href} text={token.text} />;
case 'url': {
const href = token.href;
const linkMediaInfo = getLinkMediaInfo(href);
+4
View File
@@ -657,8 +657,12 @@
color: var(--post-mobile-abbr-text-color);
--post-link-text-color: var(--post-mobile-abbr-text-color);
--post-link-text-color-hover: var(--post-mobile-abbr-text-color);
--post-content-link-text-decoration: underline;
--post-content-link-text-decoration-hover: underline;
--post-quotelink-text-color: var(--post-mobile-abbr-text-color);
--post-quotelink-text-color-hover: var(--post-mobile-abbr-text-color);
--post-quotelink-text-decoration: underline;
--post-quotelink-text-decoration-hover: underline;
}
.failedPublishNotice {