From fb089ae297b27f51777318e3a28bca8b172a4165 Mon Sep 17 00:00:00 2001 From: Ivan Gabaldon Date: Tue, 2 Dec 2025 10:18:00 +0000 Subject: [mod] client/simple: client plugins (#5406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [mod] client/simple: client plugins Defines a new interface for client side *"plugins"* that coexist with server side plugin system. Each plugin (e.g., `InfiniteScroll`) extends the base `ts Plugin`. Client side plugins are independent and lazy‑loaded via `router.ts` when their `load()` conditions are met. On each navigation request, all applicable plugins are instanced. Since these are client side plugins, we can only invoke them once DOM is fully loaded. E.g. `Calculator` will not render a new `answer` block until fully loaded and executed. For some plugins, we might want to handle its availability in `settings.yml` and toggle in UI, like we do for server side plugins. In that case, we extend `py Plugin` instancing only the information and then checking client side if [`settings.plugins`](https://github.com/inetol/searxng/blob/1ad832b1dc33f3f388da361ff2459b05dc86a164/client/simple/src/js/toolkit.ts#L134) array has the plugin id. * [mod] client/simple: rebuild static --- client/simple/src/js/toolkit.ts | 134 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 client/simple/src/js/toolkit.ts (limited to 'client/simple/src/js/toolkit.ts') diff --git a/client/simple/src/js/toolkit.ts b/client/simple/src/js/toolkit.ts new file mode 100644 index 000000000..2eaf1d02c --- /dev/null +++ b/client/simple/src/js/toolkit.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { KeyBindingLayout } from "./main/keyboard.ts"; + +// synced with searx/webapp.py get_client_settings +type Settings = { + plugins?: string[]; + advanced_search?: boolean; + autocomplete?: string; + autocomplete_min?: number; + doi_resolver?: string; + favicon_resolver?: string; + hotkeys?: KeyBindingLayout; + method?: "GET" | "POST"; + query_in_title?: boolean; + results_on_new_tab?: boolean; + safesearch?: 0 | 1 | 2; + search_on_category_select?: boolean; + theme?: string; + theme_static_path?: string; + translations?: Record; + url_formatting?: "pretty" | "full" | "host"; +}; + +type HTTPOptions = { + body?: BodyInit; + timeout?: number; +}; + +type ReadyOptions = { + // all values must be truthy for the callback to be executed + on?: (boolean | undefined)[]; +}; + +export type EndpointsKeys = keyof typeof Endpoints; + +export const Endpoints = { + index: "index", + results: "results", + preferences: "preferences", + unknown: "unknown" +} as const; + +export const mutable = { + closeDetail: undefined as (() => void) | undefined, + scrollPageToSelected: undefined as (() => void) | undefined, + selectImage: undefined as ((resultElement: HTMLElement) => void) | undefined, + selectNext: undefined as ((openDetailView?: boolean) => void) | undefined, + selectPrevious: undefined as ((openDetailView?: boolean) => void) | undefined +}; + +const getEndpoint = (): EndpointsKeys => { + const metaEndpoint = document.querySelector('meta[name="endpoint"]')?.getAttribute("content"); + + if (metaEndpoint && metaEndpoint in Endpoints) { + return metaEndpoint as EndpointsKeys; + } + + return Endpoints.unknown; +}; + +const getSettings = (): Settings => { + const settings = document.querySelector("script[client_settings]")?.getAttribute("client_settings"); + if (!settings) return {}; + + try { + return JSON.parse(atob(settings)); + } catch (error) { + console.error("Failed to load client_settings:", error); + return {}; + } +}; + +export const http = async (method: string, url: string | URL, options?: HTTPOptions): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), options?.timeout ?? 30_000); + + const res = await fetch(url, { + body: options?.body, + method: method, + signal: controller.signal + }).finally(() => clearTimeout(timeoutId)); + if (!res.ok) { + throw new Error(res.statusText); + } + + return res; +}; + +export const listen = ( + type: string | K, + target: string | Document | E, + listener: (this: E, event: DocumentEventMap[K]) => void | Promise, + options?: AddEventListenerOptions +): void => { + if (typeof target !== "string") { + target.addEventListener(type, listener as EventListener, options); + return; + } + + document.addEventListener( + type, + (event: Event) => { + for (const node of event.composedPath()) { + if (node instanceof HTMLElement && node.matches(target)) { + try { + listener.call(node as E, event as DocumentEventMap[K]); + } catch (error) { + console.error(error); + } + break; + } + } + }, + options + ); +}; + +export const ready = (callback: () => void, options?: ReadyOptions): void => { + for (const condition of options?.on ?? []) { + if (!condition) { + return; + } + } + + if (document.readyState !== "loading") { + callback(); + } else { + listen("DOMContentLoaded", document, callback, { once: true }); + } +}; + +export const endpoint: EndpointsKeys = getEndpoint(); +export const settings: Settings = getSettings(); -- cgit v1.2.3