diff options
| author | Ivan Gabaldon <igabaldon@inetol.net> | 2025-12-02 10:18:00 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-02 10:18:00 +0000 |
| commit | fb089ae297b27f51777318e3a28bca8b172a4165 (patch) | |
| tree | 293e17a6ba3a7ae17c31bc6746794b97c012c6af /client/simple/src/js/toolkit.ts | |
| parent | ab8224c9394236d2cbcf6ec7d9bf0d7c602ca6ac (diff) | |
[mod] client/simple: client plugins (#5406)
* [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
Diffstat (limited to 'client/simple/src/js/toolkit.ts')
| -rw-r--r-- | client/simple/src/js/toolkit.ts | 134 |
1 files changed, 134 insertions, 0 deletions
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<string, string>; + 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<Response> => { + 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 = <K extends keyof DocumentEventMap, E extends HTMLElement>( + type: string | K, + target: string | Document | E, + listener: (this: E, event: DocumentEventMap[K]) => void | Promise<void>, + 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(); |