Refactor htmx and fetch-action related code (#37186)

This is the first step (the hardest part):

* repo file list last commit message lazy load
* admin server status monitor
* watch/unwatch (normal page, watchers page)
* star/unstar (normal page, watchers page)
* project view, delete column
* workflow dispatch, switch the branch
* commit page: load branches and tags referencing this commit

The legacy "data-redirect" attribute is removed, it only makes the page
reload (sometimes using an incorrect link).

Also did cleanup for some devtest pages.
This commit is contained in:
wxiaoguang
2026-04-14 02:53:55 +08:00
committed by GitHub
parent 6eae04241d
commit 6bcb666a9d
41 changed files with 457 additions and 242 deletions

View File

@@ -285,6 +285,6 @@ function initAdminNotice() {
}
}
await POST(this.getAttribute('data-link')!, {data});
window.location.href = this.getAttribute('data-redirect')!;
window.location.reload();
});
}

View File

@@ -1,73 +1,138 @@
import {request} from '../modules/fetch.ts';
import {GET, request} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts';
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {Idiomorph} from 'idiomorph';
import {parseDom} from '../utils.ts';
import {html} from '../utils/html.ts';
const {appSubUrl} = window.config;
type FetchActionOpts = {
method: string;
url: string;
headers?: HeadersInit;
body?: FormData;
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
successSync: string;
// null: no indicator
// empty string: the current element
// '.css-selector': find the element by selector
loadingIndicator: string | null;
};
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler
function fetchActionDoRedirect(redirect: string) {
const form = document.createElement('form');
const input = document.createElement('input');
form.method = 'post';
form.action = `${appSubUrl}/-/fetch-redirect`;
input.type = 'hidden';
input.name = 'redirect';
input.value = redirect;
form.append(input);
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
document.body.append(form);
form.submit();
}
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
const showErrorForResponse = (code: number, message: string) => {
showErrorToast(`Error ${code || 'request'}: ${message}`);
};
let respStatus = 0;
let respText = '';
try {
hideToastsAll();
const resp = await request(url, opt);
respStatus = resp.status;
respText = await resp.text();
const respJson = JSON.parse(respText);
if (respStatus === 200) {
let {redirect} = respJson;
redirect = redirect || actionElem.getAttribute('data-redirect');
ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading
if (redirect) {
fetchActionDoRedirect(redirect);
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
const loadingIndicatorElems = opt.loadingIndicator === null ? [] : (opt.loadingIndicator === '' ? [el] : document.querySelectorAll(opt.loadingIndicator));
for (const indicatorEl of loadingIndicatorElems) {
if (isLoading) {
if ('disabled' in indicatorEl) {
indicatorEl.disabled = true;
} else {
window.location.reload();
indicatorEl.classList.add('is-loading');
if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px');
}
return;
}
if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
showErrorForResponse(respStatus, respText);
}
} catch (e) {
if (e.name === 'SyntaxError') {
showErrorForResponse(respStatus, (respText || '').substring(0, 100));
} else if (e.name !== 'AbortError') {
console.error('fetchActionDoRequest error', e);
showErrorForResponse(respStatus, `${e}`);
if ('disabled' in indicatorEl) {
indicatorEl.disabled = false;
} else {
indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
}
}
}
actionElem.classList.remove('is-loading', 'loading-icon-2px');
}
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
e.preventDefault();
await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)});
async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
if (respJson?.redirect) {
fetchActionDoRedirect(respJson.redirect);
} else {
window.location.reload();
}
}
async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(respText) : null;
if (isRespJson) {
await handleFetchActionSuccessJson(el, respJson);
} else if (opt.successSync) {
await handleFetchActionSuccessSync(el, opt.successSync, respText);
} else {
showErrorToast(`Unsupported fetch action response, expected JSON but got: ${respText.substring(0, 200)}`);
}
}
async function handleFetchActionError(resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(await resp.text()) : null;
if (respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
showErrorToast(`Error ${resp.status} ${resp.statusText}. Response: ${respText.substring(0, 200)}`);
}
}
function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
let url = opt.url;
if ('name' in el && 'value' in el) {
// ref: https://htmx.org/attributes/hx-get/
// If the element with the hx-get attribute also has a value, this will be included as a parameter
const name = (el as HTMLInputElement).name;
const val = (el as HTMLInputElement).value;
const u = new URL(url, window.location.href);
if (name && !u.searchParams.has(name)) {
u.searchParams.set(name, val);
url = u.toString();
}
}
return url;
}
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
const attrIsLoading = 'data-fetch-is-loading';
if (el.getAttribute(attrIsLoading)) return;
if (!await confirmFetchAction(el)) return;
el.setAttribute(attrIsLoading, 'true');
toggleLoadingIndicator(el, opt, true);
try {
const url = buildFetchActionUrl(el, opt);
const headers = new Headers(opt.headers);
headers.set('X-Gitea-Fetch-Action', '1');
const resp = await request(url, {method: opt.method, body: opt.body, headers});
if (resp.ok) {
await handleFetchActionSuccess(el, opt, resp);
return;
}
await handleFetchActionError(resp);
} catch (e) {
if (e.name !== 'AbortError') {
console.error(`Fetch action request error:`, e);
showErrorToast(`Error: ${e.message ?? e}`);
}
} finally {
toggleLoadingIndicator(el, opt, false);
el.removeAttribute(attrIsLoading);
}
}
type SubmitFormFetchActionOpts = {
@@ -75,15 +140,8 @@ type SubmitFormFetchActionOpts = {
formData?: FormData;
};
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
if (formEl.classList.contains('is-loading')) return;
formEl.classList.add('is-loading');
if (formEl.clientHeight < 50) {
formEl.classList.add('loading-icon-2px');
}
const formMethod = formEl.getAttribute('method') || 'get';
function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts {
const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET';
const formActionUrl = formEl.getAttribute('action') || window.location.href;
const formData = opts.formData ?? new FormData(formEl);
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
@@ -92,11 +150,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
}
let reqUrl = formActionUrl;
const reqOpt = {
method: formMethod.toUpperCase(),
body: null as FormData | null,
};
if (formMethod.toLowerCase() === 'get') {
let reqBody: FormData | undefined;
if (formMethodUpper === 'GET') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
params.append(key, value as string);
@@ -107,25 +162,23 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
}
reqUrl += `?${params.toString()}`;
} else {
reqOpt.body = formData;
reqBody = formData;
}
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
return {
method: formMethodUpper,
url: reqUrl,
body: reqBody,
loadingIndicator: '', // for form submit, by default, the loading indicator is the whole form
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
};
}
async function onLinkActionClick(el: HTMLElement, e: Event) {
// A "link-action" can post AJAX request to its "data-url"
// Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading.
// If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action.
// Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog.
e.preventDefault();
const url = el.getAttribute('data-url')!;
const doRequest = async () => {
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});
if ('disabled' in el) el.disabled = false;
};
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
hideToastsAll();
await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
}
async function confirmFetchAction(el: HTMLElement) {
let elModal: HTMLElement | null = null;
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
if (dataModalConfirm.startsWith('#')) {
@@ -147,18 +200,179 @@ async function onLinkActionClick(el: HTMLElement, e: Event) {
});
}
}
if (!elModal) return true;
return await confirmModal(elModal);
}
if (!elModal) {
await doRequest();
async function performLinkFetchAction(el: HTMLElement) {
hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
url: el.getAttribute('data-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') || '', // by default, the link-action itself is the loading indicator
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
});
}
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
async function performFetchActionTriggerRequest(el: HTMLElement, triggerType: FetchActionTriggerType) {
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
const defaultLoadingIndicator = isUserInitiated ? '' : null;
if (isUserInitiated) hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'GET', // by default, the method is GET for fetch trigger action
url: el.getAttribute('data-fetch-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? defaultLoadingIndicator,
successSync: el.getAttribute('data-fetch-sync') ?? '$this', // by default, the response will replace the current element
});
}
async function handleFetchActionSuccessSync(el: HTMLElement, successSync: string, respText: string) {
const cmds = successSync.split(' ').map((s) => s.trim()).filter(Boolean) || [];
let target = el, replaceInner = false, useMorph = false;
for (const cmd of cmds) {
if (cmd === '$this') {
target = el;
} else if (cmd === '$body') {
target = document.body;
} else if (cmd === '$innerHTML') {
replaceInner = true;
} else if (cmd === '$morph') {
useMorph = true;
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
const selector = cmd.substring('$closest('.length, cmd.length - 1);
target = target.closest(selector) as HTMLElement;
} else {
target = target.querySelector(cmd) as HTMLElement;
}
}
if (useMorph) {
Idiomorph.morph(target, respText, {morphStyle: replaceInner ? 'innerHTML' : 'outerHTML'});
} else if (replaceInner) {
target.innerHTML = respText;
} else {
target.outerHTML = respText;
}
await fetchActionReloadOutdatedElements();
}
async function fetchActionReloadOutdatedElements() {
const outdatedElems: HTMLElement[] = [];
for (const outdated of document.querySelectorAll<HTMLElement>('[data-fetch-trigger~="fetch-reload"]')) {
if (!outdated.id) throw new Error(`Elements with "fetch-reload" trigger must have an id to be reloaded after fetch sync: ${outdated.outerHTML.substring(0, 100)}`);
outdatedElems.push(outdated);
}
if (!outdatedElems.length) return;
const resp = await GET(window.location.href);
if (!resp.ok) {
showErrorToast(`Failed to reload page content after fetch action: ${resp.status} ${resp.statusText}`);
return;
}
const newPageHtml = await resp.text();
const newPageDom = parseDom(newPageHtml, 'text/html');
for (const oldEl of outdatedElems) {
// eslint-disable-next-line unicorn/prefer-query-selector
const newEl = newPageDom.getElementById(oldEl.id);
if (newEl) {
oldEl.replaceWith(newEl);
} else {
oldEl.remove();
}
}
}
if (await confirmModal(elModal)) {
await doRequest();
function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
const interval = trigger.substring('every '.length);
const match = /^(\d+)(ms|s)$/.exec(interval);
if (!match) throw new Error(`Invalid interval format: ${interval}`);
const num = parseInt(match[1], 10), unit = match[2];
const intervalMs = unit === 's' ? num * 1000 : num;
const fn = async () => {
try {
await performFetchActionTriggerRequest(el, 'every');
} finally {
// only continue if the element is still in the document
if (document.contains(el)) {
setTimeout(fn, intervalMs);
}
}
};
setTimeout(fn, intervalMs);
}
function initFetchActionTrigger(el: HTMLElement) {
const trigger = el.getAttribute('data-fetch-trigger');
// this trigger is managed internally, only triggered after fetch sync success, not triggered by event or timer
if (trigger === 'fetch-reload') return;
if (trigger === 'load') {
performFetchActionTriggerRequest(el, trigger);
} else if (trigger === 'change') {
el.addEventListener('change', () => performFetchActionTriggerRequest(el, trigger));
} else if (trigger?.startsWith('every ')) {
initFetchActionTriggerEvery(el, trigger);
} else if (!trigger || trigger === 'click') {
el.addEventListener('click', (e) => {
e.preventDefault();
performFetchActionTriggerRequest(el, 'click');
});
} else {
throw new Error(`Unsupported fetch trigger: ${trigger}`);
}
}
export function initGlobalFetchAction() {
addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit);
addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick);
// "fetch-action" is a general approach for elements to trigger fetch requests:
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
//
// Attributes:
//
// * data-fetch-method: the HTTP method to use
// * default to "GET" for "data-fetch-url" actions, "POST" for "link-action" elements
// * this attribute is ignored, the method will be determined by the form's "method" attribute, and default to "GET"
//
// * data-fetch-url: the URL for the request
//
// * data-fetch-trigger: the event to trigger the fetch action, can be:
// * "click", "change" (user-initiated events)
// * "load" (triggered on page load)
// * "every 5s" (also support "ms" unit)
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
//
// * data-fetch-indicator: the loading indicator element selector
//
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
// "$" prefix means it is our private command (for special logic)
// * "" (empty string): replace the current element with the response
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
// * "$morph": use morph algorithm to update the target element
// * "$body #the-id .the-class": query the selector one by one from body
// * "$closest(tr) td": pseudo command can help to find the target element in a more flexible way
//
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
// * it can be a string for the content of the modal dialog
// * it has "-header" and "-content" variants to set the header and content of the confirm modal
// * it can refer an existing modal element by "#the-modal-id"
addDelegatedEventListener(document, 'submit', '.form-fetch-action', async (el: HTMLFormElement, e) => {
// "fetch-action" will use the form's data to send the request
e.preventDefault();
await submitFormFetchAction(el, {formSubmitter: submitEventSubmitter(e)});
});
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
// `<a class="link-action" data-url="...">` is a shorthand for
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="">`
e.preventDefault();
await performLinkFetchAction(el);
});
registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger);
}

View File

@@ -37,11 +37,6 @@ export function initCompWebHookEditor() {
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
this.classList.add('is-loading', 'disabled');
await POST(this.getAttribute('data-link')!);
setTimeout(() => {
const redirectUrl = this.getAttribute('data-redirect');
if (redirectUrl) {
window.location.href = redirectUrl;
}
}, 5000);
setTimeout(() => window.location.reload(), 5000);
});
}

View File

@@ -4,7 +4,7 @@ import {GET} from '../modules/fetch.ts';
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
loadingButton.classList.add('disabled');
try {
const res = await GET(loadingButton.getAttribute('data-fetch-url')!);
const res = await GET(loadingButton.getAttribute('data-url')!);
const data = await res.json();
hideElem(loadingButton);
addTags(area, data.tags);

View File

@@ -11,7 +11,6 @@ function initRepoPullRequestUpdate(el: HTMLElement) {
const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown')!;
prUpdateButton.addEventListener('click', async function (e) {
e.preventDefault();
const redirect = this.getAttribute('data-redirect');
this.classList.add('is-loading');
let response: Response | undefined;
try {
@@ -29,8 +28,6 @@ function initRepoPullRequestUpdate(el: HTMLElement) {
}
if (data?.redirect) {
window.location.href = data.redirect;
} else if (redirect) {
window.location.href = redirect;
} else {
window.location.reload();
}

View File

@@ -1,6 +1,5 @@
import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports
import 'idiomorph/htmx'; // eslint-disable-line no-restricted-imports
// Some users still use inline scripts and expect jQuery to be available globally.
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.

View File

@@ -185,5 +185,3 @@ document.body.addEventListener('htmx:responseError', (event) => {
// TODO: add translations
showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
});
document.dispatchEvent(new CustomEvent('gitea:index-ready'));

View File

@@ -1,20 +1,57 @@
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
import type {Toast} from './toast.ts';
import {registerGlobalInitFunc} from './observer.ts';
import {fomanticQuery} from './fomantic/base.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
type LevelMap = Record<string, (message: string) => Toast | null>;
export function initDevtest() {
registerGlobalInitFunc('initDevtestPage', () => {
const els = document.querySelectorAll('.toast-test-button');
if (!els.length) return;
function initDevtestPage() {
const toastButtons = document.querySelectorAll('.toast-test-button');
if (toastButtons.length) {
const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
for (const el of els) {
for (const el of toastButtons) {
el.addEventListener('click', () => {
const level = el.getAttribute('data-toast-level')!;
const message = el.getAttribute('data-toast-message')!;
levelMap[level](message);
});
}
});
}
const modalButtons = document.querySelector('.modal-buttons');
if (modalButtons) {
for (const el of document.querySelectorAll('.ui.modal:not([data-skip-button])')) {
const btn = createElementFromHTML(html`<button class="ui button">${el.id}</button`);
btn.addEventListener('click', () => fomanticQuery(el).modal('show'));
modalButtons.append(btn);
}
}
const sampleButtons = document.querySelectorAll('#devtest-button-samples button.ui.button');
if (sampleButtons.length) {
const buttonStyles = document.querySelectorAll<HTMLInputElement>('input[name*="button-style"]');
for (const elStyle of buttonStyles) {
elStyle.addEventListener('click', () => {
for (const btn of sampleButtons) {
for (const el of buttonStyles) {
if (el.value) btn.classList.toggle(el.value, el.checked);
}
}
});
}
const buttonStates = document.querySelectorAll<HTMLInputElement>('input[name*="button-state"]');
for (const elState of buttonStates) {
elState.addEventListener('click', () => {
for (const btn of sampleButtons) {
(btn as any)[elState.value] = elState.checked;
}
});
}
}
}
export function initDevtest() {
registerGlobalInitFunc('initDevtestPage', initDevtestPage);
}

View File

@@ -2,7 +2,7 @@ import {isObject} from '../utils.ts';
import type {RequestOpts} from '../types.ts';
// fetch wrapper, use below method name functions and the `data` option to pass in data
// which will automatically set an appropriate headers. For json content, only object
// which will automatically set an appropriate headers. For JSON content, only object
// and array types are currently supported.
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
let body: string | FormData | URLSearchParams | undefined;
@@ -14,17 +14,13 @@ export function request(url: string, {method = 'GET', data, headers = {}, ...oth
body = JSON.stringify(data);
}
const headersMerged = new Headers({
...(contentType && {'content-type': contentType}),
});
for (const [name, value] of Object.entries(headers)) {
headersMerged.set(name, value);
headers = new Headers(headers);
if (!headers.has('content-type') && contentType) {
headers.set('content-type', contentType);
}
return fetch(url, { // eslint-disable-line no-restricted-globals
method,
headers: headersMerged,
headers,
...other,
...(body && {body}),
});

View File

@@ -1,6 +1,6 @@
import {
dirname, basename, extname, isObject, stripTags, parseIssueHref,
parseUrl, translateMonth, translateDay, blobToDataURI,
translateMonth, translateDay, blobToDataURI,
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
urlQueryEscape,
} from './utils.ts';
@@ -68,18 +68,6 @@ test('parseRepoOwnerPathInfo', () => {
window.config.appSubUrl = '';
});
test('parseUrl', () => {
expect(parseUrl('').pathname).toEqual('/');
expect(parseUrl('/path').pathname).toEqual('/path');
expect(parseUrl('/path?search').pathname).toEqual('/path');
expect(parseUrl('/path?search').search).toEqual('?search');
expect(parseUrl('/path?search#hash').hash).toEqual('#hash');
expect(parseUrl('https://localhost/path').pathname).toEqual('/path');
expect(parseUrl('https://localhost/path?search').pathname).toEqual('/path');
expect(parseUrl('https://localhost/path?search').search).toEqual('?search');
expect(parseUrl('https://localhost/path?search#hash').hash).toEqual('#hash');
});
test('translateMonth', () => {
const originalLang = document.documentElement.lang;
document.documentElement.lang = 'en-US';

View File

@@ -84,11 +84,6 @@ export function parseIssuePageInfo(): IssuePageInfo {
};
}
/** parse a URL, either relative '/path' or absolute 'https://localhost/path' */
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(): string {
return document.documentElement.lang;