diff --git a/src/components/markdown/__tests__/markdown.test.tsx b/src/components/markdown/__tests__/markdown.test.tsx index 18638d85..85265735 100644 --- a/src/components/markdown/__tests__/markdown.test.tsx +++ b/src/components/markdown/__tests__/markdown.test.tsx @@ -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('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),', diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index b430ba3e..98e9844e 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -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 = /(?>>\/((?:[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 ? : <>{token.value}; + case 'markdownLink': + return ; case 'url': { const href = token.href; const linkMediaInfo = getLinkMediaInfo(href); diff --git a/src/views/post/post.module.css b/src/views/post/post.module.css index 298051fa..dbb5b2b9 100644 --- a/src/views/post/post.module.css +++ b/src/views/post/post.module.css @@ -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 {