26 Commits

Author SHA1 Message Date
searxng-bot
308659964a [l10n] update translations from Weblate
a4922b114 - 2025-08-21 - KOUSTAV <koustav@noreply.codeberg.org>
3f61bddd1 - 2025-08-18 - reis2724 <reis2724@noreply.codeberg.org>
7313a683d - 2025-08-19 - Kemystra <kemystra@noreply.codeberg.org>
92e1bce48 - 2025-08-19 - Artiman <artiman@noreply.codeberg.org>
0f27c1bc2 - 2025-08-18 - youtherthyf <youtherthyf@noreply.codeberg.org>
60496bc76 - 2025-08-16 - alexgabi <alexgabi@noreply.codeberg.org>
742982d00 - 2025-08-15 - lucasmz.dev <lucasmz.dev@noreply.codeberg.org>
2025-08-22 07:10:08 +00:00
Markus Heiser
cfb6649b90 [build] /static 2025-08-21 14:04:13 +02:00
Amit Katyal
5ca70ca17e [feat] client/simple: move cursor to end of search input on mobile
On mobile devices, when the search input is focused, move the cursor
to the end of the existing text. This improves the user experience by
making it easier to edit or append to the current query without
manually moving the cursor first.

Closes: https://github.com/searxng/searxng/issues/5112
2025-08-21 14:04:13 +02:00
Markus Heiser
22c2c93274 [build] /static 2025-08-21 09:07:08 +02:00
Markus Heiser
d2b3c92e81 [fix] move initial "JS is enabled?" (no-js) to client side
To avoid an `unsafe-inline` in the CSP header, the JS code must be moved to the
client side [1].

The `<script>` tag at the end of the HTML originates from the old implementation
of the JS client. Since PR-5073 [2] was merged, the `type` is now `module`, and
the tag must be moved to the beginning of the HTML.

> We need to inline this "JS is enabled?" thing to prevent layout shifts and
> temporary "no JS enabled" visuals as ESM scripts loads and evals everything
> deferred from initial DOM render [3]

That's true in theory, but in practice, this effect is unnoticeable because it's
masked by another effect (which we can't avoid): If we load the page with a
severely throttled connection, the HTML (result list) takes a long time to
load. Then the CSS is loaded, which also takes longer. Until the CSS has loaded,
there's no layout. A layout shift is therefore largely determined by the loading
of the HTML and CSS itself.

The running times of the ESM script can be neglected compared to the loading
times of HTML & CSS.

[1] https://github.com/searxng/searxng-docker/pull/424#issuecomment-3199494256
[2] https://github.com/searxng/searxng/pull/5073
[3] https://github.com/searxng/searxng-docker/pull/424#issuecomment-3199622504
2025-08-21 09:07:08 +02:00
Ivan Gabaldon
41a4a3e224 [fix] template: safesearch uses the wrong type (#5136)
This fixes a regression from
60bd8b9#diff-1b714182564ef1fa942ff81b28f9ba3365ab76f75346f4255398bf9d6064b4cc

Closes https://github.com/searxng/searxng/issues/5135
2025-08-20 18:04:13 +02:00
Bnyro
3649917ce6 [fix] webapp: safesearch uses wrong default value (#5134)
- this fixes a regression from 60bd8b90f0 (diff-1b714182564ef1fa942ff81b28f9ba3365ab76f75346f4255398bf9d6064b4cc)
- closes https://github.com/searxng/searxng/issues/5130
2025-08-20 14:39:05 +02:00
Bnyro
0369682690 [fix] selfhst icons: icon list url invalid, set to active
- the previous CDN icon list url no longer works
- a list of all icons is mirrored to the JSDelivr CDN however
- there's no reason to set the engine to inactive now that we use public CDNs
2025-08-20 14:27:17 +02:00
Filip Mikina
6b57705e50 [feat] engines: add GitHub Code Search engine (#5074)
This patch adds GitHub Code Search [1] engine to allow querying the codebases.

Template code.html is changed to allow passthrough of strip and highlighting
options.

Engine Searchcode is adjusted to pass filename and not rely on hardcoded
extensions.

GitHub search code API does not return the exact code line indices, this
implementation assigns the code arbitrary numbers starting from 1
(effectively relabeling the code).

The API allows for unauth calls, and the default engine settings default to
that, although the calls are heavily rate limited.

The 'text' lexer is the default pygments lexer when parsing fails.

[1] https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-code

Co-authored-by: Markus Heiser <markus.heiser@darmarIT.de>
2025-08-20 07:35:31 +02:00
Markus Heiser
25647c20d1 [mod] switching from pyright to basedpyright (plus first rules)
pyrightconfig.json :

  for the paths searx, searxng_extra and tests, individual rules were
  defined (for example, in test fewer / different rules are needed than in the
  searx package

searx/engines/__builtins__.pyi :

  The builtin types that are added to the global namespace of a module by the
  intended monkey patching of the engine modules / replaces the previous
  filtering of the stdout using grep.

test.pyright_modified (utils/lib_sxng_test.sh) :

  static type check of local modified files not yet commited

make test :

  prerequisite 'test.pyright' has been replaced by 'test.pyright_modified'

searx/engines/__init__.py, searx/enginelib/__init__.py :

  First, minimal typifications that were considered necessary.
2025-08-19 12:04:35 +02:00
dependabot[bot]
9bb04e288d [upd] web-client (simple): Bump the minor group across 1 directory with 6 updates (#5128)
* [upd] web-client (simple): Bump the minor group across 1 directory with 6 updates

Bumps the minor group with 6 updates in the /client/simple directory:

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.1.3` | `2.2.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.2.0` | `24.3.0` |
| [browserslist](https://github.com/browserslist/browserslist) | `4.25.1` | `4.25.2` |
| [edge.js](https://github.com/edge-js/edge) | `6.2.1` | `6.3.0` |
| [stylelint](https://github.com/stylelint/stylelint) | `16.23.0` | `16.23.1` |
| [vite-bundle-analyzer](https://github.com/nonzzz/vite-bundle-analyzer) | `1.1.0` | `1.2.1` |



Updates `@biomejs/biome` from 2.1.3 to 2.2.0
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.2.0/packages/@biomejs/biome)

Updates `@types/node` from 24.2.0 to 24.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `browserslist` from 4.25.1 to 4.25.2
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.25.1...4.25.2)

Updates `edge.js` from 6.2.1 to 6.3.0
- [Release notes](https://github.com/edge-js/edge/releases)
- [Changelog](https://github.com/edge-js/edge/blob/6.x/CHANGELOG.md)
- [Commits](https://github.com/edge-js/edge/compare/v6.2.1...v6.3.0)

Updates `stylelint` from 16.23.0 to 16.23.1
- [Release notes](https://github.com/stylelint/stylelint/releases)
- [Changelog](https://github.com/stylelint/stylelint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stylelint/stylelint/compare/16.23.0...16.23.1)

Updates `vite-bundle-analyzer` from 1.1.0 to 1.2.1
- [Release notes](https://github.com/nonzzz/vite-bundle-analyzer/releases)
- [Changelog](https://github.com/nonzzz/vite-bundle-analyzer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nonzzz/vite-bundle-analyzer/compare/v1.1.0...v1.2.1)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: "@types/node"
  dependency-version: 24.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: browserslist
  dependency-version: 4.25.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: edge.js
  dependency-version: 6.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: stylelint
  dependency-version: 16.23.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: vite-bundle-analyzer
  dependency-version: 1.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* [fix] themes/simple: recreate lock

* [fix] themes/simple: promote from nursery

- noAwaitInLoop
- noBitwiseOperators
- noConstantBinaryExpression
- noGlobalDirnameFilename
- noImplicitCoercion
- noUnassignedVariables
- noUselessBackrefInRegex
- noUselessEscapeInString
- useAdjacentGetterSetter
- useConsistentObjectDefinition
- useConsistentResponse
- useIndexOf
- useIterableCallbackReturn
- useJsonImportAttribute
- useNumericSeparators
- useObjectSpread
- useParseIntRadix
- useReadonlyClassProperties
- useSingleJsDocAsterisk
- useUnifiedTypeSignature

* [fix] themes/simple: build static

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Gabaldon <igabaldon@inetol.net>
2025-08-18 23:17:50 +02:00
Ivan Gabaldon
5f364ad999 [fix] container: change mirror (#5127)
`mirrors.edge.kernel.org` has weird issues, use the official mirror and avoid
future issues.
2025-08-18 21:33:27 +02:00
dependabot[bot]
547d9dcee7 [upd] pypi: Bump the minor group across 1 directory with 2 updates (#5126)
Bumps the minor group with 2 updates in the / directory: [valkey](https://github.com/valkey-io/valkey-py) and [selenium](https://github.com/SeleniumHQ/Selenium).


Updates `valkey` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/valkey-io/valkey-py/releases)
- [Commits](https://github.com/valkey-io/valkey-py/compare/v6.1.0...v6.1.1)

Updates `selenium` from 4.34.2 to 4.35.0
- [Release notes](https://github.com/SeleniumHQ/Selenium/releases)
- [Commits](https://github.com/SeleniumHQ/Selenium/commits/selenium-4.35.0)

---
updated-dependencies:
- dependency-name: valkey
  dependency-version: 6.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: selenium
  dependency-version: 4.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 20:00:37 +02:00
github-actions[bot]
4304150d7f [l10n] update translations from Weblate (#5117)
b0a695592 - 2025-08-14 - zymurge <zymurge@noreply.codeberg.org>
17c30fdae - 2025-08-14 - c2qd <c2qd@noreply.codeberg.org>
a1dc25ec1 - 2025-08-12 - Kemystra <kemystra@noreply.codeberg.org>
a6c4a30e3 - 2025-08-12 - ijxp <ijxp@noreply.codeberg.org>

Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2025-08-18 19:42:38 +02:00
Markus Heiser
9b4ea64429 [build] /static 2025-08-18 16:38:32 +02:00
Markus Heiser
4e586a7eca [fix] add missing nvm environment to the "./manage dev.env" command 2025-08-18 16:38:32 +02:00
Markus Heiser
48cec1fff6 [fix] drop obsolte client/simple/static/ 2025-08-18 16:38:32 +02:00
Markus Heiser
34e993a854 [license] client/simple: SPDX-License-Identifier: AGPL-3.0-or-later
SPDX short-form identifiers to communicate license information in a simple,
efficient, portable and machine-readable manner [1]

[1] https://spdx.dev/learn/handling-license-info/
2025-08-18 16:38:32 +02:00
Ivan Gabaldon
60bd8b90f0 [enh] theme/simple: custom router
Lay the foundation for loading scripts granularly depending on the endpoint it's
on.

Remove vendor specific prefixes as there are now managed by browserslist and
LightningCSS.

Enabled quite a few rules in Biome that don't come in recommended to better
catch issues and improve consistency.

Related:

- https://github.com/searxng/searxng/pull/5073#discussion_r2256037965
- https://github.com/searxng/searxng/pull/5073#discussion_r2256057100
2025-08-18 16:38:32 +02:00
Ivan Gabaldon
adc4361eb9 [fix] theme/simple: broken highlightResult
From `mod-simple-strict`
2025-08-18 16:38:32 +02:00
Ivan Gabaldon
7e1c7096ce [enh] theme/simple: package.json revision
Set minor versioning for most of the packages that iterate fast or we know won't
cause problems, and fixed versioning for the rest.

Packages going into bundles should be placed in "dependencies".

The inspection of prod bundles is necessary. Although it does not make a lot of
sense right now, it will be useful in later PR and will give us a reference to
start with.
2025-08-18 16:38:32 +02:00
Ivan Gabaldon
0b913053a7 [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.
2025-08-18 16:38:32 +02:00
Markus Heiser
4fb6105d69 [fix] revision of utils.HTMLTextExtractor (#5125)
Related:

- https://github.com/searxng/searxng/pull/5073#issuecomment-3196282632
2025-08-18 16:30:51 +02:00
Ishbir Singh
b606103352 [fix] reuters: published date not parsed correctly in some cases
FIxes publishedDate format in reuters engine to encompass ISO 8601 times both with and without milliseconds.
Why is this change important?

Previously, the engine would sometimes fail saying:

2025-08-12 21:13:23,091 ERROR:searx.engines.reuters: exception : time data '2024-04-15T19:08:30.833Z' does not match format '%Y-%m-%dT%H:%M:%SZ'

Traceback (most recent call last):

...
  File "/usr/local/searxng/searx/engines/reuters.py", line 87, in response

    publishedDate=datetime.strptime(result["display_time"], "%Y-%m-%dT%H:%M:%SZ"),

                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...

Note that most queries seem to work with Reuters, but there are some results that have the additional milliseconds and fail. Regardless, the change is backwards compatible as both the formats (with and without the ms) should now parse correctly.
2025-08-16 15:50:38 +00:00
dependabot[bot]
792ec2afea [upd] github-actions: Bump actions/checkout from 4.2.2 to 5.0.0 (#5118)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 5.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](11bd71901b...08c6903cd8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-15 18:06:22 +02:00
dependabot[bot]
90f997c181 [upd] github-actions: Bump github/codeql-action from 3.29.6 to 3.29.9 (#5119)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.29.6 to 3.29.9.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](a4e1a019f5...df559355d5)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 3.29.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-15 18:05:43 +02:00
161 changed files with 4259 additions and 4035 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

@@ -29,7 +29,7 @@ jobs:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: "false"

View File

@@ -38,7 +38,7 @@ jobs:
steps:
- if: github.repository_owner == 'searxng'
name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: "false"
@@ -135,7 +135,7 @@ jobs:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: "false"
@@ -210,7 +210,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: "false"
@@ -245,7 +245,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: "false"

View File

@@ -45,7 +45,7 @@ jobs:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: "false"

View File

@@ -37,7 +37,7 @@ jobs:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: "false"
fetch-depth: "0"

View File

@@ -40,7 +40,7 @@ jobs:
python-version: "${{ matrix.python-version }}"
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: "false"
@@ -67,7 +67,7 @@ jobs:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: "false"
@@ -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

View File

@@ -40,7 +40,7 @@ jobs:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
token: "${{ secrets.WEBLATE_GITHUB_TOKEN }}"
fetch-depth: "0"
@@ -87,7 +87,7 @@ jobs:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
token: "${{ secrets.WEBLATE_GITHUB_TOKEN }}"
fetch-depth: "0"

View File

@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: "false"
@@ -41,6 +41,6 @@ jobs:
write-comment: "false"
- name: Upload SARIFs
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
uses: github/codeql-action/upload-sarif@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
with:
sarif_file: "./scout.sarif"

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

@@ -178,3 +178,4 @@ features or generally made SearXNG better:
- `Bearz314 <https://github.com/bearz314>`_
- Tommaso Colella `<https://github.com/gioleppe>`
- @AgentScrubbles
- Filip Mikina `<https://github.com/fiffek>`

View File

@@ -47,7 +47,7 @@ search.checker.%: install
$(Q)./manage pyenv.cmd searxng-checker -v "$(subst _, ,$(patsubst search.checker.%,%,$@))"
PHONY += test ci.test test.shell
test: test.yamllint test.black test.pyright test.pylint test.unit test.robot test.rst test.shell test.shfmt
test: test.yamllint test.black test.pyright_modified test.pylint test.unit test.robot test.rst test.shell test.shfmt
ci.test: test test.pybabel
test.shell:
$(Q)shellcheck -x -s dash \
@@ -80,8 +80,8 @@ MANAGE += node.env node.env.dev node.clean
MANAGE += py.build py.clean
MANAGE += pyenv pyenv.install pyenv.uninstall
MANAGE += format.python format.shell
MANAGE += test.yamllint test.pylint test.black test.pybabel test.unit test.coverage test.robot test.rst test.clean test.themes test.pyright test.shfmt
MANAGE += themes.all themes.simple themes.fix themes.lint themes.test
MANAGE += test.yamllint test.pylint test.black test.pybabel test.unit test.coverage test.robot test.rst test.clean test.themes test.pyright test.pyright_modified test.shfmt
MANAGE += themes.all themes.simple themes.simple.analyze themes.fix themes.lint themes.test
MANAGE += static.build.commit static.build.drop static.build.restore
MANAGE += nvm.install nvm.clean nvm.status nvm.nodejs
MANAGE += go.env.dev go.clean

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"files": {
"includes": ["**", "!dist/**", "!node_modules/**"],
"includes": ["**", "!dist", "!node_modules"],
"ignoreUnknown": true
},
"vcs": {
@@ -28,7 +28,111 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"complexity": {
"noForEach": "error",
"noImplicitCoercions": "error",
"useSimplifiedLogicExpression": "error"
},
"correctness": {
"noGlobalDirnameFilename": "error",
"noUndeclaredVariables": {
"level": "error",
"options": {
"checkTypes": true
}
},
"useImportExtensions": "error",
"useJsonImportAttributes": "error",
"useSingleJsDocAsterisk": "error"
},
"nursery": {
"noMisusedPromises": "warn",
"noUselessUndefined": "warn",
"useExhaustiveSwitchCases": "warn",
"useExplicitType": "warn"
},
"performance": {
"noAwaitInLoops": "error",
"noBarrelFile": "error",
"noDelete": "error",
"noNamespaceImport": "error",
"noReExportAll": "error",
"useTopLevelRegex": "error"
},
"style": {
"noCommonJs": "error",
"noEnum": "error",
"noInferrableTypes": "error",
"noNamespace": "error",
"noNegationElse": "error",
"noNestedTernary": "error",
"noParameterAssign": "error",
"noParameterProperties": "error",
"noRestrictedTypes": {
"level": "error",
"options": {
"types": {
"Element": {
"message": "Element is too generic",
"use": "HTMLElement"
}
}
}
},
"noSubstr": "error",
"noUnusedTemplateLiteral": "error",
"noUselessElse": "error",
"noYodaExpression": "error",
"useAsConstAssertion": "error",
"useAtIndex": "error",
"useCollapsedElseIf": "error",
"useCollapsedIf": "error",
"useConsistentArrayType": {
"level": "error",
"options": {
"syntax": "shorthand"
}
},
"useConsistentBuiltinInstantiation": "error",
"useConsistentMemberAccessibility": {
"level": "error",
"options": {
"accessibility": "explicit"
}
},
"useConsistentObjectDefinitions": {
"level": "error",
"options": {
"syntax": "explicit"
}
},
"useDefaultSwitchClause": "error",
"useExplicitLengthCheck": "error",
"useForOf": "error",
"useGroupedAccessorPairs": "error",
"useNumberNamespace": "error",
"useNumericSeparators": "error",
"useObjectSpread": "error",
"useReadonlyClassProperties": "error",
"useShorthandAssign": "error",
"useSingleVarDeclarator": "error",
"useThrowNewError": "error",
"useThrowOnlyError": "error",
"useTrimStartEnd": "error",
"useUnifiedTypeSignatures": "error"
},
"suspicious": {
"noAlert": "error",
"noBitwiseOperators": "error",
"noConstantBinaryExpressions": "error",
"noEmptyBlockStatements": "error",
"noEvolvingTypes": "error",
"noUnassignedVariables": "error",
"noVar": "error",
"useNumberToFixedDigitsArgument": "error",
"useStaticResponseMethods": "error"
}
}
},
"javascript": {

View File

@@ -1,6 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/*
this file is generated automatically by searxng_extra/update/update_pygments.py
using pygments version 2.19.2
using pygments version 2.19.2:
./manage templates.simple.pygments
*/

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,51 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "simple",
"name": "@searxng/theme-simple",
"version": "0.0.0",
"private": true,
"license": "AGPL-3.0",
"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"
],
"dependencies": {
"ionicons": "~8.0.0",
"normalize.css": "8.0.1",
"ol": "~10.6.0",
"swiped-events": "1.2.0"
},
"devDependencies": {
"@biomejs/biome": "~2.0.6",
"edge.js": "^6.2.1",
"ionicons": "^8.0.10",
"leaflet": "^1.9.4",
"less": "^4.3.0",
"normalize.css": "^8.0.1",
"sharp": "^0.34.2",
"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",
"vite-plugin-static-copy": "^3.1.0"
"@biomejs/biome": "2.2.0",
"@types/node": "~24.3.0",
"browserslist": "~4.25.2",
"browserslist-to-esbuild": "~2.1.0",
"edge.js": "~6.3.0",
"less": "~4.4.0",
"lightningcss": "~1.30.0",
"sharp": "~0.34.0",
"sort-package-json": "~3.4.0",
"stylelint": "~16.23.1",
"stylelint-config-standard-less": "~3.0.0",
"stylelint-prettier": "~5.0.0",
"svgo": "~4.0.0",
"typescript": "~5.9.0",
"vite": "npm:rolldown-vite@7.1.3",
"vite-bundle-analyzer": "~1.2.1"
}
}

View File

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

View File

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

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

@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { Endpoints, endpoint, ready, settings } from "./toolkit.ts";
ready(
() => {
import("../main/keyboard.ts");
import("../main/search.ts");
if (settings.autocomplete) {
import("../main/autocomplete.ts");
}
},
{ on: [endpoint === Endpoints.index] }
);
ready(
() => {
import("../main/keyboard.ts");
import("../main/mapresult.ts");
import("../main/results.ts");
import("../main/search.ts");
if (settings.infinite_scroll) {
import("../main/infinite_scroll.ts");
}
if (settings.autocomplete) {
import("../main/autocomplete.ts");
}
},
{ on: [endpoint === Endpoints.results] }
);
ready(
() => {
import("../main/preferences.ts");
},
{ on: [endpoint === Endpoints.preferences] }
);

View File

@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type { KeyBindingLayout } from "../main/keyboard.ts";
// synced with searx/webapp.py get_client_settings
type Settings = {
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;
safesearch?: 0 | 1 | 2;
search_on_category_select?: boolean;
theme?: string;
theme_static_path?: string;
translations?: Record<string, string>;
url_formatting?: "pretty" | "full" | "host";
};
type HTTPOptions = {
body?: BodyInit;
timeout?: number;
};
type ReadyOptions = {
// all values must be truthy for the callback to be executed
on?: (boolean | undefined)[];
};
type AssertElement = (element?: HTMLElement | null) => asserts element is HTMLElement;
export type EndpointsKeys = keyof typeof Endpoints;
export const Endpoints = {
index: "index",
results: "results",
preferences: "preferences",
unknown: "unknown"
} as const;
export const mutable = {
closeDetail: undefined as (() => void) | undefined,
scrollPageToSelected: undefined as (() => void) | undefined,
selectImage: undefined as ((resultElement: HTMLElement) => void) | undefined,
selectNext: undefined as ((openDetailView?: boolean) => void) | undefined,
selectPrevious: undefined as ((openDetailView?: boolean) => void) | undefined
};
const getEndpoint = (): EndpointsKeys => {
const metaEndpoint = document.querySelector('meta[name="endpoint"]')?.getAttribute("content");
if (metaEndpoint && metaEndpoint in Endpoints) {
return metaEndpoint as EndpointsKeys;
}
return Endpoints.unknown;
};
const getSettings = (): Settings => {
const settings = document.querySelector("script[client_settings]")?.getAttribute("client_settings");
if (!settings) return {};
try {
return JSON.parse(atob(settings));
} catch (error) {
console.error("Failed to load client_settings:", error);
return {};
}
};
export const 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);
const res = await fetch(url, {
body: options?.body,
method: method,
signal: controller.signal
}).finally(() => clearTimeout(timeoutId));
if (!res.ok) {
throw new Error(res.statusText);
}
return res;
};
export const listen = <K extends keyof DocumentEventMap, E extends HTMLElement>(
type: string | K,
target: string | Document | E,
listener: (this: E, event: DocumentEventMap[K]) => void | Promise<void>,
options?: AddEventListenerOptions
): void => {
if (typeof target !== "string") {
target.addEventListener(type, listener as EventListener, options);
return;
}
document.addEventListener(
type,
(event: Event) => {
for (const node of event.composedPath()) {
if (node instanceof HTMLElement && node.matches(target)) {
try {
listener.call(node as E, event as DocumentEventMap[K]);
} catch (error) {
console.error(error);
}
break;
}
}
},
options
);
};
export const ready = (callback: () => void, options?: ReadyOptions): void => {
for (const condition of options?.on ?? []) {
if (!condition) {
return;
}
}
if (document.readyState !== "loading") {
callback();
} else {
listen("DOMContentLoaded", document, callback, { once: true });
}
};
export const endpoint: EndpointsKeys = getEndpoint();
export const settings: Settings = getSettings();

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,131 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { assertElement, http, listen, settings } from "../core/toolkit.ts";
const fetchResults = async (qInput: HTMLInputElement, query: string): Promise<void> => {
try {
let res: Response;
if (settings.method === "GET") {
res = await http("GET", `./autocompleter?q=${query}`);
} else {
res = await http("POST", "./autocompleter", { body: 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 === 0) {
const noItemFoundMessage = Object.assign(document.createElement("li"), {
className: "no-item-found",
textContent: 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 });
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);
}
};
const qInput = document.getElementById("q") as HTMLInputElement | null;
assertElement(qInput);
let timeoutId: number;
listen("input", qInput, () => {
clearTimeout(timeoutId);
const query = qInput.value;
const minLength = settings.autocomplete_min ?? 2;
if (query.length < minLength) return;
timeoutId = window.setTimeout(async () => {
if (query === qInput.value) {
await fetchResults(qInput, query);
}
}, 300);
});
const autocomplete: HTMLElement | null = document.querySelector<HTMLElement>(".autocomplete");
const autocompleteList: HTMLUListElement | null = document.querySelector<HTMLUListElement>(".autocomplete ul");
if (autocompleteList) {
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;
default:
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 ?? "";
}
}
}
}
});
}

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,100 @@
// 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);
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,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,468 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { assertElement, listen, mutable, settings } from "../core/toolkit.ts";
export type KeyBindingLayout = "default" | "vim";
type KeyBinding = {
key: string;
fun: (event: KeyboardEvent) => void;
des: string;
cat: string;
};
type HighlightResultElement = "down" | "up" | "visible" | "bottom" | "top";
/* common base for layouts */
const baseKeyBinding: Record<string, KeyBinding> = {
Escape: {
key: "ESC",
fun: (event: KeyboardEvent) => 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: Record<string, KeyBinding> =
settings.hotkeys && settings.hotkeys in keyBindingLayouts
? keyBindingLayouts[settings.hotkeys]
: keyBindingLayouts.default;
const isElementInDetail = (element?: HTMLElement): boolean => {
const ancestor = element?.closest(".detail, .result");
return ancestor?.classList.contains("detail") ?? false;
};
const getResultElement = (element?: HTMLElement): HTMLElement | undefined => {
return element?.closest(".result") ?? undefined;
};
const isImageResult = (resultElement?: HTMLElement): boolean => {
return resultElement?.classList.contains("result-images") ?? false;
};
const highlightResult =
(which: HighlightResultElement | HTMLElement) =>
(noScroll?: boolean, keepFocus?: boolean): void => {
let effectiveWhich = which;
let current = document.querySelector<HTMLElement>(".result[data-vim-selected]");
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;
}
}
const results = Array.from(document.querySelectorAll<HTMLElement>(".result"));
let next: HTMLElement | undefined;
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 (const element of results) {
const etop = element.offsetTop;
const ebot = etop + element.clientHeight;
if (ebot <= bot && etop > top) {
next = element;
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.at(-1);
break;
// biome-ignore lint/complexity/noUselessSwitchCase: fallthrough is intended
case "top":
default:
next = results[0];
}
}
if (next) {
current.removeAttribute("data-vim-selected");
next.setAttribute("data-vim-selected", "true");
if (!keepFocus) {
const link = next.querySelector<HTMLAnchorElement>("h3 a") || next.querySelector<HTMLAnchorElement>("a");
link?.focus();
}
if (!noScroll) {
mutable.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 {
mutable.closeDetail?.();
}
};
const pageButtonClick = (css_selector: string): void => {
const button = document.querySelector<HTMLButtonElement>(css_selector);
if (button) {
button.click();
}
};
const GoToNextPage = (): void => {
pageButtonClick('nav#pagination .next_page button[type="submit"]');
};
const GoToPreviousPage = (): void => {
pageButtonClick('nav#pagination .previous_page button[type="submit"]');
};
mutable.scrollPageToSelected = (): void => {
const sel = document.querySelector<HTMLElement>(".result[data-vim-selected]");
if (!sel) return;
const wtop = document.documentElement.scrollTop || document.body.scrollTop;
const height = document.documentElement.clientHeight;
const etop = sel.offsetTop;
const ebot = etop + sel.clientHeight;
const 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: HighlightResultElement): 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) {
// toggle hidden
helpPanel.classList.toggle("invisible");
} else {
// 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);
}
}
};
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);
}
};
listen("click", ".result", function (this: HTMLElement, event: PointerEvent) {
if (!isElementInDetail(event.target as HTMLElement)) {
highlightResult(this)(true, true);
const resultElement = getResultElement(event.target as HTMLElement);
if (resultElement && isImageResult(resultElement)) {
event.preventDefault();
mutable.selectImage?.(resultElement);
}
}
});
// FIXME: Focus might also trigger Pointer event ^^^
listen(
"focus",
".result a",
(event: FocusEvent) => {
if (!isElementInDetail(event.target as HTMLElement)) {
const resultElement = getResultElement(event.target as HTMLElement);
if (resultElement && !resultElement.hasAttribute("data-vim-selected")) {
highlightResult(resultElement)(true);
}
if (resultElement && isImageResult(resultElement)) {
event.preventDefault();
mutable.selectImage?.(resultElement);
}
}
},
{ capture: true }
);
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 HTMLElement)?.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);
}
}
});
mutable.selectNext = highlightResult("down");
mutable.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,86 @@
// 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");
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,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,70 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { http, listen, settings } from "../core/toolkit.ts";
let engineDescriptions: Record<string, [string, string]> | undefined;
const loadEngineDescriptions = async (): Promise<void> => {
if (engineDescriptions) return;
try {
const res = await 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>${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;
}
}
};
const engineElements: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>("[data-engine-name]");
for (const engineElement of engineElements) {
listen("mouseenter", engineElement, loadEngineDescriptions);
}
const engineToggles: NodeListOf<HTMLInputElement> = document.querySelectorAll<HTMLInputElement>(
"tbody input[type=checkbox][class~=checkbox-onoff]"
);
const enableAllEngines: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>(".enable-all-engines");
for (const engine of enableAllEngines) {
listen("click", engine, () => toggleEngines(true, engineToggles));
}
const disableAllEngines: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>(".disable-all-engines");
for (const engine of disableAllEngines) {
listen("click", engine, () => toggleEngines(false, engineToggles));
}
const copyHashButton: HTMLElement | null = document.querySelector<HTMLElement>("#copy-hash");
if (copyHashButton) {
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);
}
});
}

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,177 @@
// 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";
let imgTimeoutID: number;
const imageLoader = (resultElement: HTMLElement): void => {
if (imgTimeoutID) clearTimeout(imgTimeoutID);
const imgElement = resultElement.querySelector<HTMLImageElement>(".result-images-source img");
if (!imgElement) return;
// use thumbnail until full image loads
const thumbnail = resultElement.querySelector<HTMLImageElement>(".image_thumbnail");
if (thumbnail) {
if (thumbnail.src === `${settings.theme_static_path}/img/img_load_error.svg`) return;
imgElement.onerror = (): void => {
imgElement.src = thumbnail.src;
};
imgElement.src = thumbnail.src;
}
const imgSource = imgElement.getAttribute("data-src");
if (!imgSource) return;
// unsafe nodejs specific, cast to https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#return_value
// https://github.com/searxng/searxng/pull/5073#discussion_r2265767231
imgTimeoutID = setTimeout(() => {
imgElement.src = imgSource;
imgElement.removeAttribute("data-src");
}, 1000) as unknown as number;
};
const imageThumbnails: NodeListOf<HTMLImageElement> =
document.querySelectorAll<HTMLImageElement>("#urls img.image_thumbnail");
for (const thumbnail of imageThumbnails) {
if (thumbnail.complete && thumbnail.naturalWidth === 0) {
thumbnail.src = `${settings.theme_static_path}/img/img_load_error.svg`;
}
thumbnail.onerror = (): void => {
thumbnail.src = `${settings.theme_static_path}/img/img_load_error.svg`;
};
}
const copyUrlButton: HTMLButtonElement | null =
document.querySelector<HTMLButtonElement>("#search_url button#copy_url");
copyUrlButton?.style.setProperty("display", "block");
mutable.selectImage = (resultElement: HTMLElement): 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";
mutable.scrollPageToSelected?.();
// if there is no element given by the caller, stop here
if (!resultElement) return;
imageLoader(resultElement);
};
mutable.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();
}
mutable.scrollPageToSelected?.();
};
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");
});
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);
}
}
});
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;
}
});
listen("click", ".result-detail-close", (event: Event) => {
event.preventDefault();
mutable.closeDetail?.();
});
listen("click", ".result-detail-previous", (event: Event) => {
event.preventDefault();
mutable.selectPrevious?.(false);
});
listen("click", ".result-detail-next", (event: Event) => {
event.preventDefault();
mutable.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") {
mutable.closeDetail?.();
}
});
const swipeHorizontal: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>(".swipe-horizontal");
for (const element of swipeHorizontal) {
listen("swiped-left", element, () => {
mutable.selectNext?.(false);
});
listen("swiped-right", element, () => {
mutable.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
);

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,118 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { assertElement, listen, settings } from "../core/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);
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 isMobile: boolean = window.matchMedia("(max-width: 50em)").matches;
const isResultsPage: boolean = document.querySelector("main")?.id === "main_results";
// focus search input on large screens
if (!(isMobile || isResultsPage)) {
qInput.focus();
}
// On mobile, move cursor to the end of the input on focus
if (isMobile) {
listen("focus", qInput, () => {
// 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;
});
});
}
createClearButton(qInput);
// Additionally to searching when selecting a new category, we also
// automatically start a new search request when the user changes a search
// filter (safesearch, time range or language) (this requires JavaScript
// though)
if (
settings.search_on_category_select &&
// If .search_filters is undefined (invisible) we are on the homepage and
// hence don't have to set any listeners
document.querySelector(".search_filters")
) {
const safesearchElement = document.getElementById("safesearch");
if (safesearchElement) {
listen("change", safesearchElement, () => submitIfQuery(qInput));
}
const timeRangeElement = document.getElementById("time_range");
if (timeRangeElement) {
listen("change", timeRangeElement, () => submitIfQuery(qInput));
}
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) {
event.preventDefault();
button.classList.toggle("selected");
return;
}
// deselect all other categories
for (const categoryButton of categoryButtons) {
categoryButton.classList.toggle("selected", categoryButton === button);
}
});
}
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(",");
}
form.submit();
});

View File

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

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

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
.dialog-modal {
animation-name: dialogmodal;
animation-duration: 0.13s;

View File

@@ -1,4 +1,4 @@
/*! Autocomplete.js v2.6.3 | license MIT | (c) 2017, Baptiste Donaux | http://autocomplete-js.com */
// SPDX-License-Identifier: AGPL-3.0-or-later
.autocomplete {
position: absolute;

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
@import "../../generated/pygments.less";
.codelines {
@@ -18,12 +20,9 @@
cursor: default;
&::selection {
background: transparent; /* WebKit/Blink Browsers */
background: transparent;
}
&::-moz-selection {
background: transparent; /* Gecko Browsers */
}
margin-right: 8px;
text-align: right;
}

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/*
* SearXNG, A privacy-respecting, hackable metasearch engine
*

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#main_results #results.image-detail-open.only_template_images {
width: min(98%, 59.25rem) !important;
}

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
iframe[src^="https://w.soundcloud.com"]
{
height: 120px;

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#main_index {
margin-top: 26vh;
}

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
.info-page {
code {
font-family: monospace;

View File

@@ -2,9 +2,6 @@
// Mixins
.text-size-adjust (@property: 100%) {
-webkit-text-size-adjust: @property;
-ms-text-size-adjust: @property;
-moz-text-size-adjust: @property;
text-size-adjust: @property;
}
@@ -22,7 +19,6 @@
// disable user selection
.disable-user-select () {
-webkit-touch-callout: none;
user-select: none;
}

View File

@@ -1,4 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
.stats_endpoint {
.github-issue-button {
display: block;

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
table {
border-collapse: collapse;

View File

@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/*
Layout of the KeyValue result class
*/
#main_results .result-keyvalue {
caption {
padding: 0.8rem 0.5rem;

View File

@@ -1,12 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
@import (inline) "../../node_modules/normalize.css/normalize.css";
@import "definitions.less";
.text-size-adjust (@property: 100%) {
-webkit-text-size-adjust: @property;
-ms-text-size-adjust: @property;
-moz-text-size-adjust: @property;
text-size-adjust: @property;
}
@import "mixins.less";
// Reset padding and margin
html,

View File

@@ -1,6 +1,4 @@
/*
* SearXNG, A privacy-respecting, hackable metasearch engine
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
#search {
padding: 0;
@@ -196,11 +194,6 @@ html.no-js #clear_search.hide_if_nojs {
.ltr-rounded-left-corners(0.8rem);
}
#q::-ms-clear,
#q::-webkit-search-cancel-button {
display: none;
}
#send_search {
.ltr-rounded-right-corners(0.8rem);
@@ -271,7 +264,6 @@ html.no-js #clear_search.hide_if_nojs {
width: 100%;
.ltr-text-align-left();
overflow: scroll hidden;
-webkit-overflow-scrolling: touch;
}
}
}
@@ -374,11 +366,6 @@ html.no-js #clear_search.hide_if_nojs {
#categories {
.disable-user-select;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
#categories_container {

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/*
--center-page-width overrides the less variable @results-width when the results are centered
see the CSS rules for #results in style.less ( grid-template-columns and gap).

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
.ltr-left(@offset) {
left: @offset;
}

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
.ltr-left(@offset) {
right: @offset;
}
@@ -129,13 +131,7 @@
}
// select HTML element
@supports (
(background-position-x: 100%) and
(
(appearance: none) or (-webkit-appearance: none) or
(-moz-appearance: none)
)
) {
@supports ((background-position-x: 100%) and ((appearance: none))) {
select {
border-width: 0 0 0 2rem;
background-position-x: -2rem;

View File

@@ -1,8 +1,4 @@
/*
* SearXNG, A privacy-respecting, hackable metasearch engine
*
* To convert "style.less" to "style.css" run: $make styles
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
// stylelint-disable no-descending-specificity

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// other solution : http://stackoverflow.com/questions/1577598/how-to-hide-parts-of-html-when-javascript-is-disabled/13857783#13857783
// stylelint-disable no-descending-specificity
@@ -156,9 +158,7 @@ div.selectable_url {
td {
padding: 0 1em 0 0;
padding-top: 0;
.ltr-padding-right(1rem);
padding-bottom: 0;
.ltr-padding-left(0);
}
@@ -307,7 +307,7 @@ html body .tabs > input:checked {
}
~ label {
position: inherited;
position: inherit;
background: inherit;
border-bottom: 2px solid transparent;
font-weight: normal;
@@ -347,17 +347,9 @@ select {
}
}
@supports (
(background-position-x: 100%) and
(
(appearance: none) or (-webkit-appearance: none) or
(-moz-appearance: none)
)
) {
@supports ((background-position-x: 100%) and ((appearance: none))) {
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
border-width: 0 2rem 0 0;
border-color: transparent;
background: data-uri("image/svg+xml;charset=UTF-8", @select-light-svg-path)
@@ -400,8 +392,6 @@ select {
/* -- checkbox-onoff -- */
input.checkbox-onoff[type="checkbox"] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
display: inline-block;
@@ -475,8 +465,6 @@ input.checkbox-onoff.reversed-checkbox[type="checkbox"] {
/* -- checkbox -- */
@supports (transform: rotate(-45deg)) {
input[type="checkbox"]:not(.checkbox-onoff) {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 20px;
@@ -549,33 +537,16 @@ input.checkbox-onoff.reversed-checkbox[type="checkbox"] {
border-right: 0.5em solid var(--color-toolkit-loader-border);
border-bottom: 0.5em solid var(--color-toolkit-loader-border);
border-left: 0.5em solid var(--color-toolkit-loader-borderleft);
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation: load8 1.2s infinite linear;
animation: load8 1.2s infinite linear;
}
@-webkit-keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@@ -606,9 +577,6 @@ td:hover .engine-tooltip,
margin: 0;
padding: 0 0.125rem 0 4rem;
width: 100%;
width: -moz-available;
width: -webkit-fill-available;
width: fill;
flex-flow: row nowrap;
align-items: center;
display: inline-flex;

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
.loader,
.loader::after {
border-radius: 50%;

View File

@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#answers .weather {
summary {
display: block;

View File

@@ -1,2 +0,0 @@
leaflet.css -diff
leaflet.js -diff

View File

@@ -1,43 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* Generate icons.html for the jinja templates of the simple theme.
*/
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 dest = resolve(HERE, "../../searx/templates/simple/icons.html");
const HERE = `${dirname(argv[1] || "")}/`;
const dest: string = 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,64 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import sharp from "sharp";
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.
*/
/**
* Convert a list of SVG files to PNG.
*
* @param {Src2Dest[]} items - Array of SVG files (src: SVG, dest:PNG) to convert.
*/
async function svg2png(items) {
for (const item of items) {
try {
fs.mkdir(path.dirname(item.dest), { recursive: true }, (err) => {
if (err) throw err;
});
const info = await sharp(item.src)
.png({
force: true,
compressionLevel: 9,
palette: true
})
.toFile(item.dest);
console.log(`[svg2png] created ${item.dest} -- bytes: ${info.size}, w:${info.width}px, h:${info.height}px`);
} catch (err) {
console.error(`ERROR: ${item.dest} -- ${err}`);
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.
*/
async function svg2svg(items, svgo_opts) {
for (const item of items) {
try {
fs.mkdir(path.dirname(item.dest), { recursive: true }, (err) => {
if (err) throw err;
});
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) {
console.error(`ERROR: optimize src: ${item.src} -- ${err}`);
throw err;
}
}
}
export { svg2png, svg2svg };

View File

@@ -0,0 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
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";
// 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 items - Array of SVG files (src: SVG, dest:PNG) to convert.
*/
export const svg2png = (items: Src2Dest[]): void => {
for (const item of items) {
fs.mkdirSync(path.dirname(item.dest), { recursive: true });
sharp(item.src)
.png({
force: true,
compressionLevel: 9,
palette: true
})
.toFile(item.dest)
.then((info) => {
console.log(`[svg2png] created ${item.dest} -- bytes: ${info.size}, w:${info.width}px, h:${info.height}px`);
})
.catch((error) => {
console.error(`ERROR: ${item.dest} -- ${error}`);
throw error;
});
}
};
/**
* 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 svg2svg = (items: Src2Dest[], svgo_opts: Config): void => {
for (const item of items) {
try {
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 (error) {
console.error(`ERROR: optimize src: ${item.src} -- ${error}`);
throw error;
}
}
};

View File

@@ -1,4 +1,6 @@
{{--
SPDX-License-Identifier: AGPL-3.0-or-later
This is a EDGE https://edgejs.dev/ template to generate a HTML Jinja template
for the backend. Example output of this EDGE template:
- https://github.com/searxng/searxng/blob/master/searx/templates/simple/icons.html

View File

@@ -1,119 +0,0 @@
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";
const __dirname = dirname(fileURLToPath(import.meta.url));
const __jinja_class_placeholder__ = "__jinja_class_placeholder__";
// -- types
/**
* @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.
*/
/**
* @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
/**
* Generate a jinja template with a catalog of SVG icons that can be
* used in 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.
*/
function jinja_svg_catalog(dest, macros, items) {
const svg_catalog = {};
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({
name: "addClassesToSVGElement",
params: {
classNames: [__jinja_class_placeholder__]
}
});
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;
});
const ctx = {
svg_catalog: svg_catalog,
macros: macros,
edge_template: edge_template,
__jinja_class_placeholder__: __jinja_class_placeholder__,
// see https://github.com/edge-js/edge/issues/162
open_curly_brace: "{{",
close_curly_brace: "}}"
};
const jinjatmpl = Edge.create().renderRawSync(fs.readFileSync(edge_template, "utf-8"), ctx);
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.
*/
function jinja_svg_sets(dest, macros, sets) {
/** @type IconSVG[] */
const items = [];
const all = [];
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`);
}
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 };

View File

@@ -0,0 +1,118 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from "node:fs";
import { dirname, resolve } from "node:path";
import { Edge } from "edge.js";
import { type Config as SvgoConfig, optimize as svgo } from "svgo";
const __jinja_class_placeholder__ = "__jinja_class_placeholder__";
// 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;
};
// 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;
};
// 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 other HTML jinja templates.
*
* @param dest - filename of the generate jinja template.
* @param macros - Jinja macros to create.
* @param items - Array of SVG items.
*/
export const jinja_svg_catalog = (dest: string, macros: JinjaMacro[], items: IconSVG[]): void => {
const svg_catalog: Record<string, string> = {};
const edge_template = resolve(import.meta.dirname, "jinja_svg_catalog.html.edge");
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__]
}
});
try {
const raw = fs.readFileSync(item.src, "utf8");
const opt = svgo(raw, svgo_opts);
svg_catalog[item.name] = opt.data;
} catch (error) {
console.error(`ERROR: jinja_svg_catalog processing ${item.name} src: ${item.src} -- ${error}`);
throw error;
}
}
fs.mkdirSync(dirname(dest), { recursive: true });
const ctx = {
svg_catalog: svg_catalog,
macros: macros,
edge_template: edge_template,
__jinja_class_placeholder__: __jinja_class_placeholder__,
// see https://github.com/edge-js/edge/issues/162
open_curly_brace: "{{",
close_curly_brace: "}}"
};
const jinjatmpl = Edge.create().renderRawSync(fs.readFileSync(edge_template, "utf-8"), ctx);
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 dest - filename of the generate jinja template.
* @param macros - Jinja macros to create.
* @param sets - Array of SVG sets.
*/
export const jinja_svg_sets = (dest: string, macros: JinjaMacro[], sets: IconSet[]): void => {
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);
};

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,45 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* 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",
writeBundle: () => {
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

@@ -1,141 +0,0 @@
/**
* CONFIG: https://vite.dev/config/
*/
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";
const ROOT = "../.."; // root of the git reposetory
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")
};
/**
* @type {import('svgo').Config}
*/
const svg2svg_opts = {
plugins: [{ name: "preset-default" }, "sortAttrs", "convertStyleToAttrs"]
};
/**
* @type {import('svgo').Config}
*/
const svg2svg_favicon_opts = {
plugins: [{ name: "preset-default" }, "sortAttrs"]
};
export default defineConfig({
root: PATH.src,
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"],
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`,
// build JS files
"js/searxng.head.min": `${PATH.src}/js/searxng.head.js`,
"js/searxng.min": `${PATH.src}/js/searxng.js`
},
// 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,
}
}
}, // 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` },
{ src: `${PATH.src}/svg/select-dark.svg`, dest: `${PATH.dist}/img/select-dark.svg` },
{ src: `${PATH.src}/svg/select-light.svg`, dest: `${PATH.dist}/img/select-light.svg` }
],
svg2svg_opts
),
// 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` }
]),
// -- svg
plg_svg2svg(
[
{ src: `${PATH.brand}/searxng.svg`, dest: `${PATH.dist}/img/searxng.svg` },
{ src: `${PATH.brand}/img_load_error.svg`, dest: `${PATH.dist}/img/img_load_error.svg` }
],
svg2svg_opts
),
// -- favicon
plg_svg2svg(
[{ src: `${PATH.brand}/searxng-wordmark.svg`, dest: `${PATH.dist}/img/favicon.svg` }],
svg2svg_favicon_opts
),
// -- simple templates
plg_svg2svg(
[{ src: `${PATH.brand}/searxng-wordmark.svg`, dest: `${PATH.templates}/searxng-wordmark.min.svg` }],
svg2svg_opts
)
] // end: plugins
});

View File

@@ -0,0 +1,168 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* CONFIG: https://vite.dev/config/
*/
import { resolve } from "node:path";
import { constants as zlibConstants } from "node:zlib";
import browserslistToEsbuild from "browserslist-to-esbuild";
import { browserslistToTargets } from "lightningcss";
import type { PreRenderedAsset } from "rolldown";
import type { Config } from "svgo";
import type { UserConfig } from "vite";
import analyzer from "vite-bundle-analyzer";
import manifest from "./package.json" with { type: "json" };
import { plg_svg2png, plg_svg2svg } from "./tools/plg.ts";
const ROOT = "../../"; // root of the git repository
const PATH = {
brand: "src/brand/",
dist: resolve(ROOT, "searx/static/themes/simple/"),
modules: "node_modules/",
src: "src/",
templates: resolve(ROOT, "searx/templates/simple/")
} as const;
const svg2svg_opts: Config = {
plugins: [{ name: "preset-default" }, "sortAttrs", "convertStyleToAttrs"]
};
const svg2svg_favicon_opts: Config = {
plugins: [{ name: "preset-default" }, "sortAttrs"]
};
export default {
base: "/static/themes/simple/",
publicDir: "static/",
mode: "production",
// mode: "development",
build: {
target: browserslistToEsbuild(manifest.browserslist),
cssTarget: browserslistToEsbuild(manifest.browserslist),
manifest: "manifest.json",
emptyOutDir: true,
assetsDir: "",
outDir: PATH.dist,
sourcemap: true,
rollupOptions: {
input: {
// build CSS files
"searxng-ltr.css": `${PATH.src}/less/style-ltr.less`,
"searxng-rtl.css": `${PATH.src}/less/style-rtl.less`,
"rss.css": `${PATH.src}/less/rss.less`,
// build script files
"searxng.core": `${PATH.src}/js/core/index.ts`,
// ol
ol: `${PATH.src}/js/pkg/ol.ts`,
"ol.css": `${PATH.modules}/ol/ol.css`
},
// file naming conventions / pathnames are relative to outDir (PATH.dist)
output: {
entryFileNames: "js/[name].min.js",
chunkFileNames: "js/[name].min.js",
assetFileNames: ({ names }: PreRenderedAsset): string => {
const [name] = names;
const extension = name?.split(".").pop();
switch (extension) {
case "css":
return "css/[name].min[extname]";
case "js":
return "js/[name].min[extname]";
case "png":
case "svg":
return "img/[name][extname]";
default:
console.warn("Unknown asset:", name);
return "[name][extname]";
}
}
}
}
}, // end: build
plugins: [
// -- bundle analyzer
analyzer({
enabled: process.env.VITE_BUNDLE_ANALYZE === "true",
analyzerPort: "auto",
summary: true,
reportTitle: manifest.name,
// sidecars with max compression
gzipOptions: {
level: zlibConstants.Z_BEST_COMPRESSION
},
brotliOptions: {
params: {
[zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY
}
}
}),
// -- svg images
plg_svg2svg(
[
{ src: `${PATH.src}/svg/empty_favicon.svg`, dest: `${PATH.dist}/img/empty_favicon.svg` },
{ src: `${PATH.src}/svg/select-dark.svg`, dest: `${PATH.dist}/img/select-dark.svg` },
{ src: `${PATH.src}/svg/select-light.svg`, dest: `${PATH.dist}/img/select-light.svg` }
],
svg2svg_opts
),
// 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` }
]),
// -- svg
plg_svg2svg(
[
{ src: `${PATH.brand}/searxng.svg`, dest: `${PATH.dist}/img/searxng.svg` },
{ src: `${PATH.brand}/img_load_error.svg`, dest: `${PATH.dist}/img/img_load_error.svg` }
],
svg2svg_opts
),
// -- favicon
plg_svg2svg(
[{ src: `${PATH.brand}/searxng-wordmark.svg`, dest: `${PATH.dist}/img/favicon.svg` }],
svg2svg_favicon_opts
),
// -- simple templates
plg_svg2svg(
[{ src: `${PATH.brand}/searxng-wordmark.svg`, dest: `${PATH.templates}/searxng-wordmark.min.svg` }],
svg2svg_opts
)
], // 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(manifest.browserslist)
},
devSourcemap: true
}, // end: css
experimental: {
enableNativePlugin: true
} // end: experimental
} satisfies UserConfig;

View File

@@ -1,7 +1,7 @@
contents:
repositories:
- https://mirrors.edge.kernel.org/alpine/edge/main
- https://mirrors.edge.kernel.org/alpine/edge/community
- https://dl-cdn.alpinelinux.org/alpine/edge/main
- https://dl-cdn.alpinelinux.org/alpine/edge/community
packages:
- alpine-base
- build-base

View File

@@ -1,6 +1,6 @@
contents:
repositories:
- https://mirrors.edge.kernel.org/alpine/edge/main
- https://dl-cdn.alpinelinux.org/alpine/edge/main
packages:
- alpine-baselayout
- ca-certificates-bundle

View File

@@ -0,0 +1,8 @@
.. _github code engine:
===========
Github Code
===========
.. automodule:: searx.engines.github_code
:members:

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

1
manage
View File

@@ -117,6 +117,7 @@ EOF
dev.env() {
go.env.dev
nvm.env
node.env.dev
export GOENV

View File

@@ -1,7 +1,7 @@
{
"name": "searxng.org/devtools",
"type": "module",
"dependencies": {
"pyright": "^1.1.403"
},
"scripts": {
"clean": "rm -Rf node_modules package-lock.json"

View File

@@ -1,9 +1,48 @@
{
"venvPath": "local",
"venv": "py3",
"include": [
"searx",
"searxng_extra",
"tests"
]
"venvPath": "local",
"venv": "py3",
"include": [
"searx",
"searxng_extra",
"tests"
],
"reportAny" : "information",
"enableTypeIgnoreComments": true,
"reportIgnoreCommentWithoutRule": true,
"reportImplicitStringConcatenation": false,
"reportUninitializedInstanceVariable": false,
"reportUnnecessaryIsInstance": false,
"reportUnreachable": "information",
"reportUnusedCallResult": false,
"executionEnvironments": [
{
"root": "searx",
"extraPaths": ["."]
},
{
"root": "searxng_extra",
"extraPaths": ["."],
"reportAny" : false,
"reportUnknownMemberType": false,
"reportUnknownVariableType": false
},
{
"root": "tests",
"extraPaths": ["."],
"reportAny" : false,
"reportImplicitOverride": false,
"reportMissingParameterType": false,
"reportMissingTypeArgument": false,
"reportMissingTypeStubs": false,
"reportPrivateLocalImportUsage": false,
"reportPrivateUsage": false,
"reportUnannotatedClassAttribute": false,
"reportUnknownArgumentType": false,
"reportUnknownLambdaType": false,
"reportUnknownMemberType": false,
"reportUnknownParameterType": false,
"reportUnknownVariableType": false,
"reportUnusedParameter": false
}
]
}

View File

@@ -4,7 +4,7 @@ cov-core==1.15.0
black==24.3.0
pylint==3.3.7
splinter==0.21.0
selenium==4.34.2
selenium==4.35.0
Pallets-Sphinx-Themes==2.3.0
Sphinx==7.4.7
sphinx-issues==5.0.1
@@ -22,3 +22,4 @@ coloredlogs==15.0.1
docutils>=0.21.2
parameterized==0.9.0
granian[reload]==2.5.0
basedpyright==1.31.0

View File

@@ -12,7 +12,7 @@ httpx-socks[asyncio]==0.10.0
Brotli==1.1.0
uvloop==0.21.0
setproctitle==1.3.6
valkey==6.1.0
valkey==6.1.1
markdown-it-py==3.0.0
fasttext-predict==0.9.2.4
tomli==2.2.1; python_version < '3.11'

View File

@@ -252,7 +252,7 @@ class Engine: # pylint: disable=too-few-public-methods
display_error_messages: bool
"""Display error messages on the web UI."""
proxies: dict
proxies: dict[str, dict[str, str]]
"""Set proxies for a specific engine (YAML):
.. code:: yaml

View File

@@ -0,0 +1,43 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""The builtin types that are added to the global namespace of a module by the
intended monkey patching of the engine modules.
.. attention::
Monkey-patching modules is a practice from the past that shouldn't be
expanded upon. In the long run, there should be an engine class that can be
inherited. However, as long as this class doesn't exist, and as long as all
engine modules aren't converted to an engine class, these builtin types will
still be needed.
"""
from __future__ import annotations
import logging
logger: logging.Logger
supported_languages: str
language_aliases: str
# from searx.engines.ENGINE_DEFAULT_ARGS
about: dict[str, dict[str, str | None | bool]]
categories: list[str]
disabled: bool
display_error_messages: bool
enable_http: bool
engine_type: str
inactive: bool
max_page: int
paging: int
safesearch: int
send_accept_language_header: bool
shortcut: str
time_range_support: int
timeout: int
tokens: list[str]
using_tor_proxy: bool
# from searx.engines.check_engine_module
network: str
# from searx.engines.update_attributes_for_tor
search_url: str

View File

@@ -9,24 +9,26 @@ usage::
"""
from __future__ import annotations
import typing as t
import sys
import copy
from os.path import realpath, dirname
from typing import TYPE_CHECKING, Dict
import types
import inspect
from searx import logger, settings
from searx.utils import load_module
if TYPE_CHECKING:
if t.TYPE_CHECKING:
from searx.enginelib import Engine
logger = logger.getChild('engines')
ENGINE_DIR = dirname(realpath(__file__))
ENGINE_DEFAULT_ARGS = {
# Defaults for the namespace of an engine module, see load_engine()
ENGINE_DEFAULT_ARGS: dict[str, int | str | list[t.Any] | dict[str, t.Any] | bool] = {
# Common options in the engine module
"engine_type": "online",
"paging": False,
@@ -49,11 +51,8 @@ ENGINE_DEFAULT_ARGS = {
# set automatically when an engine does not have any tab category
DEFAULT_CATEGORY = 'other'
# Defaults for the namespace of an engine module, see :py:func:`load_engine`
categories = {'general': []}
engines: Dict[str, Engine | types.ModuleType] = {}
categories: dict[str, list[str]] = {'general': []}
engines: dict[str, Engine | types.ModuleType] = {}
engine_shortcuts = {}
"""Simple map of registered *shortcuts* to name of the engine (or ``None``).
@@ -77,7 +76,7 @@ def check_engine_module(module: types.ModuleType):
raise TypeError(msg)
def load_engine(engine_data: dict) -> Engine | types.ModuleType | None:
def load_engine(engine_data: dict[str, t.Any]) -> Engine | types.ModuleType | None:
"""Load engine from ``engine_data``.
:param dict engine_data: Attributes from YAML ``settings:engines/<engine>``

View File

@@ -0,0 +1,272 @@
# SPDX-License-Identifier: AGPL-3.0-or-lat_er
"""GitHub code search with `search syntax`_ as described in `Constructing a
search query`_ in the documentation of GitHub's REST API.
.. _search syntax:
https://docs.github.com/en/search-github/getting-started-with-searching-on-github/understanding-the-search-syntax
.. _Constructing a search query:
https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#constructing-a-search-query
.. _Github REST API for code search:
https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-code
.. _Github REST API auth for code search:
https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-code--fine-grained-access-tokens
Configuration
=============
The engine has the following mandatory setting:
- :py:obj:`ghc_auth`
Change the authentication method used when using the API, defaults to none.
Optional settings are:
- :py:obj:`ghc_highlight_matching_lines`
Control the highlighting of the matched text (turns off/on).
- :py:obj:`ghc_strip_new_lines`
Strip new lines at the start or end of each code fragment.
- :py:obj:`ghc_strip_whitespace`
Strip any whitespace at the start or end of each code fragment.
- :py:obj:`ghc_insert_block_separator`
Add a `...` between each code fragment before merging them.
.. code:: yaml
- name: github code
engine: github_code
shortcut: ghc
ghc_auth:
type: "none"
- name: github code
engine: github_code
shortcut: ghc
ghc_auth:
type: "personal_access_token"
token: "<token>"
ghc_highlight_matching_lines: true
ghc_strip_whitespace: true
ghc_strip_new_lines: true
- name: github code
engine: github_code
shortcut: ghc
ghc_auth:
type: "bearer"
token: "<token>"
Implementation
===============
GitHub does not return the code line indices alongside the code fragment in the
search API. Since these are not super important for the user experience all the
code lines are just relabeled (starting from 1) and appended (a disjoint set of
code blocks in a single file might be returned from the API).
"""
from __future__ import annotations
import typing as t
from urllib.parse import urlencode, urlparse
from pygments.lexers import guess_lexer_for_filename
from pygments.util import ClassNotFound
from searx.result_types import EngineResults
from searx.extended_types import SXNG_Response
from searx.network import raise_for_httperror
# about
about = {
"website": 'https://github.com/',
"wikidata_id": 'Q364',
"official_api_documentation": 'https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-code',
"use_official_api": True,
"require_api_key": False,
"results": 'JSON',
}
# engine dependent config
categories = ['code']
search_url = 'https://api.github.com/search/code?sort=indexed&{query}&{page}'
# https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#text-match-metadata
accept_header = 'application/vnd.github.text-match+json'
paging = True
ghc_auth = {
"type": "none",
"token": "",
}
"""Change the method of authenticating to the github API.
``type`` needs to be one of ``none``, ``personal_access_token``, or ``bearer``.
When type is not `none` a token is expected to be passed as well in
``auth.token``.
If there is any privacy concerns about generating a token, one can use the API
without authentication. The calls will be heavily rate limited, this is what the
API returns on such calls::
API rate limit exceeded for <redacted ip>.
(But here's the good news: Authenticated requests get a higher rate limit)
The personal access token or a bearer for an org or a group can be generated [in
the `GitHub settings`_.
.. _GitHub settings:
https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-code--fine-grained-access-tokens
"""
ghc_highlight_matching_lines = True
"""Highlight the matching code lines."""
ghc_strip_new_lines = True
"""Strip leading and trailing newlines for each returned fragment.
Single file might return multiple code fragments.
"""
ghc_strip_whitespace = False
"""Strip all leading and trailing whitespace for each returned fragment.
Single file might return multiple code fragments. Enabling this might break
code indentation.
"""
ghc_api_version = "2022-11-28"
"""The version of the GitHub REST API.
"""
ghc_insert_block_separator = False
"""Each file possibly consists of more than one code block that matches the
search, if this is set to true, the blocks will be separated with ``...`` line.
This might break the lexer and thus result in the lack of code highlighting.
"""
def request(query: str, params: dict[str, t.Any]) -> None:
params['url'] = search_url.format(query=urlencode({'q': query}), page=urlencode({'page': params['pageno']}))
params['headers']['Accept'] = accept_header
params['headers']['X-GitHub-Api-Version'] = ghc_api_version
if ghc_auth['type'] == "none":
# Without the auth header the query fails, so add a dummy instead.
# Queries without auth are heavily rate limited.
params['headers']['Authorization'] = "placeholder"
if ghc_auth['type'] == "personal_access_token":
params['headers']['Authorization'] = f"token {ghc_auth['token']}"
if ghc_auth['type'] == "bearer":
params['headers']['Authorization'] = f"Bearer {ghc_auth['token']}"
params['raise_for_httperror'] = False
def get_code_language_name(filename: str, code_snippet: str) -> str | None:
"""Returns a code language name by pulling information from the filename if
possible otherwise by scanning the passed code snippet. In case there is any
parsing error just default to no syntax highlighting."""
try:
lexer = guess_lexer_for_filename(filename, _text=code_snippet)
if lexer is None:
return None
code_name_aliases = lexer.aliases
if len(code_name_aliases) == 0:
return None
return code_name_aliases[0]
except ClassNotFound:
return None
def extract_code(code_matches: list[dict[str, t.Any]]) -> tuple[list[str], set[int]]:
"""
Iterate over multiple possible matches, for each extract a code fragment.
GitHub additionally sends context for _word_ highlights; pygments supports
highlighting lines, as such we calculate which lines to highlight while
traversing the text.
"""
lines: list[str] = []
highlighted_lines_index: set[int] = set()
for i, match in enumerate(code_matches):
if i > 0 and ghc_insert_block_separator:
lines.append("...")
buffer: list[str] = []
highlight_groups = [highlight_group['indices'] for highlight_group in match['matches']]
code: str = match['fragment']
original_code_lenght = len(code)
if ghc_strip_whitespace:
code = code.lstrip()
if ghc_strip_new_lines:
code = code.lstrip("\n")
offset = original_code_lenght - len(code)
if ghc_strip_whitespace:
code = code.rstrip()
if ghc_strip_new_lines:
code = code.rstrip("\n")
for i, letter in enumerate(code):
if len(highlight_groups) > 0:
# the API ensures these are sorted already, and we have a
# guaranteed match in the code (all indices are in the range 0
# and len(fragment)), so only check the first highlight group
[after, before] = highlight_groups[0]
if after <= (i + offset) < before:
# pygments enumerates lines from 1, highlight the next line
highlighted_lines_index.add(len(lines) + 1)
highlight_groups.pop(0)
if letter == "\n":
lines.append("".join(buffer))
buffer = []
continue
buffer.append(letter)
lines.append("".join(buffer))
return lines, highlighted_lines_index
def response(resp: SXNG_Response) -> EngineResults:
results = EngineResults()
if resp.status_code == 422:
# on a invalid search term the status code 422 "Unprocessable Content"
# is returned / e.g. search term is "user: foo" instead "user:foo"
return results
# raise for other errors
raise_for_httperror(resp)
for item in resp.json().get('items', []):
repo = item['repository']
text_matches = item['text_matches']
# ensure picking only the code contents in the blob
code_matches = [
match for match in text_matches if match["object_type"] == "FileContent" and match["property"] == "content"
]
lines, highlighted_lines_index = extract_code(code_matches)
if not ghc_highlight_matching_lines:
highlighted_lines_index: set[int] = set()
code_snippet = "\n".join(lines)
kwargs: dict[str, t.Any] = {
'template': 'code.html',
'url': item['html_url'],
'title': f"{repo['full_name']} · {item['path']}",
'content': repo['description'],
'repository': repo['html_url'],
'codelines': [(i + 1, line) for (i, line) in enumerate(lines)],
'hl_lines': highlighted_lines_index,
'code_language': get_code_language_name(filename=item['name'], code_snippet=code_snippet),
# important to set for highlighing
'strip_whitespace': ghc_strip_whitespace,
'strip_new_lines': ghc_strip_new_lines,
'parsed_url': urlparse(item['html_url']),
}
results.add(results.types.LegacyResult(**kwargs))
return results

View File

@@ -84,7 +84,7 @@ def response(resp) -> EngineResults:
content=result["description"],
thumbnail=result.get("thumbnail", {}).get("url", ""),
metadata=result.get("kicker", {}).get("name"),
publishedDate=datetime.strptime(result["display_time"], "%Y-%m-%dT%H:%M:%SZ"),
publishedDate=datetime.fromisoformat(result["display_time"]),
)
)
return res

View File

@@ -70,6 +70,8 @@ def response(resp):
'codelines': sorted(lines.items()),
'code_language': code_language,
'template': 'code.html',
'strip_whitespace': True,
'strip_new_lines': True,
}
)

View File

@@ -14,12 +14,11 @@ about = {
categories = ['images', 'icons']
icons_list_url = 'https://cdn.selfh.st/directory/icons.json'
icons_cdn_base_url = 'https://cdn.jsdelivr.net'
cdn_base_url = 'https://cdn.jsdelivr.net/gh/selfhst/icons'
def request(query, params):
params['url'] = icons_list_url
params['url'] = f"{cdn_base_url}/index.json"
params['query'] = query
return params
@@ -39,7 +38,7 @@ def response(resp):
img_format = format_name.lower()
break
img_src = f'{icons_cdn_base_url}/gh/selfhst/icons/{img_format}/{item["Reference"]}.{img_format}'
img_src = f'{cdn_base_url}/{img_format}/{item["Reference"]}.{img_format}'
result = {
'template': 'images.html',
'url': img_src,

View File

@@ -983,6 +983,24 @@ engines:
engine: github
shortcut: gh
- name: github code
engine: github_code
shortcut: ghc
disabled: true
ghc_auth:
# type is one of:
# * none
# * personal_access_token
# * bearer
# When none is passed, the token is not requried.
type: "none"
token: "token"
# specify whether to highlight the matching lines to the query
ghc_highlight_matching_lines: true
ghc_strip_new_lines: true
ghc_strip_whitespace: false
timeout: 10.0
- name: codeberg
# https://docs.searxng.org/dev/engines/online/gitea.html
engine: gitea
@@ -1851,7 +1869,6 @@ engines:
- name: selfhst icons
engine: selfhst
shortcut: si
inactive: true
disabled: true
- name: sepiasearch

View File

@@ -1,2 +0,0 @@
leaflet.css -diff
leaflet.js -diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

View File

@@ -1,661 +0,0 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More