nx-router

nx-router is a declarative URL routing extension for Nyxt. In short, it's an abstraction around Nyxt request resource handlers that uses router objects to make it more convenient to handle routes. See Examples for a walk-through on how to set up routers.

The main drive behind nx-router is that I initially found Nyxt built-in handlers difficult to reason about and I soon became frustrated with how much imperative logic I had to maintain in my configuration. nx-router aims to simplify request resource handling in Nyxt with declarative redirects, blockers, and resource handlers. You may think of it as a more batteries-included url-dispatching-handler.

Installation

To install the extension, you need to download the source and place it in Nyxt's extensions path, given by the value of nyxt-source-registry (by default ~/.local/share/nyxt/extensions).

git clone https://git.migalmoreno.com/nx-router ~/.local/share/nyxt/extensions/nx-router

The extension works with Nyxt 3 onward but it's encouraged to use it with the latest version of Nyxt master for the time being.

If you want to place the extension elsewhere in the system, such as for development purposes, you can configure so via the ASDF source registry mechanism. For this, you'll need to create a file in the source registry directory, ~/.config/common-lisp/source-registry.conf.d/, and then put the following contents into it, replacing the path with the desired system path.

(:tree "/path/to/user/location")

Then, make sure to refresh the ASDF cache via asdf:clear-source-registry. ASDF will now be able to find the extension on the custom path. For more information on this utility, please refer to the ASDF manual.

By default, Nyxt won't read the custom source registry path we provided, so ensure to include a reset-asdf-registries invocation in Nyxt's configuration file too.

In your Nyxt configuration file, place the following.

(define-nyxt-user-system-and-load nyxt-user/router
  :depends-on (nx-router)
  :components ("router.lisp"))

Where router.lisp is a custom file that you should create relative to Nyxt's configuration directory (*config-file*'s pathname by default) to provide the extension settings after the nx-router system has been successfully loaded. Inside this file, place the following.

(define-configuration web-buffer
  ((default-modes `(router:router-mode ,@%slot-value%))))

In addition, you should add the extension options, explained in the following section.

Configuration

This shows some example routers you'd set up inside the router-mode configuration class:

(define-configuration router:router-mode
  ((router:routers
    (list
     (make-instance 'router:redirector
                    :route (match-domain "example.org")
                    :redirect
                    '(("https://acme.org/example" . (not ".*/$" ".*/wiki$"))))
     (make-instance 'router:blocker
                    :name 'wonka
                    :route (match-hostname "www.wonka.inc")
                    :routes-builder
                    (make-instance 'router:routes-builder
                                   :source "https://www.wonka.inc/routes.json"
                                   :builder (lambda (routes)
                                              (json:decode-json-from-string
                                               routes)))
                    :blocklist ".*/factory")
     (make-instance 'router:opener
                    :name 'wonka
                    :resource "mpv --video=no ~a")
     (make-instance 'router:redirector
                    :name 'wonka
                    :redirect
                    '(("https://\\1.acme.org/\\2" . ".*/p/(\\w+)/(.*)")))))))

The first router specifies a redirect so that any route of https://example.org that doesn't match the regexps .*/$ or .*/wiki$ will get redirected to https://acme.org/example. The second router sets a block-list for the router if its route matches the .*/factory PCRE and adds a routes-builder which will fetch a list of routes and append them to the router's. Note that the second router has a name slot, which will allow subsequent routers to inherit the parent-class slots. The third router instructs a resource to be opened upon route activation, and the fourth router uses regexp interpolation to redirect the .*/p/(\\w+)/(.*) regexp to https://\\1.acme.org/\\2, where \\1 and \\2 will be replaced with the corresponding capture groups.

All routers derive from a router parent class that holds common router settings:

name
a symbol for the name of the router which allows for router composition.
route
the route to match for router activation, akin to the predicates used in Nyxt auto-rules. One of match-domain, match-host, match-regex, match-port, a user-defined function, or a PCRE.
routes-builder
this takes a routes-builder object, which in turn takes a source to retrieve routes from and a builder which assembles them into a list.
toplevel-p (default: t)
whether the router is meant to process only top-level requests.

Do note the WebkitGTK renderer poses some limitations with requests. For example, some of them might not get processed because click events are obscured, and iframes cannot be redirected at all. To alleviate this, you can control whether only top-level requests should be processed . If you want all requests to be processed, including non top-level ones, for all routers by default you can configure the router user-class like this:

(define-configuration router:router
  ((router:toplevel-p nil)))

If you'd like to process non top-level requests only for redirector and opener routers by default, you can configure them like this:

(define-configuration (router:redirector router:opener)
  ((router:toplevel-p nil)))

Finally, if you'd like to process non top-level requests only for a given instance of a redirector class, add this on router instantiation:

(make-instance 'router:redirector
               :route (match-domain "example.org")
               :redirect "acme.org"
               :toplevel-p nil)

redirector is a redirect router that takes the following direct slots:

redirect
a string for a URL hostname to redirect to, a quri:uri object for a complete URL to redirect to, a PCRE (used as the replacement string of ppcre:regex-replace against route), or an association list of redirection rules. In the latter, each entry is a cons of the form REDIRECT . ROUTES, where ROUTES is a list of regexps from the route that will be matched against and redirected to REDIRECT. To redirect all paths except ROUTES to REDIRECT, prefix this list with not.
reverse
a string for the router's original host or a quri:uri object for the original complete URL. This is useful for storage purposes (bookmarks, history, etc.) so that the original URL is recorded instead of the redirect's URL.

blocker is a blocking router that takes the following direct slots:

block-banner-p (default: t)
whether to display a block banner upon blocking the route.
blocklist
A PCRE to match against the current route, t to block the entire route, or a list of regexps to draw the comparison against. If any single list is prefixed with not, the entire route will be blocked except for the specified regexps. If all of the lists are prefixed with or, this follows an exception-based blocking where you can specify a more general block target first and bypass it for more specific routes.

opener is a router that instructs resources to be opened externally. It takes the following direct slots:

resource
a resource can be either a function form, in which case it takes a single parameter URL and can invoke arbitrary Lisp forms with it. If it's a string, it runs the specified command via uiop:run-program with the current URL as argument, and can be given in a format-like syntax.
(make-instance 'router:redirector
               :route (match-regex ".*://.*google.com/search.*")
               :redirect (quri:uri "http://localhost:5000")
               :reverse (quri:uri "https://www.google.com"))

You can include a :reverse slot in the redirector router with a quri:uri object or a string host to perform a reverse redirection of the route, which can be useful when copying the current page's URL or saving your history so that the original URL is recorded. To enable this you have to wrap or override Nyxt internal methods like this:

(define-command copy-url ()
  "Save current URL to clipboard."
  (let ((url (render-url (router:trace-url (url (current-buffer))))))
    (copy-to-clipboard url)
    (echo "~a copied to clipboard." url)))

(defmethod nyxt:on-signal-load-finished :around ((mode nyxt/history-mode:history-mode) url)
  (call-next-method mode (router:trace-url url)))

Examples

Redirect YouTube requests that match certain regexps to their corresponding Tubo counterparts.

(make-instance 'router:redirector
               :route (match-domain "youtube.com")
               :redirect
               '(("https://tubo.media/stream?url=\\&" . (".*/watch\\?v.*" ".*/shorts/.*"))
                 ("https://tubo.media/playlist?list=\\&" . ".*/playlist/.*")
                 ("https://tubo.media/channel?url=\\&" . ".*/channel/.*")))

Set up a redirect for all Instagram requests except those that match the regexps .*/tv/.* or .*/reels/.* to https://picuki.com/profile/, and redirect routes that match the regexp .*/p/(.*) to https://picuki.com/media/\\1, where the replacement string \\1 will be replaced by the (.*) capture group in the URL.

(make-instance 'router:redirector
               :route (match-regex "https://(www.)?insta.*")
               :redirect
               '(("https://picuki.com/profile/" . (not ".*/tv/.*" ".*/reels/.*"))
                 ("https://pickuki.com/media/\\1" . ".*/p/(.*)")))

Redirect all TikTok requests except the index path, videos, or usernames to https://tok.artemislena.eu/@placeholder/video/, and redirect the rest of routes to https://tok.artemislena.eu/\\1, where the replacement string \\1 will be interpolated with everything following the route's hostname. Additionally, block all the routes except those that contain the .*/video/.* or the .*/t/.* regexps.

(list
 (make-instance 'router:redirector
                :name 'tiktok
                :route (match-domain "tiktok.com")
                :redirect
                '(("https://tok.artemislena.eu/@placeholder/video/" . (not ".*/@.*" ".*/t/.*"))
                  ("https://tok.artemislena.eu/\\1" . ".*://[^/]*/(.*)$")))
 (make-instance 'router:blocker
                :name 'tiktok
                :blocklist '(or (not ".*/video/.*") (not ".*/t/.*"))))

Use a redirector router with a PCRE trigger to redirect all Fandom routes to your preferred BreezeWiki instance.

(make-instance 'router:redirector
               :route "https://([\\w'-]+)\\.fandom.com/wiki/(.*)"
               :redirect "https://breezewiki.com/\\1/wiki/\\2")

Use a redirector router to match YouTube-like video URLs and MP3 files and redirect these to https://www.youtube.com, and dispatch an opener router that launches an mpv player IPC client process through mpv.el to control the player from Emacs. You can also pass a one-placeholder format string such as mpv --video=no ~a to the resource slot if you'd rather not use a Lisp form, where ~a represents the matched URL.

(list
 (make-instance 'router:redirector
                :name 'youtube
                :route '((match-regex ".*/watch\\?v=.*")
                         (match-file-extension "mp3"))
                :redirect "www.youtube.com")
 (make-instance 'router:opener
                :name 'youtube
                :resource (lambda (url)
                           (eval-in-emacs `(mpv-start ,url)))))

Pass a routes-builder to generate a list of routes that will be added on router instantiation. Also provide redirect as a function to compute the redirect hostname to use.

(defun set-invidious-redirect ()
  "Set the primary Invidious redirect URL."
  (let ((routes
          (remove-if-not
           (lambda (route)
             (and (string= (alex:assoc-value (second route)
                                             :region)
                           "DE")
                  (string= (alex:assoc-value (second route)
                                             :type)
                           "https")))
           (json:with-decoder-simple-list-semantics
             (json:decode-json-from-string
              (dex:get
               "https://api.invidious.io/instances.json"))))))
    (first (car routes))))

(make-instance 'router:redirector
               :route (match-domain "youtube.com" "youtu.be")
               :redirect #'set-invidious-redirect
               :routes-builder
               (make-instance 'routes-builder
                              :source "https://api.invidious.io/instances.json"
                              :builder
                              (lambda (routes)
                                (mapcar 'first
                                        (json:with-decoder-simple-list-semantics
                                          (json:decode-json-from-string routes))))))

If you want to randomize your redirect between a list of hosts, you can use a service like Farside and write a router along these lines:

(make-instance 'router:redirector
               :route (match-domain "twitter.com")
               :redirect '(("https://farside.link/nitter/\\1" . ".*://[^/]*/(.*)$")))

Use a router with an exception-based blocklist for https://github.com. These rules allow you to specify two or more predicates to draw the block-list comparison against. In the example below, the first regexp indicates we want to block routes that consist of a single path entry (e.g. https://github.com/path) or block routes except those that contain the /pulls or /search paths. This allows you to provide a general block rule and bypass it for specific routes.

(make-instance 'router:blocker
               :route (match-domain "github.com")
               :blocklist '(or ".*://[^/]*/[^/]*$" (not ".*/pulls.*" ".*/search.*")))