feat: vendor gitea 1.16.2

This commit is contained in:
2026-06-02 08:36:01 +00:00
parent 247cd60f69
commit 54798b4615
2145 changed files with 162965 additions and 126447 deletions
@@ -1,22 +1,21 @@
import tinycolor from 'tinycolor2';
import type {ColorInput} from 'tinycolor2';
import {colord} from 'colord';
import type {AnyColor} from 'colord';
/** Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance */
// Keep this in sync with modules/util/color.go
function getRelativeLuminance(color: ColorInput): number {
const {r, g, b} = tinycolor(color).toRgb();
function getRelativeLuminance(color: AnyColor): number {
const {r, g, b} = colord(color).toRgb();
return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255;
}
function useLightText(backgroundColor: ColorInput): boolean {
function useLightText(backgroundColor: AnyColor): boolean {
return getRelativeLuminance(backgroundColor) < 0.453;
}
/** Given a background color, returns a black or white foreground color that the highest
* contrast ratio. */
/** Given a background color, returns a black or white foreground color with the highest contrast ratio. */
// In the future, the APCA contrast function, or CSS `contrast-color` will be better.
// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
export function contrastColor(backgroundColor: ColorInput): string {
export function contrastColor(backgroundColor: AnyColor): string {
return useLightText(backgroundColor) ? '#fff' : '#000';
}
@@ -28,13 +28,13 @@ test('querySingleVisibleElem', () => {
let el = createElementFromHTML('<div></div>');
expect(querySingleVisibleElem(el, 'span')).toBeNull();
el = createElementFromHTML('<div><span>foo</span></div>');
expect(querySingleVisibleElem(el, 'span').textContent).toEqual('foo');
expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('foo');
el = createElementFromHTML('<div><span style="display: none;">foo</span><span>bar</span></div>');
expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar');
expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('bar');
el = createElementFromHTML('<div><span class="some-class tw-hidden">foo</span><span>bar</span></div>');
expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar');
expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('bar');
el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>');
expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element');
expect(() => querySingleVisibleElem(el, 'span')).toThrow('Expected exactly one visible element');
});
test('queryElemChildren', () => {
@@ -44,11 +44,11 @@ test('queryElemChildren', () => {
});
test('toggleElem', () => {
const el = createElementFromHTML('<p><div>a</div><div class="tw-hidden">b</div></p>');
const el = createElementFromHTML('<div><div>a</div><div class="tw-hidden">b</div></div>');
toggleElem(el.children);
expect(el.outerHTML).toEqual('<p><div class="tw-hidden">a</div><div class="">b</div></p>');
expect(el.outerHTML).toEqual('<div><div class="tw-hidden">a</div><div class="">b</div></div>');
toggleElem(el.children, false);
expect(el.outerHTML).toEqual('<p><div class="tw-hidden">a</div><div class="tw-hidden">b</div></p>');
expect(el.outerHTML).toEqual('<div><div class="tw-hidden">a</div><div class="tw-hidden">b</div></div>');
toggleElem(el.children, true);
expect(el.outerHTML).toEqual('<p><div class="">a</div><div class="">b</div></p>');
expect(el.outerHTML).toEqual('<div><div class="">a</div><div class="">b</div></div>');
});
+15 -14
View File
@@ -7,7 +7,6 @@ type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Ar
type ElementArg = Element | string | ArrayLikeIterable<Element> | ReturnType<typeof $>;
type ElementsCallback<T extends Element> = (el: T) => Promisable<any>;
type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
export type DOMEvent<E extends Event, T extends Element = HTMLElement> = E & {target: Partial<T>;};
function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]): ArrayLikeIterable<Element> {
if (typeof el === 'string' || el instanceof String) {
@@ -65,6 +64,7 @@ function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?:
}
export function queryElemSiblings<T extends Element>(el: Element, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
if (!el.parentNode) return [];
const elems = Array.from(el.parentNode.children) as T[];
return applyElemsCallback<T>(elems.filter((child: Element) => {
return child !== el && child.matches(selector);
@@ -125,10 +125,10 @@ export function isDocumentFragmentOrElementNode(el: Node) {
export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = 0}: {viewportMarginBottom?: number} = {}) {
let isUserResized = false;
// lastStyleHeight and initialStyleHeight are CSS values like '100px'
let lastMouseX: number;
let lastMouseY: number;
let lastStyleHeight: string;
let initialStyleHeight: string;
let lastMouseX: number | undefined;
let lastMouseY: number | undefined;
let lastStyleHeight: string | undefined;
let initialStyleHeight: string | undefined;
function onUserResize(event: MouseEvent) {
if (isUserResized) return;
@@ -153,7 +153,8 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom =
el = el.offsetParent as HTMLTextAreaElement;
}
const top = offsetTop - document.defaultView.scrollY;
const scrollY = document.defaultView ? document.defaultView.scrollY : 0;
const top = offsetTop - scrollY;
const bottom = document.documentElement.clientHeight - (top + textarea.offsetHeight);
return {top, bottom};
}
@@ -265,10 +266,10 @@ export function submitEventSubmitter(e: any) {
return needSubmitEventPolyfill ? (e.target._submitter || null) : e.submitter;
}
function submitEventPolyfillListener(e: DOMEvent<Event>) {
const form = e.target.closest('form');
function submitEventPolyfillListener(e: Event) {
const form = (e.target as HTMLElement).closest('form');
if (!form) return;
form._submitter = e.target.closest('button:not([type]), button[type="submit"], input[type="submit"]');
form._submitter = (e.target as HTMLElement).closest('button:not([type]), button[type="submit"], input[type="submit"]');
}
export function initSubmitEventPolyfill() {
@@ -283,24 +284,24 @@ export function isElemVisible(el: HTMLElement): boolean {
// This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem"
if (!el) return false;
// checking el.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout
return !el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none';
return Boolean(!el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none');
}
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
export function createElementFromHTML<T extends Element>(htmlString: string): T {
htmlString = htmlString.trim();
// There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
// eslint-disable-next-line github/unescaped-html-literal
if (htmlString.startsWith('<tr')) {
const container = document.createElement('table');
container.innerHTML = htmlString;
return container.querySelector<T>('tr');
return container.querySelector<T>('tr')!;
}
const div = document.createElement('div');
div.innerHTML = htmlString;
return div.firstChild as T;
}
export function createElementFromAttrs(tagName: string, attrs: Record<string, any>, ...children: (Node | string)[]): HTMLElement {
export function createElementFromAttrs<T extends HTMLElement>(tagName: string, attrs: Record<string, any> | null, ...children: (Node | string)[]): T {
const el = document.createElement(tagName);
for (const [key, value] of Object.entries(attrs || {})) {
if (value === undefined || value === null) continue;
@@ -313,7 +314,7 @@ export function createElementFromAttrs(tagName: string, attrs: Record<string, an
for (const child of children) {
el.append(child instanceof Node ? child : document.createTextNode(child));
}
return el;
return el as T;
}
export function animateOnce(el: Element, animationClassName: string): Promise<void> {
@@ -117,7 +117,6 @@ test('GlobCompiler', async () => {
for (const c of golangCases) {
const compiled = globCompile(c.pattern, c.separators);
const msg = `pattern: ${c.pattern}, input: ${c.input}, separators: ${c.separators || '(none)'}, compiled: ${compiled.regexpPattern}`;
// eslint-disable-next-line vitest/valid-expect -- Unlike Jest, Vitest supports a message as the second argument
expect(compiled.regexp.test(c.input), msg).toBe(c.matched);
}
@@ -5,7 +5,7 @@ const pngPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91
const pngEmpty = 'data:image/png;base64,';
async function dataUriToBlob(datauri: string) {
return await (await globalThis.fetch(datauri)).blob();
return await (await globalThis.fetch(datauri)).blob(); // eslint-disable-line no-restricted-properties
}
test('pngChunks', async () => {
@@ -1,50 +1,78 @@
import {GET} from '../modules/fetch.ts';
import {matchEmoji, matchMention} from './match.ts';
vi.mock('../modules/fetch.ts', () => ({
GET: vi.fn(),
}));
const testMentions = [
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
{key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
{key: 'org3 User 3', value: 'org3', name: 'org3', fullname: 'User 3', avatar: 'https://avatar3.com'},
{key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'},
{key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'},
{key: 'org6 User 6', value: 'org6', name: 'org6', fullname: 'User 6', avatar: 'https://avatar6.com'},
{key: 'org7 User 7', value: 'org7', name: 'org7', fullname: 'User 7', avatar: 'https://avatar7.com'},
];
test('matchEmoji', () => {
expect(matchEmoji('')).toEqual([
'+1',
'-1',
'100',
'1234',
'1st_place_medal',
'2nd_place_medal',
]);
expect(matchEmoji('')).toMatchInlineSnapshot(`
[
"+1",
"-1",
"100",
"1234",
"1st_place_medal",
"2nd_place_medal",
]
`);
expect(matchEmoji('hea')).toEqual([
'headphones',
'headstone',
'health_worker',
'hear_no_evil',
'heard_mcdonald_islands',
'heart',
]);
expect(matchEmoji('hea')).toMatchInlineSnapshot(`
[
"head_shaking_horizontally",
"head_shaking_vertically",
"headphones",
"headstone",
"health_worker",
"hear_no_evil",
]
`);
expect(matchEmoji('hear')).toEqual([
'hear_no_evil',
'heard_mcdonald_islands',
'heart',
'heart_decoration',
'heart_eyes',
'heart_eyes_cat',
]);
expect(matchEmoji('hear')).toMatchInlineSnapshot(`
[
"hear_no_evil",
"heard_mcdonald_islands",
"heart",
"heart_decoration",
"heart_eyes",
"heart_eyes_cat",
]
`);
expect(matchEmoji('poo')).toEqual([
'poodle',
'hankey',
'spoon',
'bowl_with_spoon',
]);
expect(matchEmoji('poo')).toMatchInlineSnapshot(`
[
"poodle",
"hankey",
"spoon",
"bowl_with_spoon",
]
`);
expect(matchEmoji('1st_')).toEqual([
'1st_place_medal',
]);
expect(matchEmoji('1st_')).toMatchInlineSnapshot(`
[
"1st_place_medal",
]
`);
expect(matchEmoji('jellyfis')).toEqual([
'jellyfish',
]);
expect(matchEmoji('jellyfis')).toMatchInlineSnapshot(`
[
"jellyfish",
]
`);
});
test('matchMention', () => {
expect(matchMention('')).toEqual(window.config.mentionValues.slice(0, 6));
expect(matchMention('user4')).toEqual([window.config.mentionValues[3]]);
test('matchMention', async () => {
vi.mocked(GET).mockResolvedValue({ok: true, json: () => Promise.resolve(testMentions)} as Response);
expect(await matchMention('/any-mentions', '')).toEqual(testMentions.slice(0, 6));
expect(await matchMention('/any-mentions', 'user4')).toEqual([testMentions[3]]);
});
+30 -5
View File
@@ -1,6 +1,8 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
import {GET} from '../modules/fetch.ts';
import type {Issue} from '../types.ts';
import {showErrorToast} from '../modules/toast.ts';
import {parseIssuePageInfo} from '../utils.ts';
import type {Issue, Mention} from '../types.ts';
const maxMatches = 6;
@@ -29,13 +31,36 @@ export function matchEmoji(queryText: string): string[] {
return sortAndReduce(results);
}
type MentionSuggestion = {value: string; name: string; fullname: string; avatar: string};
export function matchMention(queryText: string): MentionSuggestion[] {
let cachedMentionsPromise: Promise<Mention[]> | undefined;
let cachedMentionsUrl: string;
export function fetchMentions(mentionsUrl: string): Promise<Mention[]> {
if (cachedMentionsPromise && cachedMentionsUrl === mentionsUrl) {
return cachedMentionsPromise;
}
cachedMentionsUrl = mentionsUrl;
cachedMentionsPromise = (async () => {
try {
const issueIndex = parseIssuePageInfo().issueNumber;
const query = issueIndex ? `?issue_index=${issueIndex}` : '';
const res = await GET(`${mentionsUrl}${query}`);
if (!res.ok) throw new Error(res.statusText);
return await res.json() as Mention[];
} catch (e) {
showErrorToast(`Failed to load mentions: ${e}`);
return [];
}
})();
return cachedMentionsPromise;
}
export async function matchMention(mentionsUrl: string, queryText: string): Promise<Mention[]> {
const values = await fetchMentions(mentionsUrl);
const query = queryText.toLowerCase();
// results is a map of weights, lower is better
const results = new Map<MentionSuggestion, number>();
for (const obj of window.config.mentionValues ?? []) {
const results = new Map<Mention, number>();
for (const obj of values) {
const index = obj.key.toLowerCase().indexOf(query);
if (index === -1) continue;
const existing = results.get(obj);
@@ -2,5 +2,21 @@
// even if backend is in testing mode, frontend could be complied in production mode
// so this function only checks if the frontend is in unit testing mode (usually from *.test.ts files)
export function isInFrontendUnitTest() {
return process.env.TEST === 'true';
return import.meta.env.MODE === 'test';
}
/** strip common indentation from a string and trim it */
export function dedent(str: string) {
const match = str.match(/^[ \t]*(?=\S)/gm);
if (!match) return str;
let minIndent = Number.POSITIVE_INFINITY;
for (const indent of match) {
minIndent = Math.min(minIndent, indent.length);
}
if (minIndent === 0 || minIndent === Number.POSITIVE_INFINITY) {
return str;
}
return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim();
}
@@ -77,7 +77,6 @@ export function formatDatetime(date: Date | number): string {
hour: 'numeric',
hour12: !Number.isInteger(Number(new Intl.DateTimeFormat([], {hour: 'numeric'}).format())),
minute: '2-digit',
timeZoneName: 'short',
});
}
return dateFormat.format(date);
@@ -1,22 +1,47 @@
import {pathEscapeSegments, toOriginUrl} from './url.ts';
import {linkifyURLs, pathEscape, pathEscapeSegments, urlQueryEscape} from './url.ts';
test('pathEscapeSegments', () => {
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
describe('escape', () => {
const queryNonAscii = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
test('urlQueryEscape', () => {
const expected = '+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~';
expect(urlQueryEscape(queryNonAscii)).toEqual(expected);
});
test('pathEscape', () => {
const expected = '%20%21%22%23$%25&%27%28%29%2A+%2C-.%2F:%3B%3C=%3E%3F@%5B%5C%5D%5E_%60%7B%7C%7D~';
expect(pathEscape(queryNonAscii)).toEqual(expected);
});
test('pathEscapeSegments', () => {
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
expect(pathEscapeSegments('a/b+c')).toEqual('a/b+c');
});
});
test('toOriginUrl', () => {
const oldLocation = String(window.location);
for (const origin of ['https://example.com', 'https://example.com:3000']) {
window.location.assign(`${origin}/`);
expect(toOriginUrl('/')).toEqual(`${origin}/`);
expect(toOriginUrl('/org/repo.git')).toEqual(`${origin}/org/repo.git`);
expect(toOriginUrl('https://another.com')).toEqual(`${origin}/`);
expect(toOriginUrl('https://another.com/')).toEqual(`${origin}/`);
expect(toOriginUrl('https://another.com/org/repo.git')).toEqual(`${origin}/org/repo.git`);
expect(toOriginUrl('https://another.com:4000')).toEqual(`${origin}/`);
expect(toOriginUrl('https://another.com:4000/')).toEqual(`${origin}/`);
expect(toOriginUrl('https://another.com:4000/org/repo.git')).toEqual(`${origin}/org/repo.git`);
}
window.location.assign(oldLocation);
test('linkifyURLs', () => {
const link = (url: string) => `<a href="${url}" target="_blank">${url}</a>`;
expect(linkifyURLs('https://example.com')).toEqual(link('https://example.com'));
expect(linkifyURLs('https://dl.google.com/go/go1.23.6.linux-amd64.tar.gz')).toEqual(link('https://dl.google.com/go/go1.23.6.linux-amd64.tar.gz'));
expect(linkifyURLs('https://example.com/path?query=1&amp;b=2#frag')).toEqual(link('https://example.com/path?query=1&amp;b=2#frag'));
expect(linkifyURLs('visit https://example.com/repo for info')).toEqual(`visit ${link('https://example.com/repo')} for info`);
expect(linkifyURLs('See https://example.com.')).toEqual(`See ${link('https://example.com')}.`);
expect(linkifyURLs('https://example.com, and more')).toEqual(`${link('https://example.com')}, and more`);
expect(linkifyURLs('<span class="ansi-green-fg">https://proxy.golang.org/cached-only</span>')).toEqual(`<span class="ansi-green-fg">${link('https://proxy.golang.org/cached-only')}</span>`);
expect(linkifyURLs('<span style="color:rgb(0,255,0)">https://registry.npmjs.org/@types/node</span>')).toEqual(`<span style="color:rgb(0,255,0)">${link('https://registry.npmjs.org/@types/node')}</span>`);
expect(linkifyURLs('https://a.com and https://b.org')).toEqual(`${link('https://a.com')} and ${link('https://b.org')}`);
expect(linkifyURLs('no urls here')).toEqual('no urls here');
expect(linkifyURLs('http://example.com/path')).toEqual(link('http://example.com/path'));
expect(linkifyURLs('http://localhost:3000/repo')).toEqual(link('http://localhost:3000/repo'));
expect(linkifyURLs('https://')).toEqual('https://');
expect(linkifyURLs('<a href="https://example.com">Click here</a>')).toEqual('<a href="https://example.com">Click here</a>');
expect(linkifyURLs('<a\nhref="https://example.com">Click here</a>')).toEqual('<a\nhref="https://example.com">Click here</a>');
expect(linkifyURLs('<a href="https://example.com">https://example.com</a>')).toEqual('<a href="https://example.com">https://example.com</a>');
expect(linkifyURLs('https://evil.com/<script>alert(1)</script>')).toEqual(`${link('https://evil.com/')}<script>alert(1)</script>`);
expect(linkifyURLs('https://evil.com/"onmouseover="alert(1)')).toEqual(`${link('https://evil.com/')}"onmouseover="alert(1)`);
expect(linkifyURLs('javascript:alert(1)')).toEqual('javascript:alert(1)'); // eslint-disable-line no-script-url
expect(linkifyURLs("https://evil.com/'onclick='alert(1)")).toEqual(`${link('https://evil.com/')}'onclick='alert(1)`);
expect(linkifyURLs('data:text/html,<script>alert(1)</script>')).toEqual('data:text/html,<script>alert(1)</script>');
expect(linkifyURLs('https://evil.com/\nonclick=alert(1)')).toEqual(`${link('https://evil.com/')}\nonclick=alert(1)`);
expect(linkifyURLs('https://evil.com/&#34;onmouseover=alert(1)')).toEqual(`${link('https://evil.com/&#34;onmouseover=alert')}(1)`);
});
+55 -15
View File
@@ -1,19 +1,59 @@
export function pathEscapeSegments(s: string): string {
return s.split('/').map(encodeURIComponent).join('/');
export function urlQueryEscape(s: string) {
// See "TestQueryEscape" in backend
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
return encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
).replaceAll('%20', '+');
}
/** Convert an absolute or relative URL to an absolute URL with the current origin. It only
* processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. */
export function toOriginUrl(urlStr: string) {
try {
if (urlStr.startsWith('http://') || urlStr.startsWith('https://') || urlStr.startsWith('/')) {
const {origin, protocol, hostname, port} = window.location;
const url = new URL(urlStr, origin);
url.protocol = protocol;
url.hostname = hostname;
url.port = port || (protocol === 'https:' ? '443' : '80');
return url.toString();
export function pathEscape(s: string): string {
// See "TestPathEscape" in backend
return encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
).replaceAll(/%(\w\w)/g, (v) => {
switch (v) {
case '%24': return '$';
case '%26': return '&';
case '%2B': return '+';
case '%3A': return ':';
case '%3D': return '=';
case '%40': return '@';
default: return v;
}
} catch {}
return urlStr;
});
}
export function pathEscapeSegments(s: string): string {
// The same as backend's PathEscapeSegments
return s.split('/').map(pathEscape).join('/');
}
// Match HTML tags (to skip) or URLs (to linkify) in HTML content
const urlLinkifyPattern = /(<([-\w]+)[^>]*>)|(<\/([-\w]+)[^>]*>)|(https?:\/\/[^\s<>"'`|(){}[\]]+)/gi;
const trailingPunctPattern = /[.,;:!?]+$/;
// Convert URLs to clickable links in HTML, preserving existing HTML tags
export function linkifyURLs(html: string): string {
let inAnchor = false;
return html.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => {
// skip URLs inside existing <a> tags
if (openTag === 'a') {
inAnchor = true;
return match;
} else if (closeTag === 'a') {
inAnchor = false;
return match;
}
if (inAnchor || !url) {
return match;
}
const trailingPunct = url.match(trailingPunctPattern);
const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url;
const trailing = trailingPunct ? trailingPunct[0] : '';
// safe because regexp only matches valid URLs (no quotes or angle brackets)
return `<a href="${cleanUrl}" target="_blank">${cleanUrl}</a>${trailing}`; // eslint-disable-line github/unescaped-html-literal
});
}