diff --git a/types.d.ts b/types.d.ts index a8dc09e064..68081af606 100644 --- a/types.d.ts +++ b/types.d.ts @@ -10,22 +10,52 @@ declare module '*.css' { declare let __webpack_public_path__: string; -interface Window { - config: import('./web_src/js/types.ts').Config; - $: typeof import('@types/jquery'), - jQuery: typeof import('@types/jquery'), - htmx: typeof import('htmx.org'), - _globalHandlerErrors: Array & { - _inited: boolean, - push: (e: ErrorEvent & PromiseRejectionEvent) => void | number, - }, -} - declare module 'htmx.org/dist/htmx.esm.js' { const value = await import('htmx.org'); export default value; } +declare module 'uint8-to-base64' { + export function encode(arrayBuffer: ArrayBuffer): string; + export function decode(base64str: string): ArrayBuffer; +} + +declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' { + const value = await import('swagger-ui-dist'); + export default value.SwaggerUIBundle; +} + +interface JQuery { + api: any, // fomantic + areYouSure: any, // jquery.are-you-sure + dimmer: any, // fomantic + dropdown: any; // fomantic + modal: any; // fomantic + tab: any; // fomantic + transition: any, // fomantic +} + +interface JQueryStatic { + api: any, // fomantic +} + interface Element { _tippy: import('tippy.js').Instance; } + +type Writable = { -readonly [K in keyof T]: T[K] }; + +interface Window { + config: import('./web_src/js/types.ts').Config; + $: typeof import('@types/jquery'), + jQuery: typeof import('@types/jquery'), + htmx: Omit & { + config?: Writable, + }, + ui?: any, + _globalHandlerErrors: Array & { + _inited: boolean, + push: (e: ErrorEvent & PromiseRejectionEvent) => void | number, + }, + __webpack_public_path__: string; +} diff --git a/web_src/js/htmx.ts b/web_src/js/htmx.ts index bfc2147736..d4f317ee5a 100644 --- a/web_src/js/htmx.ts +++ b/web_src/js/htmx.ts @@ -1,20 +1,21 @@ import {showErrorToast} from './modules/toast.ts'; +import 'idiomorph/dist/idiomorph-ext.js'; // https://github.com/bigskysoftware/idiomorph#htmx +import type {HtmxResponseInfo} from 'htmx.org'; -// https://github.com/bigskysoftware/idiomorph#htmx -import 'idiomorph/dist/idiomorph-ext.js'; +type HtmxEvent = Event & {detail: HtmxResponseInfo}; // https://htmx.org/reference/#config window.htmx.config.requestClass = 'is-loading'; window.htmx.config.scrollIntoViewOnBoost = false; // https://htmx.org/events/#htmx:sendError -document.body.addEventListener('htmx:sendError', (event) => { +document.body.addEventListener('htmx:sendError', (event: HtmxEvent) => { // TODO: add translations showErrorToast(`Network error when calling ${event.detail.requestConfig.path}`); }); // https://htmx.org/events/#htmx:responseError -document.body.addEventListener('htmx:responseError', (event) => { +document.body.addEventListener('htmx:responseError', (event: HtmxEvent) => { // TODO: add translations showErrorToast(`Error ${event.detail.xhr.status} when calling ${event.detail.requestConfig.path}`); }); diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 2bdc8655fe..db678a25ba 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -98,12 +98,12 @@ initGiteaFomantic(); initDirAuto(); initSubmitEventPolyfill(); -function callInitFunctions(functions) { +function callInitFunctions(functions: (() => any)[]) { // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1" // It is a quick check, no side effect so no need to do slow URL parsing. const initStart = performance.now(); if (window.location.search.includes('_ui_performance_trace=1')) { - let results = []; + let results: {name: string, dur: number}[] = []; for (const func of functions) { const start = performance.now(); func(); diff --git a/web_src/js/render/ansi.ts b/web_src/js/render/ansi.ts index bb622dd1eb..685e916c9a 100644 --- a/web_src/js/render/ansi.ts +++ b/web_src/js/render/ansi.ts @@ -1,12 +1,12 @@ import {AnsiUp} from 'ansi_up'; -const replacements = [ +const replacements: Array<[RegExp, string]> = [ [/\x1b\[\d+[A-H]/g, ''], // Move cursor, treat them as no-op [/\x1b\[\d?[JK]/g, '\r'], // Erase display/line, treat them as a Carriage Return ]; // render ANSI to HTML -export function renderAnsi(line) { +export function renderAnsi(line: string): string { // create a fresh ansi_up instance because otherwise previous renders can influence // the output of future renders, because ansi_up is stateful and remembers things like // unclosed opening tags for colors. diff --git a/web_src/js/standalone/swagger.ts b/web_src/js/standalone/swagger.ts index 2928813167..63b676b2ea 100644 --- a/web_src/js/standalone/swagger.ts +++ b/web_src/js/standalone/swagger.ts @@ -8,7 +8,7 @@ window.addEventListener('load', async () => { // Make the page's protocol be at the top of the schemes list const proto = window.location.protocol.slice(0, -1); - spec.schemes.sort((a, b) => { + spec.schemes.sort((a: string, b: string) => { if (a === proto) return -1; if (b === proto) return 1; return 0; diff --git a/web_src/js/svg.test.ts b/web_src/js/svg.test.ts index 015758a271..7f3e0496ec 100644 --- a/web_src/js/svg.test.ts +++ b/web_src/js/svg.test.ts @@ -17,7 +17,7 @@ test('svgParseOuterInner', () => { test('SvgIcon', () => { const root = document.createElement('div'); createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base', className: 'extra'})}).mount(root); - const node = root.firstChild; + const node = root.firstChild as Element; expect(node.nodeName).toEqual('svg'); expect(node.getAttribute('width')).toEqual('24'); expect(node.getAttribute('height')).toEqual('24'); diff --git a/web_src/js/types.ts b/web_src/js/types.ts index 3bd1c072a8..f3ac305162 100644 --- a/web_src/js/types.ts +++ b/web_src/js/types.ts @@ -29,3 +29,10 @@ export type RequestData = string | FormData | URLSearchParams; export type RequestOpts = { data?: RequestData, } & RequestInit; + +export type IssueData = { + owner: string, + repo: string, + type: string, + index: string, +} diff --git a/web_src/js/utils.test.ts b/web_src/js/utils.test.ts index 4c09f49ba8..55896706ff 100644 --- a/web_src/js/utils.test.ts +++ b/web_src/js/utils.test.ts @@ -95,23 +95,20 @@ test('toAbsoluteUrl', () => { }); test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => { - // TextEncoder is Node.js API while Uint8Array is jsdom API and their outputs are not - // structurally comparable, so we convert to array to compare. The conversion can be - // removed once https://github.com/jsdom/jsdom/issues/2524 is resolved. const encoder = new TextEncoder(); const uint8array = encoder.encode.bind(encoder); expect(encodeURLEncodedBase64(uint8array('AA?'))).toEqual('QUE_'); // standard base64: "QUE/" expect(encodeURLEncodedBase64(uint8array('AA~'))).toEqual('QUF-'); // standard base64: "QUF+" - expect(Array.from(decodeURLEncodedBase64('QUE/'))).toEqual(Array.from(uint8array('AA?'))); - expect(Array.from(decodeURLEncodedBase64('QUF+'))).toEqual(Array.from(uint8array('AA~'))); - expect(Array.from(decodeURLEncodedBase64('QUE_'))).toEqual(Array.from(uint8array('AA?'))); - expect(Array.from(decodeURLEncodedBase64('QUF-'))).toEqual(Array.from(uint8array('AA~'))); + expect(new Uint8Array(decodeURLEncodedBase64('QUE/'))).toEqual(uint8array('AA?')); + expect(new Uint8Array(decodeURLEncodedBase64('QUF+'))).toEqual(uint8array('AA~')); + expect(new Uint8Array(decodeURLEncodedBase64('QUE_'))).toEqual(uint8array('AA?')); + expect(new Uint8Array(decodeURLEncodedBase64('QUF-'))).toEqual(uint8array('AA~')); expect(encodeURLEncodedBase64(uint8array('a'))).toEqual('YQ'); // standard base64: "YQ==" - expect(Array.from(decodeURLEncodedBase64('YQ'))).toEqual(Array.from(uint8array('a'))); - expect(Array.from(decodeURLEncodedBase64('YQ=='))).toEqual(Array.from(uint8array('a'))); + expect(new Uint8Array(decodeURLEncodedBase64('YQ'))).toEqual(uint8array('a')); + expect(new Uint8Array(decodeURLEncodedBase64('YQ=='))).toEqual(uint8array('a')); }); test('file detection', () => { diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts index 2d40fa20a8..c52bf500d4 100644 --- a/web_src/js/utils.ts +++ b/web_src/js/utils.ts @@ -1,13 +1,14 @@ import {encode, decode} from 'uint8-to-base64'; +import type {IssueData} from './types.ts'; // transform /path/to/file.ext to file.ext -export function basename(path) { +export function basename(path: string): string { const lastSlashIndex = path.lastIndexOf('/'); return lastSlashIndex < 0 ? path : path.substring(lastSlashIndex + 1); } // transform /path/to/file.ext to .ext -export function extname(path) { +export function extname(path: string): string { const lastSlashIndex = path.lastIndexOf('/'); const lastPointIndex = path.lastIndexOf('.'); if (lastSlashIndex > lastPointIndex) return ''; @@ -15,54 +16,54 @@ export function extname(path) { } // test whether a variable is an object -export function isObject(obj) { +export function isObject(obj: any): boolean { return Object.prototype.toString.call(obj) === '[object Object]'; } // returns whether a dark theme is enabled -export function isDarkTheme() { +export function isDarkTheme(): boolean { const style = window.getComputedStyle(document.documentElement); return style.getPropertyValue('--is-dark-theme').trim().toLowerCase() === 'true'; } // strip from a string -export function stripTags(text) { +export function stripTags(text: string): string { return text.replace(/<[^>]*>?/g, ''); } -export function parseIssueHref(href) { +export function parseIssueHref(href: string): IssueData { const path = (href || '').replace(/[#?].*$/, ''); const [_, owner, repo, type, index] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || []; return {owner, repo, type, index}; } // parse a URL, either relative '/path' or absolute 'https://localhost/path' -export function parseUrl(str) { +export function parseUrl(str: string): URL { return new URL(str, str.startsWith('http') ? undefined : window.location.origin); } // return current locale chosen by user -export function getCurrentLocale() { +export function getCurrentLocale(): string { return document.documentElement.lang; } // given a month (0-11), returns it in the documents language -export function translateMonth(month) { +export function translateMonth(month: number) { return new Date(Date.UTC(2022, month, 12)).toLocaleString(getCurrentLocale(), {month: 'short', timeZone: 'UTC'}); } // given a weekday (0-6, Sunday to Saturday), returns it in the documents language -export function translateDay(day) { +export function translateDay(day: number) { return new Date(Date.UTC(2022, 7, day)).toLocaleString(getCurrentLocale(), {weekday: 'short', timeZone: 'UTC'}); } // convert a Blob to a DataURI -export function blobToDataURI(blob) { +export function blobToDataURI(blob: Blob): Promise { return new Promise((resolve, reject) => { try { const reader = new FileReader(); reader.addEventListener('load', (e) => { - resolve(e.target.result); + resolve(e.target.result as string); }); reader.addEventListener('error', () => { reject(new Error('FileReader failed')); @@ -75,7 +76,7 @@ export function blobToDataURI(blob) { } // convert image Blob to another mime-type format. -export function convertImage(blob, mime) { +export function convertImage(blob: Blob, mime: string): Promise { return new Promise(async (resolve, reject) => { try { const img = new Image(); @@ -104,7 +105,7 @@ export function convertImage(blob, mime) { }); } -export function toAbsoluteUrl(url) { +export function toAbsoluteUrl(url: string): string { if (url.startsWith('http://') || url.startsWith('https://')) { return url; } @@ -118,15 +119,15 @@ export function toAbsoluteUrl(url) { } // Encode an ArrayBuffer into a URLEncoded base64 string. -export function encodeURLEncodedBase64(arrayBuffer) { +export function encodeURLEncodedBase64(arrayBuffer: ArrayBuffer): string { return encode(arrayBuffer) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } -// Decode a URLEncoded base64 to an ArrayBuffer string. -export function decodeURLEncodedBase64(base64url) { +// Decode a URLEncoded base64 to an ArrayBuffer. +export function decodeURLEncodedBase64(base64url: string): ArrayBuffer { return decode(base64url .replace(/_/g, '/') .replace(/-/g, '+')); @@ -135,20 +136,22 @@ export function decodeURLEncodedBase64(base64url) { const domParser = new DOMParser(); const xmlSerializer = new XMLSerializer(); -export function parseDom(text, contentType) { +export function parseDom(text: string, contentType: DOMParserSupportedType): Document { return domParser.parseFromString(text, contentType); } -export function serializeXml(node) { +export function serializeXml(node: Element | Node): string { return xmlSerializer.serializeToString(node); } -export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} -export function isImageFile({name, type}) { +export function isImageFile({name, type}: {name: string, type?: string}): boolean { return /\.(jpe?g|png|gif|webp|svg|heic)$/i.test(name || '') || type?.startsWith('image/'); } -export function isVideoFile({name, type}) { +export function isVideoFile({name, type}: {name: string, type?: string}): boolean { return /\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/'); } diff --git a/web_src/js/utils/color.ts b/web_src/js/utils/color.ts index 3ee32395fb..a0409353d2 100644 --- a/web_src/js/utils/color.ts +++ b/web_src/js/utils/color.ts @@ -3,23 +3,23 @@ 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) { +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) { +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) { +export function contrastColor(backgroundColor: ColorInput): string { return useLightText(backgroundColor) ? '#fff' : '#000'; } -function resolveColors(obj: Record) { +function resolveColors(obj: Record): Record { 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)])); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 5fc2183194..7dd63ecbbf 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -266,10 +266,8 @@ export function initSubmitEventPolyfill() { /** * Check if an element is visible, equivalent to jQuery's `:visible` pseudo. * Note: This function doesn't account for all possible visibility scenarios. - * @param {HTMLElement} element The element to check. - * @returns {boolean} True if the element is visible. */ -export function isElemVisible(element: HTMLElement) { +export function isElemVisible(element: HTMLElement): boolean { if (!element) return false; return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); diff --git a/web_src/js/utils/image.ts b/web_src/js/utils/image.ts index c71d715941..558a63f22e 100644 --- a/web_src/js/utils/image.ts +++ b/web_src/js/utils/image.ts @@ -1,6 +1,11 @@ -export async function pngChunks(blob) { +type PngChunk = { + name: string, + data: Uint8Array, +} + +export async function pngChunks(blob: Blob): Promise { const uint8arr = new Uint8Array(await blob.arrayBuffer()); - const chunks = []; + const chunks: PngChunk[] = []; if (uint8arr.length < 12) return chunks; const view = new DataView(uint8arr.buffer); if (view.getBigUint64(0) !== 9894494448401390090n) return chunks; @@ -19,9 +24,14 @@ export async function pngChunks(blob) { 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) { +export async function imageInfo(blob: Blob): Promise { let width = 0, dppx = 1; // dppx: 1 dot per pixel for non-HiDPI screens if (blob.type === 'image/png') { // only png is supported currently diff --git a/web_src/js/utils/match.ts b/web_src/js/utils/match.ts index 17fdfed113..0ce7e2b1a2 100644 --- a/web_src/js/utils/match.ts +++ b/web_src/js/utils/match.ts @@ -2,17 +2,17 @@ import emojis from '../../../assets/emoji.json'; const maxMatches = 6; -function sortAndReduce(map) { +function sortAndReduce(map: Map) { 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) { +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(); + const results = new Map(); for (const {aliases} of emojis) { const mainAlias = aliases[0]; for (const [aliasIndex, alias] of aliases.entries()) { @@ -27,7 +27,7 @@ export function matchEmoji(queryText) { return sortAndReduce(results); } -export function matchMention(queryText) { +export function matchMention(queryText: string): string[] { const query = queryText.toLowerCase(); // results is a map of weights, lower is better diff --git a/web_src/js/utils/time.ts b/web_src/js/utils/time.ts index d3a986e736..5251386230 100644 --- a/web_src/js/utils/time.ts +++ b/web_src/js/utils/time.ts @@ -1,16 +1,17 @@ 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 startConfig The start date. Can take any type that `Date` accepts. - * @param endConfig The end date. Can take any type that `Date` accepts. + * @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, endDate) { +export function startDaysBetween(startDate: ConfigType, endDate: ConfigType): number[] { const start = dayjs.utc(startDate); const end = dayjs.utc(endDate); @@ -21,7 +22,7 @@ export function startDaysBetween(startDate, endDate) { current = current.add(1, 'day'); } - const startDays = []; + const startDays: number[] = []; while (current.isBefore(end)) { startDays.push(current.valueOf()); current = current.add(1, 'week'); @@ -30,7 +31,7 @@ export function startDaysBetween(startDate, endDate) { return startDays; } -export function firstStartDateAfterDate(inputDate) { +export function firstStartDateAfterDate(inputDate: Date): number { if (!(inputDate instanceof Date)) { throw new Error('Invalid date'); } @@ -41,7 +42,14 @@ export function firstStartDateAfterDate(inputDate) { return resultDate.valueOf(); } -export function fillEmptyStartDaysWithZeroes(startDays, data) { +type DayData = { + week: number, + additions: number, + deletions: number, + commits: number, +} + +export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayData): DayData[] { const result = {}; for (const startDay of startDays) { @@ -51,11 +59,11 @@ export function fillEmptyStartDaysWithZeroes(startDays, data) { return Object.values(result); } -let dateFormat; +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) { +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(), { diff --git a/web_src/js/utils/url.ts b/web_src/js/utils/url.ts index 470ece31b0..c5a28774a9 100644 --- a/web_src/js/utils/url.ts +++ b/web_src/js/utils/url.ts @@ -1,12 +1,12 @@ -export function pathEscapeSegments(s) { +export function pathEscapeSegments(s: string): string { return s.split('/').map(encodeURIComponent).join('/'); } -function stripSlash(url) { +function stripSlash(url: string): string { return url.endsWith('/') ? url.slice(0, -1) : url; } -export function isUrl(url) { +export function isUrl(url: string): boolean { try { return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim(); } catch { diff --git a/web_src/js/vitest.setup.ts b/web_src/js/vitest.setup.ts index 6fb0f5dc8f..68e300f551 100644 --- a/web_src/js/vitest.setup.ts +++ b/web_src/js/vitest.setup.ts @@ -1,10 +1,16 @@ window.__webpack_public_path__ = ''; window.config = { + appUrl: 'http://localhost:3000/', + appSubUrl: '', + assetVersionEncoded: '', + assetUrlPrefix: '', + runModeIsProd: true, + customEmojis: {}, csrfToken: 'test-csrf-token-123456', pageData: {}, - i18n: {}, - appSubUrl: '', + notificationSettings: {}, + enableTimeTracking: true, mentionValues: [ {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'}, @@ -14,4 +20,6 @@ window.config = { {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'}, ], + mermaidMaxSourceCharacters: 5000, + i18n: {}, };