summaryrefslogtreecommitdiff
path: root/client/simple/src/js
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
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')
-rw-r--r--client/simple/src/js/Plugin.ts66
-rw-r--r--client/simple/src/js/core/index.ts6
-rw-r--r--client/simple/src/js/core/listener.ts7
-rw-r--r--client/simple/src/js/core/nojs.ts8
-rw-r--r--client/simple/src/js/core/router.ts40
-rw-r--r--client/simple/src/js/index.ts4
-rw-r--r--client/simple/src/js/loader.ts36
-rw-r--r--client/simple/src/js/main/autocomplete.ts3
-rw-r--r--client/simple/src/js/main/infinite_scroll.ts100
-rw-r--r--client/simple/src/js/main/keyboard.ts3
-rw-r--r--client/simple/src/js/main/mapresult.ts86
-rw-r--r--client/simple/src/js/main/preferences.ts3
-rw-r--r--client/simple/src/js/main/results.ts3
-rw-r--r--client/simple/src/js/main/search.ts124
-rw-r--r--client/simple/src/js/pkg/ol.ts28
-rw-r--r--client/simple/src/js/plugin/Calculator.ts93
-rw-r--r--client/simple/src/js/plugin/InfiniteScroll.ts110
-rw-r--r--client/simple/src/js/plugin/MapView.ts90
-rw-r--r--client/simple/src/js/router.ts69
-rw-r--r--client/simple/src/js/toolkit.ts (renamed from client/simple/src/js/core/toolkit.ts)12
-rw-r--r--client/simple/src/js/util/appendAnswerElement.ts34
-rw-r--r--client/simple/src/js/util/assertElement.ts8
-rw-r--r--client/simple/src/js/util/getElement.ts21
23 files changed, 590 insertions, 364 deletions
diff --git a/client/simple/src/js/Plugin.ts b/client/simple/src/js/Plugin.ts
new file mode 100644
index 000000000..bd41ff909
--- /dev/null
+++ b/client/simple/src/js/Plugin.ts
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+/**
+ * Base class for client-side plugins.
+ *
+ * @remarks
+ * Handle conditional loading of the plugin in:
+ *
+ * - client/simple/src/js/router.ts
+ *
+ * @abstract
+ */
+export abstract class Plugin {
+ /**
+ * Plugin name.
+ */
+ protected readonly id: string;
+
+ /**
+ * @remarks
+ * Don't hold references of this instance outside the class.
+ */
+ protected constructor(id: string) {
+ this.id = id;
+
+ void this.invoke();
+ }
+
+ private async invoke(): Promise<void> {
+ try {
+ console.debug(`[PLUGIN] ${this.id}: Running...`);
+ const result = await this.run();
+ if (!result) return;
+
+ console.debug(`[PLUGIN] ${this.id}: Running post-exec...`);
+ // @ts-expect-error
+ void (await this.post(result as NonNullable<Awaited<ReturnType<this["run"]>>>));
+ } catch (error) {
+ console.error(`[PLUGIN] ${this.id}:`, error);
+ } finally {
+ console.debug(`[PLUGIN] ${this.id}: Done.`);
+ }
+ }
+
+ /**
+ * Plugin goes here.
+ *
+ * @remarks
+ * The plugin is already loaded at this point. If you wish to execute
+ * conditions to exit early, consider moving the logic to:
+ *
+ * - client/simple/src/js/router.ts
+ *
+ * ...to avoid unnecessarily loading this plugin on the client.
+ */
+ protected abstract run(): Promise<unknown>;
+
+ /**
+ * Post-execution hook.
+ *
+ * @remarks
+ * The hook is only executed if `#run()` returns a truthy value.
+ */
+ // @ts-expect-error
+ protected abstract post(result: NonNullable<Awaited<ReturnType<this["run"]>>>): Promise<void>;
+}
diff --git a/client/simple/src/js/core/index.ts b/client/simple/src/js/core/index.ts
deleted file mode 100644
index 59e64182c..000000000
--- a/client/simple/src/js/core/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import "./nojs.ts";
-import "./router.ts";
-import "./toolkit.ts";
-import "./listener.ts";
diff --git a/client/simple/src/js/core/listener.ts b/client/simple/src/js/core/listener.ts
deleted file mode 100644
index b8c0cbfd5..000000000
--- a/client/simple/src/js/core/listener.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import { listen } from "./toolkit.ts";
-
-listen("click", ".close", function (this: HTMLElement) {
- (this.parentNode as HTMLElement)?.classList.add("invisible");
-});
diff --git a/client/simple/src/js/core/nojs.ts b/client/simple/src/js/core/nojs.ts
deleted file mode 100644
index 65c62dd90..000000000
--- a/client/simple/src/js/core/nojs.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import { ready } from "./toolkit.ts";
-
-ready(() => {
- document.documentElement.classList.remove("no-js");
- document.documentElement.classList.add("js");
-});
diff --git a/client/simple/src/js/core/router.ts b/client/simple/src/js/core/router.ts
deleted file mode 100644
index bea838713..000000000
--- a/client/simple/src/js/core/router.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import { Endpoints, endpoint, ready, settings } from "./toolkit.ts";
-
-ready(
- () => {
- void import("../main/keyboard.ts");
- void import("../main/search.ts");
-
- if (settings.autocomplete) {
- void import("../main/autocomplete.ts");
- }
- },
- { on: [endpoint === Endpoints.index] }
-);
-
-ready(
- () => {
- void import("../main/keyboard.ts");
- void import("../main/mapresult.ts");
- void import("../main/results.ts");
- void import("../main/search.ts");
-
- if (settings.infinite_scroll) {
- void import("../main/infinite_scroll.ts");
- }
-
- if (settings.autocomplete) {
- void import("../main/autocomplete.ts");
- }
- },
- { on: [endpoint === Endpoints.results] }
-);
-
-ready(
- () => {
- void import("../main/preferences.ts");
- },
- { on: [endpoint === Endpoints.preferences] }
-);
diff --git a/client/simple/src/js/index.ts b/client/simple/src/js/index.ts
new file mode 100644
index 000000000..7a9802f4f
--- /dev/null
+++ b/client/simple/src/js/index.ts
@@ -0,0 +1,4 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// core
+void import.meta.glob(["./*.ts", "./util/**/.ts"], { eager: true });
diff --git a/client/simple/src/js/loader.ts b/client/simple/src/js/loader.ts
new file mode 100644
index 000000000..ed374a2d8
--- /dev/null
+++ b/client/simple/src/js/loader.ts
@@ -0,0 +1,36 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import type { Plugin } from "./Plugin.ts";
+import { type EndpointsKeys, endpoint } from "./toolkit.ts";
+
+type Options =
+ | {
+ on: "global";
+ }
+ | {
+ on: "endpoint";
+ where: EndpointsKeys[];
+ };
+
+export const load = <T extends Plugin>(instance: () => Promise<T>, options: Options): void => {
+ if (!check(options)) return;
+
+ void instance();
+};
+
+const check = (options: Options): boolean => {
+ // biome-ignore lint/style/useDefaultSwitchClause: options is typed
+ switch (options.on) {
+ case "global": {
+ return true;
+ }
+ case "endpoint": {
+ if (!options.where.includes(endpoint)) {
+ // not on the expected endpoint
+ return false;
+ }
+
+ return true;
+ }
+ }
+};
diff --git a/client/simple/src/js/main/autocomplete.ts b/client/simple/src/js/main/autocomplete.ts
index 57788dfd5..7da197ddf 100644
--- a/client/simple/src/js/main/autocomplete.ts
+++ b/client/simple/src/js/main/autocomplete.ts
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-import { assertElement, http, listen, settings } from "../core/toolkit.ts";
+import { http, listen, settings } from "../toolkit.ts";
+import { assertElement } from "../util/assertElement.ts";
const fetchResults = async (qInput: HTMLInputElement, query: string): Promise<void> => {
try {
diff --git a/client/simple/src/js/main/infinite_scroll.ts b/client/simple/src/js/main/infinite_scroll.ts
deleted file mode 100644
index b286bce37..000000000
--- a/client/simple/src/js/main/infinite_scroll.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import { assertElement, http, settings } from "../core/toolkit.ts";
-
-const newLoadSpinner = (): HTMLDivElement => {
- return Object.assign(document.createElement("div"), {
- className: "loader"
- });
-};
-
-const loadNextPage = async (onlyImages: boolean, callback: () => void): Promise<void> => {
- const searchForm = document.querySelector<HTMLFormElement>("#search");
- assertElement(searchForm);
-
- const form = document.querySelector<HTMLFormElement>("#pagination form.next_page");
- assertElement(form);
-
- const action = searchForm.getAttribute("action");
- if (!action) {
- throw new Error("Form action not defined");
- }
-
- const paginationElement = document.querySelector<HTMLElement>("#pagination");
- assertElement(paginationElement);
-
- paginationElement.replaceChildren(newLoadSpinner());
-
- try {
- const res = await http("POST", action, { body: new FormData(form) });
- const nextPage = await res.text();
- if (!nextPage) return;
-
- const nextPageDoc = new DOMParser().parseFromString(nextPage, "text/html");
- const articleList = nextPageDoc.querySelectorAll<HTMLElement>("#urls article");
- const nextPaginationElement = nextPageDoc.querySelector<HTMLElement>("#pagination");
-
- document.querySelector("#pagination")?.remove();
-
- const urlsElement = document.querySelector<HTMLElement>("#urls");
- if (!urlsElement) {
- throw new Error("URLs element not found");
- }
-
- if (articleList.length > 0 && !onlyImages) {
- // do not add <hr> element when there are only images
- urlsElement.appendChild(document.createElement("hr"));
- }
-
- urlsElement.append(...Array.from(articleList));
-
- if (nextPaginationElement) {
- const results = document.querySelector<HTMLElement>("#results");
- results?.appendChild(nextPaginationElement);
- callback();
- }
- } catch (error) {
- console.error("Error loading next page:", error);
-
- const errorElement = Object.assign(document.createElement("div"), {
- textContent: settings.translations?.error_loading_next_page ?? "Error loading next page",
- className: "dialog-error"
- });
- errorElement.setAttribute("role", "alert");
- document.querySelector("#pagination")?.replaceChildren(errorElement);
- }
-};
-
-const resultsElement: HTMLElement | null = document.getElementById("results");
-if (!resultsElement) {
- throw new Error("Results element not found");
-}
-
-const onlyImages: boolean = resultsElement.classList.contains("only_template_images");
-const observedSelector = "article.result:last-child";
-
-const intersectionObserveOptions: IntersectionObserverInit = {
- rootMargin: "320px"
-};
-
-const observer: IntersectionObserver = new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
- const [paginationEntry] = entries;
-
- if (paginationEntry?.isIntersecting) {
- observer.unobserve(paginationEntry.target);
-
- void loadNextPage(onlyImages, () => {
- const nextObservedElement = document.querySelector<HTMLElement>(observedSelector);
- if (nextObservedElement) {
- observer.observe(nextObservedElement);
- }
- }).then(() => {
- // wait until promise is resolved
- });
- }
-}, intersectionObserveOptions);
-
-const initialObservedElement: HTMLElement | null = document.querySelector<HTMLElement>(observedSelector);
-if (initialObservedElement) {
- observer.observe(initialObservedElement);
-}
diff --git a/client/simple/src/js/main/keyboard.ts b/client/simple/src/js/main/keyboard.ts
index f165a601a..7a8fa8e28 100644
--- a/client/simple/src/js/main/keyboard.ts
+++ b/client/simple/src/js/main/keyboard.ts
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-import { assertElement, listen, mutable, settings } from "../core/toolkit.ts";
+import { listen, mutable, settings } from "../toolkit.ts";
+import { assertElement } from "../util/assertElement.ts";
export type KeyBindingLayout = "default" | "vim";
diff --git a/client/simple/src/js/main/mapresult.ts b/client/simple/src/js/main/mapresult.ts
deleted file mode 100644
index 32bbec4fc..000000000
--- a/client/simple/src/js/main/mapresult.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import { listen } from "../core/toolkit.ts";
-
-listen("click", ".searxng_init_map", async function (this: HTMLElement, event: Event) {
- event.preventDefault();
- this.classList.remove("searxng_init_map");
-
- const {
- View,
- OlMap,
- TileLayer,
- VectorLayer,
- OSM,
- VectorSource,
- Style,
- Stroke,
- Fill,
- Circle,
- fromLonLat,
- GeoJSON,
- Feature,
- Point
- } = await import("../pkg/ol.ts");
- void import("ol/ol.css");
-
- const { leafletTarget: target, mapLon, mapLat, mapGeojson } = this.dataset;
-
- const lon = Number.parseFloat(mapLon || "0");
- const lat = Number.parseFloat(mapLat || "0");
- const view = new View({ maxZoom: 16, enableRotation: false });
- const map = new OlMap({
- target: target,
- layers: [new TileLayer({ source: new OSM({ maxZoom: 16 }) })],
- view: view
- });
-
- try {
- const markerSource = new VectorSource({
- features: [
- new Feature({
- geometry: new Point(fromLonLat([lon, lat]))
- })
- ]
- });
-
- const markerLayer = new VectorLayer({
- source: markerSource,
- style: new Style({
- image: new Circle({
- radius: 6,
- fill: new Fill({ color: "#3050ff" })
- })
- })
- });
-
- map.addLayer(markerLayer);
- } catch (error) {
- console.error("Failed to create marker layer:", error);
- }
-
- if (mapGeojson) {
- try {
- const geoSource = new VectorSource({
- features: new GeoJSON().readFeatures(JSON.parse(mapGeojson), {
- dataProjection: "EPSG:4326",
- featureProjection: "EPSG:3857"
- })
- });
-
- const geoLayer = new VectorLayer({
- source: geoSource,
- style: new Style({
- stroke: new Stroke({ color: "#3050ff", width: 2 }),
- fill: new Fill({ color: "#3050ff33" })
- })
- });
-
- map.addLayer(geoLayer);
-
- view.fit(geoSource.getExtent(), { padding: [20, 20, 20, 20] });
- } catch (error) {
- console.error("Failed to create GeoJSON layer:", error);
- }
- }
-});
diff --git a/client/simple/src/js/main/preferences.ts b/client/simple/src/js/main/preferences.ts
index 620370ee5..eb8974bf2 100644
--- a/client/simple/src/js/main/preferences.ts
+++ b/client/simple/src/js/main/preferences.ts
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-import { assertElement, http, listen, settings } from "../core/toolkit.ts";
+import { http, listen, settings } from "../toolkit.ts";
+import { assertElement } from "../util/assertElement.ts";
let engineDescriptions: Record<string, [string, string]> | undefined;
diff --git a/client/simple/src/js/main/results.ts b/client/simple/src/js/main/results.ts
index 42298f9f8..b59032a2c 100644
--- a/client/simple/src/js/main/results.ts
+++ b/client/simple/src/js/main/results.ts
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import "../../../node_modules/swiped-events/src/swiped-events.js";
-import { assertElement, listen, mutable, settings } from "../core/toolkit.ts";
+import { listen, mutable, settings } from "../toolkit.ts";
+import { assertElement } from "../util/assertElement.ts";
let imgTimeoutID: number;
diff --git a/client/simple/src/js/main/search.ts b/client/simple/src/js/main/search.ts
index c7ce9e090..eecc1afb0 100644
--- a/client/simple/src/js/main/search.ts
+++ b/client/simple/src/js/main/search.ts
@@ -1,88 +1,49 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-import { assertElement, listen, settings } from "../core/toolkit.ts";
+import { listen } from "../toolkit.ts";
+import { getElement } from "../util/getElement.ts";
-const submitIfQuery = (qInput: HTMLInputElement): void => {
- if (qInput.value.length > 0) {
- const search = document.getElementById("search") as HTMLFormElement | null;
- search?.submit();
- }
-};
-
-const updateClearButton = (qInput: HTMLInputElement, cs: HTMLElement): void => {
- cs.classList.toggle("empty", qInput.value.length === 0);
-};
-
-const createClearButton = (qInput: HTMLInputElement): void => {
- const cs = document.getElementById("clear_search");
- assertElement(cs);
-
- updateClearButton(qInput, cs);
-
- listen("click", cs, (event: MouseEvent) => {
- event.preventDefault();
- qInput.value = "";
- qInput.focus();
- updateClearButton(qInput, cs);
- });
-
- listen("input", qInput, () => updateClearButton(qInput, cs), { passive: true });
-};
-
-const qInput = document.getElementById("q") as HTMLInputElement | null;
-assertElement(qInput);
+const searchForm: HTMLFormElement = getElement<HTMLFormElement>("search");
+const searchInput: HTMLInputElement = getElement<HTMLInputElement>("q");
+const searchReset: HTMLButtonElement = getElement<HTMLButtonElement>("clear_search");
const isMobile: boolean = window.matchMedia("(max-width: 50em)").matches;
const isResultsPage: boolean = document.querySelector("main")?.id === "main_results";
+const categoryButtons: HTMLButtonElement[] = Array.from(
+ document.querySelectorAll<HTMLButtonElement>("#categories_container button.category")
+);
+
+if (searchInput.value.length === 0) {
+ searchReset.classList.add("empty");
+}
+
// focus search input on large screens
if (!(isMobile || isResultsPage)) {
- qInput.focus();
+ searchInput.focus();
}
// On mobile, move cursor to the end of the input on focus
if (isMobile) {
- listen("focus", qInput, () => {
+ listen("focus", searchInput, () => {
// Defer cursor move until the next frame to prevent a visual jump
requestAnimationFrame(() => {
- const end = qInput.value.length;
- qInput.setSelectionRange(end, end);
- qInput.scrollLeft = qInput.scrollWidth;
+ const end = searchInput.value.length;
+ searchInput.setSelectionRange(end, end);
+ searchInput.scrollLeft = searchInput.scrollWidth;
});
});
}
-createClearButton(qInput);
-
-// Additionally to searching when selecting a new category, we also
-// automatically start a new search request when the user changes a search
-// filter (safesearch, time range or language) (this requires JavaScript
-// though)
-if (
- settings.search_on_category_select &&
- // If .search_filters is undefined (invisible) we are on the homepage and
- // hence don't have to set any listeners
- document.querySelector(".search_filters")
-) {
- const safesearchElement = document.getElementById("safesearch");
- if (safesearchElement) {
- listen("change", safesearchElement, () => submitIfQuery(qInput));
- }
-
- const timeRangeElement = document.getElementById("time_range");
- if (timeRangeElement) {
- listen("change", timeRangeElement, () => submitIfQuery(qInput));
- }
+listen("input", searchInput, () => {
+ searchReset.classList.toggle("empty", searchInput.value.length === 0);
+});
- const languageElement = document.getElementById("language");
- if (languageElement) {
- listen("change", languageElement, () => submitIfQuery(qInput));
- }
-}
+listen("click", searchReset, () => {
+ searchReset.classList.add("empty");
+ searchInput.focus();
+});
-const categoryButtons: HTMLButtonElement[] = [
- ...document.querySelectorAll<HTMLButtonElement>("button.category_button")
-];
for (const button of categoryButtons) {
listen("click", button, (event: MouseEvent) => {
if (event.shiftKey) {
@@ -98,21 +59,34 @@ for (const button of categoryButtons) {
});
}
-const form: HTMLFormElement | null = document.querySelector<HTMLFormElement>("#search");
-assertElement(form);
+if (document.querySelector("div.search_filters")) {
+ const safesearchElement = document.getElementById("safesearch");
+ if (safesearchElement) {
+ listen("change", safesearchElement, () => searchForm.submit());
+ }
+
+ const timeRangeElement = document.getElementById("time_range");
+ if (timeRangeElement) {
+ listen("change", timeRangeElement, () => searchForm.submit());
+ }
-// override form submit action to update the actually selected categories
-listen("submit", form, (event: Event) => {
+ const languageElement = document.getElementById("language");
+ if (languageElement) {
+ listen("change", languageElement, () => searchForm.submit());
+ }
+}
+
+// override searchForm submit event
+listen("submit", searchForm, (event: Event) => {
event.preventDefault();
- const categoryValuesInput = document.querySelector<HTMLInputElement>("#selected-categories");
- if (categoryValuesInput) {
- const categoryValues = categoryButtons
+ if (categoryButtons.length > 0) {
+ const searchCategories = getElement<HTMLInputElement>("selected-categories");
+ searchCategories.value = categoryButtons
.filter((button) => button.classList.contains("selected"))
- .map((button) => button.name.replace("category_", ""));
-
- categoryValuesInput.value = categoryValues.join(",");
+ .map((button) => button.name.replace("category_", ""))
+ .join(",");
}
- form.submit();
+ searchForm.submit();
});
diff --git a/client/simple/src/js/pkg/ol.ts b/client/simple/src/js/pkg/ol.ts
deleted file mode 100644
index 28eed3c03..000000000
--- a/client/simple/src/js/pkg/ol.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import { Feature, Map as OlMap, View } from "ol";
-import { createEmpty } from "ol/extent";
-import { GeoJSON } from "ol/format";
-import { Point } from "ol/geom";
-import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
-import { fromLonLat } from "ol/proj";
-import { OSM, Vector as VectorSource } from "ol/source";
-import { Circle, Fill, Stroke, Style } from "ol/style";
-
-export {
- View,
- OlMap,
- TileLayer,
- VectorLayer,
- OSM,
- createEmpty,
- VectorSource,
- Style,
- Stroke,
- Fill,
- Circle,
- fromLonLat,
- GeoJSON,
- Feature,
- Point
-};
diff --git a/client/simple/src/js/plugin/Calculator.ts b/client/simple/src/js/plugin/Calculator.ts
new file mode 100644
index 000000000..95196a840
--- /dev/null
+++ b/client/simple/src/js/plugin/Calculator.ts
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import {
+ absDependencies,
+ addDependencies,
+ create,
+ divideDependencies,
+ eDependencies,
+ evaluateDependencies,
+ expDependencies,
+ factorialDependencies,
+ gcdDependencies,
+ lcmDependencies,
+ log1pDependencies,
+ log2Dependencies,
+ log10Dependencies,
+ logDependencies,
+ modDependencies,
+ multiplyDependencies,
+ nthRootDependencies,
+ piDependencies,
+ powDependencies,
+ roundDependencies,
+ signDependencies,
+ sqrtDependencies,
+ subtractDependencies
+} from "mathjs/number";
+import { Plugin } from "../Plugin.ts";
+import { appendAnswerElement } from "../util/appendAnswerElement.ts";
+import { getElement } from "../util/getElement.ts";
+
+/**
+ * Parses and solves mathematical expressions. Can do basic arithmetic and
+ * evaluate some functions.
+ *
+ * @example
+ * "(3 + 5) / 2" = "4"
+ * "e ^ 2 + pi" = "10.530648752520442"
+ * "gcd(48, 18) + lcm(4, 5)" = "26"
+ *
+ * @remarks
+ * Depends on `mathjs` library.
+ */
+export default class Calculator extends Plugin {
+ public constructor() {
+ super("calculator");
+ }
+
+ /**
+ * @remarks
+ * Compare bundle size after adding or removing features.
+ */
+ private static readonly math = create({
+ ...absDependencies,
+ ...addDependencies,
+ ...divideDependencies,
+ ...eDependencies,
+ ...evaluateDependencies,
+ ...expDependencies,
+ ...factorialDependencies,
+ ...gcdDependencies,
+ ...lcmDependencies,
+ ...log10Dependencies,
+ ...log1pDependencies,
+ ...log2Dependencies,
+ ...logDependencies,
+ ...modDependencies,
+ ...multiplyDependencies,
+ ...nthRootDependencies,
+ ...piDependencies,
+ ...powDependencies,
+ ...roundDependencies,
+ ...signDependencies,
+ ...sqrtDependencies,
+ ...subtractDependencies
+ });
+
+ protected async run(): Promise<string | undefined> {
+ const searchInput = getElement<HTMLInputElement>("q");
+ const node = Calculator.math.parse(searchInput.value);
+
+ try {
+ return `${node.toString()} = ${node.evaluate()}`;
+ } catch {
+ // not a compatible math expression
+ return;
+ }
+ }
+
+ protected async post(result: string): Promise<void> {
+ appendAnswerElement(result);
+ }
+}
diff --git a/client/simple/src/js/plugin/InfiniteScroll.ts b/client/simple/src/js/plugin/InfiniteScroll.ts
new file mode 100644
index 000000000..96e57d2f6
--- /dev/null
+++ b/client/simple/src/js/plugin/InfiniteScroll.ts
@@ -0,0 +1,110 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { Plugin } from "../Plugin.ts";
+import { http, settings } from "../toolkit.ts";
+import { assertElement } from "../util/assertElement.ts";
+import { getElement } from "../util/getElement.ts";
+
+/**
+ * Automatically loads the next page when scrolling to bottom of the current page.
+ */
+export default class InfiniteScroll extends Plugin {
+ public constructor() {
+ super("infiniteScroll");
+ }
+
+ protected async run(): Promise<void> {
+ const resultsElement = getElement<HTMLElement>("results");
+
+ const onlyImages: boolean = resultsElement.classList.contains("only_template_images");
+ const observedSelector = "article.result:last-child";
+
+ const spinnerElement = document.createElement("div");
+ spinnerElement.className = "loader";
+
+ const loadNextPage = async (callback: () => void): Promise<void> => {
+ const searchForm = document.querySelector<HTMLFormElement>("#search");
+ assertElement(searchForm);
+
+ const form = document.querySelector<HTMLFormElement>("#pagination form.next_page");
+ assertElement(form);
+
+ const action = searchForm.getAttribute("action");
+ if (!action) {
+ throw new Error("Form action not defined");
+ }
+
+ const paginationElement = document.querySelector<HTMLElement>("#pagination");
+ assertElement(paginationElement);
+
+ paginationElement.replaceChildren(spinnerElement);
+
+ try {
+ const res = await http("POST", action, { body: new FormData(form) });
+ const nextPage = await res.text();
+ if (!nextPage) return;
+
+ const nextPageDoc = new DOMParser().parseFromString(nextPage, "text/html");
+ const articleList = nextPageDoc.querySelectorAll<HTMLElement>("#urls article");
+ const nextPaginationElement = nextPageDoc.querySelector<HTMLElement>("#pagination");
+
+ document.querySelector("#pagination")?.remove();
+
+ const urlsElement = document.querySelector<HTMLElement>("#urls");
+ if (!urlsElement) {
+ throw new Error("URLs element not found");
+ }
+
+ if (articleList.length > 0 && !onlyImages) {
+ // do not add <hr> element when there are only images
+ urlsElement.appendChild(document.createElement("hr"));
+ }
+
+ urlsElement.append(...articleList);
+
+ if (nextPaginationElement) {
+ const results = document.querySelector<HTMLElement>("#results");
+ results?.appendChild(nextPaginationElement);
+ callback();
+ }
+ } catch (error) {
+ console.error("Error loading next page:", error);
+
+ const errorElement = Object.assign(document.createElement("div"), {
+ textContent: settings.translations?.error_loading_next_page ?? "Error loading next page",
+ className: "dialog-error"
+ });
+ errorElement.setAttribute("role", "alert");
+ document.querySelector("#pagination")?.replaceChildren(errorElement);
+ }
+ };
+
+ const intersectionObserveOptions: IntersectionObserverInit = {
+ rootMargin: "320px"
+ };
+
+ const observer: IntersectionObserver = new IntersectionObserver(async (entries: IntersectionObserverEntry[]) => {
+ const [paginationEntry] = entries;
+
+ if (paginationEntry?.isIntersecting) {
+ observer.unobserve(paginationEntry.target);
+
+ await loadNextPage(() => {
+ const nextObservedElement = document.querySelector<HTMLElement>(observedSelector);
+ if (nextObservedElement) {
+ observer.observe(nextObservedElement);
+ }
+ });
+ }
+ }, intersectionObserveOptions);
+
+ const initialObservedElement: HTMLElement | null = document.querySelector<HTMLElement>(observedSelector);
+ if (initialObservedElement) {
+ observer.observe(initialObservedElement);
+ }
+ }
+
+ protected async post(): Promise<void> {
+ // noop
+ }
+}
diff --git a/client/simple/src/js/plugin/MapView.ts b/client/simple/src/js/plugin/MapView.ts
new file mode 100644
index 000000000..b1e199a57
--- /dev/null
+++ b/client/simple/src/js/plugin/MapView.ts
@@ -0,0 +1,90 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import "ol/ol.css?inline";
+import { Feature, Map as OlMap, View } from "ol";
+import { GeoJSON } from "ol/format";
+import { Point } from "ol/geom";
+import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
+import { fromLonLat } from "ol/proj";
+import { OSM, Vector as VectorSource } from "ol/source";
+import { Circle, Fill, Stroke, Style } from "ol/style";
+import { Plugin } from "../Plugin.ts";
+
+/**
+ * MapView
+ */
+export default class MapView extends Plugin {
+ private readonly map: HTMLElement;
+
+ public constructor(map: HTMLElement) {
+ super("mapView");
+
+ this.map = map;
+ }
+
+ protected async run(): Promise<void> {
+ const { leafletTarget: target, mapLon, mapLat, mapGeojson } = this.map.dataset;
+
+ const lon = Number.parseFloat(mapLon || "0");
+ const lat = Number.parseFloat(mapLat || "0");
+ const view = new View({ maxZoom: 16, enableRotation: false });
+ const map = new OlMap({
+ target: target,
+ layers: [new TileLayer({ source: new OSM({ maxZoom: 16 }) })],
+ view: view
+ });
+
+ try {
+ const markerSource = new VectorSource({
+ features: [
+ new Feature({
+ geometry: new Point(fromLonLat([lon, lat]))
+ })
+ ]
+ });
+
+ const markerLayer = new VectorLayer({
+ source: markerSource,
+ style: new Style({
+ image: new Circle({
+ radius: 6,
+ fill: new Fill({ color: "#3050ff" })
+ })
+ })
+ });
+
+ map.addLayer(markerLayer);
+ } catch (error) {
+ console.error("Failed to create marker layer:", error);
+ }
+
+ if (mapGeojson) {
+ try {
+ const geoSource = new VectorSource({
+ features: new GeoJSON().readFeatures(JSON.parse(mapGeojson), {
+ dataProjection: "EPSG:4326",
+ featureProjection: "EPSG:3857"
+ })
+ });
+
+ const geoLayer = new VectorLayer({
+ source: geoSource,
+ style: new Style({
+ stroke: new Stroke({ color: "#3050ff", width: 2 }),
+ fill: new Fill({ color: "#3050ff33" })
+ })
+ });
+
+ map.addLayer(geoLayer);
+
+ view.fit(geoSource.getExtent(), { padding: [20, 20, 20, 20] });
+ } catch (error) {
+ console.error("Failed to create GeoJSON layer:", error);
+ }
+ }
+ }
+
+ protected async post(): Promise<void> {
+ // noop
+ }
+}
diff --git a/client/simple/src/js/router.ts b/client/simple/src/js/router.ts
new file mode 100644
index 000000000..24abb64c3
--- /dev/null
+++ b/client/simple/src/js/router.ts
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { load } from "./loader.ts";
+import { Endpoints, endpoint, listen, ready, settings } from "./toolkit.ts";
+
+ready(() => {
+ document.documentElement.classList.remove("no-js");
+ document.documentElement.classList.add("js");
+
+ listen("click", ".close", function (this: HTMLElement) {
+ (this.parentNode as HTMLElement)?.classList.add("invisible");
+ });
+
+ listen("click", ".searxng_init_map", async function (this: HTMLElement, event: Event) {
+ event.preventDefault();
+ this.classList.remove("searxng_init_map");
+
+ load(() => import("./plugin/MapView.ts").then(({ default: Plugin }) => new Plugin(this)), {
+ on: "endpoint",
+ where: [Endpoints.results]
+ });
+ });
+
+ if (settings.plugins?.includes("infiniteScroll")) {
+ load(() => import("./plugin/InfiniteScroll.ts").then(({ default: Plugin }) => new Plugin()), {
+ on: "endpoint",
+ where: [Endpoints.results]
+ });
+ }
+
+ if (settings.plugins?.includes("calculator")) {
+ load(() => import("./plugin/Calculator.ts").then(({ default: Plugin }) => new Plugin()), {
+ on: "endpoint",
+ where: [Endpoints.results]
+ });
+ }
+});
+
+ready(
+ () => {
+ void import("./main/keyboard.ts");
+ void import("./main/search.ts");
+
+ if (settings.autocomplete) {
+ void import("./main/autocomplete.ts");
+ }
+ },
+ { on: [endpoint === Endpoints.index] }
+);
+
+ready(
+ () => {
+ void import("./main/keyboard.ts");
+ void import("./main/results.ts");
+ void import("./main/search.ts");
+
+ if (settings.autocomplete) {
+ void import("./main/autocomplete.ts");
+ }
+ },
+ { on: [endpoint === Endpoints.results] }
+);
+
+ready(
+ () => {
+ void import("./main/preferences.ts");
+ },
+ { on: [endpoint === Endpoints.preferences] }
+);
diff --git a/client/simple/src/js/core/toolkit.ts b/client/simple/src/js/toolkit.ts
index d80167aa5..2eaf1d02c 100644
--- a/client/simple/src/js/core/toolkit.ts
+++ b/client/simple/src/js/toolkit.ts
@@ -1,16 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-import type { KeyBindingLayout } from "../main/keyboard.ts";
+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;
- infinite_scroll?: boolean;
method?: "GET" | "POST";
query_in_title?: boolean;
results_on_new_tab?: boolean;
@@ -32,8 +32,6 @@ type ReadyOptions = {
on?: (boolean | undefined)[];
};
-type AssertElement = (element?: HTMLElement | null) => asserts element is HTMLElement;
-
export type EndpointsKeys = keyof typeof Endpoints;
export const Endpoints = {
@@ -73,12 +71,6 @@ const getSettings = (): Settings => {
}
};
-export const assertElement: AssertElement = (element?: HTMLElement | null): asserts element is HTMLElement => {
- if (!element) {
- throw new Error("Bad assertion: DOM element not found");
- }
-};
-
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);
diff --git a/client/simple/src/js/util/appendAnswerElement.ts b/client/simple/src/js/util/appendAnswerElement.ts
new file mode 100644
index 000000000..d21db3f48
--- /dev/null
+++ b/client/simple/src/js/util/appendAnswerElement.ts
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { getElement } from "./getElement.ts";
+
+export const appendAnswerElement = (element: HTMLElement | string | number): void => {
+ const results = getElement<HTMLDivElement>("results");
+
+ // ./searx/templates/elements/answers.html
+ let answers = getElement<HTMLDivElement>("answers", { assert: false });
+ if (!answers) {
+ // what is this?
+ const answersTitle = document.createElement("h4");
+ answersTitle.setAttribute("class", "title");
+ answersTitle.setAttribute("id", "answers-title");
+ answersTitle.textContent = "Answers : ";
+
+ answers = document.createElement("div");
+ answers.setAttribute("id", "answers");
+ answers.setAttribute("role", "complementary");
+ answers.setAttribute("aria-labelledby", "answers-title");
+ answers.appendChild(answersTitle);
+ }
+
+ if (!(element instanceof HTMLElement)) {
+ const span = document.createElement("span");
+ span.innerHTML = element.toString();
+ // biome-ignore lint/style/noParameterAssign: TODO
+ element = span;
+ }
+
+ answers.appendChild(element);
+
+ results.insertAdjacentElement("afterbegin", answers);
+};
diff --git a/client/simple/src/js/util/assertElement.ts b/client/simple/src/js/util/assertElement.ts
new file mode 100644
index 000000000..a362fcf8f
--- /dev/null
+++ b/client/simple/src/js/util/assertElement.ts
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+type AssertElement = <T>(element?: T | null) => asserts element is T;
+export const assertElement: AssertElement = <T>(element?: T | null): asserts element is T => {
+ if (!element) {
+ throw new Error("DOM element not found");
+ }
+};
diff --git a/client/simple/src/js/util/getElement.ts b/client/simple/src/js/util/getElement.ts
new file mode 100644
index 000000000..cfb2caf4e
--- /dev/null
+++ b/client/simple/src/js/util/getElement.ts
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { assertElement } from "./assertElement.ts";
+
+type Options = {
+ assert?: boolean;
+};
+
+export function getElement<T>(id: string, options?: { assert: true }): T;
+export function getElement<T>(id: string, options?: { assert: false }): T | null;
+export function getElement<T>(id: string, options: Options = {}): T | null {
+ options.assert ??= true;
+
+ const element = document.getElementById(id) as T | null;
+
+ if (options.assert) {
+ assertElement(element);
+ }
+
+ return element;
+}