[mod] theme/simple: migrate codebase to TypeScript

TypeScript is a superset of JavaScript, converting the entire theme to
TypeScript allows us to receive much more feedback on possible issues made in
package updates or our own typos, furthermore, it allows to transpile properly
to lower specs. This PR couldn't be done in smaller commits, a lot of work
needed to make everything *work properly*:

- A browser baseline has been set that requires minimum **Chromium 93, Firefox
  92 and Safari 15** (proper visuals/operation on older browser versions is not
  guaranteed)
- LightningCSS now handles minification and prefix creation for CSS.
- All hardcoded polyfills and support for previous browser baseline versions
  have been removed.
- Convert codebase to TypeScript.
- Convert IIFE to ESM, handling globals with IIFE is cumbersome, ESM is the
  standard for virtually any use of JS nowadays.
- Vite now builds the theme without the need for `vite-plugin-static-copy`.
- `searxng.ready` now accepts an array of conditions for the callback to be
  executed.
- Replace `leaflet` with `ol` as there were some issues with proper Vite
  bundling.
- Merged `head` with `main` script, as head was too small now.
- Add `assertElement` to properly check the existence of critical DOM elements.
- `searxng.on` renamed to `searxng.listen` with some handling improvements.
This commit is contained in:
Ivan Gabaldon 2025-07-06 12:27:28 +02:00 committed by Markus Heiser
parent 4fb6105d69
commit 0b913053a7
39 changed files with 2614 additions and 2442 deletions

View file

@ -36,12 +36,24 @@ indent_size = 2
[*.js]
indent_size = 2
[*.ts]
indent_size = 2
[*.json]
indent_size = 2
insert_final_newline = ignore
[*.map]
indent_size = ignore
insert_final_newline = ignore
# Minified JavaScript files shouldn't be changed
[**.min.js]
[*.min.js]
indent_style = ignore
insert_final_newline = ignore
# Minified CSS files shouldn't be changed
[*.min.css]
indent_style = ignore
insert_final_newline = ignore

View file

@ -92,5 +92,8 @@ jobs:
- name: Setup venv
run: make V=1 install
- name: Lint
run: make themes.lint
- name: Build
run: make themes.all

2
.nvmrc
View file

@ -1 +1 @@
v23.5
24

View file

@ -1,4 +1,4 @@
nodejs 23.5.0
nodejs 24.3.0
python 3.13.1
shellcheck 0.10.0
sqlite 3.47.2
sqlite 3.47.2

View file

@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"$schema": "https://biomejs.dev/schemas/2.1.1/schema.json",
"files": {
"includes": ["**", "!dist/**", "!node_modules/**"],
"ignoreUnknown": true

File diff suppressed because it is too large Load diff

View file

@ -6,31 +6,43 @@
"type": "module",
"scripts": {
"build": "npm run build:icons && npm run build:vite",
"build:icons": "node theme_icons.js",
"build:icons": "node theme_icons.ts",
"build:vite": "vite build",
"clean": "rm -Rf node_modules",
"fix": "npm run fix:stylelint && npm run fix:biome && npm run fix:package",
"fix:biome": "biome check --write",
"fix:package": "sort-package-json --quiet",
"fix:stylelint": "stylelint --fix strict 'src/**/*.{scss,sass,less,styl}'",
"lint": "npm run lint:biome",
"lint:biome": "biome lint"
"lint": "npm run lint:biome && npm run lint:tsc",
"lint:biome": "biome lint",
"lint:tsc": "tsc --noEmit"
},
"browserslist": [
"Chrome >= 93",
"Firefox >= 92",
"Safari >= 15.4",
"not dead"
],
"devDependencies": {
"@biomejs/biome": "~2.0.6",
"@biomejs/biome": "~2.1.0",
"@types/node": "^24.0.0",
"browserslist": "^4.25.1",
"browserslist-to-esbuild": "^2.1.1",
"edge.js": "^6.2.1",
"ionicons": "^8.0.10",
"leaflet": "^1.9.4",
"less": "^4.3.0",
"lightningcss": "^1.30.1",
"normalize.css": "^8.0.1",
"sharp": "^0.34.2",
"ol": "^10.6.1",
"sharp": "^0.34.3",
"sort-package-json": "^3.4.0",
"stylelint": "^16.21.1",
"stylelint-config-standard-less": "^3.0.1",
"stylelint-prettier": "^5.0.3",
"svgo": "^4.0.0",
"swiped-events": "^1.2.0",
"vite": "^7.0.2",
"typescript": "~5.8.3",
"vite": "npm:rolldown-vite@~7.0.8",
"vite-plugin-static-copy": "^3.1.0"
}
}

View file

@ -1,19 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
((w, d) => {
// add data- properties
const getLastScriptElement = () => {
const scripts = d.getElementsByTagName("script");
return scripts[scripts.length - 1];
};
const script = d.currentScript || getLastScriptElement();
w.searxng = {
settings: JSON.parse(atob(script.getAttribute("client_settings")))
};
// update the css
const htmlElement = d.getElementsByTagName("html")[0];
htmlElement.classList.remove("no-js");
htmlElement.classList.add("js");
})(window, document);

View file

@ -1,178 +0,0 @@
/**
* @license
* (C) Copyright Contributors to the SearXNG project.
* (C) Copyright Contributors to the searx project (2014 - 2021).
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
window.searxng = ((w, d) => {
// not invented here toolkit with bugs fixed elsewhere
// purposes : be just good enough and as small as possible
// from https://plainjs.com/javascript/events/live-binding-event-handlers-14/
if (w.Element) {
((ElementPrototype) => {
ElementPrototype.matches =
ElementPrototype.matches ||
ElementPrototype.matchesSelector ||
ElementPrototype.webkitMatchesSelector ||
ElementPrototype.msMatchesSelector ||
function (selector) {
const nodes = (this.parentNode || this.document).querySelectorAll(selector);
let i = -1;
while (nodes[++i] && nodes[i] !== this);
return !!nodes[i];
};
})(Element.prototype);
}
function callbackSafe(callback, el, e) {
try {
callback.call(el, e);
} catch (exception) {
console.log(exception);
}
}
const searxng = window.searxng || {};
searxng.on = (obj, eventType, callback, useCapture) => {
useCapture = useCapture || false;
if (typeof obj !== "string") {
// obj HTMLElement, HTMLDocument
obj.addEventListener(eventType, callback, useCapture);
} else {
// obj is a selector
d.addEventListener(
eventType,
(e) => {
let el = e.target || e.srcElement;
let found = false;
while (el?.matches && el !== d) {
found = el.matches(obj);
if (found) break;
el = el.parentElement;
}
if (found) {
callbackSafe(callback, el, e);
}
},
useCapture
);
}
};
searxng.ready = (callback) => {
if (document.readyState !== "loading") {
callback.call(w);
} else {
w.addEventListener("DOMContentLoaded", callback.bind(w));
}
};
searxng.http = (method, url, data = null) =>
new Promise((resolve, reject) => {
try {
const req = new XMLHttpRequest();
req.open(method, url, true);
req.timeout = 20000;
// On load
req.onload = () => {
if (req.status === 200) {
resolve(req.response, req.responseType);
} else {
reject(Error(req.statusText));
}
};
// Handle network errors
req.onerror = () => {
reject(Error("Network Error"));
};
req.onabort = () => {
reject(Error("Transaction is aborted"));
};
req.ontimeout = () => {
reject(Error("Timeout"));
};
// Make the request
if (data) {
req.send(data);
} else {
req.send();
}
} catch (ex) {
reject(ex);
}
});
searxng.loadStyle = (src) => {
const path = `${searxng.settings.theme_static_path}/${src}`;
const id = `style_${src.replace(".", "_")}`;
let s = d.getElementById(id);
if (s === null) {
s = d.createElement("link");
s.setAttribute("id", id);
s.setAttribute("rel", "stylesheet");
s.setAttribute("type", "text/css");
s.setAttribute("href", path);
d.body.appendChild(s);
}
};
searxng.loadScript = (src, callback) => {
const path = `${searxng.settings.theme_static_path}/${src}`;
const id = `script_${src.replace(".", "_")}`;
let s = d.getElementById(id);
if (s === null) {
s = d.createElement("script");
s.setAttribute("id", id);
s.setAttribute("src", path);
s.onload = callback;
s.onerror = () => {
s.setAttribute("error", "1");
};
d.body.appendChild(s);
} else if (!s.hasAttribute("error")) {
try {
callback.apply(s, []);
} catch (exception) {
console.log(exception);
}
} else {
console.log(`callback not executed : script '${path}' not loaded.`);
}
};
searxng.insertBefore = (newNode, referenceNode) => {
referenceNode.parentNode.insertBefore(newNode, referenceNode);
};
searxng.insertAfter = (newNode, referenceNode) => {
referenceNode.parentNode.insertAfter(newNode, referenceNode.nextSibling);
};
searxng.on(".close", "click", function () {
this.parentNode.classList.add("invisible");
});
function getEndpoint() {
for (const className of d.getElementsByTagName("body")[0].classList.values()) {
if (className.endsWith("_endpoint")) {
return className.split("_")[0];
}
}
return "";
}
searxng.endpoint = getEndpoint();
return searxng;
})(window, document);

View file

@ -0,0 +1,118 @@
import type { KeyBindingLayout } from "./keyboard.ts";
type Settings = {
theme_static_path?: string;
method?: string;
hotkeys?: KeyBindingLayout;
infinite_scroll?: boolean;
autocomplete?: boolean;
autocomplete_min?: number;
search_on_category_select?: boolean;
translations?: Record<string, string>;
[key: string]: unknown;
};
type ReadyOptions = {
// all values must be truthy for the callback to be executed
on?: (boolean | undefined)[];
};
const getEndpoint = (): string => {
const endpointClass = Array.from(document.body.classList).find((className) => className.endsWith("_endpoint"));
return endpointClass?.split("_")[0] ?? "";
};
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 {};
}
};
type AssertElement = (element?: Element | null) => asserts element is Element;
export const assertElement: AssertElement = (element?: Element | null): asserts element is Element => {
if (!element) {
throw new Error("Bad assertion: DOM element not found");
}
};
export const searxng = {
// dynamic functions
closeDetail: undefined as (() => void) | undefined,
scrollPageToSelected: undefined as (() => void) | undefined,
selectImage: undefined as ((resultElement: Element) => void) | undefined,
selectNext: undefined as ((openDetailView?: boolean) => void) | undefined,
selectPrevious: undefined as ((openDetailView?: boolean) => void) | undefined,
endpoint: getEndpoint(),
http: async (method: string, url: string | URL, data?: BodyInit): Promise<Response> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const res = await fetch(url, {
body: data,
method,
signal: controller.signal
}).finally(() => clearTimeout(timeoutId));
if (!res.ok) {
throw new Error(res.statusText);
}
return res;
},
listen: <K extends keyof DocumentEventMap, E extends Element>(
type: string | K,
target: string | Document | E,
listener: (this: E, event: DocumentEventMap[K]) => 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 Element && node.matches(target)) {
try {
listener.call(node as E, event as DocumentEventMap[K]);
} catch (error) {
console.error(error);
}
break;
}
}
},
options
);
},
ready: (callback: () => void, options?: ReadyOptions): void => {
for (const condition of options?.on ?? []) {
if (!condition) {
return;
}
}
if (document.readyState !== "loading") {
callback();
} else {
searxng.listen("DOMContentLoaded", document, callback, { once: true });
}
},
settings: getSettings()
};
searxng.listen("click", ".close", function (this: Element) {
(this.parentNode as Element)?.classList.add("invisible");
});

View file

@ -0,0 +1,13 @@
/**
* @preserve (C) Copyright Contributors to the SearXNG project.
* @preserve (C) Copyright Contributors to the searx project (2014 - 2021).
* @license AGPL-3.0-or-later
*/
import "./00_toolkit.ts";
import "./infinite_scroll.ts";
import "./keyboard.ts";
import "./mapresult.ts";
import "./preferences.ts";
import "./results.ts";
import "./search.ts";

View file

@ -1,84 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/* global searxng */
searxng.ready(() => {
searxng.infinite_scroll_supported =
"IntersectionObserver" in window &&
"IntersectionObserverEntry" in window &&
"intersectionRatio" in window.IntersectionObserverEntry.prototype;
if (searxng.endpoint !== "results") {
return;
}
if (!searxng.infinite_scroll_supported) {
console.log("IntersectionObserver not supported");
return;
}
const d = document;
const onlyImages = d.getElementById("results").classList.contains("only_template_images");
function newLoadSpinner() {
const loader = d.createElement("div");
loader.classList.add("loader");
return loader;
}
function replaceChildrenWith(element, children) {
element.textContent = "";
children.forEach((child) => element.appendChild(child));
}
function loadNextPage(callback) {
const form = d.querySelector("#pagination form.next_page");
if (!form) {
return;
}
replaceChildrenWith(d.querySelector("#pagination"), [newLoadSpinner()]);
const formData = new FormData(form);
searxng
.http("POST", d.querySelector("#search").getAttribute("action"), formData)
.then((response) => {
const nextPageDoc = new DOMParser().parseFromString(response, "text/html");
const articleList = nextPageDoc.querySelectorAll("#urls article");
const paginationElement = nextPageDoc.querySelector("#pagination");
d.querySelector("#pagination").remove();
if (articleList.length > 0 && !onlyImages) {
// do not add <hr> element when there are only images
d.querySelector("#urls").appendChild(d.createElement("hr"));
}
articleList.forEach((articleElement) => {
d.querySelector("#urls").appendChild(articleElement);
});
if (paginationElement) {
d.querySelector("#results").appendChild(paginationElement);
callback();
}
})
.catch((err) => {
console.log(err);
const e = d.createElement("div");
e.textContent = searxng.settings.translations.error_loading_next_page;
e.classList.add("dialog-error");
e.setAttribute("role", "alert");
replaceChildrenWith(d.querySelector("#pagination"), [e]);
});
}
if (searxng.settings.infinite_scroll && searxng.infinite_scroll_supported) {
const intersectionObserveOptions = {
rootMargin: "20rem"
};
const observedSelector = "article.result:last-child";
const observer = new IntersectionObserver((entries) => {
const paginationEntry = entries[0];
if (paginationEntry.isIntersecting) {
observer.unobserve(paginationEntry.target);
loadNextPage(() => observer.observe(d.querySelector(observedSelector), intersectionObserveOptions));
}
});
observer.observe(d.querySelector(observedSelector), intersectionObserveOptions);
}
});

View file

@ -0,0 +1,108 @@
import { assertElement, searxng } from "./00_toolkit";
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 formData = new FormData(form);
const action = searchForm.getAttribute("action");
if (!action) {
console.error("Form action not found");
return;
}
const paginationElement = document.querySelector<HTMLElement>("#pagination");
assertElement(paginationElement);
paginationElement.replaceChildren(newLoadSpinner());
try {
const res = await searxng.http("POST", action, formData);
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) {
console.error("URLs element not found");
return;
}
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: searxng.settings.translations?.error_loading_next_page ?? "Error loading next page",
className: "dialog-error"
});
errorElement.setAttribute("role", "alert");
document.querySelector("#pagination")?.replaceChildren(errorElement);
}
};
searxng.ready(
() => {
const resultsElement = document.getElementById("results");
if (!resultsElement) {
console.error("Results element not found");
return;
}
const onlyImages = resultsElement.classList.contains("only_template_images");
const observedSelector = "article.result:last-child";
const intersectionObserveOptions: IntersectionObserverInit = {
rootMargin: "320px"
};
const observer = new IntersectionObserver(async (entries: IntersectionObserverEntry[]) => {
const [paginationEntry] = entries;
if (paginationEntry?.isIntersecting) {
observer.unobserve(paginationEntry.target);
await loadNextPage(onlyImages, () => {
const nextObservedElement = document.querySelector<HTMLElement>(observedSelector);
if (nextObservedElement) {
observer.observe(nextObservedElement);
}
});
}
}, intersectionObserveOptions);
const initialObservedElement = document.querySelector<HTMLElement>(observedSelector);
if (initialObservedElement) {
observer.observe(initialObservedElement);
}
},
{
on: [searxng.endpoint === "results", searxng.settings.infinite_scroll]
}
);

View file

@ -1,473 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* global searxng */
searxng.ready(() => {
function isElementInDetail(el) {
while (el !== undefined) {
if (el.classList.contains("detail")) {
return true;
}
if (el.classList.contains("result")) {
// we found a result, no need to go to the root of the document:
// el is not inside a <div class="detail"> element
return false;
}
el = el.parentNode;
}
return false;
}
function getResultElement(el) {
while (el !== undefined) {
if (el.classList.contains("result")) {
return el;
}
el = el.parentNode;
}
return undefined;
}
function isImageResult(resultElement) {
return resultElement?.classList.contains("result-images");
}
searxng.on(".result", "click", function (e) {
if (!isElementInDetail(e.target)) {
highlightResult(this)(true, true);
const resultElement = getResultElement(e.target);
if (isImageResult(resultElement)) {
e.preventDefault();
searxng.selectImage(resultElement);
}
}
});
searxng.on(
".result a",
"focus",
(e) => {
if (!isElementInDetail(e.target)) {
const resultElement = getResultElement(e.target);
if (resultElement && resultElement.getAttribute("data-vim-selected") === null) {
highlightResult(resultElement)(true);
}
if (isImageResult(resultElement)) {
searxng.selectImage(resultElement);
}
}
},
true
);
/* common base for layouts */
const baseKeyBinding = {
Escape: {
key: "ESC",
fun: removeFocus,
des: "remove focus from the focused input",
cat: "Control"
},
c: {
key: "c",
fun: copyURLToClipboard,
des: "copy url of the selected result to the clipboard",
cat: "Results"
},
h: {
key: "h",
fun: toggleHelp,
des: "toggle help window",
cat: "Other"
},
i: {
key: "i",
fun: searchInputFocus,
des: "focus on the search input",
cat: "Control"
},
n: {
key: "n",
fun: GoToNextPage(),
des: "go to next page",
cat: "Results"
},
o: {
key: "o",
fun: openResult(false),
des: "open search result",
cat: "Results"
},
p: {
key: "p",
fun: GoToPreviousPage(),
des: "go to previous page",
cat: "Results"
},
r: {
key: "r",
fun: reloadPage,
des: "reload page from the server",
cat: "Control"
},
t: {
key: "t",
fun: openResult(true),
des: "open the result in a new tab",
cat: "Results"
}
};
const keyBindingLayouts = {
default: Object.assign(
{
/* SearXNG layout */
ArrowLeft: {
key: "←",
fun: highlightResult("up"),
des: "select previous search result",
cat: "Results"
},
ArrowRight: {
key: "→",
fun: highlightResult("down"),
des: "select next search result",
cat: "Results"
}
},
baseKeyBinding
),
vim: Object.assign(
{
/* Vim-like Key Layout. */
b: {
key: "b",
fun: scrollPage(-window.innerHeight),
des: "scroll one page up",
cat: "Navigation"
},
f: {
key: "f",
fun: scrollPage(window.innerHeight),
des: "scroll one page down",
cat: "Navigation"
},
u: {
key: "u",
fun: scrollPage(-window.innerHeight / 2),
des: "scroll half a page up",
cat: "Navigation"
},
d: {
key: "d",
fun: scrollPage(window.innerHeight / 2),
des: "scroll half a page down",
cat: "Navigation"
},
g: {
key: "g",
fun: scrollPageTo(-document.body.scrollHeight, "top"),
des: "scroll to the top of the page",
cat: "Navigation"
},
v: {
key: "v",
fun: scrollPageTo(document.body.scrollHeight, "bottom"),
des: "scroll to the bottom of the page",
cat: "Navigation"
},
k: {
key: "k",
fun: highlightResult("up"),
des: "select previous search result",
cat: "Results"
},
j: {
key: "j",
fun: highlightResult("down"),
des: "select next search result",
cat: "Results"
},
y: {
key: "y",
fun: copyURLToClipboard,
des: "copy url of the selected result to the clipboard",
cat: "Results"
}
},
baseKeyBinding
)
};
const keyBindings = keyBindingLayouts[searxng.settings.hotkeys] || keyBindingLayouts.default;
searxng.on(document, "keydown", (e) => {
// check for modifiers so we don't break browser's hotkeys
if (
// biome-ignore lint/suspicious/noPrototypeBuiltins: FIXME: support for Chromium 93-87, Firefox 92-78, Safari 15.4-14
Object.prototype.hasOwnProperty.call(keyBindings, e.key) &&
!e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
!e.metaKey
) {
const tagName = e.target.tagName.toLowerCase();
if (e.key === "Escape") {
keyBindings[e.key].fun(e);
} else {
if (e.target === document.body || tagName === "a" || tagName === "button") {
e.preventDefault();
keyBindings[e.key].fun();
}
}
}
});
function highlightResult(which) {
return (noScroll, keepFocus) => {
let current = document.querySelector(".result[data-vim-selected]"),
effectiveWhich = which;
if (current === null) {
// no selection : choose the first one
current = document.querySelector(".result");
if (current === null) {
// no first one : there are no results
return;
}
// replace up/down actions by selecting first one
if (which === "down" || which === "up") {
effectiveWhich = current;
}
}
let next,
results = document.querySelectorAll(".result");
results = Array.from(results); // convert NodeList to Array for further use
if (typeof effectiveWhich !== "string") {
next = effectiveWhich;
} else {
switch (effectiveWhich) {
case "visible": {
const top = document.documentElement.scrollTop || document.body.scrollTop;
const bot = top + document.documentElement.clientHeight;
for (let i = 0; i < results.length; i++) {
next = results[i];
const etop = next.offsetTop;
const ebot = etop + next.clientHeight;
if (ebot <= bot && etop > top) {
break;
}
}
break;
}
case "down":
next = results[results.indexOf(current) + 1] || current;
break;
case "up":
next = results[results.indexOf(current) - 1] || current;
break;
case "bottom":
next = results[results.length - 1];
break;
// biome-ignore lint/complexity/noUselessSwitchCase: fallthrough is intended
case "top":
/* falls through */
default:
next = results[0];
}
}
if (next) {
current.removeAttribute("data-vim-selected");
next.setAttribute("data-vim-selected", "true");
if (!keepFocus) {
const link = next.querySelector("h3 a") || next.querySelector("a");
if (link !== null) {
link.focus();
}
}
if (!noScroll) {
scrollPageToSelected();
}
}
};
}
function reloadPage() {
document.location.reload(true);
}
function removeFocus(e) {
const tagName = e.target.tagName.toLowerCase();
if (document.activeElement && (tagName === "input" || tagName === "select" || tagName === "textarea")) {
document.activeElement.blur();
} else {
searxng.closeDetail();
}
}
function pageButtonClick(css_selector) {
return () => {
const button = document.querySelector(css_selector);
if (button) {
button.click();
}
};
}
function GoToNextPage() {
return pageButtonClick('nav#pagination .next_page button[type="submit"]');
}
function GoToPreviousPage() {
return pageButtonClick('nav#pagination .previous_page button[type="submit"]');
}
function scrollPageToSelected() {
const sel = document.querySelector(".result[data-vim-selected]");
if (sel === null) {
return;
}
const wtop = document.documentElement.scrollTop || document.body.scrollTop,
wheight = document.documentElement.clientHeight,
etop = sel.offsetTop,
ebot = etop + sel.clientHeight,
offset = 120;
// first element ?
if (sel.previousElementSibling === null && ebot < wheight) {
// set to the top of page if the first element
// is fully included in the viewport
window.scroll(window.scrollX, 0);
return;
}
if (wtop > etop - offset) {
window.scroll(window.scrollX, etop - offset);
} else {
const wbot = wtop + wheight;
if (wbot < ebot + offset) {
window.scroll(window.scrollX, ebot - wheight + offset);
}
}
}
function scrollPage(amount) {
return () => {
window.scrollBy(0, amount);
highlightResult("visible")();
};
}
function scrollPageTo(position, nav) {
return () => {
window.scrollTo(0, position);
highlightResult(nav)();
};
}
function searchInputFocus() {
window.scrollTo(0, 0);
const q = document.querySelector("#q");
q.focus();
if (q.setSelectionRange) {
const len = q.value.length;
q.setSelectionRange(len, len);
}
}
function openResult(newTab) {
return () => {
let link = document.querySelector(".result[data-vim-selected] h3 a");
if (link === null) {
link = document.querySelector(".result[data-vim-selected] > a");
}
if (link !== null) {
const url = link.getAttribute("href");
if (newTab) {
window.open(url);
} else {
window.location.href = url;
}
}
};
}
function initHelpContent(divElement) {
const categories = {};
for (const k in keyBindings) {
const key = keyBindings[k];
categories[key.cat] = categories[key.cat] || [];
categories[key.cat].push(key);
}
const sorted = Object.keys(categories).sort((a, b) => categories[b].length - categories[a].length);
if (sorted.length === 0) {
return;
}
let html = '<a href="#" class="close" aria-label="close" title="close">×</a>';
html += "<h3>How to navigate SearXNG with hotkeys</h3>";
html += "<table>";
for (let i = 0; i < sorted.length; i++) {
const cat = categories[sorted[i]];
const lastCategory = i === sorted.length - 1;
const first = i % 2 === 0;
if (first) {
html += "<tr>";
}
html += "<td>";
html += `<h4>${cat[0].cat}</h4>`;
html += '<ul class="list-unstyled">';
for (const cj in cat) {
html += `<li><kbd>${cat[cj].key}</kbd> ${cat[cj].des}</li>`;
}
html += "</ul>";
html += "</td>"; // col-sm-*
if (!first || lastCategory) {
html += "</tr>"; // row
}
}
html += "</table>";
divElement.innerHTML = html;
}
function toggleHelp() {
let helpPanel = document.querySelector("#vim-hotkeys-help");
if (helpPanel === undefined || helpPanel === null) {
// first call
helpPanel = document.createElement("div");
helpPanel.id = "vim-hotkeys-help";
helpPanel.className = "dialog-modal";
initHelpContent(helpPanel);
const body = document.getElementsByTagName("body")[0];
body.appendChild(helpPanel);
} else {
// toggle hidden
helpPanel.classList.toggle("invisible");
}
}
function copyURLToClipboard() {
const currentUrlElement = document.querySelector(".result[data-vim-selected] h3 a");
if (currentUrlElement === null) return;
const url = currentUrlElement.getAttribute("href");
navigator.clipboard.writeText(url);
}
searxng.scrollPageToSelected = scrollPageToSelected;
searxng.selectNext = highlightResult("down");
searxng.selectPrevious = highlightResult("up");
});

View file

@ -0,0 +1,469 @@
import { assertElement, searxng } from "./00_toolkit.ts";
export type KeyBindingLayout = "default" | "vim";
type KeyBinding = {
key: string;
fun: (event: KeyboardEvent) => void;
des: string;
cat: string;
};
/* common base for layouts */
const baseKeyBinding: Record<string, KeyBinding> = {
Escape: {
key: "ESC",
fun: (event) => removeFocus(event),
des: "remove focus from the focused input",
cat: "Control"
},
c: {
key: "c",
fun: () => copyURLToClipboard(),
des: "copy url of the selected result to the clipboard",
cat: "Results"
},
h: {
key: "h",
fun: () => toggleHelp(keyBindings),
des: "toggle help window",
cat: "Other"
},
i: {
key: "i",
fun: () => searchInputFocus(),
des: "focus on the search input",
cat: "Control"
},
n: {
key: "n",
fun: () => GoToNextPage(),
des: "go to next page",
cat: "Results"
},
o: {
key: "o",
fun: () => openResult(false),
des: "open search result",
cat: "Results"
},
p: {
key: "p",
fun: () => GoToPreviousPage(),
des: "go to previous page",
cat: "Results"
},
r: {
key: "r",
fun: () => reloadPage(),
des: "reload page from the server",
cat: "Control"
},
t: {
key: "t",
fun: () => openResult(true),
des: "open the result in a new tab",
cat: "Results"
}
};
const keyBindingLayouts: Record<KeyBindingLayout, Record<string, KeyBinding>> = {
// SearXNG layout
default: {
ArrowLeft: {
key: "←",
fun: () => highlightResult("up"),
des: "select previous search result",
cat: "Results"
},
ArrowRight: {
key: "→",
fun: () => highlightResult("down"),
des: "select next search result",
cat: "Results"
},
...baseKeyBinding
},
// Vim-like keyboard layout
vim: {
b: {
key: "b",
fun: () => scrollPage(-window.innerHeight),
des: "scroll one page up",
cat: "Navigation"
},
d: {
key: "d",
fun: () => scrollPage(window.innerHeight / 2),
des: "scroll half a page down",
cat: "Navigation"
},
f: {
key: "f",
fun: () => scrollPage(window.innerHeight),
des: "scroll one page down",
cat: "Navigation"
},
g: {
key: "g",
fun: () => scrollPageTo(-document.body.scrollHeight, "top"),
des: "scroll to the top of the page",
cat: "Navigation"
},
j: {
key: "j",
fun: () => highlightResult("down"),
des: "select next search result",
cat: "Results"
},
k: {
key: "k",
fun: () => highlightResult("up"),
des: "select previous search result",
cat: "Results"
},
u: {
key: "u",
fun: () => scrollPage(-window.innerHeight / 2),
des: "scroll half a page up",
cat: "Navigation"
},
v: {
key: "v",
fun: () => scrollPageTo(document.body.scrollHeight, "bottom"),
des: "scroll to the bottom of the page",
cat: "Navigation"
},
y: {
key: "y",
fun: () => copyURLToClipboard(),
des: "copy url of the selected result to the clipboard",
cat: "Results"
},
...baseKeyBinding
}
};
const keyBindings =
searxng.settings.hotkeys && searxng.settings.hotkeys in keyBindingLayouts
? keyBindingLayouts[searxng.settings.hotkeys]
: keyBindingLayouts.default;
const isElementInDetail = (element?: Element): boolean => {
const ancestor = element?.closest(".detail, .result");
return ancestor?.classList.contains("detail") ?? false;
};
const getResultElement = (element?: Element): Element | undefined => {
return element?.closest(".result") ?? undefined;
};
const isImageResult = (resultElement?: Element): boolean => {
return resultElement?.classList.contains("result-images") ?? false;
};
const highlightResult =
(which: string | Element) =>
(noScroll?: boolean, keepFocus?: boolean): void => {
let current = document.querySelector<HTMLElement>(".result[data-vim-selected]");
let effectiveWhich = which;
if (!current) {
// no selection : choose the first one
current = document.querySelector<HTMLElement>(".result");
if (!current) {
// no first one : there are no results
return;
}
// replace up/down actions by selecting first one
if (which === "down" || which === "up") {
effectiveWhich = current;
}
}
let next: Element | null | undefined = null;
const results = Array.from(document.querySelectorAll<HTMLElement>(".result"));
if (typeof effectiveWhich !== "string") {
next = effectiveWhich;
} else {
switch (effectiveWhich) {
case "visible": {
const top = document.documentElement.scrollTop || document.body.scrollTop;
const bot = top + document.documentElement.clientHeight;
for (let i = 0; i < results.length; i++) {
const element = results[i] as HTMLElement;
next = element;
const etop = element.offsetTop;
const ebot = etop + element.clientHeight;
if (ebot <= bot && etop > top) {
break;
}
}
break;
}
case "down":
next = results[results.indexOf(current) + 1] || current;
break;
case "up":
next = results[results.indexOf(current) - 1] || current;
break;
case "bottom":
next = results[results.length - 1];
break;
// biome-ignore lint/complexity/noUselessSwitchCase: fallthrough is intended
case "top":
default:
next = results[0];
}
}
if (next && current) {
current.removeAttribute("data-vim-selected");
next.setAttribute("data-vim-selected", "true");
if (!keepFocus) {
const link = next.querySelector<HTMLElement>("h3 a") || next.querySelector<HTMLElement>("a");
if (link) {
link.focus();
}
}
if (!noScroll) {
scrollPageToSelected();
}
}
};
const reloadPage = (): void => {
document.location.reload();
};
const removeFocus = (event: KeyboardEvent): void => {
const target = event.target as HTMLElement;
const tagName = target?.tagName?.toLowerCase();
if (document.activeElement && (tagName === "input" || tagName === "select" || tagName === "textarea")) {
(document.activeElement as HTMLElement).blur();
} else {
searxng.closeDetail?.();
}
};
const pageButtonClick = (css_selector: string): void => {
const button = document.querySelector<HTMLButtonElement>(css_selector);
if (button) {
button.click();
}
};
const GoToNextPage = () => {
pageButtonClick('nav#pagination .next_page button[type="submit"]');
};
const GoToPreviousPage = () => {
pageButtonClick('nav#pagination .previous_page button[type="submit"]');
};
const scrollPageToSelected = (): void => {
const sel = document.querySelector<HTMLElement>(".result[data-vim-selected]");
if (!sel) return;
const wtop = document.documentElement.scrollTop || document.body.scrollTop,
height = document.documentElement.clientHeight,
etop = sel.offsetTop,
ebot = etop + sel.clientHeight,
offset = 120;
// first element ?
if (!sel.previousElementSibling && ebot < height) {
// set to the top of page if the first element
// is fully included in the viewport
window.scroll(window.scrollX, 0);
return;
}
if (wtop > etop - offset) {
window.scroll(window.scrollX, etop - offset);
} else {
const wbot = wtop + height;
if (wbot < ebot + offset) {
window.scroll(window.scrollX, ebot - height + offset);
}
}
};
const scrollPage = (amount: number): void => {
window.scrollBy(0, amount);
highlightResult("visible")();
};
const scrollPageTo = (position: number, nav: string): void => {
window.scrollTo(0, position);
highlightResult(nav)();
};
const searchInputFocus = (): void => {
window.scrollTo(0, 0);
const q = document.querySelector<HTMLInputElement>("#q");
if (q) {
q.focus();
if (q.setSelectionRange) {
const len = q.value.length;
q.setSelectionRange(len, len);
}
}
};
const openResult = (newTab: boolean): void => {
let link = document.querySelector<HTMLAnchorElement>(".result[data-vim-selected] h3 a");
if (!link) {
link = document.querySelector<HTMLAnchorElement>(".result[data-vim-selected] > a");
}
if (!link) return;
const url = link.getAttribute("href");
if (url) {
if (newTab) {
window.open(url);
} else {
window.location.href = url;
}
}
};
const initHelpContent = (divElement: HTMLElement, keyBindings: typeof baseKeyBinding): void => {
const categories: Record<string, KeyBinding[]> = {};
for (const binding of Object.values(keyBindings)) {
const cat = binding.cat;
categories[cat] ??= [];
categories[cat].push(binding);
}
const sortedCategoryKeys = Object.keys(categories).sort(
(a, b) => (categories[b]?.length ?? 0) - (categories[a]?.length ?? 0)
);
let html = '<a href="#" class="close" aria-label="close" title="close">×</a>';
html += "<h3>How to navigate SearXNG with hotkeys</h3>";
html += "<table>";
for (const [i, categoryKey] of sortedCategoryKeys.entries()) {
const bindings = categories[categoryKey];
if (!bindings || bindings.length === 0) continue;
const isFirst = i % 2 === 0;
const isLast = i === sortedCategoryKeys.length - 1;
if (isFirst) {
html += "<tr>";
}
html += "<td>";
html += `<h4>${categoryKey}</h4>`;
html += '<ul class="list-unstyled">';
for (const binding of bindings) {
html += `<li><kbd>${binding.key}</kbd> ${binding.des}</li>`;
}
html += "</ul>";
html += "</td>";
if (!isFirst || isLast) {
html += "</tr>";
}
}
html += "</table>";
divElement.innerHTML = html;
};
const toggleHelp = (keyBindings: typeof baseKeyBinding): void => {
let helpPanel = document.querySelector<HTMLElement>("#vim-hotkeys-help");
if (!helpPanel) {
// first call
helpPanel = Object.assign(document.createElement("div"), {
id: "vim-hotkeys-help",
className: "dialog-modal"
});
initHelpContent(helpPanel, keyBindings);
const body = document.getElementsByTagName("body")[0];
if (body) {
body.appendChild(helpPanel);
}
} else {
// toggle hidden
helpPanel.classList.toggle("invisible");
}
};
const copyURLToClipboard = async (): Promise<void> => {
const currentUrlElement = document.querySelector<HTMLAnchorElement>(".result[data-vim-selected] h3 a");
assertElement(currentUrlElement);
const url = currentUrlElement.getAttribute("href");
if (url) {
await navigator.clipboard.writeText(url);
}
};
searxng.ready(() => {
searxng.listen("click", ".result", function (this: Element, event: Event) {
if (!isElementInDetail(event.target as Element)) {
highlightResult(this)(true, true);
const resultElement = getResultElement(event.target as Element);
if (resultElement && isImageResult(resultElement)) {
event.preventDefault();
searxng.selectImage?.(resultElement);
}
}
});
searxng.listen(
"focus",
".result a",
(event: Event) => {
if (!isElementInDetail(event.target as Element)) {
const resultElement = getResultElement(event.target as Element);
if (resultElement && !resultElement.getAttribute("data-vim-selected")) {
highlightResult(resultElement)(true);
}
if (resultElement && isImageResult(resultElement)) {
searxng.selectImage?.(resultElement);
}
}
},
{ capture: true }
);
searxng.listen("keydown", document, (event: KeyboardEvent) => {
// check for modifiers so we don't break browser's hotkeys
if (Object.hasOwn(keyBindings, event.key) && !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) {
const tagName = (event.target as Element)?.tagName?.toLowerCase();
if (event.key === "Escape") {
keyBindings[event.key]?.fun(event);
} else {
if (event.target === document.body || tagName === "a" || tagName === "button") {
event.preventDefault();
keyBindings[event.key]?.fun(event);
}
}
}
});
searxng.scrollPageToSelected = scrollPageToSelected;
searxng.selectNext = highlightResult("down");
searxng.selectPrevious = highlightResult("up");
});

View file

@ -1,77 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* global L */
((_w, _d, searxng) => {
searxng.ready(() => {
searxng.on(".searxng_init_map", "click", function (event) {
// no more request
this.classList.remove("searxng_init_map");
//
const leaflet_target = this.dataset.leafletTarget;
const map_lon = parseFloat(this.dataset.mapLon);
const map_lat = parseFloat(this.dataset.mapLat);
const map_zoom = parseFloat(this.dataset.mapZoom);
const map_boundingbox = JSON.parse(this.dataset.mapBoundingbox);
const map_geojson = JSON.parse(this.dataset.mapGeojson);
searxng.loadStyle("css/leaflet.css");
searxng.loadScript("js/leaflet.js", () => {
let map_bounds = null;
if (map_boundingbox) {
const southWest = L.latLng(map_boundingbox[0], map_boundingbox[2]);
const northEast = L.latLng(map_boundingbox[1], map_boundingbox[3]);
map_bounds = L.latLngBounds(southWest, northEast);
}
// init map
const map = L.map(leaflet_target);
// create the tile layer with correct attribution
const osmMapnikUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const osmMapnikAttrib = 'Map data © <a href="https://openstreetmap.org">OpenStreetMap</a> contributors';
const osmMapnik = new L.TileLayer(osmMapnikUrl, { minZoom: 1, maxZoom: 19, attribution: osmMapnikAttrib });
const osmWikimediaUrl = "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png";
const osmWikimediaAttrib =
'Wikimedia maps | Maps data © <a href="https://openstreetmap.org">OpenStreetMap contributors</a>';
const osmWikimedia = new L.TileLayer(osmWikimediaUrl, {
minZoom: 1,
maxZoom: 19,
attribution: osmWikimediaAttrib
});
// init map view
if (map_bounds) {
// TODO hack: https://github.com/Leaflet/Leaflet/issues/2021
// Still useful ?
setTimeout(() => {
map.fitBounds(map_bounds, {
maxZoom: 17
});
}, 0);
} else if (map_lon && map_lat) {
if (map_zoom) {
map.setView(new L.LatLng(map_lat, map_lon), map_zoom);
} else {
map.setView(new L.LatLng(map_lat, map_lon), 8);
}
}
map.addLayer(osmMapnik);
const baseLayers = {
"OSM Mapnik": osmMapnik,
"OSM Wikimedia": osmWikimedia
};
L.control.layers(baseLayers).addTo(map);
if (map_geojson) {
L.geoJson(map_geojson).addTo(map);
} /* else if(map_bounds) {
L.rectangle(map_bounds, {color: "#ff7800", weight: 3, fill:false}).addTo(map);
} */
});
// this event occur only once per element
event.preventDefault();
});
});
})(window, document, window.searxng);

View file

@ -0,0 +1,89 @@
import { searxng } from "./00_toolkit.ts";
searxng.ready(
() => {
searxng.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");
import("ol/ol.css");
const { leafletTarget: target, mapLon, mapLat, mapGeojson } = this.dataset;
const lon = parseFloat(mapLon || "0");
const lat = parseFloat(mapLat || "0");
const view = new View({ maxZoom: 16, enableRotation: false });
const map = new OlMap({
target,
layers: [new TileLayer({ source: new OSM({ maxZoom: 16 }) })],
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);
}
}
});
},
{ on: [searxng.endpoint === "results"] }
);

View file

@ -1,52 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
((_w, d, searxng) => {
if (searxng.endpoint !== "preferences") {
return;
}
searxng.ready(() => {
let engine_descriptions = null;
function load_engine_descriptions() {
if (engine_descriptions == null) {
searxng.http("GET", "engine_descriptions.json").then((content) => {
engine_descriptions = JSON.parse(content);
for (const [engine_name, description] of Object.entries(engine_descriptions)) {
const elements = d.querySelectorAll(`[data-engine-name="${engine_name}"] .engine-description`);
for (const element of elements) {
const source = ` (<i>${searxng.settings.translations.Source}:&nbsp;${description[1]}</i>)`;
element.innerHTML = description[0] + source;
}
}
});
}
}
for (const el of d.querySelectorAll("[data-engine-name]")) {
searxng.on(el, "mouseenter", load_engine_descriptions);
}
const enableAllEngines = d.querySelectorAll(".enable-all-engines");
const disableAllEngines = d.querySelectorAll(".disable-all-engines");
const engineToggles = d.querySelectorAll("tbody input[type=checkbox][class~=checkbox-onoff]");
const toggleEngines = (enable) => {
for (const el of engineToggles) {
// check if element visible, so that only engines of the current category are modified
if (el.offsetParent !== null) el.checked = !enable;
}
};
for (const el of enableAllEngines) {
searxng.on(el, "click", () => toggleEngines(true));
}
for (const el of disableAllEngines) {
searxng.on(el, "click", () => toggleEngines(false));
}
const copyHashButton = d.querySelector("#copy-hash");
searxng.on(copyHashButton, "click", (e) => {
e.preventDefault();
navigator.clipboard.writeText(copyHashButton.dataset.hash);
copyHashButton.innerText = copyHashButton.dataset.copiedText;
});
});
})(window, document, window.searxng);

View file

@ -0,0 +1,71 @@
import { searxng } from "./00_toolkit.ts";
const loadEngineDescriptions = async (): Promise<void> => {
let engineDescriptions: Record<string, [string, string]> | null = null;
try {
const res = await searxng.http("GET", "engine_descriptions.json");
engineDescriptions = await res.json();
} catch (error) {
console.error("Error fetching engineDescriptions:", error);
}
if (!engineDescriptions) return;
for (const [engine_name, [description, source]] of Object.entries(engineDescriptions)) {
const elements = document.querySelectorAll<HTMLElement>(`[data-engine-name="${engine_name}"] .engine-description`);
const sourceText = ` (<i>${searxng.settings.translations?.Source}:&nbsp;${source}</i>)`;
for (const element of elements) {
element.innerHTML = description + sourceText;
}
}
};
const toggleEngines = (enable: boolean, engineToggles: NodeListOf<HTMLInputElement>): void => {
for (const engineToggle of engineToggles) {
// check if element visible, so that only engines of the current category are modified
if (engineToggle.offsetParent) {
engineToggle.checked = !enable;
}
}
};
searxng.ready(
() => {
const engineElements = document.querySelectorAll<HTMLElement>("[data-engine-name]");
for (const engineElement of engineElements) {
searxng.listen("mouseenter", engineElement, loadEngineDescriptions);
}
const engineToggles = document.querySelectorAll<HTMLInputElement>(
"tbody input[type=checkbox][class~=checkbox-onoff]"
);
const enableAllEngines = document.querySelectorAll<HTMLElement>(".enable-all-engines");
for (const engine of enableAllEngines) {
searxng.listen("click", engine, () => toggleEngines(true, engineToggles));
}
const disableAllEngines = document.querySelectorAll<HTMLElement>(".disable-all-engines");
for (const engine of disableAllEngines) {
searxng.listen("click", engine, () => toggleEngines(false, engineToggles));
}
const copyHashButton = document.querySelector<HTMLElement>("#copy-hash");
if (copyHashButton) {
searxng.listen("click", copyHashButton, async (event: Event) => {
event.preventDefault();
const { copiedText, hash } = copyHashButton.dataset;
if (!copiedText || !hash) return;
try {
await navigator.clipboard.writeText(hash);
copyHashButton.innerText = copiedText;
} catch (error) {
console.error("Failed to copy hash:", error);
}
});
}
},
{ on: [searxng.endpoint === "preferences"] }
);

View file

@ -1,182 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
import "../../../node_modules/swiped-events/src/swiped-events.js";
((w, d, searxng) => {
if (searxng.endpoint !== "results") {
return;
}
searxng.ready(() => {
d.querySelectorAll("#urls img").forEach((img) =>
img.addEventListener(
"error",
() => {
// console.log("ERROR can't load: " + img.src);
img.src = `${window.searxng.settings.theme_static_path}/img/img_load_error.svg`;
},
{ once: true }
)
);
if (d.querySelector("#search_url button#copy_url")) {
d.querySelector("#search_url button#copy_url").style.display = "block";
}
searxng.on(".btn-collapse", "click", function () {
const btnLabelCollapsed = this.getAttribute("data-btn-text-collapsed");
const btnLabelNotCollapsed = this.getAttribute("data-btn-text-not-collapsed");
const target = this.getAttribute("data-target");
const targetElement = d.querySelector(target);
let html = this.innerHTML;
if (this.classList.contains("collapsed")) {
html = html.replace(btnLabelCollapsed, btnLabelNotCollapsed);
} else {
html = html.replace(btnLabelNotCollapsed, btnLabelCollapsed);
}
this.innerHTML = html;
this.classList.toggle("collapsed");
targetElement.classList.toggle("invisible");
});
searxng.on(".media-loader", "click", function () {
const target = this.getAttribute("data-target");
const iframe_load = d.querySelector(`${target} > iframe`);
const srctest = iframe_load.getAttribute("src");
if (srctest === null || srctest === undefined || srctest === false) {
iframe_load.setAttribute("src", iframe_load.getAttribute("data-src"));
}
});
searxng.on("#copy_url", "click", function () {
const target = this.parentElement.querySelector("pre");
navigator.clipboard.writeText(target.innerText);
this.innerText = this.dataset.copiedText;
});
// searxng.selectImage (gallery)
// -----------------------------
// setTimeout() ID, needed to cancel *last* loadImage
let imgTimeoutID;
// progress spinner, while an image is loading
const imgLoaderSpinner = d.createElement("div");
imgLoaderSpinner.classList.add("loader");
// singleton image object, which is used for all loading processes of a
// detailed image
const imgLoader = new Image();
const loadImage = (imgSrc, onSuccess) => {
// if defered image load exists, stop defered task.
if (imgTimeoutID) clearTimeout(imgTimeoutID);
// defer load of the detail image for 1 sec
imgTimeoutID = setTimeout(() => {
imgLoader.src = imgSrc;
}, 1000);
// set handlers in the on-properties
imgLoader.onload = () => {
onSuccess();
imgLoaderSpinner.remove();
};
imgLoader.onerror = () => {
imgLoaderSpinner.remove();
};
};
searxng.selectImage = (resultElement) => {
// add a class that can be evaluated in the CSS and indicates that the
// detail view is open
d.getElementById("results").classList.add("image-detail-open");
// add a hash to the browser history so that pressing back doesn't return
// to the previous page this allows us to dismiss the image details on
// pressing the back button on mobile devices
window.location.hash = "#image-viewer";
searxng.scrollPageToSelected();
// if there is none element given by the caller, stop here
if (!resultElement) return;
// find <img> object in the element, if there is none, stop here.
const img = resultElement.querySelector(".result-images-source img");
if (!img) return;
// <img src="" data-src="http://example.org/image.jpg">
const src = img.getAttribute("data-src");
// already loaded high-res image or no high-res image available
if (!src) return;
// use the image thumbnail until the image is fully loaded
const thumbnail = resultElement.querySelector(".image_thumbnail");
img.src = thumbnail.src;
// show a progress spinner
const detailElement = resultElement.querySelector(".detail");
detailElement.appendChild(imgLoaderSpinner);
// load full size image in background
loadImage(src, () => {
// after the singelton loadImage has loaded the detail image into the
// cache, it can be used in the origin <img> as src property.
img.src = src;
img.removeAttribute("data-src");
});
};
searxng.closeDetail = () => {
d.getElementById("results").classList.remove("image-detail-open");
// remove #image-viewer hash from url by navigating back
if (window.location.hash === "#image-viewer") window.history.back();
searxng.scrollPageToSelected();
};
searxng.on(".result-detail-close", "click", (e) => {
e.preventDefault();
searxng.closeDetail();
});
searxng.on(".result-detail-previous", "click", (e) => {
e.preventDefault();
searxng.selectPrevious(false);
});
searxng.on(".result-detail-next", "click", (e) => {
e.preventDefault();
searxng.selectNext(false);
});
// listen for the back button to be pressed and dismiss the image details when called
window.addEventListener("hashchange", () => {
if (window.location.hash !== "#image-viewer") searxng.closeDetail();
});
d.querySelectorAll(".swipe-horizontal").forEach((obj) => {
obj.addEventListener("swiped-left", () => {
searxng.selectNext(false);
});
obj.addEventListener("swiped-right", () => {
searxng.selectPrevious(false);
});
});
w.addEventListener(
"scroll",
() => {
const e = d.getElementById("backToTop"),
scrollTop = document.documentElement.scrollTop || document.body.scrollTop,
results = d.getElementById("results");
if (e !== null) {
if (scrollTop >= 100) {
results.classList.add("scrolling");
} else {
results.classList.remove("scrolling");
}
}
},
true
);
});
})(window, document, window.searxng);

View file

@ -0,0 +1,181 @@
import "../../../node_modules/swiped-events/src/swiped-events.js";
import { assertElement, searxng } from "./00_toolkit.ts";
const loadImage = (imgSrc: string, onSuccess: () => void): void => {
// singleton image object, which is used for all loading processes of a detailed image
const imgLoader = new Image();
// set handlers in the on-properties
imgLoader.onload = () => {
onSuccess();
};
imgLoader.src = imgSrc;
};
searxng.ready(
() => {
const imageThumbnails = document.querySelectorAll<HTMLImageElement>("#urls img.image_thumbnail");
for (const thumbnail of imageThumbnails) {
if (thumbnail.complete && thumbnail.naturalWidth === 0) {
thumbnail.src = `${searxng.settings.theme_static_path}/img/img_load_error.svg`;
}
thumbnail.onerror = () => {
thumbnail.src = `${searxng.settings.theme_static_path}/img/img_load_error.svg`;
};
}
const copyUrlButton = document.querySelector<HTMLButtonElement>("#search_url button#copy_url");
copyUrlButton?.style.setProperty("display", "block");
searxng.listen("click", ".btn-collapse", function (this: HTMLElement) {
const btnLabelCollapsed = this.getAttribute("data-btn-text-collapsed");
const btnLabelNotCollapsed = this.getAttribute("data-btn-text-not-collapsed");
const target = this.getAttribute("data-target");
if (!target || !btnLabelCollapsed || !btnLabelNotCollapsed) return;
const targetElement = document.querySelector<HTMLElement>(target);
assertElement(targetElement);
const isCollapsed = this.classList.contains("collapsed");
const newLabel = isCollapsed ? btnLabelNotCollapsed : btnLabelCollapsed;
const oldLabel = isCollapsed ? btnLabelCollapsed : btnLabelNotCollapsed;
this.innerHTML = this.innerHTML.replace(oldLabel, newLabel);
this.classList.toggle("collapsed");
targetElement.classList.toggle("invisible");
});
searxng.listen("click", ".media-loader", function (this: HTMLElement) {
const target = this.getAttribute("data-target");
if (!target) return;
const iframeLoad = document.querySelector<HTMLIFrameElement>(`${target} > iframe`);
assertElement(iframeLoad);
const srctest = iframeLoad.getAttribute("src");
if (!srctest) {
const dataSrc = iframeLoad.getAttribute("data-src");
if (dataSrc) {
iframeLoad.setAttribute("src", dataSrc);
}
}
});
searxng.listen("click", "#copy_url", async function (this: HTMLElement) {
const target = this.parentElement?.querySelector<HTMLPreElement>("pre");
assertElement(target);
await navigator.clipboard.writeText(target.innerText);
const copiedText = this.dataset.copiedText;
if (copiedText) {
this.innerText = copiedText;
}
});
searxng.selectImage = (resultElement: Element): void => {
// add a class that can be evaluated in the CSS and indicates that the
// detail view is open
const resultsElement = document.getElementById("results");
resultsElement?.classList.add("image-detail-open");
// add a hash to the browser history so that pressing back doesn't return
// to the previous page this allows us to dismiss the image details on
// pressing the back button on mobile devices
window.location.hash = "#image-viewer";
searxng.scrollPageToSelected?.();
// if there is no element given by the caller, stop here
if (!resultElement) return;
// find image element, if there is none, stop here
const img = resultElement.querySelector<HTMLImageElement>(".result-images-source img");
if (!img) return;
// <img src="" data-src="http://example.org/image.jpg">
const src = img.getAttribute("data-src");
if (!src) return;
// use thumbnail until full image loads
const thumbnail = resultElement.querySelector<HTMLImageElement>(".image_thumbnail");
if (thumbnail) {
img.src = thumbnail.src;
}
// load full size image
loadImage(src, () => {
img.src = src;
img.onerror = () => {
img.src = `${searxng.settings.theme_static_path}/img/img_load_error.svg`;
};
img.removeAttribute("data-src");
});
};
searxng.closeDetail = (): void => {
const resultsElement = document.getElementById("results");
resultsElement?.classList.remove("image-detail-open");
// remove #image-viewer hash from url by navigating back
if (window.location.hash === "#image-viewer") {
window.history.back();
}
searxng.scrollPageToSelected?.();
};
searxng.listen("click", ".result-detail-close", (event: Event) => {
event.preventDefault();
searxng.closeDetail?.();
});
searxng.listen("click", ".result-detail-previous", (event: Event) => {
event.preventDefault();
searxng.selectPrevious?.(false);
});
searxng.listen("click", ".result-detail-next", (event: Event) => {
event.preventDefault();
searxng.selectNext?.(false);
});
// listen for the back button to be pressed and dismiss the image details when called
window.addEventListener("hashchange", () => {
if (window.location.hash !== "#image-viewer") {
searxng.closeDetail?.();
}
});
const swipeHorizontal = document.querySelectorAll<HTMLElement>(".swipe-horizontal");
for (const element of swipeHorizontal) {
searxng.listen("swiped-left", element, () => {
searxng.selectNext?.(false);
});
searxng.listen("swiped-right", element, () => {
searxng.selectPrevious?.(false);
});
}
window.addEventListener(
"scroll",
() => {
const backToTopElement = document.getElementById("backToTop");
const resultsElement = document.getElementById("results");
if (backToTopElement && resultsElement) {
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
const isScrolling = scrollTop >= 100;
resultsElement.classList.toggle("scrolling", isScrolling);
}
},
true
);
},
{ on: [searxng.endpoint === "results"] }
);

View file

@ -1,197 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* exported AutoComplete */
((_w, d, searxng) => {
const qinput_id = "q";
let qinput;
const isMobile = window.matchMedia("only screen and (max-width: 50em)").matches;
const isResultsPage = document.querySelector("main").id === "main_results";
function submitIfQuery() {
if (qinput.value.length > 0) {
const search = document.getElementById("search");
setTimeout(search.submit.bind(search), 0);
}
}
function createClearButton(qinput) {
const cs = document.getElementById("clear_search");
const updateClearButton = () => {
if (qinput.value.length === 0) {
cs.classList.add("empty");
} else {
cs.classList.remove("empty");
}
};
// update status, event listener
updateClearButton();
cs.addEventListener("click", (ev) => {
qinput.value = "";
qinput.focus();
updateClearButton();
ev.preventDefault();
});
qinput.addEventListener("input", updateClearButton, false);
}
const fetchResults = async (query) => {
let request;
if (searxng.settings.method === "GET") {
const reqParams = new URLSearchParams();
reqParams.append("q", query);
request = fetch(`./autocompleter?${reqParams.toString()}`);
} else {
const formData = new FormData();
formData.append("q", query);
request = fetch("./autocompleter", {
method: "POST",
body: formData
});
}
request.then(async (response) => {
const results = await response.json();
if (!results) return;
const autocomplete = d.querySelector(".autocomplete");
const autocompleteList = d.querySelector(".autocomplete ul");
autocomplete.classList.add("open");
autocompleteList.innerHTML = "";
// show an error message that no result was found
if (!results[1] || results[1].length === 0) {
const noItemFoundMessage = document.createElement("li");
noItemFoundMessage.classList.add("no-item-found");
noItemFoundMessage.innerHTML = searxng.settings.translations.no_item_found;
autocompleteList.appendChild(noItemFoundMessage);
return;
}
for (const result of results[1]) {
const li = document.createElement("li");
li.innerText = result;
searxng.on(li, "mousedown", () => {
qinput.value = result;
const form = d.querySelector("#search");
form.submit();
autocomplete.classList.remove("open");
});
autocompleteList.appendChild(li);
}
});
};
searxng.ready(() => {
// focus search input on large screens
if (!isMobile && !isResultsPage) document.getElementById("q").focus();
qinput = d.getElementById(qinput_id);
const autocomplete = d.querySelector(".autocomplete");
const autocompleteList = d.querySelector(".autocomplete ul");
if (qinput !== null) {
// clear button
createClearButton(qinput);
// autocompleter
if (searxng.settings.autocomplete) {
searxng.on(qinput, "input", () => {
const query = qinput.value;
if (query.length < searxng.settings.autocomplete_min) return;
setTimeout(() => {
if (query === qinput.value) fetchResults(query);
}, 300);
});
searxng.on(qinput, "keyup", (e) => {
let currentIndex = -1;
const listItems = autocompleteList.children;
for (let i = 0; i < listItems.length; i++) {
if (listItems[i].classList.contains("active")) {
currentIndex = i;
break;
}
}
let newCurrentIndex = -1;
if (e.key === "ArrowUp") {
if (currentIndex >= 0) listItems[currentIndex].classList.remove("active");
// we need to add listItems.length to the index calculation here because the JavaScript modulos
// operator doesn't work with negative numbers
newCurrentIndex = (currentIndex - 1 + listItems.length) % listItems.length;
} else if (e.key === "ArrowDown") {
if (currentIndex >= 0) listItems[currentIndex].classList.remove("active");
newCurrentIndex = (currentIndex + 1) % listItems.length;
} else if (e.key === "Tab" || e.key === "Enter") {
autocomplete.classList.remove("open");
}
if (newCurrentIndex !== -1) {
const selectedItem = listItems[newCurrentIndex];
selectedItem.classList.add("active");
if (!selectedItem.classList.contains("no-item-found")) qinput.value = selectedItem.innerText;
}
});
}
}
// 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 (
qinput !== null &&
searxng.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
d.querySelector(".search_filters") != null
) {
searxng.on(d.getElementById("safesearch"), "change", submitIfQuery);
searxng.on(d.getElementById("time_range"), "change", submitIfQuery);
searxng.on(d.getElementById("language"), "change", submitIfQuery);
}
const categoryButtons = d.querySelectorAll("button.category_button");
for (const button of categoryButtons) {
searxng.on(button, "click", (event) => {
if (event.shiftKey) {
event.preventDefault();
button.classList.toggle("selected");
return;
}
// manually deselect the old selection when a new category is selected
const selectedCategories = d.querySelectorAll("button.category_button.selected");
for (const categoryButton of selectedCategories) {
categoryButton.classList.remove("selected");
}
button.classList.add("selected");
});
}
// override form submit action to update the actually selected categories
const form = d.querySelector("#search");
if (form != null) {
searxng.on(form, "submit", (event) => {
event.preventDefault();
const categoryValuesInput = d.querySelector("#selected-categories");
if (categoryValuesInput) {
const categoryValues = [];
for (const categoryButton of categoryButtons) {
if (categoryButton.classList.contains("selected")) {
categoryValues.push(categoryButton.name.replace("category_", ""));
}
}
categoryValuesInput.value = categoryValues.join(",");
}
form.submit();
});
}
});
})(window, document, window.searxng);

View file

@ -0,0 +1,233 @@
import { assertElement, searxng } from "./00_toolkit.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);
searxng.listen("click", cs, (event: MouseEvent) => {
event.preventDefault();
qInput.value = "";
qInput.focus();
updateClearButton(qInput, cs);
});
searxng.listen("input", qInput, () => updateClearButton(qInput, cs), { passive: true });
};
const fetchResults = async (qInput: HTMLInputElement, query: string): Promise<void> => {
try {
let res: Response;
if (searxng.settings.method === "GET") {
res = await searxng.http("GET", `./autocompleter?q=${query}`);
} else {
res = await searxng.http("POST", "./autocompleter", new URLSearchParams({ q: query }));
}
const results = await res.json();
const autocomplete = document.querySelector<HTMLElement>(".autocomplete");
assertElement(autocomplete);
const autocompleteList = document.querySelector<HTMLUListElement>(".autocomplete ul");
assertElement(autocompleteList);
autocomplete.classList.add("open");
autocompleteList.replaceChildren();
// show an error message that no result was found
if (!results?.[1]?.length) {
const noItemFoundMessage = Object.assign(document.createElement("li"), {
className: "no-item-found",
textContent: searxng.settings.translations?.no_item_found ?? "No results found"
});
autocompleteList.append(noItemFoundMessage);
return;
}
const fragment = new DocumentFragment();
for (const result of results[1]) {
const li = Object.assign(document.createElement("li"), { textContent: result });
searxng.listen("mousedown", li, () => {
qInput.value = result;
const form = document.querySelector<HTMLFormElement>("#search");
form?.submit();
autocomplete.classList.remove("open");
});
fragment.append(li);
}
autocompleteList.append(fragment);
} catch (error) {
console.error("Error fetching autocomplete results:", error);
}
};
searxng.ready(
() => {
const qInput = document.getElementById("q") as HTMLInputElement | null;
assertElement(qInput);
const isMobile = window.matchMedia("(max-width: 50em)").matches;
const isResultsPage = document.querySelector("main")?.id === "main_results";
// focus search input on large screens
if (!isMobile && !isResultsPage) {
qInput.focus();
}
createClearButton(qInput);
// autocompleter
if (searxng.settings.autocomplete) {
let timeoutId: number;
searxng.listen("input", qInput, () => {
clearTimeout(timeoutId);
const query = qInput.value;
const minLength = searxng.settings.autocomplete_min ?? 2;
if (query.length < minLength) return;
timeoutId = window.setTimeout(async () => {
if (query === qInput.value) {
await fetchResults(qInput, query);
}
}, 300);
});
const autocomplete = document.querySelector<HTMLElement>(".autocomplete");
const autocompleteList = document.querySelector<HTMLUListElement>(".autocomplete ul");
if (autocompleteList) {
searxng.listen("keyup", qInput, (event: KeyboardEvent) => {
const listItems = [...autocompleteList.children] as HTMLElement[];
const currentIndex = listItems.findIndex((item) => item.classList.contains("active"));
let newCurrentIndex = -1;
switch (event.key) {
case "ArrowUp": {
const currentItem = listItems[currentIndex];
if (currentItem && currentIndex >= 0) {
currentItem.classList.remove("active");
}
// we need to add listItems.length to the index calculation here because the JavaScript modulos
// operator doesn't work with negative numbers
newCurrentIndex = (currentIndex - 1 + listItems.length) % listItems.length;
break;
}
case "ArrowDown": {
const currentItem = listItems[currentIndex];
if (currentItem && currentIndex >= 0) {
currentItem.classList.remove("active");
}
newCurrentIndex = (currentIndex + 1) % listItems.length;
break;
}
case "Tab":
case "Enter":
if (autocomplete) {
autocomplete.classList.remove("open");
}
break;
}
if (newCurrentIndex !== -1) {
const selectedItem = listItems[newCurrentIndex];
if (selectedItem) {
selectedItem.classList.add("active");
if (!selectedItem.classList.contains("no-item-found")) {
const qInput = document.getElementById("q") as HTMLInputElement | null;
if (qInput) {
qInput.value = selectedItem.textContent ?? "";
}
}
}
}
});
}
}
// 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 (
searxng.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) {
searxng.listen("change", safesearchElement, () => submitIfQuery(qInput));
}
const timeRangeElement = document.getElementById("time_range");
if (timeRangeElement) {
searxng.listen("change", timeRangeElement, () => submitIfQuery(qInput));
}
const languageElement = document.getElementById("language");
if (languageElement) {
searxng.listen("change", languageElement, () => submitIfQuery(qInput));
}
}
const categoryButtons = [...document.querySelectorAll<HTMLButtonElement>("button.category_button")];
for (const button of categoryButtons) {
searxng.listen("click", button, (event: MouseEvent) => {
if (event.shiftKey) {
event.preventDefault();
button.classList.toggle("selected");
return;
}
// deselect all other categories
for (const categoryButton of categoryButtons) {
categoryButton.classList.toggle("selected", categoryButton === button);
}
});
}
const form = document.querySelector<HTMLFormElement>("#search");
assertElement(form);
// override form submit action to update the actually selected categories
searxng.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(",");
}
form.submit();
});
},
{ on: [searxng.endpoint === "index" || searxng.endpoint === "results"] }
);

View file

@ -0,0 +1,26 @@
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

@ -1 +0,0 @@
import "./head/00_init.js";

View file

@ -1,7 +0,0 @@
import "./main/00_toolkit.js";
import "./main/infinite_scroll.js";
import "./main/keyboard.js";
import "./main/mapresult.js";
import "./main/preferences.js";
import "./main/results.js";
import "./main/search.js";

View file

@ -24,6 +24,7 @@
&::-moz-selection {
background: transparent; /* Gecko Browsers */
}
margin-right: 8px;
text-align: right;
}

View file

@ -4,40 +4,33 @@
import { dirname, resolve } from "node:path";
import { argv } from "node:process";
import { jinja_svg_sets } from "./tools/jinja_svg_catalog.js";
import type { Config as SvgoConfig } from "svgo";
import { type IconSet, type JinjaMacro, jinja_svg_sets } from "./tools/jinja_svg_catalog.ts";
const HERE = `${dirname(argv[1])}/`;
const HERE = `${dirname(argv[1] || "")}/`;
const dest = resolve(HERE, "../../searx/templates/simple/icons.html");
/** @type import("./tools/jinja_svg_catalog.js").JinjaMacro[] */
const searxng_jinja_macros = [
const searxng_jinja_macros: JinjaMacro[] = [
{ name: "icon", class: "sxng-icon-set" },
{ name: "icon_small", class: "sxng-icon-set-small" },
{ name: "icon_big", class: "sxng-icon-set-big" }
];
const sxng_icon_opts = {
const sxng_icon_opts: SvgoConfig = {
multipass: true,
plugins: [
{ name: "removeTitle" },
{ name: "removeXMLNS" },
"removeTitle",
"removeXMLNS",
{
name: "addAttributesToSVGElement",
params: {
attributes: [
{
"aria-hidden": "true"
}
]
attributes: [{ "aria-hidden": "true" }]
}
}
]
};
/**
* @type import("./tools/jinja_svg_catalog.js").IconSet[]
*/
const simple_icons = [
const simple_icons: IconSet[] = [
{
base: resolve(HERE, "node_modules/ionicons/dist/svg"),
set: {

View file

@ -1,25 +1,26 @@
import fs from "node:fs";
import path from "node:path";
import sharp from "sharp";
import type { Config } from "svgo";
import { optimize as svgo } from "svgo";
/**
* @typedef {object} Src2Dest - Mapping of src to dest
* @property {string} src - Name of the source file.
* @property {string} dest - Name of the destination file.
*/
// Mapping of src to dest
export type Src2Dest = {
// Name of the source file.
src: string;
// Name of the destination file.
dest: string;
};
/**
* Convert a list of SVG files to PNG.
*
* @param {Src2Dest[]} items - Array of SVG files (src: SVG, dest:PNG) to convert.
* @param items - Array of SVG files (src: SVG, dest:PNG) to convert.
*/
async function svg2png(items) {
export const svg2png = async (items: Src2Dest[]) => {
for (const item of items) {
try {
fs.mkdir(path.dirname(item.dest), { recursive: true }, (err) => {
if (err) throw err;
});
fs.mkdirSync(path.dirname(item.dest), { recursive: true });
const info = await sharp(item.src)
.png({
@ -35,23 +36,22 @@ async function svg2png(items) {
throw err;
}
}
}
};
/**
* Optimize SVG images for WEB.
*
* @param {Src2Dest[]} items - Array of SVG files (src:SVG, dest:SVG) to optimize.
* @param {import('svgo').Config} svgo_opts - Options passed to svgo.
* @param items - Array of SVG files (src:SVG, dest:SVG) to optimize.
* @param svgo_opts - Options passed to svgo.
*/
async function svg2svg(items, svgo_opts) {
export const svg2svg = (items: Src2Dest[], svgo_opts: Config) => {
for (const item of items) {
try {
fs.mkdir(path.dirname(item.dest), { recursive: true }, (err) => {
if (err) throw err;
});
fs.mkdirSync(path.dirname(item.dest), { recursive: true });
const raw = fs.readFileSync(item.src, "utf8");
const opt = svgo(raw, svgo_opts);
fs.writeFileSync(item.dest, opt.data);
console.log(`[svg2svg] optimized: ${item.dest} -- src: ${item.src}`);
} catch (err) {
@ -59,6 +59,4 @@ async function svg2svg(items, svgo_opts) {
throw err;
}
}
}
export { svg2png, svg2svg };
};

View file

@ -2,55 +2,56 @@ import fs from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Edge } from "edge.js";
import { optimize as svgo } from "svgo";
import { type Config as SvgoConfig, optimize as svgo } from "svgo";
const __dirname = dirname(fileURLToPath(import.meta.url));
const __jinja_class_placeholder__ = "__jinja_class_placeholder__";
// -- types
// A set of icons
export type IconSet = {
// Object of SVG icons, where property name is the name of the icon and value is the src of the SVG (relative to base)
set: Record<string, string>;
// Folder in which the SVG src files are located
base: string;
// svgo options for this set
svgo_opts: SvgoConfig;
};
/**
* @typedef {object} IconSet - A set of icons
* @property {object} set - Object of SVG icons, where property name is the
* name of the icon and value is the src of the SVG (relative to base).
* @property {string} base - Folder in which the SVG src files are located.
* @property {import("svgo").Config} svgo_opts - svgo options for this set.
*/
// Mapping of icon name to SVG source file
type IconSVG = {
// Name of the icon isource file
name: string;
// Name of the destination file
src: string;
// Options passed to svgo
svgo_opts: SvgoConfig;
};
/**
* @typedef {object} IconSVG - Mapping of icon name to SVG source file.
* @property {string} name - Name of the icon isource file.
* @property {string} src - Name of the destination file.
* @property {import("svgo").Config} svgo_opts - Options passed to svgo.
*/
/**
* @typedef {object} JinjaMacro - Arguments to create a jinja macro
* @property {string} name - Name of the jinja macro.
* @property {string} class - SVG's class name (value of XML class attribute)
*/
// -- functions
// Arguments to create a jinja macro
export type JinjaMacro = {
// Name of the jinja macro
name: string;
// SVG's class name (value of XML class attribute)
class: string;
};
/**
* Generate a jinja template with a catalog of SVG icons that can be
* used in in other HTML jinja templates.
* used in other HTML jinja templates.
*
* @param {string} dest - filename of the generate jinja template.
* @param {JinjaMacro} macros - Jinja macros to create.
* @param {IconSVG[]} items - Array of SVG items.
* @param dest - filename of the generate jinja template.
* @param macros - Jinja macros to create.
* @param items - Array of SVG items.
*/
function jinja_svg_catalog(dest, macros, items) {
const svg_catalog = {};
export const jinja_svg_catalog = (dest: string, macros: JinjaMacro[], items: IconSVG[]) => {
const svg_catalog: Record<string, string> = {};
const edge_template = resolve(__dirname, "jinja_svg_catalog.html.edge");
items.forEach((item) => {
/** @type {import("svgo").Config} */
// JSON.stringify & JSON.parse are used to create a deep copy of the
// item.svgo_opts object
const svgo_opts = JSON.parse(JSON.stringify(item.svgo_opts));
svgo_opts.plugins.push({
for (const item of items) {
// JSON.stringify & JSON.parse are used to create a deep copy of the item.svgo_opts object
const svgo_opts: SvgoConfig = JSON.parse(JSON.stringify(item.svgo_opts));
svgo_opts.plugins?.push({
name: "addClassesToSVGElement",
params: {
classNames: [__jinja_class_placeholder__]
@ -60,12 +61,13 @@ function jinja_svg_catalog(dest, macros, items) {
try {
const raw = fs.readFileSync(item.src, "utf8");
const opt = svgo(raw, svgo_opts);
svg_catalog[item.name] = opt.data;
} catch (err) {
console.error(`ERROR: jinja_svg_catalog processing ${item.name} src: ${item.src} -- ${err}`);
throw err;
}
});
}
fs.mkdir(dirname(dest), { recursive: true }, (err) => {
if (err) throw err;
@ -85,35 +87,34 @@ function jinja_svg_catalog(dest, macros, items) {
fs.writeFileSync(dest, jinjatmpl);
console.log(`[jinja_svg_catalog] created: ${dest}`);
}
};
/**
* Calls jinja_svg_catalog for a collection of icon sets where each set has its
* own parameters.
*
* @param {string} dest - filename of the generate jinja template.
* @param {JinjaMacro} macros - Jinja macros to create.
* @param {IconSet[]} sets - Array of SVG sets.
* @param dest - filename of the generate jinja template.
* @param macros - Jinja macros to create.
* @param sets - Array of SVG sets.
*/
function jinja_svg_sets(dest, macros, sets) {
/** @type IconSVG[] */
const items = [];
const all = [];
export const jinja_svg_sets = (dest: string, macros: JinjaMacro[], sets: IconSet[]) => {
const items: IconSVG[] = [];
const all: string[] = [];
for (const obj of sets) {
for (const [name, file] of Object.entries(obj.set)) {
if (all.includes(name)) {
throw new Error(`ERROR: ${name} has already been defined`);
}
all.push(name);
items.push({
name: name,
src: resolve(obj.base, file),
svgo_opts: obj.svgo_opts
});
}
jinja_svg_catalog(dest, macros, items);
}
}
// -- exports
export { jinja_svg_sets, jinja_svg_catalog };
jinja_svg_catalog(dest, macros, items);
};

View file

@ -1,44 +0,0 @@
/**
* Custom vite plugins to build the web-client components of the simple theme.
*
* HINT:
*
* This is an inital implementation for the migration of the build process
* from grunt to vite. For fully support (vite: build & serve) more work is
* needed.
*/
import { svg2png, svg2svg } from "./img.js";
/**
* Vite plugin to convert a list of SVG files to PNG.
*
* @param {import('./img.js').Src2Dest[]} items - Array of SVG files (src: SVG, dest:PNG) to convert.
*/
function plg_svg2png(items) {
return {
name: "searxng-simple-svg2png",
apply: "build", // or 'serve'
async writeBundle() {
await svg2png(items);
}
};
}
/**
* Vite plugin to optimize SVG images for WEB.
*
* @param {import('./img.js').Src2Dest[]} items - Array of SVG files (src:SVG, dest:SVG) to optimize.
* @param {import('svgo').Config} svgo_opts - Options passed to svgo.
*/
function plg_svg2svg(items, svgo_opts) {
return {
name: "searxng-simple-svg2png",
apply: "build", // or 'serve'
async writeBundle() {
await svg2svg(items, svgo_opts);
}
};
}
export { plg_svg2png, plg_svg2svg };

View file

@ -0,0 +1,43 @@
/**
* Custom vite plugins to build the web-client components of the simple theme.
*
* HINT:
* This is an inital implementation for the migration of the build process
* from grunt to vite. For fully support (vite: build & serve) more work is
* needed.
*/
import type { Config } from "svgo";
import type { Plugin } from "vite";
import { type Src2Dest, svg2png, svg2svg } from "./img.ts";
/**
* Vite plugin to convert a list of SVG files to PNG.
*
* @param items - Array of SVG files (src: SVG, dest:PNG) to convert.
*/
export const plg_svg2png = (items: Src2Dest[]): Plugin => {
return {
name: "searxng-simple-svg2png",
apply: "build",
async writeBundle() {
await svg2png(items);
}
};
};
/**
* Vite plugin to optimize SVG images for WEB.
*
* @param items - Array of SVG files (src:SVG, dest:SVG) to optimize.
* @param svgo_opts - Options passed to svgo.
*/
export const plg_svg2svg = (items: Src2Dest[], svgo_opts: Config): Plugin => {
return {
name: "searxng-simple-svg2svg",
apply: "build",
writeBundle() {
svg2svg(items, svgo_opts);
}
};
};

View file

@ -0,0 +1,40 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"allowImportingTsExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"incremental": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"exactOptionalPropertyTypes": false,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": true,
"noUncheckedSideEffectImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
"types": ["vite/client"]
},
"include": ["./"],
"exclude": ["./node_modules/"]
}

View file

@ -3,104 +3,89 @@
*/
import { resolve } from "node:path";
import { defineConfig } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
import { plg_svg2png, plg_svg2svg } from "./tools/plg.js";
import browserslistToEsbuild from "browserslist-to-esbuild";
import { browserslistToTargets } from "lightningcss";
import type { Config } from "svgo";
import type { UserConfig } from "vite";
import { browserslist } from "./package.json";
import { plg_svg2png, plg_svg2svg } from "./tools/plg";
const ROOT = "../.."; // root of the git reposetory
const ROOT = "../../"; // root of the git repository
const PATH = {
dist: resolve(ROOT, "searx/static/themes/simple"),
// dist: resolve(ROOT, "client/simple/dist"),
src: "src",
modules: "node_modules",
brand: "src/brand",
static: resolve(ROOT, "client/simple/static"),
leaflet: resolve(ROOT, "client/simple/node_modules/leaflet/dist"),
templates: resolve(ROOT, "searx/templates/simple")
brand: "src/brand/",
dist: resolve(ROOT, "searx/static/themes/simple/"),
modules: "node_modules/",
src: "src/",
templates: resolve(ROOT, "searx/templates/simple/")
};
/**
* @type {import('svgo').Config}
*/
const svg2svg_opts = {
const svg2svg_opts: Config = {
plugins: [{ name: "preset-default" }, "sortAttrs", "convertStyleToAttrs"]
};
/**
* @type {import('svgo').Config}
*/
const svg2svg_favicon_opts = {
const svg2svg_favicon_opts: Config = {
plugins: [{ name: "preset-default" }, "sortAttrs"]
};
export default defineConfig({
root: PATH.src,
export default {
base: "/static/themes/simple/",
publicDir: "static/",
mode: "production",
// mode: "development",
// FIXME: missing CCS sourcemaps!!
// see: https://github.com/vitejs/vite/discussions/13845#discussioncomment-11992084
//
// what I have tried so far (see config below):
//
// - build.sourcemap
// - esbuild.sourcemap
// - css.preprocessorOptions.less.sourceMap
css: {
devSourcemap: true
}, // end: css
build: {
target: ["chrome87", "edge88", "firefox78", "safari14"],
target: browserslistToEsbuild(browserslist),
cssTarget: browserslistToEsbuild(browserslist),
manifest: "manifest.json",
emptyOutDir: true,
assetsDir: "",
outDir: PATH.dist,
sourcemap: true,
minify: "esbuild",
cssMinify: "esbuild",
rollupOptions: {
input: {
// build CSS files
"css/searxng.min.css": `${PATH.src}/less/style-ltr.less`,
"css/searxng-rtl.min.css": `${PATH.src}/less/style-rtl.less`,
"css/rss.min.css": `${PATH.src}/less/rss.less`,
"searxng-ltr.min.css": `${PATH.src}/less/style-ltr.less`,
"searxng-rtl.min.css": `${PATH.src}/less/style-rtl.less`,
"rss.min.css": `${PATH.src}/less/rss.less`,
// build JS files
"js/searxng.head.min": `${PATH.src}/js/searxng.head.js`,
"js/searxng.min": `${PATH.src}/js/searxng.js`
// build script files
"searxng.min": `${PATH.src}/js/main/index.ts`,
// ol
"ol.min": `${PATH.src}/js/pkg/ol.ts`,
"ol.min.css": `${PATH.modules}/ol/ol.css`
},
// file naming conventions / pathnames are relative to outDir (PATH.dist)
output: {
entryFileNames: "[name].js",
chunkFileNames: "[name].js",
assetFileNames: "[name].[ext]"
// Vite does not support "rollupOptions.output.sourcemap".
// Please use "build.sourcemap" instead.
// sourcemap: true,
entryFileNames: "js/[name].js",
chunkFileNames: "js/[name].js",
assetFileNames: ({ names }) => {
const [name] = names;
const extension = name?.split(".").pop();
switch (extension) {
case "css":
return `css/[name][extname]`;
case "js":
return `js/[name][extname]`;
case "png":
case "svg":
return `img/[name][extname]`;
default:
console.warn("Unknown asset:", name);
return `[name][extname]`;
}
}
}
}
}, // end: build
plugins: [
// Leaflet
viteStaticCopy({
targets: [
{ src: `${PATH.leaflet}/leaflet.{js,js.map}`, dest: `${PATH.dist}/js` },
{ src: `${PATH.leaflet}/images/*.png`, dest: `${PATH.dist}/css/images/` },
{ src: `${PATH.leaflet}/*.{css,css.map}`, dest: `${PATH.dist}/css` },
{ src: `${PATH.static}/**/*`, dest: PATH.dist }
]
}),
// -- svg images
plg_svg2svg(
[
{ src: `${PATH.src}/svg/empty_favicon.svg`, dest: `${PATH.dist}/img/empty_favicon.svg` },
@ -111,7 +96,6 @@ export default defineConfig({
),
// SearXNG brand (static)
plg_svg2png([
{ src: `${PATH.brand}/searxng-wordmark.svg`, dest: `${PATH.dist}/img/favicon.png` },
{ src: `${PATH.brand}/searxng.svg`, dest: `${PATH.dist}/img/searxng.png` }
@ -137,5 +121,25 @@ export default defineConfig({
[{ src: `${PATH.brand}/searxng-wordmark.svg`, dest: `${PATH.templates}/searxng-wordmark.min.svg` }],
svg2svg_opts
)
] // end: plugins
});
], // end: plugins
// FIXME: missing CCS sourcemaps!!
// see: https://github.com/vitejs/vite/discussions/13845#discussioncomment-11992084
//
// what I have tried so far (see config below):
//
// - build.sourcemap
// - esbuild.sourcemap
// - css.preprocessorOptions.less.sourceMap
css: {
transformer: "lightningcss",
lightningcss: {
targets: browserslistToTargets(browserslist)
},
devSourcemap: true
}, // end: css
experimental: {
enableNativePlugin: true
} // end: experimental
} satisfies UserConfig;

View file

@ -103,8 +103,8 @@ Node.js environment (``make node.env``)
Node.js_ version {{version.node}} or higher is required to build the themes.
If the requirement is not met, the build chain uses nvm_ (Node Version
Manager) to install latest LTS of Node.js_ locally: there is no need to
install nvm_ or npm_ on your system.
Manager) to install Node.js_ locally: there is no need to install
nvm_ or npm_ on your system.
To install NVM_ and Node.js_ in once you can use :ref:`make nvm.nodejs`.
@ -150,7 +150,7 @@ setup.
``make nvm.nodejs``
-------------------
Install latest Node.js_ LTS locally (uses nvm_)::
Install latest Node.js_ locally (uses nvm_)::
$ make nvm.nodejs
INFO: install (update) NVM at /share/searxng/.nvm

View file

@ -1,5 +1,6 @@
{
"name": "searxng.org/devtools",
"type": "module",
"dependencies": {
"pyright": "^1.1.403"
},

View file

@ -8,21 +8,21 @@
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noarchive">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="HandheldFriendly" content="True">
<meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1">
<title>{% block title %}{% endblock %}{{ instance_name }}</title>
{% block meta %}{% endblock %}
{% if rtl %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/searxng-rtl.min.css') }}" type="text/css" media="screen">
{% else %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/searxng.min.css') }}" type="text/css" media="screen">
<link rel="stylesheet" href="{{ url_for('static', filename='css/searxng-ltr.min.css') }}" type="text/css" media="screen">
{% endif %}
{% if get_setting('server.limiter') or get_setting('server.public_instance') %}
<link rel="stylesheet" href="{{ url_for('client_token', token=link_token) }}" type="text/css">
{% endif %}
<!--[if gte IE 9]>-->
<script src="{{ url_for('static', filename='js/searxng.head.min.js') }}" client_settings="{{ client_settings }}"></script>
<!--<![endif]-->
<script>
// update the css
document.documentElement.classList.remove('no-js');
document.documentElement.classList.add('js');
</script>
{% block head %}
<link title="{{ instance_name }}" type="application/opensearchdescription+xml" rel="search" href="{{ opensearch_url }}">
{% endblock %}
@ -82,8 +82,6 @@
{% endfor %}
</p>
</footer>
<!--[if gte IE 9]>-->
<script src="{{ url_for('static', filename='js/searxng.min.js') }}"></script>
<!--<![endif]-->
<script type="module" src="{{ url_for('static', filename='js/searxng.min.js') }}" client_settings="{{ client_settings }}"></script>
</body>
</html>

View file

@ -94,7 +94,7 @@ nvm.: use nvm (without dot) to execute nvm commands directly
install : install NVM locally at $(git rev-parse --show-toplevel)/${NVM_LOCAL_FOLDER}
clean : remove NVM installation
status : prompt some status information about nvm & node
nodejs : install Node.js latest LTS
nodejs : install latest Node.js
cmd ... : run command ... in NVM environment
bash : start bash interpreter with NVM environment sourced
EOF
@ -163,7 +163,7 @@ nvm.status() {
info_msg "NVM is installed at ${NVM_DIR}"
else
warn_msg "NVM is not installed"
info_msg "to install NVM and Node.js (LTS) use: ${main_cmd} nvm.nodejs"
info_msg "to install NVM and Node.js use: ${main_cmd} nvm.nodejs"
fi
}

View file

@ -4,7 +4,7 @@
declare _Blue
declare _creset
export NODE_MINIMUM_VERSION="18.17.0"
export NODE_MINIMUM_VERSION="23.6.0"
node.help() {
cat <<EOF