This commit is contained in:
@@ -9,6 +9,8 @@ import {
|
||||
test('createElementFromHTML', () => {
|
||||
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
|
||||
expect(createElementFromHTML('<tr data-x="1"><td>foo</td></tr>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
|
||||
expect(createElementFromHTML('<TR data-x="1"><td>foo</td></TR>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
|
||||
expect(createElementFromHTML('<trx></trx>').outerHTML).toEqual('<trx></trx>');
|
||||
});
|
||||
|
||||
test('createElementFromAttrs', () => {
|
||||
|
||||
@@ -9,8 +9,8 @@ type ElementsCallback<T extends Element> = (el: T) => Promisable<any>;
|
||||
type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
|
||||
|
||||
function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]): ArrayLikeIterable<Element> {
|
||||
if (typeof el === 'string' || el instanceof String) {
|
||||
el = document.querySelectorAll(el as string);
|
||||
if (typeof el === 'string') {
|
||||
el = document.querySelectorAll(el);
|
||||
}
|
||||
if (el instanceof Node) {
|
||||
func(el, ...args);
|
||||
@@ -257,28 +257,6 @@ export function loadElem(el: LoadableElement, src: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// some browsers like PaleMoon don't have "SubmitEvent" support, so polyfill it by a tricky method: use the last clicked button as submitter
|
||||
// it can't use other transparent polyfill patches because PaleMoon also doesn't support "addEventListener(capture)"
|
||||
const needSubmitEventPolyfill = typeof SubmitEvent === 'undefined';
|
||||
|
||||
export function submitEventSubmitter(e: any) {
|
||||
e = e.originalEvent ?? e; // if the event is wrapped by jQuery, use "originalEvent", otherwise, use the event itself
|
||||
return needSubmitEventPolyfill ? (e.target._submitter || null) : e.submitter;
|
||||
}
|
||||
|
||||
function submitEventPolyfillListener(e: Event) {
|
||||
const form = (e.target as HTMLElement).closest('form');
|
||||
if (!form) return;
|
||||
form._submitter = (e.target as HTMLElement).closest('button:not([type]), button[type="submit"], input[type="submit"]');
|
||||
}
|
||||
|
||||
export function initSubmitEventPolyfill() {
|
||||
if (!needSubmitEventPolyfill) return;
|
||||
console.warn(`This browser doesn't have "SubmitEvent" support, use a tricky method to polyfill`);
|
||||
document.body.addEventListener('click', submitEventPolyfillListener);
|
||||
document.body.addEventListener('focus', submitEventPolyfillListener);
|
||||
}
|
||||
|
||||
export function isElemVisible(el: HTMLElement): boolean {
|
||||
// Check if an element is visible, equivalent to jQuery's `:visible` pseudo.
|
||||
// This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem"
|
||||
@@ -289,9 +267,14 @@ export function isElemVisible(el: HTMLElement): boolean {
|
||||
|
||||
export function createElementFromHTML<T extends Element>(htmlString: string): T {
|
||||
htmlString = htmlString.trim();
|
||||
const isLetter = (code: number) => (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
|
||||
const startsWithTag = (s: string, tag: string) => {
|
||||
return s.startsWith('<') &&
|
||||
s.substring(1, 1 + tag.length).toLowerCase() === tag.toLowerCase() &&
|
||||
!isLetter(s[1 + tag.length].charCodeAt(0));
|
||||
};
|
||||
// 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')) {
|
||||
if (startsWithTag(htmlString, 'tr')) {
|
||||
const container = document.createElement('table');
|
||||
container.innerHTML = htmlString;
|
||||
return container.querySelector<T>('tr')!;
|
||||
|
||||
@@ -2,12 +2,13 @@ import emojis from '../../../assets/emoji.json' with {type: 'json'};
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {parseIssuePageInfo} from '../utils.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import type {Issue, Mention} from '../types.ts';
|
||||
|
||||
const maxMatches = 6;
|
||||
|
||||
function sortAndReduce<T>(map: Map<T, number>): T[] {
|
||||
const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1]));
|
||||
const sortedMap = new Map(Array.from(map).sort((a, b) => a[1] - b[1]));
|
||||
return Array.from(sortedMap.keys()).slice(0, maxMatches);
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ export function fetchMentions(mentionsUrl: string): Promise<Mention[]> {
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return await res.json() as Mention[];
|
||||
} catch (e) {
|
||||
showErrorToast(`Failed to load mentions: ${e}`);
|
||||
showErrorToast(`Failed to load mentions: ${errorMessage(e)}`);
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {cutString} from './string.ts';
|
||||
|
||||
test('cutString', () => {
|
||||
let [before, after, ok] = cutString('a = b = c', '=');
|
||||
expect(before).toBe('a ');
|
||||
expect(after).toBe(' b = c');
|
||||
expect(ok).toBe(true);
|
||||
|
||||
[before, after, ok] = cutString(' a ', '=');
|
||||
expect(before).toBe(' a ');
|
||||
expect(after).toBe('');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export function cutString(s: string, sep: string): [string, string, boolean] {
|
||||
const index = s.indexOf(sep);
|
||||
if (index === -1) return [s, '', false];
|
||||
return [s.substring(0, index), s.substring(index + sep.length), true];
|
||||
}
|
||||
@@ -10,13 +10,21 @@ export function dedent(str: string) {
|
||||
const match = str.match(/^[ \t]*(?=\S)/gm);
|
||||
if (!match) return str;
|
||||
|
||||
let minIndent = Number.POSITIVE_INFINITY;
|
||||
let minIndent = Infinity;
|
||||
for (const indent of match) {
|
||||
minIndent = Math.min(minIndent, indent.length);
|
||||
}
|
||||
if (minIndent === 0 || minIndent === Number.POSITIVE_INFINITY) {
|
||||
if (minIndent === 0 || minIndent === Infinity) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim();
|
||||
}
|
||||
|
||||
export function normalizeTestHtml(s: string) {
|
||||
const lines = s.replace(/>\s+</g, '>\n<').trim().split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
lines[i] = lines[i].trim();
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function firstStartDateAfterDate(inputDate: Date): number {
|
||||
}
|
||||
const dayOfWeek = inputDate.getUTCDay();
|
||||
const daysUntilSunday = 7 - dayOfWeek;
|
||||
const resultDate = new Date(inputDate.getTime());
|
||||
const resultDate = new Date(inputDate);
|
||||
resultDate.setUTCDate(resultDate.getUTCDate() + daysUntilSunday);
|
||||
return resultDate.valueOf();
|
||||
}
|
||||
@@ -65,18 +65,25 @@ export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayDataO
|
||||
|
||||
let dateFormat: Intl.DateTimeFormat;
|
||||
|
||||
/** Format a Date object to document's locale, but with 24h format from user's current locale because this
|
||||
* option is a personal preference of the user, not something that the document's locale should dictate. */
|
||||
// ISO 8601 UTC with 7-digit fractional seconds, matching Go's `2006-01-02T15:04:05.0000000Z07:00`
|
||||
export function formatDatetimeISO(unixSeconds: number): string {
|
||||
const base = new Date(unixSeconds * 1000).toISOString().slice(0, 19);
|
||||
const frac = unixSeconds - Math.floor(unixSeconds);
|
||||
const fracInt = Math.floor(frac * 10_000_000);
|
||||
return `${base}.${String(fracInt).padStart(7, '0')}Z`;
|
||||
}
|
||||
|
||||
/** Format a Date to a localized format, for example "21 May 2026, 14:30:45". */
|
||||
export function formatDatetime(date: Date | number): string {
|
||||
if (!dateFormat) {
|
||||
// TODO: replace `hour12` with `Intl.Locale.prototype.getHourCycles` once there is broad browser support
|
||||
dateFormat = new Intl.DateTimeFormat(getCurrentLocale(), {
|
||||
day: 'numeric',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
hour12: !Number.isInteger(Number(new Intl.DateTimeFormat([], {hour: 'numeric'}).format())),
|
||||
hour: '2-digit',
|
||||
hourCycle: new Intl.DateTimeFormat([], {hour: 'numeric'}).resolvedOptions().hourCycle === 'h12' ? 'h12' : 'h23',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
return dateFormat.format(date);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {linkifyURLs, pathEscape, pathEscapeSegments, urlQueryEscape} from './url.ts';
|
||||
import {pathEscape, pathEscapeSegments, trimUrlPunctuation, urlQueryEscape, urlRawRegex} from './url.ts';
|
||||
|
||||
describe('escape', () => {
|
||||
const queryNonAscii = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
|
||||
@@ -19,29 +19,36 @@ describe('escape', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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)`);
|
||||
test('matchUrls', () => {
|
||||
const matchUrls = (text: string) => Array.from(text.matchAll(urlRawRegex()), (m) => trimUrlPunctuation(m[0]));
|
||||
expect(matchUrls('visit https://example.com for info')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com.')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com, and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com; and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('(https://example.com)')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('"https://example.com"')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash.')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://x.co')).toEqual(['https://x.co']);
|
||||
expect(matchUrls('https://example.com/path_(wiki)')).toEqual(['https://example.com/path_(wiki)']);
|
||||
expect(matchUrls('https://en.wikipedia.org/wiki/Rust_(programming_language)')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('(https://en.wikipedia.org/wiki/Rust_(programming_language))')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('http://example.com')).toEqual(['http://example.com']);
|
||||
expect(matchUrls('no url here')).toEqual([]);
|
||||
expect(matchUrls('https://a.com and https://b.com')).toEqual(['https://a.com', 'https://b.com']);
|
||||
expect(matchUrls('[](https://www.npmjs.org/package/pkg)')).toEqual(['https://img.shields.io/npm/v/pkg.svg?style=flat', 'https://www.npmjs.org/package/pkg']);
|
||||
});
|
||||
|
||||
test('trimUrlPunctuation', () => {
|
||||
expect(trimUrlPunctuation('https://example.com.')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com,')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com:')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation("https://example.com'")).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com"')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com.,;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com/path')).toEqual('https://example.com/path');
|
||||
expect(trimUrlPunctuation('https://example.com/path_(wiki)')).toEqual('https://example.com/path_(wiki)');
|
||||
expect(trimUrlPunctuation('https://example.com)')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://en.wikipedia.org/wiki/Rust_(lang))')).toEqual('https://en.wikipedia.org/wiki/Rust_(lang)');
|
||||
});
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/** Matches URLs, excluding characters that are never valid unencoded in URLs per RFC 3986. */
|
||||
export const urlRawRegex = () => /\bhttps?:\/\/[^\s<>[\]]+/gi; // JS regexp has internal states, so always use a new instance
|
||||
|
||||
/** Strip trailing punctuation that is likely not part of the URL. */
|
||||
export function trimUrlPunctuation(url: string): string {
|
||||
url = url.replace(/[.,;:'"]+$/, '');
|
||||
// Strip trailing closing parens only if unbalanced (not part of the URL like Wikipedia links)
|
||||
while (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -29,31 +42,3 @@ 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