diff --git a/entry_types/scrolled/package/spec/review/AutoLinkText-spec.js b/entry_types/scrolled/package/spec/review/AutoLinkText-spec.js new file mode 100644 index 0000000000..be3d04a583 --- /dev/null +++ b/entry_types/scrolled/package/spec/review/AutoLinkText-spec.js @@ -0,0 +1,150 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import {AutoLinkText} from 'review/AutoLinkText'; + +describe('AutoLinkText', () => { + it('turns URLs into links with shortened text and the full URL as title', () => { + const url = 'https://www.example.com/a/very/long/path/that/keeps/going/without/' + + 'short/segments?utm_campaign=long-url-in-comment-thread'; + + const {getByRole} = render(); + + const link = getByRole('link'); + expect(link).toHaveAttribute('href', url); + expect(link).toHaveAttribute('title', url); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(link).toHaveTextContent( + 'www.example.com/a/very/long/path/that/keeps/…-comment-thread' + ); + }); + + it('shortens display text only when it exceeds 60 characters', () => { + const sixtyCharacterUrl = `https://${'a'.repeat(56)}.com`; + const sixtyOneCharacterUrl = `https://${'a'.repeat(57)}.com`; + const {getByRole, rerender} = render(); + + expect(getByRole('link')).toHaveTextContent(sixtyCharacterUrl.slice(8)); + + rerender(); + + expect(getByRole('link').textContent).toHaveLength(60); + expect(getByRole('link')).toHaveTextContent('…'); + }); + + it('does not split Unicode characters when shortening display text', () => { + const url = `https://example.com/${'a'.repeat(31)}😀${'b'.repeat(20)}`; + const expected = `example.com/${'a'.repeat(31)}😀…${'b'.repeat(15)}`; + + const {getByRole} = render(); + + expect(getByRole('link')).toHaveTextContent(expected); + }); + + it('leaves sentence punctuation outside the link', () => { + const {getByRole} = render( + + ); + + const link = getByRole('link'); + expect(link).toHaveAttribute('href', 'https://example.com/docs'); + expect(link.nextSibling).toHaveTextContent('). Next.'); + }); + + it.each([ + ['(https://example.com/docs.)', 'https://example.com/docs', '.)'], + ['[(https://example.com/docs)]', 'https://example.com/docs', ')]'], + [ + '(https://example.com/docs/(section)).', + 'https://example.com/docs/(section)', + ').' + ] + ])('removes surrounding punctuation from %s', (text, url, trailingText) => { + const {getByRole} = render(); + + const link = getByRole('link'); + expect(link).toHaveAttribute('href', url); + expect(link.nextSibling).toHaveTextContent(trailingText); + }); + + it('keeps balanced parentheses that are part of a URL', () => { + const url = 'https://example.com/docs/(section)'; + + const {getByRole} = render(); + + expect(getByRole('link')).toHaveAttribute('href', url); + }); + + it('keeps apostrophes that are part of a URL', () => { + const url = "https://en.wikipedia.org/wiki/O'Reilly_Media"; + + const {getByRole} = render(); + + expect(getByRole('link')).toHaveAttribute('href', url); + expect(getByRole('link')).toHaveAttribute('title', url); + }); + + it('leaves enclosing single quotes outside the link', () => { + const url = 'https://example.com/docs'; + + const {getByRole} = render(); + + const link = getByRole('link'); + expect(link).toHaveAttribute('href', url); + expect(link.previousSibling).toHaveTextContent("See '"); + expect(link.nextSibling).toHaveTextContent("'"); + }); + + it.each([ + ["He wrote 'see https://example.com/docs'.", "'."], + ["'(https://example.com/docs)'", ")'"], + ['“https://example.com/docs”', '”'] + ])('leaves closing sentence quotes outside the link in %s', (text, trailingText) => { + const {getByRole} = render(); + + const link = getByRole('link'); + expect(link).toHaveAttribute('href', 'https://example.com/docs'); + expect(link.nextSibling).toHaveTextContent(trailingText); + }); + + it('links multiple URLs while preserving intervening text', () => { + const {getAllByRole, container} = render( + + ); + + expect(getAllByRole('link').map(link => link.getAttribute('href'))).toEqual([ + 'https://one.example.com', + 'http://two.example.com' + ]); + expect(container.textContent).toBe('First one.example.com\nthen two.example.com.'); + }); + + it('does not link unsupported URL schemes', () => { + const text = 'Do not open javascript:alert(1), mailto:test@example.com, ' + + 'git+https://example.com/repo or blob:https://example.com/id'; + const {queryByRole, getByText} = render(); + + expect(queryByRole('link')).toBeNull(); + expect(getByText(text)).toBeInTheDocument(); + }); + + it('only links standalone URLs following nested unsupported schemes', () => { + const text = 'view-source:blob:https://example.com/id then https://standalone.example.com'; + const {getByRole, container} = render(); + + expect(getByRole('link')).toHaveAttribute('href', 'https://standalone.example.com'); + expect(container.textContent) + .toBe('view-source:blob:https://example.com/id then standalone.example.com'); + }); + + it('links a valid URL following a malformed candidate', () => { + const {getByRole, container} = render( + + ); + + expect(getByRole('link')).toHaveAttribute('href', 'https://valid.example.com'); + expect(container.textContent).toBe('Broken https://? then valid.example.com'); + }); +}); diff --git a/entry_types/scrolled/package/spec/review/Comment-spec.js b/entry_types/scrolled/package/spec/review/Comment-spec.js index d154f60612..b92806d9e7 100644 --- a/entry_types/scrolled/package/spec/review/Comment-spec.js +++ b/entry_types/scrolled/package/spec/review/Comment-spec.js @@ -62,6 +62,14 @@ describe('Comment', () => { expect(getByText(/^Mar \d+$/)).toHaveAttribute('datetime', '2026-03-15T14:30:00Z'); }); + it('renders URLs in the comment body as links', () => { + const {getByRole} = renderWithReviewState( + + ); + + expect(getByRole('link')).toHaveAttribute('href', 'https://example.com/docs'); + }); + describe('edited hint', () => { useFakeTranslations({ 'en.pageflow_scrolled.review.edited': 'Edited %{date}', diff --git a/entry_types/scrolled/package/src/review/AutoLinkText.js b/entry_types/scrolled/package/src/review/AutoLinkText.js new file mode 100644 index 0000000000..6666174d9c --- /dev/null +++ b/entry_types/scrolled/package/src/review/AutoLinkText.js @@ -0,0 +1,109 @@ +import React from 'react'; + +const URL_PATTERN = /\bhttps?:\/\/[^\s<>"]+/gi; +const MAX_LINK_TEXT_LENGTH = 60; +const TRAILING_PUNCTUATION = new Set(['.', ',', '!', '?', ';', ':', "'", '’', '”']); +const BRACKETS = { + '(': ')', + '[': ']', + '{': '}' +}; +const OPENING_BRACKET = Object.fromEntries( + Object.entries(BRACKETS).map(([opening, closing]) => [closing, opening]) +); + +export function AutoLinkText({text}) { + const parts = []; + let start = 0; + let match; + + URL_PATTERN.lastIndex = 0; + + while ((match = URL_PATTERN.exec(text))) { + if (startsInsideUnsupportedScheme(text, match.index)) continue; + + const candidate = match[0]; + const url = removeTrailingPunctuation(candidate); + + if (!isValidUrl(url)) continue; + + parts.push(text.slice(start, match.index)); + parts.push( + + {shortenUrl(url)} + + ); + + start = match.index + url.length; + URL_PATTERN.lastIndex = start; + } + + parts.push(text.slice(start)); + return parts; +} + +function isValidUrl(value) { + try { + return Boolean(new URL(value).hostname); + } + catch (e) { + return false; + } +} + +function startsInsideUnsupportedScheme(text, index) { + let start = index - 1; + + while (start >= 0 && /[a-z0-9+.:-]/i.test(text[start])) start--; + + return /^[a-z][a-z0-9+.:-]*[:+.-]$/i.test(text.slice(start + 1, index)); +} + +function removeTrailingPunctuation(value) { + const excessClosingBrackets = {')': 0, ']': 0, '}': 0}; + + for (const character of value) { + if (BRACKETS[character]) { + excessClosingBrackets[BRACKETS[character]]--; + } + else if (OPENING_BRACKET[character]) { + excessClosingBrackets[character]++; + } + } + + let end = value.length; + + while (end > 0) { + const character = value[end - 1]; + + if (TRAILING_PUNCTUATION.has(character)) { + end--; + } + else if (excessClosingBrackets[character] > 0) { + excessClosingBrackets[character]--; + end--; + } + else { + break; + } + } + + return value.slice(0, end); +} + +function shortenUrl(value) { + const displayValue = value.replace(/^https?:\/\//i, ''); + const characters = Array.from(displayValue); + + if (characters.length <= MAX_LINK_TEXT_LENGTH) return displayValue; + + const endLength = 15; + const startLength = MAX_LINK_TEXT_LENGTH - endLength - 1; + + return `${characters.slice(0, startLength).join('')}…` + + characters.slice(-endLength).join(''); +} diff --git a/entry_types/scrolled/package/src/review/Comment.js b/entry_types/scrolled/package/src/review/Comment.js index 5b54586308..ff1549bfe0 100644 --- a/entry_types/scrolled/package/src/review/Comment.js +++ b/entry_types/scrolled/package/src/review/Comment.js @@ -7,6 +7,7 @@ import {useCurrentUser, useUpdateComment} from './ReviewStateProvider'; import {autoGrow, autoResize} from './autoGrow'; import {formatDate, formatDateTime} from './formatDate'; import {isSubmitShortcut} from './submitShortcut'; +import {AutoLinkText} from './AutoLinkText'; import EditIcon from './images/edit.svg'; import styles from './Comment.module.css'; @@ -49,7 +50,7 @@ export function Comment({ {editing ? : <> -

{comment.body}

+

{comment.editedAt &&

{t('pageflow_scrolled.review.edited', diff --git a/entry_types/scrolled/package/src/review/Comment.module.css b/entry_types/scrolled/package/src/review/Comment.module.css index ed90c07eee..ac1afac428 100644 --- a/entry_types/scrolled/package/src/review/Comment.module.css +++ b/entry_types/scrolled/package/src/review/Comment.module.css @@ -45,6 +45,7 @@ line-height: 1.4; color: var(--ui-on-surface-color); white-space: pre-wrap; + overflow-wrap: anywhere; } .editedHint {