Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions entry_types/scrolled/package/spec/review/AutoLinkText-spec.js
Original file line number Diff line number Diff line change
@@ -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(<AutoLinkText text={`Please check ${url}`} />);

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(<AutoLinkText text={sixtyCharacterUrl} />);

expect(getByRole('link')).toHaveTextContent(sixtyCharacterUrl.slice(8));

rerender(<AutoLinkText text={sixtyOneCharacterUrl} />);

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(<AutoLinkText text={url} />);

expect(getByRole('link')).toHaveTextContent(expected);
});

it('leaves sentence punctuation outside the link', () => {
const {getByRole} = render(
<AutoLinkText text="See https://example.com/docs). Next." />
);

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(<AutoLinkText text={text} />);

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(<AutoLinkText text={url} />);

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(<AutoLinkText text={url} />);

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(<AutoLinkText text={`See '${url}'`} />);

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(<AutoLinkText text={text} />);

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(
<AutoLinkText text={'First https://one.example.com\nthen http://two.example.com.'} />
);

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(<AutoLinkText text={text} />);

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(<AutoLinkText text={text} />);

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(
<AutoLinkText text="Broken https://? then https://valid.example.com" />
);

expect(getByRole('link')).toHaveAttribute('href', 'https://valid.example.com');
expect(container.textContent).toBe('Broken https://? then valid.example.com');
});
});
8 changes: 8 additions & 0 deletions entry_types/scrolled/package/spec/review/Comment-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Comment comment={{...comment, body: 'See https://example.com/docs'}} />
);

expect(getByRole('link')).toHaveAttribute('href', 'https://example.com/docs');
});

describe('edited hint', () => {
useFakeTranslations({
'en.pageflow_scrolled.review.edited': 'Edited %{date}',
Expand Down
109 changes: 109 additions & 0 deletions entry_types/scrolled/package/src/review/AutoLinkText.js
Original file line number Diff line number Diff line change
@@ -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(
<a key={match.index}
href={url}
title={url}
target="_blank"
rel="noopener noreferrer">
{shortenUrl(url)}
</a>
);

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('');
}
3 changes: 2 additions & 1 deletion entry_types/scrolled/package/src/review/Comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -49,7 +50,7 @@ export function Comment({
{editing
? <EditForm comment={comment} threadId={threadId} onDone={onEditEnd} />
: <>
<p className={styles.body}>{comment.body}</p>
<p className={styles.body}><AutoLinkText text={comment.body} /></p>
{comment.editedAt &&
<p className={styles.editedHint}>
{t('pageflow_scrolled.review.edited',
Expand Down
1 change: 1 addition & 0 deletions entry_types/scrolled/package/src/review/Comment.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
line-height: 1.4;
color: var(--ui-on-surface-color);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

.editedHint {
Expand Down
Loading