[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`](1ad832b1dc/client/simple/src/js/toolkit.ts (L134))
array has the plugin id.

* [mod] client/simple: rebuild static
This commit is contained in:
Ivan Gabaldon
2025-12-02 10:18:00 +00:00
committed by GitHub
parent ab8224c939
commit fb089ae297
92 changed files with 962 additions and 881 deletions

View File

@@ -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>;
}

View File

@@ -1,6 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import "./nojs.ts";
import "./router.ts";
import "./toolkit.ts";
import "./listener.ts";

View File

@@ -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");
});

View File

@@ -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");
});

View File

@@ -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] }
);

View File

@@ -0,0 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// core
void import.meta.glob(["./*.ts", "./util/**/.ts"], { eager: true });

View File

@@ -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;
}
}
};

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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);
}
}
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
listen("input", searchInput, () => {
searchReset.classList.toggle("empty", searchInput.value.length === 0);
});
// 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));
}
listen("click", searchReset, () => {
searchReset.classList.add("empty");
searchInput.focus();
});
const timeRangeElement = document.getElementById("time_range");
if (timeRangeElement) {
listen("change", timeRangeElement, () => submitIfQuery(qInput));
}
const languageElement = document.getElementById("language");
if (languageElement) {
listen("change", languageElement, () => submitIfQuery(qInput));
}
}
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);
// override form submit action to update the actually selected categories
listen("submit", form, (event: Event) => {
event.preventDefault();
const categoryValuesInput = document.querySelector<HTMLInputElement>("#selected-categories");
if (categoryValuesInput) {
const categoryValues = categoryButtons
.filter((button) => button.classList.contains("selected"))
.map((button) => button.name.replace("category_", ""));
categoryValuesInput.value = categoryValues.join(",");
if (document.querySelector("div.search_filters")) {
const safesearchElement = document.getElementById("safesearch");
if (safesearchElement) {
listen("change", safesearchElement, () => searchForm.submit());
}
form.submit();
const timeRangeElement = document.getElementById("time_range");
if (timeRangeElement) {
listen("change", timeRangeElement, () => searchForm.submit());
}
const languageElement = document.getElementById("language");
if (languageElement) {
listen("change", languageElement, () => searchForm.submit());
}
}
// override searchForm submit event
listen("submit", searchForm, (event: Event) => {
event.preventDefault();
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_", ""))
.join(",");
}
searchForm.submit();
});

View File

@@ -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
};

View File

@@ -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);
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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] }
);

View File

@@ -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);

View File

@@ -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);
};

View File

@@ -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");
}
};

View File

@@ -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;
}

View File

@@ -1,19 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
iframe[src^="https://w.soundcloud.com"]
{
iframe[src^="https://w.soundcloud.com"] {
height: 120px;
}
iframe[src^="https://www.deezer.com"]
{
iframe[src^="https://www.deezer.com"] {
// The real size is 92px, but 94px are needed to avoid an inner scrollbar of
// the embedded HTML.
height: 94px;
}
iframe[src^="https://www.mixcloud.com"]
{
iframe[src^="https://www.mixcloud.com"] {
// the embedded player from mixcloud has some quirks: initial there is an
// issue with an image URL that is blocked since it is an a Cross-Origin
// request. The alternative text (<img alt='Mixcloud Logo'> then cause an
@@ -23,19 +20,16 @@ iframe[src^="https://www.mixcloud.com"]
height: 250px;
}
iframe[src^="https://bandcamp.com/EmbeddedPlayer"]
{
iframe[src^="https://bandcamp.com/EmbeddedPlayer"] {
// show playlist
height: 350px;
}
iframe[src^="https://bandcamp.com/EmbeddedPlayer/track"]
{
iframe[src^="https://bandcamp.com/EmbeddedPlayer/track"] {
// hide playlist
height: 120px;
}
iframe[src^="https://genius.com/songs"]
{
iframe[src^="https://genius.com/songs"] {
height: 65px;
}

View File

@@ -8,7 +8,7 @@
text-align: center;
.title {
background: url("../img/searxng.png") no-repeat;
background: url("./img/searxng.png") no-repeat;
min-height: 4rem;
margin: 4rem auto;
background-position: center;