summaryrefslogtreecommitdiff
path: root/client/simple/src/js/toolkit.ts
diff options
context:
space:
mode:
authorIvan Gabaldon <igabaldon@inetol.net>2025-12-02 10:18:00 +0000
committerGitHub <noreply@github.com>2025-12-02 10:18:00 +0000
commitfb089ae297b27f51777318e3a28bca8b172a4165 (patch)
tree293e17a6ba3a7ae17c31bc6746794b97c012c6af /client/simple/src/js/toolkit.ts
parentab8224c9394236d2cbcf6ec7d9bf0d7c602ca6ac (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.ts134
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();