gitea source for verification 2026-05-22
Some checks are pending
release-nightly / nightly-binary (push) Waiting to run
release-nightly / nightly-docker-rootful (push) Waiting to run
release-nightly / nightly-docker-rootless (push) Waiting to run

This commit is contained in:
2026-05-22 16:44:59 +08:00
commit 7a61cd3abc
5650 changed files with 690128 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import {contrastColor} from './color.ts';
test('contrastColor', () => {
expect(contrastColor('#d73a4a')).toBe('#fff');
expect(contrastColor('#0075ca')).toBe('#fff');
expect(contrastColor('#cfd3d7')).toBe('#000');
expect(contrastColor('#a2eeef')).toBe('#000');
expect(contrastColor('#7057ff')).toBe('#fff');
expect(contrastColor('#008672')).toBe('#fff');
expect(contrastColor('#e4e669')).toBe('#000');
expect(contrastColor('#d876e3')).toBe('#000');
expect(contrastColor('#ffffff')).toBe('#000');
expect(contrastColor('#2b8684')).toBe('#fff');
expect(contrastColor('#2b8786')).toBe('#fff');
expect(contrastColor('#2c8786')).toBe('#000');
expect(contrastColor('#3bb6b3')).toBe('#000');
expect(contrastColor('#7c7268')).toBe('#fff');
expect(contrastColor('#7e716c')).toBe('#fff');
expect(contrastColor('#81706d')).toBe('#fff');
expect(contrastColor('#807070')).toBe('#fff');
expect(contrastColor('#84b6eb')).toBe('#000');
});

35
web_src/js/utils/color.ts Normal file
View File

@@ -0,0 +1,35 @@
import tinycolor from 'tinycolor2';
import type {ColorInput} from 'tinycolor2';
/** 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();
return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255;
}
function useLightText(backgroundColor: ColorInput): boolean {
return getRelativeLuminance(backgroundColor) < 0.453;
}
/** Given a background color, returns a black or white foreground color that 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 {
return useLightText(backgroundColor) ? '#fff' : '#000';
}
function resolveColors(obj: Record<string, string>): Record<string, string> {
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name: string) => styles.getPropertyValue(name).trim();
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)]));
}
export const chartJsColors = resolveColors({
text: '--color-text',
border: '--color-secondary-alpha-60',
commits: '--color-primary-alpha-60',
additions: '--color-green',
deletions: '--color-red',
});

View File

@@ -0,0 +1,54 @@
import {
createElementFromAttrs,
createElementFromHTML,
queryElemChildren,
querySingleVisibleElem,
toggleElem,
} from './dom.ts';
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>');
});
test('createElementFromAttrs', () => {
const el = createElementFromAttrs('button', {
id: 'the-id',
class: 'cls-1 cls-2',
disabled: true,
checked: false,
required: null,
tabindex: 0,
'data-foo': 'the-data',
}, 'txt', createElementFromHTML('<span>inner</span>'));
expect(el.outerHTML).toEqual('<button id="the-id" class="cls-1 cls-2" disabled="" tabindex="0" data-foo="the-data">txt<span>inner</span></button>');
});
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');
el = createElementFromHTML('<div><span style="display: none;">foo</span><span>bar</span></div>');
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');
el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>');
expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element');
});
test('queryElemChildren', () => {
const el = createElementFromHTML('<div><span class="a">a</span><span class="b">b</span></div>');
const children = queryElemChildren(el, '.a');
expect(children.length).toEqual(1);
});
test('toggleElem', () => {
const el = createElementFromHTML('<p><div>a</div><div class="tw-hidden">b</div></p>');
toggleElem(el.children);
expect(el.outerHTML).toEqual('<p><div class="tw-hidden">a</div><div class="">b</div></p>');
toggleElem(el.children, false);
expect(el.outerHTML).toEqual('<p><div class="tw-hidden">a</div><div class="tw-hidden">b</div></p>');
toggleElem(el.children, true);
expect(el.outerHTML).toEqual('<p><div class="">a</div><div class="">b</div></p>');
});

357
web_src/js/utils/dom.ts Normal file
View File

@@ -0,0 +1,357 @@
import {debounce} from 'throttle-debounce';
import type {Promisable} from '../types.ts';
import type $ from 'jquery';
import {isInFrontendUnitTest} from './testhelper.ts';
type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
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) {
el = document.querySelectorAll(el as string);
}
if (el instanceof Node) {
func(el, ...args);
return [el];
} else if (el.length !== undefined) {
// this works for: NodeList, HTMLCollection, Array, jQuery
const elems = el as ArrayLikeIterable<Element>;
for (const elem of elems) func(elem, ...args);
return elems;
}
throw new Error('invalid argument to be shown/hidden');
}
export function toggleElemClass(el: ElementArg, className: string, force?: boolean): ArrayLikeIterable<Element> {
return elementsCall(el, (e: Element) => {
if (force === true) {
e.classList.add(className);
} else if (force === false) {
e.classList.remove(className);
} else if (force === undefined) {
e.classList.toggle(className);
} else {
throw new Error('invalid force argument');
}
});
}
/**
* @param el ElementArg
* @param force force=true to show or force=false to hide, undefined to toggle
*/
export function toggleElem(el: ElementArg, force?: boolean): ArrayLikeIterable<Element> {
return toggleElemClass(el, 'tw-hidden', force === undefined ? force : !force);
}
export function showElem(el: ElementArg): ArrayLikeIterable<Element> {
return toggleElem(el, true);
}
export function hideElem(el: ElementArg): ArrayLikeIterable<Element> {
return toggleElem(el, false);
}
function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
if (fn) {
for (const el of elems) {
fn(el);
}
}
return elems;
}
export function queryElemSiblings<T extends Element>(el: Element, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
const elems = Array.from(el.parentNode.children) as T[];
return applyElemsCallback<T>(elems.filter((child: Element) => {
return child !== el && child.matches(selector);
}), fn);
}
/** it works like jQuery.children: only the direct children are selected */
export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
if (isInFrontendUnitTest()) {
// https://github.com/capricorn86/happy-dom/issues/1620 : ":scope" doesn't work
const selected = Array.from<T>(parent.children as any).filter((child) => child.matches(selector));
return applyElemsCallback<T>(selected, fn);
}
return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn);
}
/** it works like parent.querySelectorAll: all descendants are selected */
// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent if the targets are not for page-level components.
export function queryElems<T extends HTMLElement>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
return applyElemsCallback<T>(parent.querySelectorAll(selector), fn);
}
export function onDomReady(cb: () => Promisable<void>) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', cb);
} else {
cb();
}
}
/** checks whether an element is owned by the current document, and whether it is a document fragment or element node
* if it is, it means it is a "normal" element managed by us, which can be modified safely. */
export function isDocumentFragmentOrElementNode(el: Node) {
try {
return el.ownerDocument === document && el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
} catch {
// in case the el is not in the same origin, then the access to nodeType would fail
return false;
}
}
/** autosize a textarea to fit content. */
// Based on https://github.com/github/textarea-autosize
// ---------------------------------------------------------------------
// Copyright (c) 2018 GitHub, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
// ---------------------------------------------------------------------
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;
function onUserResize(event: MouseEvent) {
if (isUserResized) return;
if (lastMouseX !== event.clientX || lastMouseY !== event.clientY) {
const newStyleHeight = textarea.style.height;
if (lastStyleHeight && lastStyleHeight !== newStyleHeight) {
isUserResized = true;
}
lastStyleHeight = newStyleHeight;
}
lastMouseX = event.clientX;
lastMouseY = event.clientY;
}
function overflowOffset() {
let offsetTop = 0;
let el = textarea;
while (el !== document.body && el !== null) {
offsetTop += el.offsetTop || 0;
el = el.offsetParent as HTMLTextAreaElement;
}
const top = offsetTop - document.defaultView.scrollY;
const bottom = document.documentElement.clientHeight - (top + textarea.offsetHeight);
return {top, bottom};
}
function resizeToFit() {
if (isUserResized) return;
if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return;
const previousMargin = textarea.style.marginBottom;
try {
const {top, bottom} = overflowOffset();
const isOutOfViewport = top < 0 || bottom < 0;
const computedStyle = getComputedStyle(textarea);
const topBorderWidth = parseFloat(computedStyle.borderTopWidth);
const bottomBorderWidth = parseFloat(computedStyle.borderBottomWidth);
const isBorderBox = computedStyle.boxSizing === 'border-box';
const borderAddOn = isBorderBox ? topBorderWidth + bottomBorderWidth : 0;
const adjustedViewportMarginBottom = Math.min(bottom, viewportMarginBottom);
const curHeight = parseFloat(computedStyle.height);
const maxHeight = curHeight + bottom - adjustedViewportMarginBottom;
// In Firefox, setting auto height momentarily may cause the page to scroll up
// unexpectedly, prevent this by setting a temporary margin.
textarea.style.marginBottom = `${textarea.clientHeight}px`;
textarea.style.height = 'auto';
let newHeight = textarea.scrollHeight + borderAddOn;
if (isOutOfViewport) {
// it is already out of the viewport:
// * if the textarea is expanding: do not resize it
if (newHeight > curHeight) {
newHeight = curHeight;
}
// * if the textarea is shrinking, shrink line by line (just use the
// scrollHeight). do not apply max-height limit, otherwise the page
// flickers and the textarea jumps
} else {
// * if it is in the viewport, apply the max-height limit
newHeight = Math.min(maxHeight, newHeight);
}
textarea.style.height = `${newHeight}px`;
lastStyleHeight = textarea.style.height;
} finally {
// restore previous margin
if (previousMargin) {
textarea.style.marginBottom = previousMargin;
} else {
textarea.style.removeProperty('margin-bottom');
}
// ensure that the textarea is fully scrolled to the end, when the cursor
// is at the end during an input event
if (textarea.selectionStart === textarea.selectionEnd &&
textarea.selectionStart === textarea.value.length) {
textarea.scrollTop = textarea.scrollHeight;
}
}
}
function onFormReset() {
isUserResized = false;
if (initialStyleHeight !== undefined) {
textarea.style.height = initialStyleHeight;
} else {
textarea.style.removeProperty('height');
}
}
textarea.addEventListener('mousemove', onUserResize);
textarea.addEventListener('input', resizeToFit);
textarea.form?.addEventListener('reset', onFormReset);
initialStyleHeight = textarea.style.height ?? undefined;
if (textarea.value) resizeToFit();
return {
resizeToFit,
destroy() {
textarea.removeEventListener('mousemove', onUserResize);
textarea.removeEventListener('input', resizeToFit);
textarea.form?.removeEventListener('reset', onFormReset);
},
};
}
export function onInputDebounce(fn: () => Promisable<any>) {
return debounce(300, fn);
}
type LoadableElement = HTMLEmbedElement | HTMLIFrameElement | HTMLImageElement | HTMLScriptElement | HTMLTrackElement;
/** Set the `src` attribute on an element and returns a promise that resolves once the element
* has loaded or errored. */
export function loadElem(el: LoadableElement, src: string) {
return new Promise((resolve) => {
el.addEventListener('load', () => resolve(true), {once: true});
el.addEventListener('error', () => resolve(false), {once: true});
el.src = src;
});
}
// 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: DOMEvent<Event>) {
const form = e.target.closest('form');
if (!form) return;
form._submitter = e.target.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"
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';
}
export function createElementFromHTML<T extends HTMLElement>(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');
}
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 {
const el = document.createElement(tagName);
for (const [key, value] of Object.entries(attrs || {})) {
if (value === undefined || value === null) continue;
if (typeof value === 'boolean') {
el.toggleAttribute(key, value);
} else {
el.setAttribute(key, String(value));
}
}
for (const child of children) {
el.append(child instanceof Node ? child : document.createTextNode(child));
}
return el;
}
export function animateOnce(el: Element, animationClassName: string): Promise<void> {
return new Promise((resolve) => {
el.addEventListener('animationend', function onAnimationEnd() {
el.classList.remove(animationClassName);
el.removeEventListener('animationend', onAnimationEnd);
resolve();
}, {once: true});
el.classList.add(animationClassName);
});
}
export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, selector: string): T | null {
const elems = parent.querySelectorAll<HTMLElement>(selector);
const candidates = Array.from(elems).filter(isElemVisible);
if (candidates.length > 1) throw new Error(`Expected exactly one visible element matching selector "${selector}", but found ${candidates.length}`);
return candidates.length ? candidates[0] as T : null;
}
export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => Promisable<void>, options?: boolean | AddEventListenerOptions) {
parent.addEventListener(type, (e: Event) => {
const elem = (e.target as HTMLElement).closest(selector);
// It strictly checks "parent contains the target elem" to avoid side effects of selector running on outside the parent.
// Keep in mind that the elem could have been removed from parent by other event handlers before this event handler is called.
// For example, tippy popup item, the tippy popup could be hidden and removed from DOM before this.
// It is the caller's responsibility to make sure the elem is still in parent's DOM when this event handler is called.
if (!elem || (parent !== document && !parent.contains(elem))) return;
listener(elem as T, e as E);
}, options);
}
/** Returns whether a click event is a left-click without any modifiers held */
export function isPlainClick(e: MouseEvent) {
return e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
}
let elemIdCounter = 0;
export function generateElemId(prefix: string = ''): string {
return `${prefix}${elemIdCounter++}`;
}

View File

@@ -0,0 +1,129 @@
import {readFile} from 'node:fs/promises';
import * as path from 'node:path';
import {globCompile} from './glob.ts';
async function loadGlobTestData(): Promise<{caseNames: string[], caseDataMap: Record<string, string>}> {
const fileContent = await readFile(path.join(import.meta.dirname, 'glob.test.txt'), 'utf8');
const fileLines = fileContent.split('\n');
const caseDataMap: Record<string, string> = {};
const caseNameMap: Record<string, boolean> = {};
for (let line of fileLines) {
line = line.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split('=', 2);
if (parts.length !== 2) throw new Error(`Invalid test case line: ${line}`);
const key = parts[0].trim();
let value = parts[1].trim();
value = value.substring(1, value.length - 1); // remove quotes
value = value.replace(/\\\//g, '/').replace(/\\\\/g, '\\');
caseDataMap[key] = value;
if (key.startsWith('pattern_')) caseNameMap[key.substring('pattern_'.length)] = true;
}
return {caseNames: Object.keys(caseNameMap), caseDataMap};
}
function loadGlobGolangCases() {
// https://github.com/gobwas/glob/blob/master/glob_test.go
function glob(matched: boolean, pattern: string, input: string, separators: string = '') {
return {matched, pattern, input, separators};
}
return [
glob(true, '* ?at * eyes', 'my cat has very bright eyes'),
glob(true, '', ''),
glob(false, '', 'b'),
glob(true, '*ä', 'åä'),
glob(true, 'abc', 'abc'),
glob(true, 'a*c', 'abc'),
glob(true, 'a*c', 'a12345c'),
glob(true, 'a?c', 'a1c'),
glob(true, 'a.b', 'a.b', '.'),
glob(true, 'a.*', 'a.b', '.'),
glob(true, 'a.**', 'a.b.c', '.'),
glob(true, 'a.?.c', 'a.b.c', '.'),
glob(true, 'a.?.?', 'a.b.c', '.'),
glob(true, '?at', 'cat'),
glob(true, '?at', 'fat'),
glob(true, '*', 'abc'),
glob(true, `\\*`, '*'),
glob(true, '**', 'a.b.c', '.'),
glob(false, '?at', 'at'),
glob(false, '?at', 'fat', 'f'),
glob(false, 'a.*', 'a.b.c', '.'),
glob(false, 'a.?.c', 'a.bb.c', '.'),
glob(false, '*', 'a.b.c', '.'),
glob(true, '*test', 'this is a test'),
glob(true, 'this*', 'this is a test'),
glob(true, '*is *', 'this is a test'),
glob(true, '*is*a*', 'this is a test'),
glob(true, '**test**', 'this is a test'),
glob(true, '**is**a***test*', 'this is a test'),
glob(false, '*is', 'this is a test'),
glob(false, '*no*', 'this is a test'),
glob(true, '[!a]*', 'this is a test3'),
glob(true, '*abc', 'abcabc'),
glob(true, '**abc', 'abcabc'),
glob(true, '???', 'abc'),
glob(true, '?*?', 'abc'),
glob(true, '?*?', 'ac'),
glob(false, 'sta', 'stagnation'),
glob(true, 'sta*', 'stagnation'),
glob(false, 'sta?', 'stagnation'),
glob(false, 'sta?n', 'stagnation'),
glob(true, '{abc,def}ghi', 'defghi'),
glob(true, '{abc,abcd}a', 'abcda'),
glob(true, '{a,ab}{bc,f}', 'abc'),
glob(true, '{*,**}{a,b}', 'ab'),
glob(false, '{*,**}{a,b}', 'ac'),
glob(true, '/{rate,[a-z][a-z][a-z]}*', '/rate'),
glob(true, '/{rate,[0-9][0-9][0-9]}*', '/rate'),
glob(true, '/{rate,[a-z][a-z][a-z]}*', '/usd'),
glob(true, '{*.google.*,*.yandex.*}', 'www.google.com', '.'),
glob(true, '{*.google.*,*.yandex.*}', 'www.yandex.com', '.'),
glob(false, '{*.google.*,*.yandex.*}', 'yandex.com', '.'),
glob(false, '{*.google.*,*.yandex.*}', 'google.com', '.'),
glob(true, '{*.google.*,yandex.*}', 'www.google.com', '.'),
glob(true, '{*.google.*,yandex.*}', 'yandex.com', '.'),
glob(false, '{*.google.*,yandex.*}', 'www.yandex.com', '.'),
glob(false, '{*.google.*,yandex.*}', 'google.com', '.'),
glob(true, '*//{,*.}example.com', 'https://www.example.com'),
glob(true, '*//{,*.}example.com', 'http://example.com'),
glob(false, '*//{,*.}example.com', 'http://example.com.net'),
];
}
test('GlobCompiler', async () => {
const {caseNames, caseDataMap} = await loadGlobTestData();
expect(caseNames.length).toBe(10); // should have 10 test cases
for (const caseName of caseNames) {
const pattern = caseDataMap[`pattern_${caseName}`];
const regexp = caseDataMap[`regexp_${caseName}`];
expect(globCompile(pattern).regexpPattern).toBe(regexp);
}
const golangCases = loadGlobGolangCases();
expect(golangCases.length).toBe(60);
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);
}
// then our cases
expect(globCompile('*/**/x').regexpPattern).toBe('^.*/.*/x$');
expect(globCompile('*/**/x', '/').regexpPattern).toBe('^[^/]*/.*/x$');
expect(globCompile('[a-b][^-\\]]', '/').regexpPattern).toBe('^[a-b][^-\\]]$');
expect(globCompile('.+^$()|', '/').regexpPattern).toBe('^\\.\\+\\^\\$\\(\\)\\|$');
});

View File

@@ -0,0 +1,44 @@
# test cases are from https://github.com/gobwas/glob/blob/master/glob_test.go
pattern_all = "[a-z][!a-x]*cat*[h][!b]*eyes*"
regexp_all = `^[a-z][^a-x].*cat.*[h][^b].*eyes.*$`
fixture_all_match = "my cat has very bright eyes"
fixture_all_mismatch = "my dog has very bright eyes"
pattern_plain = "google.com"
regexp_plain = `^google\.com$`
fixture_plain_match = "google.com"
fixture_plain_mismatch = "gobwas.com"
pattern_multiple = "https://*.google.*"
regexp_multiple = `^https:\/\/.*\.google\..*$`
fixture_multiple_match = "https://account.google.com"
fixture_multiple_mismatch = "https://google.com"
pattern_alternatives = "{https://*.google.*,*yandex.*,*yahoo.*,*mail.ru}"
regexp_alternatives = `^(https:\/\/.*\.google\..*|.*yandex\..*|.*yahoo\..*|.*mail\.ru)$`
fixture_alternatives_match = "http://yahoo.com"
fixture_alternatives_mismatch = "http://google.com"
pattern_alternatives_suffix = "{https://*gobwas.com,http://exclude.gobwas.com}"
regexp_alternatives_suffix = `^(https:\/\/.*gobwas\.com|http://exclude\.gobwas\.com)$`
fixture_alternatives_suffix_first_match = "https://safe.gobwas.com"
fixture_alternatives_suffix_first_mismatch = "http://safe.gobwas.com"
fixture_alternatives_suffix_second = "http://exclude.gobwas.com"
pattern_prefix = "abc*"
regexp_prefix = `^abc.*$`
pattern_suffix = "*def"
regexp_suffix = `^.*def$`
pattern_prefix_suffix = "ab*ef"
regexp_prefix_suffix = `^ab.*ef$`
fixture_prefix_suffix_match = "abcdef"
fixture_prefix_suffix_mismatch = "af"
pattern_alternatives_combine_lite = "{abc*def,abc?def,abc[zte]def}"
regexp_alternatives_combine_lite = `^(abc.*def|abc.def|abc[zte]def)$`
fixture_alternatives_combine_lite = "abczdef"
pattern_alternatives_combine_hard = "{abc*[a-c]def,abc?[d-g]def,abc[zte]?def}"
regexp_alternatives_combine_hard = `^(abc.*[a-c]def|abc.[d-g]def|abc[zte].def)$`
fixture_alternatives_combine_hard = "abczqdef"

127
web_src/js/utils/glob.ts Normal file
View File

@@ -0,0 +1,127 @@
// Reference: https://github.com/gobwas/glob/blob/master/glob.go
//
// Compile creates Glob for given pattern and strings (if any present after pattern) as separators.
// The pattern syntax is:
//
// pattern:
// { term }
//
// term:
// `*` matches any sequence of non-separator characters
// `**` matches any sequence of characters
// `?` matches any single non-separator character
// `[` [ `!` ] { character-range } `]`
// character class (must be non-empty)
// `{` pattern-list `}`
// pattern alternatives
// c matches character c (c != `*`, `**`, `?`, `\`, `[`, `{`, `}`)
// `\` c matches character c
//
// character-range:
// c matches character c (c != `\\`, `-`, `]`)
// `\` c matches character c
// lo `-` hi matches character c for lo <= c <= hi
//
// pattern-list:
// pattern { `,` pattern }
// comma-separated (without spaces) patterns
//
class GlobCompiler {
nonSeparatorChars: string;
globPattern: string;
regexpPattern: string;
regexp: RegExp;
pos: number = 0;
#compileChars(): string {
let result = '';
if (this.globPattern[this.pos] === '!') {
this.pos++;
result += '^';
}
while (this.pos < this.globPattern.length) {
const c = this.globPattern[this.pos];
this.pos++;
if (c === ']') {
return `[${result}]`;
}
if (c === '\\') {
if (this.pos >= this.globPattern.length) {
throw new Error('Unterminated character class escape');
}
this.pos++;
result += `\\${this.globPattern[this.pos]}`;
} else {
result += c;
}
}
throw new Error('Unterminated character class');
}
#compile(subPattern: boolean = false): string {
let result = '';
while (this.pos < this.globPattern.length) {
const c = this.globPattern[this.pos];
this.pos++;
if (subPattern && c === '}') {
return `(${result})`;
}
switch (c) {
case '*':
if (this.globPattern[this.pos] !== '*') {
result += `${this.nonSeparatorChars}*`; // match any sequence of non-separator characters
} else {
this.pos++;
result += '.*'; // match any sequence of characters
}
break;
case '?':
result += this.nonSeparatorChars; // match any single non-separator character
break;
case '[':
result += this.#compileChars();
break;
case '{':
result += this.#compile(true);
break;
case ',':
result += subPattern ? '|' : ',';
break;
case '\\':
if (this.pos >= this.globPattern.length) {
throw new Error('No character to escape');
}
result += `\\${this.globPattern[this.pos]}`;
this.pos++;
break;
case '.': case '+': case '^': case '$': case '(': case ')': case '|':
result += `\\${c}`; // escape regexp special characters
break;
default:
result += c;
}
}
return result;
}
constructor(pattern: string, separators: string = '') {
const escapedSeparators = separators.replaceAll(/[\^\]\-\\]/g, '\\$&');
this.nonSeparatorChars = escapedSeparators ? `[^${escapedSeparators}]` : '.';
this.globPattern = pattern;
this.regexpPattern = `^${this.#compile()}$`;
this.regexp = new RegExp(`^${this.regexpPattern}$`);
}
}
export function globCompile(pattern: string, separators: string = ''): GlobCompiler {
return new GlobCompiler(pattern, separators);
}
export function globMatch(str: string, pattern: string, separators: string = ''): boolean {
try {
return globCompile(pattern, separators).regexp.test(str);
} catch {
return false;
}
}

View File

@@ -0,0 +1,8 @@
import {html, htmlEscape, htmlRaw} from './html.ts';
test('html', async () => {
expect(html`<a>${'<>&\'"'}</a>`).toBe(`<a>&lt;&gt;&amp;&#39;&quot;</a>`);
expect(html`<a>${htmlRaw('<img>')}</a>`).toBe(`<a><img></a>`);
expect(html`<a>${htmlRaw`<img ${'&'}>`}</a>`).toBe(`<a><img &amp;></a>`);
expect(htmlEscape(`<a></a>`)).toBe(`&lt;a&gt;&lt;/a&gt;`);
});

32
web_src/js/utils/html.ts Normal file
View File

@@ -0,0 +1,32 @@
export function htmlEscape(s: string, ...args: Array<any>): string {
if (args.length !== 0) throw new Error('use html or htmlRaw instead of htmlEscape'); // check legacy usages
return s.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
class rawObject {
private readonly value: string;
constructor(v: string) { this.value = v }
toString(): string { return this.value }
}
export function html(tmpl: TemplateStringsArray, ...parts: Array<any>): string {
let output = tmpl[0];
for (let i = 0; i < parts.length; i++) {
const value = parts[i];
const valueEscaped = (value instanceof rawObject) ? value.toString() : htmlEscape(String(value));
output = output + valueEscaped + tmpl[i + 1];
}
return output;
}
export function htmlRaw(s: string | TemplateStringsArray, ...tmplParts: Array<any>): rawObject {
if (typeof s === 'string') {
if (tmplParts.length !== 0) throw new Error("either htmlRaw('str') or htmlRaw`tmpl`");
return new rawObject(s);
}
return new rawObject(html(s, ...tmplParts));
}

View File

@@ -0,0 +1,30 @@
import {pngChunks, imageInfo} from './image.ts';
const pngNoPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAADUlEQVQIHQECAP3/AAAAAgABzePRKwAAAABJRU5ErkJggg==';
const pngPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAEElEQVQI12OQNZcAIgYIBQAL8gGxdzzM0A==';
const pngEmpty = 'data:image/png;base64,';
async function dataUriToBlob(datauri: string) {
return await (await globalThis.fetch(datauri)).blob();
}
test('pngChunks', async () => {
expect(await pngChunks(await dataUriToBlob(pngNoPhys))).toEqual([
{name: 'IHDR', data: new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0])},
{name: 'IDAT', data: new Uint8Array([8, 29, 1, 2, 0, 253, 255, 0, 0, 0, 2, 0, 1])},
{name: 'IEND', data: new Uint8Array([])},
]);
expect(await pngChunks(await dataUriToBlob(pngPhys))).toEqual([
{name: 'IHDR', data: new Uint8Array([0, 0, 0, 2, 0, 0, 0, 2, 8, 2, 0, 0, 0])},
{name: 'pHYs', data: new Uint8Array([0, 0, 22, 37, 0, 0, 22, 37, 1])},
{name: 'IDAT', data: new Uint8Array([8, 215, 99, 144, 53, 151, 0, 34, 6, 8, 5, 0, 11, 242, 1, 177])},
]);
expect(await pngChunks(await dataUriToBlob(pngEmpty))).toEqual([]);
});
test('imageInfo', async () => {
expect(await imageInfo(await dataUriToBlob(pngNoPhys))).toEqual({width: 1, dppx: 1});
expect(await imageInfo(await dataUriToBlob(pngPhys))).toEqual({width: 2, dppx: 2});
expect(await imageInfo(await dataUriToBlob(pngEmpty))).toEqual({width: 0, dppx: 1});
expect(await imageInfo(await dataUriToBlob(`data:image/gif;base64,`))).toEqual({});
});

58
web_src/js/utils/image.ts Normal file
View File

@@ -0,0 +1,58 @@
type PngChunk = {
name: string,
data: Uint8Array,
};
export async function pngChunks(blob: Blob): Promise<PngChunk[]> {
const uint8arr = new Uint8Array(await blob.arrayBuffer());
const chunks: PngChunk[] = [];
if (uint8arr.length < 12) return chunks;
const view = new DataView(uint8arr.buffer);
if (view.getBigUint64(0) !== 9894494448401390090n) return chunks;
const decoder = new TextDecoder();
let index = 8;
while (index < uint8arr.length) {
const len = view.getUint32(index);
chunks.push({
name: decoder.decode(uint8arr.slice(index + 4, index + 8)),
data: uint8arr.slice(index + 8, index + 8 + len),
});
index += len + 12;
}
return chunks;
}
type ImageInfo = {
width?: number,
dppx?: number,
};
/** decode a image and try to obtain width and dppx. It will never throw but instead
* return default values. */
export async function imageInfo(blob: Blob): Promise<ImageInfo> {
let width = 0, dppx = 1; // dppx: 1 dot per pixel for non-HiDPI screens
if (blob.type === 'image/png') { // only png is supported currently
try {
for (const {name, data} of await pngChunks(blob)) {
const view = new DataView(data.buffer);
if (name === 'IHDR' && data?.length) {
// extract width from mandatory IHDR chunk
width = view.getUint32(0);
} else if (name === 'pHYs' && data?.length) {
// extract dppx from optional pHYs chunk, assuming pixels are square
const unit = view.getUint8(8);
if (unit === 1) {
dppx = Math.round(view.getUint32(0) / 39.3701) / 72; // meter to inch to dppx
}
}
}
} catch {}
} else {
return {}; // no image info for non-image files
}
return {width, dppx};
}

View File

@@ -0,0 +1,50 @@
import {matchEmoji, matchMention} from './match.ts';
test('matchEmoji', () => {
expect(matchEmoji('')).toEqual([
'+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('hear')).toEqual([
'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('1st_')).toEqual([
'1st_place_medal',
]);
expect(matchEmoji('jellyfis')).toEqual([
'jellyfish',
]);
});
test('matchMention', () => {
expect(matchMention('')).toEqual(window.config.mentionValues.slice(0, 6));
expect(matchMention('user4')).toEqual([window.config.mentionValues[3]]);
});

56
web_src/js/utils/match.ts Normal file
View File

@@ -0,0 +1,56 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
import {GET} from '../modules/fetch.ts';
import type {Issue} 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]));
return Array.from(sortedMap.keys()).slice(0, maxMatches);
}
export function matchEmoji(queryText: string): string[] {
const query = queryText.toLowerCase().replaceAll('_', ' ');
if (!query) return emojis.slice(0, maxMatches).map((e) => e.aliases[0]);
// results is a map of weights, lower is better
const results = new Map<string, number>();
for (const {aliases} of emojis) {
const mainAlias = aliases[0];
for (const [aliasIndex, alias] of aliases.entries()) {
const index = alias.replaceAll('_', ' ').indexOf(query);
if (index === -1) continue;
const existing = results.get(mainAlias);
const rankedIndex = index + aliasIndex;
results.set(mainAlias, existing ? existing - rankedIndex : rankedIndex);
}
}
return sortAndReduce(results);
}
type MentionSuggestion = {value: string; name: string; fullname: string; avatar: string};
export function matchMention(queryText: string): MentionSuggestion[] {
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 index = obj.key.toLowerCase().indexOf(query);
if (index === -1) continue;
const existing = results.get(obj);
results.set(obj, existing ? existing - index : index);
}
return sortAndReduce(results);
}
export async function matchIssue(owner: string, repo: string, issueIndexStr: string, query: string): Promise<Issue[]> {
const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`);
const issues: Issue[] = await res.json();
const issueNumber = parseInt(issueIndexStr);
// filter out issue with same id
return issues.filter((i) => i.number !== issueNumber);
}

View File

@@ -0,0 +1,6 @@
// there could be different "testing" concepts, for example: backend's "setting.IsInTesting"
// 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';
}

View File

@@ -0,0 +1,15 @@
import {startDaysBetween} from './time.ts';
test('startDaysBetween', () => {
expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([
1708214400000,
1708819200000,
1709424000000,
1710028800000,
1710633600000,
1711238400000,
1711843200000,
1712448000000,
1713052800000,
]);
});

84
web_src/js/utils/time.ts Normal file
View File

@@ -0,0 +1,84 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import {getCurrentLocale} from '../utils.ts';
import type {ConfigType} from 'dayjs';
dayjs.extend(utc);
/**
* Returns an array of millisecond-timestamps of start-of-week days (Sundays)
*
* @param startDate The start date. Can take any type that dayjs accepts.
* @param endDate The end date. Can take any type that dayjs accepts.
*/
export function startDaysBetween(startDate: ConfigType, endDate: ConfigType): number[] {
const start = dayjs.utc(startDate);
const end = dayjs.utc(endDate);
let current = start;
// Ensure the start date is a Sunday
while (current.day() !== 0) {
current = current.add(1, 'day');
}
const startDays: number[] = [];
while (current.isBefore(end)) {
startDays.push(current.valueOf());
current = current.add(1, 'week');
}
return startDays;
}
export function firstStartDateAfterDate(inputDate: Date): number {
if (!(inputDate instanceof Date)) {
throw new Error('Invalid date');
}
const dayOfWeek = inputDate.getUTCDay();
const daysUntilSunday = 7 - dayOfWeek;
const resultDate = new Date(inputDate.getTime());
resultDate.setUTCDate(resultDate.getUTCDate() + daysUntilSunday);
return resultDate.valueOf();
}
export type DayData = {
week: number,
additions: number,
deletions: number,
commits: number,
};
export type DayDataObject = {
[timestamp: string]: DayData,
};
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayDataObject): DayData[] {
const result: Record<string, any> = {};
for (const startDay of startDays) {
result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0};
}
return Object.values(result);
}
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. */
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',
month: 'short',
year: 'numeric',
hour: 'numeric',
hour12: !Number.isInteger(Number(new Intl.DateTimeFormat([], {hour: 'numeric'}).format())),
minute: '2-digit',
timeZoneName: 'short',
});
}
return dateFormat.format(date);
}

View File

@@ -0,0 +1,22 @@
import {pathEscapeSegments, toOriginUrl} from './url.ts';
test('pathEscapeSegments', () => {
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
});
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);
});

19
web_src/js/utils/url.ts Normal file
View File

@@ -0,0 +1,19 @@
export function pathEscapeSegments(s: string): string {
return s.split('/').map(encodeURIComponent).join('/');
}
/** 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();
}
} catch {}
return urlStr;
}