forked from hinterland/hearth
feat: vendor gitea 1.16.2
This commit is contained in:
@@ -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>');
|
||||
});
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
|
||||
@@ -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&b=2#frag')).toEqual(link('https://example.com/path?query=1&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/"onmouseover=alert(1)')).toEqual(`${link('https://evil.com/"onmouseover=alert')}(1)`);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user