⚒Anvil
Sign in

clo / cl-site public

merged

clnet: the site as one tree in lisp memory 114

opened by common-lisp.net

Dave Cooper (@dcooper) on GitLab, 2026-09-30.

A replacement for the static generator, in clnet/. The site becomes one Common Lisp system on Gendl: every page an object in a single page tree, held in one Lisp image and shared by every visitor, rendered on first demand and kept until something it depends on changes. The old generator code is still in this repository and needs to be scrubbed.

Every address of the old site is answered under the same name (/downloads, /project-intro, /faq/emailonpush ...). The front page, getting started, documentation, features, implementations, community, hosting and about are rewritten as Lisp pages, their lists as data at the top of each file; the long reference pages (libraries, companies, FAQ, projects hub and the rest) are still read from content/, headers and all, and still edited there. Every page links to its source.

One stylesheet, built by Tailwind from the Lisp sources: colours are roles defined once per theme, so the light and the dark theme come from the same markup, with a switch in the navigation bar. No Bootstrap, jQuery, icon font or web font, and no request to a third party from any page: the Planet Lisp and Reddit embeds are gone.

New pages:

  • /registration: how to get a GitLab account, step by step, and a request form. A request is journalled, announced to the admins and kept on a token-protected queue page until marked handled.
  • /foundation: what the Common Lisp Foundation is doing, led by the rebuilt clfoundation container images and Clozure CL on arm64, with a table of the images read from Docker Hub.
  • /services: what is offered free today, and an outline of paid services built on the image pipelines, marked as a proposal.
  • /donate: the fundraising pages of the payments application (clf-apps), as plain forms that hand the donor to Stripe Checkout; a signed webhook journals completed donations. Without credentials the forms lead to a page saying card payments are not switched on.
  • /news: the items of content/news.md as a timeline by year, the three newest on the front page, and an Atom feed. Two items added.

A background thread reads Planet Lisp, the GitLab API (given a token), Docker Hub and the mail exchanger every half hour and hands the results to the pages. It checks, without sending anything, whether the admins' address is accepted; while it is rejected the pages stop printing it. A new image version on Docker Hub leaves a draft news item for a person to take or leave.

11 commits, 128 files changed

Commits
Changes
.gitignoremodifiedview

@@ -9,4 +9,11 @@
 output/

 system-index.txt

 *.dx64fsl

+*.lx64fsl

 *.fasl

+

+# The Gendl site (clnet/): runtime state and local secrets, and the

+# stylesheet build's dependencies

+clnet/data/

+clnet/tailwind/node_modules/

+clnet/deploy/.env

.gitlab-ci.ymldeleted

@@ -1,19 +1,0 @@
-build:
-    stage: build
-    script: make
-    tags:
-        - site-gen
-
-pages:
-    stage: deploy
-    script:
-      - make
-      - mv output public
-    artifacts:
-      paths:
-        - public
-    tags:
-        - site-gen
-    only: 
-        - master
-
Makefiledeleted

@@ -1,33 +1,0 @@
-all: build
-
-static:
-	mkdir -p output/static
-
-build: static
-	sbcl --load build.lisp --non-interactive
-
-clean:
-	rm -rf output
-	mkdir output
-
-serve: static
-	sbcl --load test-serve.lisp
-
-paserve: static 
-	sbcl --load servers/paserve/serve.lisp
-
-zaserve: static 
-	sbcl --load servers/zaserve/serve.lisp
-
-htoot: static 
-	sbcl --load servers/htoot/serve.lisp
-
-woo: static 
-	sbcl --load servers/woo/serve.lisp
-
-clack: static 
-	sbcl --load servers/clack/serve.lisp
-
-cl-http: static 
-	sbcl --load servers/cl-http/serve.lisp
-
README.mdmodifiedview

@@ -1,103 +1,20 @@
-# Common-Lisp.net site generator
+# The common-lisp.net site
 
-## Directory structure
+The site is a Common Lisp program: one page tree, held in one Lisp
+image and shared by every visitor, built on
+[Gendl](https://gitlab.common-lisp.net/gendl/gendl).  It lives in
+[`clnet/`](clnet/README.md), which says how to run it, how it is laid
+out, and how the news, the request line and the donations work.
 
-### Content
+The words of the long pages -- the libraries list, the companies list,
+the FAQ, the projects hub, the news -- are the Markdown and HTML files
+under `content/`, edited the way they always were.  Every page of the
+site links to its own source at the bottom.
 
-The site content is divided over the directories `content/`
-and `layout`.
+The static site generator that produced common-lisp.net from 2018 to
+2026 is in this repository's history, up to the commit that retired it
+in October 2026.
 
-The `layout/` directory holds all content
-that applies to more than one page (e.g. templates) and
-everything that's referenced from such components (e.g.
-logo and other images or CSS), or is used to override
-such a component (e.g. a template which overrides the
-default template). Referred content, such as stylesheets,
-scripts and images are located in `layout/static/*`.
-
-The `content/` directory holds the individual content items (pages)
-and everything that's referred from that content, but not referred
-from layout content. E.g.  the `index.html` page — defining the
-site's front page — is located in `content/` as well as any images
-that are included on the front page but not included on any template
-content. Referred content, such as stylesheets, scripts and images are
-located in `content/static/*`
-
-The `content/` directory can contain both `html` files and `lisp`
-files. `html` files are processed with the template stuff directly
-into the `output/` directory. `lisp` files are expected to have a call
-to `cl-site:add-content` as their toplevel form, which takes arguments
-of `:headers-plist` and `html-string`. See `content/about.lisp` for a
-reference example.
-
-If a file by the same name is stored in both
-`layout/static/` and `content/static/`, then the one
-in `layout/static/` takes precedence. 
-
-
-### Generator Source Code
-
-The source code for the site generator is located in the
-root of the repository. The `t/` directory contains tests
-for the site generator.
-
-### Server Setup Code (optional)
-
-The `servers/` directory contains code for running various CL HTTP
-servers to be able to serve the generated content locally (e.g. while
-authoring/editing).  It has [its own
-README](https://gitlab.common-lisp.net/dcooper/cl-site/blob/master/servers/README.md).
-
-
-## Dependencies
-
-* CL-MUSTACHE for HTML templating
-* CL-YAML for YAML "header" parsing
-* CLOSER-MOP (because...?)
-* CL-WHO for LHTML processing
-* MARKDOWN.CL for Markdown processing
-
-(these are all called out in the cl-site.asd file and will be fetched
- automatically by Quicklisp)
-
-## Execution
-
-
-### From command line, using make:
-
-The Makefile contains various useful commands. 
-
-Among them:
-
-* `make` - to generate the site.
-* `make serve` - to generate the site and start up a default test server. 
-* `make clean` - to remove the generated files from output directory
-
-
-### From Lisp 
-
-(The following assumes you have a reasonably recent Quicklisp loaded)
-
-* To generate the static pages:
-
-     (load ".../cl-site/build.lisp") ;; replace with your actual path to cl-site/
-
-  This will load the site-generating code and generate the static pages into `output/`
-
-
-* To generate the static pages and start the test server:
-
-     (load ".../cl-site/test-serve.lisp") ;; replace with your actual path to cl-site/
-
- This will start serving the site. The toplevel URL should be printed to the console.
-
-### Known Problems
-
- At least one of the test servers crashes on load with `invalid number
- of arguments` on certain 1.3.1 releases of SBCL (apparently a problem
- with the `ironclad` library from quicklisp with this sbcl
- version). If you get this error, feel free to [report the
- issue](https://gitlab.common-lisp.net/clo/cl-site/issues), but you
- might also try upgrading your SBCL or try [another
- implementation](https://common-lisp.net/implementations).
-
+The text of the site is licensed under a
+[Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/);
+the program is under the MIT licence, see `LICENSE`.
build.lispdeleted

@@ -1,12 +1,0 @@
-(in-package :cl-user)
-
-(pushnew (make-pathname :defaults *load-truename* :name nil :type nil)
-	 quicklisp:*local-project-directories* :test #'equalp)
-
-(ql:register-local-projects)
-
-(ql:quickload :cl-site)
-
-(cl-site:make-site)
-
-
cl-site.asddeleted

@@ -1,33 +1,0 @@
-;;;; -*- Mode: Lisp; Syntax: ANSI-Common-Lisp; Base: 10 -*-
-(in-package :cl-user)
-
-(asdf:defsystem cl-site
-  :name "cl-site"
-  :version "0.0.1"
-  :maintainer "Common-Lisp.Net maintainers"
-  :author "C. Yang & Common-Lisp.Net maintainers"
-  :licence "TBD"
-  :description "Static site generator for common-lisp.net, written in CL"
-  :depends-on (:cl-mustache :plump :cl-markdown :closer-mop :cl-who :cl-yaml)
-  :components ((:module source
-                        :pathname ""
-                        :serial t
-                        :depends-on (package)
-                        :components ((:file "patches")
-                                     (:file "globals")
-                                     (:file "helpers")
-                                     (:file "process")
-                                     (:file "main")))
-               (:module package
-                        :pathname ""
-                        :components ((:file "package"))))
-  :in-order-to ((asdf:test-op (asdf:test-op cl-site/t))))
-
-;;(asdf:defsystem cl-site/t
-;;  :defsystem-depends-on (prove-asdf)
-;;  :depends-on (cl-site prove)
-;;  :components ((:module site
-;;                        :pathname "t/"
-;;                        :components ((:test-file "site"))))
-;;  :perform (asdf:test-op (op c)
-;;                         (uiop:symbol-call :prove-asdf 'run-test-system c)))
clnet/README.mdaddedview

@@ -1,0 +1,290 @@
+# clnet: common-lisp.net as one Lisp program
+
+This directory is the site: a single Common Lisp system, `clnet`, built
+on [Gendl](https://gitlab.common-lisp.net/gendl/gendl).  Every page is
+an object in one page tree that lives in one Lisp image and is shared
+by every visitor.  Nothing is generated ahead of time and there is no
+output directory; a page is rendered when it is first asked for and
+kept until something it depends on changes.
+
+It replaced the static site generator that made common-lisp.net from
+2018 to 2026, after a preview at `common-lisp.net/facelift/` in
+September 2026.  The generator is in the repository's history.
+
+## What is here
+
+    clnet/
+      source/        the system: one file per group of pages
+      static/        the stylesheet (built) and the logo
+      tailwind/      the stylesheet's source and its build
+      deploy/        a docker-compose file that runs the site
+      data/          runtime state and local secrets (not in git)
+      clnet.asd      generated; see "Adding a file"
+      dev.lisp       load this into a Gendl image to bring the site up
+    content/         the words of the long pages, and the news
+
+The pages come in two kinds.
+
+- **Pages written in Lisp** (`source/*-pages.lisp`, `assembly.lisp`,
+  `donate.lisp`): the front page, getting started, documentation,
+  hosting, accounts, the Foundation's pages, donations.  Their lists
+  (books, implementations, campaigns, services) are data at the top of
+  each file, and most of their prose is Markdown in a string.
+- **Pages read from `content/`** (`content-page.lisp`): the libraries
+  list, the companies list, the FAQ, the projects hub and the other
+  reference pages stay the Markdown and HTML files they always were,
+  with their `title:` header, and are edited the way they always were.
+
+Every page has an *Edit this page* link to its source on GitLab.
+
+Every address the old site answered is answered here, under the same
+name: `/downloads`, `/project-intro`, `/faq/emailonpush`, and so on.
+
+## Running it
+
+In any Gendl image that can see this checkout:
+
+```lisp
+(load "/path/to/cl-site/clnet/dev.lisp")
+```
+
+Or, with nothing but Docker, from `clnet/deploy/`:
+
+```console
+$ docker compose up -d
+$ curl -H 'Host: clnet.localhost' http://127.0.0.1:9080/
+```
+
+(`CLNET_PORT=8123 docker compose up -d` for another port.  A box's own
+settings -- `CLNET_PORT`, `NTFY_PORT`, `NTFY_BASE_URL` -- can live in
+`clnet/deploy/.env`, which git ignores.)
+
+The container runs two HTTP servers.  The Gendl image's own, on its
+port 9080, carries development endpoints, one of which evaluates any
+Lisp it is sent; it is never published.  `deploy/services-init.lisp`
+starts a second server with nothing on it but the site, and that one is
+what `CLNET_PORT` reaches.  Keep it that way: **do not forward anything
+to the container's port 9080.**  From the machine itself it is how to
+talk to the running site:
+
+```console
+$ docker compose exec clnet curl -s -X POST localhost:9080/lisply/lisp-eval \
+    -d '{"code": "(clnet:refresh!)"}'
+```
+
+After editing a file under `content/`, rebuilding the stylesheet, or
+loading a changed page definition into the running image:
+
+```lisp
+(clnet:refresh!)
+```
+
+### Staying up
+
+Every container restarts on its own after a crash or a reboot of the
+machine (`restart: unless-stopped`; Docker itself starts at boot on a
+Debian host).  The site's Lisp is its container's first process, so a
+crash of the Lisp is an exit of the container, and Docker starts it
+again; with the compiled files cached in the container that takes a
+few seconds.  A Lisp that is alive but not answering is caught by the
+health check, which asks `/clnet-health` every minute, and by the
+`autoheal` container, which restarts whatever has been unhealthy for
+a while.  `docker compose ps` shows the health of each.
+
+To take a new version: `git pull`, then `docker compose restart clnet`
+for a change to the site, or `docker compose up -d --force-recreate
+clnet` when `deploy/services-init.lisp` changed (it is mounted as a
+file, and a pull gives it a new inode that a restart does not see).
+
+This compose file is the small way to run the site; the same start-up
+file loads it into a Basalt deployment as well, and that is the way to
+run it beside the other services of a machine.
+
+## Where it answers
+
+The site is served by host name: at the root of every name in
+`*host-map*` (`source/parameters.lisp`), which is `common-lisp.net`
+and `www.common-lisp.net`, the preview's `facelift.common-lisp.net`,
+and `clnet.localhost` / `clnet.test` for development.  The web server
+in front forwards the whole site to the container and **keeps the Host
+header**, which is how the container knows which site is asked for.
+For Apache, inside the `common-lisp.net` virtual host:
+
+```apache
+    ProxyPreserveHost On
+    RequestHeader set X-Forwarded-Proto "https"
+    # what Apache goes on serving itself comes first
+    ProxyPass /project !
+    ProxyPass        / http://127.0.0.1:9080/
+    ProxyPassReverse / http://127.0.0.1:9080/
+```
+
+`/project/<name>` is the redirect to `<name>.common-lisp.dev` that
+Apache has always answered; anything else the old web server should
+keep serving is excluded the same way.
+
+The preview lived under `/facelift/` on the old site (a second
+instance of the page tree under a path prefix; `*facelift-prefix*`).
+That prefix is retired: `*retired-prefixes*` makes every address under
+it a permanent redirect to the same address at the root, so the links
+handed out during the preview keep working.  Set `*preview?*` to true
+and `*facelift-prefix*` to a segment again to show a future revision
+the same way.
+
+## The news
+
+`content/news.md` holds every item.  Writing news is adding a block at
+the top of that file:
+
+    ### 2026-10-07 *An optional title*
+
+    A paragraph or more of Markdown.
+
+The news page, the three cards on the front page and the Atom feed
+(`/news.atom`) all come from that file.
+
+Keeping it flowing:
+
+- **The habit.**  The Foundation's board meets monthly.  The last item
+  of each meeting is "what changed since last time?", and the answer
+  is a news item, written there and then.
+- **The nudge.**  The site watches the `clfoundation` images on Docker
+  Hub.  When one appears in a version it has not seen before it writes
+  a draft item to `data/news-drafts.md` and shows it on the admins'
+  queue page (`/admin/requests?token=...`).  A draft is never published by itself; someone copies
+  what is worth saying into `news.md`.
+- **What needs no writing.**  The front page's "recently active
+  projects" and "Planet Lisp" blocks and the Foundation page's table of
+  images are read from their sources every half hour
+  (`source/live.lisp`), so the site shows signs of life between items.
+
+## Requests to the admins
+
+The account page and the services page carry a form
+(`source/requests.lisp`).  A request is appended to
+`data/requests.sexp`, pushed through [ntfy](https://ntfy.sh) to
+everyone subscribed to the topics in `data/ntfy-topics`, and listed on
+the queue page until an admin marks it handled.  Nothing is created
+automatically.  The push carries the whole request, and a tap on it
+opens a mail to the person who asked.
+
+An admin hears of requests by subscribing to the topic in the ntfy app
+(Android, iOS) or in a browser.  Because a request carries a name and a
+mail address, the topic lives on an ntfy server of our own, which the
+compose file runs beside the site and which is closed to everyone not
+given access:
+
+```console
+$ cd clnet/deploy
+$ NTFY_BASE_URL=https://ntfy.common-lisp.net docker compose up -d
+$ ./ntfy-setup site            # once: the site's user, its token, ../data/ntfy-topics
+$ ./ntfy-setup admin alice     # once per admin; asks for a password
+$ ./ntfy-setup queue           # the queue page's token and address
+```
+
+The script writes the files under `data/` from inside the site's
+container, so they belong to the user the site runs as.  If the
+checkout belongs to another uid than the container's (1000), the
+script says so and asks for `chmod 1777 clnet/data` first.
+
+ntfy needs a host name of its own (it cannot live under a path), so
+the web server in front gets one more virtual host, with a certificate
+for that name:
+
+```apache
+<VirtualHost *:80>
+    ServerName ntfy.common-lisp.net
+    Redirect permanent / https://ntfy.common-lisp.net/
+</VirtualHost>
+
+<VirtualHost *:443>
+    ServerName ntfy.common-lisp.net
+    SSLEngine on
+    SSLCertificateFile    /etc/letsencrypt/live/ntfy.common-lisp.net/fullchain.pem
+    SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.common-lisp.net/privkey.pem
+    ProxyPreserveHost On
+    SetEnv proxy-nokeepalive 1
+    SetEnv proxy-sendchunked 1
+    ProxyPass        / http://127.0.0.1:9081/ upgrade=websocket
+    ProxyPassReverse / http://127.0.0.1:9081/
+</VirtualHost>
+```
+
+Each admin then adds that server in the ntfy app, signs in with the
+name and password made for them, and subscribes to `clnet-requests`.
+The queue page (`/admin/requests?token=...`) has a button that sends a
+test push and says whether the server took it.
+
+The same form could post to any other ntfy server: `data/ntfy-topics`
+is a list of topic addresses, one per line.
+
+The site also checks every few hours whether the mail addresses its
+pages print (`*account-request-email*`, `*admin-email*`) are accepted
+by the mail server, without sending anything.  While one is rejected,
+the pages stop printing it and the queue page says so in red.
+
+## Donations
+
+`source/donate.lisp`.  The campaigns are the Foundation's own costs
+(hosting, domain names, the general fund); the fundraisers for
+individual projects are paused.
+
+The donor never leaves the site.  A campaign page is a plain form; its
+button asks Stripe for a Checkout session in embedded mode, and
+Stripe's payment form opens in the page where the donation form was.
+Card details go from the browser straight to Stripe, and Stripe's
+script is fetched only once the button has been pressed.  Afterwards
+the browser comes back to `/donate/return`, which asks Stripe what
+became of the session, appends a completed donation to
+`data/donations.sexp` and pushes a line to the admins' ntfy topics.
+A webhook at `/donate/webhook` does the same from Stripe's side and is
+optional.  A browser without script gets the same Checkout on Stripe's
+own page.  Without credentials the form says that card payments are not
+switched on.
+
+## Files in `data/`
+
+None is required; each switches something on.
+
+| File | What it is |
+| --- | --- |
+| `admin-token` | One line, 16 characters or more.  Opens `/admin/requests?token=...`. |
+| `ntfy-topics` | One line per ntfy topic to push requests and donations to: its address, optionally followed by an access token. |
+| `gitlab-token` | A GitLab token with `read_api`, for the "recently active projects" block. |
+| `stripe-credentials.txt` | `Test secret: ...`, `Test pub: ...`, `Live secret: ...`, `Live pub: ...`, and for the webhook `Test webhook secret: ...`, `Live webhook secret: ...` |
+| `local-config.lisp` | Lisp forms loaded before the site is published by `deploy/services-init.lisp`. |
+
+`CLNET_DATA_DIR` in the environment moves the directory elsewhere.
+
+## The stylesheet
+
+`static/css/style.css` is built by Tailwind from `tailwind/input.css`,
+which scans the Lisp sources for class names:
+
+```console
+$ cd clnet/tailwind
+$ npm install        # once
+$ npm run build      # or: npm run dev, to rebuild on every change
+```
+
+The built file is committed, so running the site needs no Node.  The
+colours are roles (ground, ink, accent ...) defined once per theme at
+the top of `input.css`; a page never names a colour, which is how the
+light and the dark theme come from the same markup.
+
+## Adding a file
+
+`clnet.asd` is generated.  Add the file's name to
+`source/file-ordering.isc`, then, in a Gendl image:
+
+```lisp
+(gdl:cl-lite "/path/to/cl-site/clnet/" :create-asd-file? t)
+```
+
+and commit the regenerated `clnet.asd` with the file.
+
+## Adding a page
+
+Define the page as a `base-site-page` (or let `content-page` read a
+file), and add it to the `:objects` of its parent in `assembly.lisp`.
+The child's name is its address.
clnet/author.iscaddedview

@@ -1,0 +1 @@
+"Common-Lisp.Net maintainers"
clnet/clnet.asdaddedview

@@ -1,0 +1,19 @@
+;;;; -*- coding: utf-8 -*-
+
+;;; Do not edit; generated by (cl-lite "/projects/gitlab.clnet/clo/cl-site/clnet/" :create-asd-file? t)
+
+(asdf:defsystem #:clnet :description
+ "The Gendl™ common-lisp.net site: one shared page tree" :author
+ "Common-Lisp.Net maintainers" :license "MIT" :serial t :version
+ "20260930" :depends-on
+ (:cl-ppcre :yason :drakma :flexi-streams :ironclad :usocket
+  :bordeaux-threads)
+ :components
+ ((:file "source/package") (:file "source/parameters")
+  (:file "source/content") (:file "source/base-site-page")
+  (:file "source/site-chrome") (:file "source/content-page")
+  (:file "source/news") (:file "source/live")
+  (:file "source/requests") (:file "source/donate")
+  (:file "source/learn-pages") (:file "source/project-pages")
+  (:file "source/foundation-pages") (:file "source/assembly")
+  (:file "source/sitemap") (:file "source/initialize")))
clnet/depends-on.iscaddedview

@@ -1,0 +1 @@
+(:cl-ppcre :yason :drakma :flexi-streams :ironclad :usocket :bordeaux-threads)
clnet/deploy/docker-compose.ymladdedview

@@ -1,0 +1,96 @@
+# The common-lisp.net site in one container: a Gendl image (Clozure CL),
+# this checkout mounted into it, and the start-up file beside this one.
+#
+#   cd clnet/deploy && docker compose up -d
+#
+# The site then answers on 127.0.0.1:${CLNET_PORT:-9080}, for a web
+# server on the same machine to forward to; see ../README.md for the
+# Apache lines.  State (requests, tokens, credentials) lives in
+# ../data/, which the container must be able to write: it runs as
+# uid 1000.
+#
+# Beside it runs an ntfy server of our own, on
+# 127.0.0.1:${NTFY_PORT:-9081}, for the pushes that tell the admins of
+# a request; ./ntfy-setup makes its users.  See "Requests to the
+# admins" in ../README.md.
+#
+# Staying up: every container restarts on its own after a crash or a
+# reboot of the machine (restart: unless-stopped).  The site's Lisp is
+# the container's first process, so a crash of the Lisp is an exit of
+# the container, and Docker restarts it.  A Lisp that is alive but
+# no longer answering is the health check's business: it asks the
+# site every minute, and the autoheal container restarts whatever
+# has been unhealthy for a while.
+
+services:
+  clnet:
+    image: ${CLNET_IMAGE:-genworks/gendl:devo-ccl}
+    restart: unless-stopped
+    # The image ends in a Lisp prompt, which leaves at end of file:
+    # keep its standard input open.
+    stdin_open: true
+    tty: true
+    environment:
+      START_HTTP: "true"
+      HTTP_PORT: "9080"
+      START_SWANK: "false"
+    ports:
+      # 9090 is the server that carries the site and nothing else.
+      # Never publish 9080: that is the image's development server,
+      # and it evaluates whatever Lisp it is sent.
+      - "127.0.0.1:${CLNET_PORT:-9080}:9090"
+    volumes:
+      - ../..:/site
+      - ./services-init.lisp:/home/gendl-user/gdl-services-init.cl:ro
+    healthcheck:
+      test: ["CMD", "curl", "-sf", "http://localhost:9090/clnet-health"]
+      interval: 60s
+      timeout: 10s
+      retries: 3
+      # loading and compiling the site on a cold start takes a while
+      start_period: 300s
+    labels:
+      autoheal: "true"
+
+  ntfy:
+    image: binwiederhier/ntfy
+    command: serve
+    restart: unless-stopped
+    environment:
+      NTFY_BASE_URL: ${NTFY_BASE_URL:-http://127.0.0.1:9081}
+      NTFY_BEHIND_PROXY: "true"
+      NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
+      NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
+      # nobody reads or writes a topic without being given access
+      NTFY_AUTH_DEFAULT_ACCESS: deny-all
+      # the web app offers a sign-in, for the admins ./ntfy-setup made
+      NTFY_ENABLE_LOGIN: "true"
+      # Instant delivery to iPhones goes through Apple by way of
+      # ntfy.sh; only a message's id is sent there, never its text.
+      NTFY_UPSTREAM_BASE_URL: https://ntfy.sh
+    ports:
+      - "127.0.0.1:${NTFY_PORT:-9081}:80"
+    volumes:
+      - ntfy:/var/lib/ntfy
+    healthcheck:
+      test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:80/v1/health || exit 1"]
+      interval: 60s
+      timeout: 10s
+      retries: 3
+    labels:
+      autoheal: "true"
+
+  # Restarts a container that has been unhealthy for a while.  It
+  # needs the Docker socket for that, and nothing else.
+  autoheal:
+    image: willfarrell/autoheal:latest
+    restart: unless-stopped
+    environment:
+      AUTOHEAL_CONTAINER_LABEL: autoheal
+      AUTOHEAL_INTERVAL: "30"
+      AUTOHEAL_START_PERIOD: "300"
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock
+
+volumes:
+  ntfy:
clnet/deploy/ntfy-setupaddedview

@@ -1,0 +1,79 @@
+#!/bin/sh
+#
+# Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see LICENSE
+# at the root of this repository.
+#
+# Set up what the admins need: the ntfy server that docker-compose.yml
+# runs beside the site, and the queue page.  Run from this directory,
+# with the containers up.
+#
+#   ./ntfy-setup site          once: make the site's own ntfy user, let
+#                              it publish to the topic, and write its
+#                              token to ../data/ntfy-topics
+#   ./ntfy-setup admin NAME    once per admin: make an ntfy user who may
+#                              read the topic (asks for a password, or
+#                              takes it from NTFY_PASSWORD)
+#   ./ntfy-setup queue         make the queue page's token if there is
+#                              none, and print the page's address
+#
+# An admin then subscribes, in the ntfy app or in a browser, to the
+# topic on this server with that name and password.
+#
+# The files under ../data are written from inside the site's container,
+# so that they belong to the user the site runs as (uid 1000) whoever
+# runs this script.
+
+set -e
+
+TOPIC=${NTFY_TOPIC:-clnet-requests}
+
+in_site() { docker compose exec -T clnet "$@"; }
+in_ntfy() { docker compose exec -T ntfy "$@"; }
+
+# The site must be able to write its data directory.  In a checkout that
+# belongs to another uid than the container's it cannot, until the
+# directory is opened the way /tmp is.
+mkdir -p ../data
+if ! in_site sh -c 'touch /site/clnet/data/.writable && rm /site/clnet/data/.writable' 2>/dev/null; then
+    echo "The site's container cannot write clnet/data.  Open the directory to it:" >&2
+    echo "    chmod 1777 $(cd .. && pwd)/data" >&2
+    echo "and run this again." >&2
+    exit 1
+fi
+
+case "$1" in
+    site)
+        if ! in_ntfy ntfy user list 2>&1 | grep -q '^user clnet-site '; then
+            docker compose exec -T \
+                   -e NTFY_PASSWORD="$(head -c 24 /dev/urandom | base64 | tr -d '/+=')" \
+                   ntfy ntfy user add clnet-site
+        fi
+        in_ntfy ntfy access clnet-site "$TOPIC" write-only
+        token=$(in_ntfy ntfy token add clnet-site 2>&1 | grep -o 'tk_[a-z0-9]*' | head -1)
+        [ -n "$token" ] || { echo "no token came back" >&2; exit 1; }
+        rm -f ../data/ntfy-topics 2>/dev/null || true
+        echo "http://ntfy/$TOPIC $token" \
+            | in_site sh -c 'umask 077; cat > /site/clnet/data/ntfy-topics'
+        echo "clnet/data/ntfy-topics written: the site publishes to $TOPIC"
+        ;;
+    admin)
+        [ -n "$2" ] || { echo "usage: $0 admin NAME" >&2; exit 2; }
+        if [ -n "$NTFY_PASSWORD" ]; then
+            docker compose exec -T -e NTFY_PASSWORD="$NTFY_PASSWORD" ntfy ntfy user add "$2"
+        else
+            docker compose exec ntfy ntfy user add "$2"
+        fi
+        in_ntfy ntfy access "$2" "$TOPIC" read-only
+        echo "$2 may read $TOPIC"
+        ;;
+    queue)
+        in_site sh -c 'umask 077; f=/site/clnet/data/admin-token
+                       [ -s "$f" ] || head -c 24 /dev/urandom | base64 | tr -d "/+=" > "$f"
+                       echo "The queue page, under the address the site answers at:"
+                       echo "    /admin/requests?token=$(cat "$f")"'
+        ;;
+    *)
+        echo "usage: $0 site | admin NAME | queue" >&2
+        exit 2
+        ;;
+esac
clnet/deploy/services-init.lispaddedview

@@ -1,0 +1,43 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+;;;;
+;;;; What the Gendl container loads when it starts (docker-compose.yml
+;;;; beside this file mounts it where the image looks for a start-up
+;;;; file): bring the site up from the checkout mounted at /site.
+
+(in-package :gdl-user)
+
+;; THE PUBLIC SERVER.  The image's own HTTP server, on port 9080,
+;; carries the image's development endpoints, and one of them evaluates
+;; any Lisp it is sent.  That server must never be reachable from
+;; outside, so it is left where it is, inside the container, and the
+;; site is given a second server that has nothing on it but the site.
+;; Port 9090 is the only one docker-compose.yml publishes.  Everything
+;; the site publishes from here on goes to the new server, because
+;; gwl:*http-server* is where Gendl publishes.
+(defparameter *development-server* gwl:*http-server*)
+
+(setq gwl:*http-server*
+      (net.aserve:start :port 9090 :server :new :listeners 25
+                        :external-format :utf-8))
+
+(load-quicklisp)
+
+;; The system is handed to ASDF by its file, not registered as a
+;; Quicklisp local project: that would write an index file into the
+;; checkout, which belongs to whoever cloned it and is often not the
+;; user this container runs as.  Nothing here writes to /site but the
+;; site's own data directory.
+(asdf:load-asd #p"/site/clnet/clnet.asd")
+(ql:quickload :clnet)
+
+;; Settings for this box, if it has any: a file of Lisp forms kept with
+;; the rest of the box's state, outside git.  For example
+;;   (setq clnet:*preview?* nil)
+;;   (setq clnet:*live-mode?* t)
+(let ((local (probe-file "/site/clnet/data/local-config.lisp")))
+  (when local (load local)))
+
+(clnet:initialize!)
clnet/description.iscaddedview

@@ -1,0 +1 @@
+"common-lisp.net site: one shared page tree"
clnet/dev.lispaddedview

@@ -1,0 +1,23 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+;;;;
+;;;; Load this file into a Gendl image to bring the site up in it:
+;;;;
+;;;;   (load "/path/to/cl-site/clnet/dev.lisp")
+;;;;
+;;;; See README.md in this directory.
+
+(in-package :gdl-user)
+
+(defparameter *clnet-directory*
+  (make-pathname :defaults (glisp:source-pathname) :name nil :type nil))
+
+(load-quicklisp)
+
+(pushnew *clnet-directory* ql:*local-project-directories* :test #'equalp)
+(ql:register-local-projects)
+(ql:quickload :clnet)
+
+(clnet:initialize!)
clnet/license.iscaddedview

@@ -1,0 +1 @@
+"MIT"
clnet/source/assembly.lispaddedview

@@ -1,0 +1,311 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; The root of the page tree, which is also the front page.  Every
+;;;; other page is one of its children, named as the old static site
+;;;; named its files, so that every address the old site answered is
+;;;; still answered: /downloads, /project-intro, /faq/emailonpush ...
+
+(defparameter *hero-repl*
+  "CL-USER> (defun greet (name)
+           (format nil \"Hello, ~a!\" name))
+GREET
+CL-USER> (greet \"world\")
+\"Hello, world!\"
+CL-USER> (mapcar #'greet '(\"Lisp\" \"Lispers\"))
+(\"Hello, Lisp!\" \"Hello, Lispers!\")
+CL-USER> (defun greet (name)   ; redefine it while it runs
+           (format nil \"Welcome back, ~a.\" name))
+GREET
+CL-USER> (greet \"world\")
+\"Welcome back, world.\""
+  "String. The session in the front page's code panel.")
+
+(defparameter *front-page-features*
+  '(("Interactive"
+     "There is no separate compile, run and debug cycle. A program is grown while it runs: functions are redefined on the fly, and every object can be inspected at any time.")
+    ("Stable"
+     "An ANSI standard since 1994. Programs written decades ago still run unmodified, and the language keeps up through its own extensibility rather than through breaking changes.")
+    ("Fast"
+     "Native-code compilers, optional type declarations and control over safety and speed put well-written Lisp within reach of C.")
+    ("Extensible"
+     "Macros make the language programmable. Build the idioms your problem needs and write the solution in them.")
+    ("Multi-paradigm"
+     "Functional, imperative and object-oriented styles side by side, with CLOS, multiple dispatch and the metaobject protocol when you want them.")
+    ("Dependable"
+     "The condition system lets a running program recover from errors without unwinding, which is why Lisp suits long-running and critical software."))
+  "List of (title text) for the front page's feature grid.")
+
+(define-object clnet-assembly (base-site-page site-chrome-mixin)
+
+  :documentation
+  (:description "The common-lisp.net site: the front page, and the
+parent of every other page.  One shared instance serves every visitor
+to the hosts that map to it.")
+
+  :computed-slots
+  ((page-title "Common-Lisp.net")
+   (title "Common-Lisp.net: a home for Common Lisp")
+   (meta-description "A gateway to Common Lisp: how to get started, where the community meets, and free hosting for open source Lisp projects, run by the Common Lisp Foundation.")
+   (clean-url (the home-url))
+   (source-path "clnet/source/assembly.lisp")
+
+   ("Plist. What the refresher last learned from Planet Lisp, GitLab,
+Docker Hub and the mail server (live.lisp).  Set from the refresher's
+thread; the pages that read it re-render when it changes."
+    live *live* :settable)
+
+   (page-content
+    (with-lhtml-string ()
+      (str (the hero-html))
+      (str (the doors-html))
+      (str (the features-html))
+      (str (the news-html))
+      (str (the live-html))
+      (str (the support-html))))
+
+   (hero-html
+    (with-lhtml-string ()
+      (:section :class "border-b border-rule bg-band"
+        (:div :class (string-append *container* " grid items-center gap-12 py-14 sm:py-20 lg:grid-cols-[1.05fr_1fr]")
+          (:div
+            (:p :class *eyebrow* "Common Lisp")
+            (:h1 :class "mt-4 text-4xl font-semibold tracking-tight text-ink sm:text-5xl lg:text-[3.4rem] lg:leading-[1.08]"
+              "The programmable programming language.")
+            (:p :class "mt-6 max-w-xl text-lg leading-relaxed text-body"
+              "Common Lisp is the modern, multi-paradigm, high-performance, compiled, ANSI-standardized descendant of the long-running family of Lisp languages. This site is one of its gateways: a place to start, and a home for the projects and people who build with it.")
+            (:div :class "mt-8 flex flex-wrap items-center gap-3"
+              (:a :class *button* :href (the downloads clean-url) "Get started")
+              (:a :class *button-quiet* :href (the project-intro clean-url) "Host a project")
+              (:a :class (string-append *arrow-link* " ml-1") :href (the features clean-url)
+                "Why Lisp " (:span :aria-hidden "true" "→"))))
+          (:div :class "min-w-0"
+            (:div :class "overflow-hidden rounded-2xl bg-code shadow-xl ring-1 ring-black/10"
+              (:div :class "flex items-center gap-2 border-b border-white/10 px-4 py-2.5"
+                (:span :class "size-2.5 rounded-full bg-white/20")
+                (:span :class "size-2.5 rounded-full bg-white/20")
+                (:span :class "size-2.5 rounded-full bg-white/20")
+                (:span :class "ml-2 font-mono text-xs text-code-dim" "REPL"))
+              (:pre :class "overflow-x-auto p-5 font-mono text-[0.82rem] leading-relaxed text-code-ink"
+                (:code (esc *hero-repl*)))))))))
+
+   (doors-html
+    (with-lhtml-string ()
+      (:section :class (string-append *container* " py-14 sm:py-16")
+        (:div :class "grid gap-6 md:grid-cols-3"
+          (:a :class *card-link* :href (the downloads clean-url)
+            (:p :class *eyebrow* "Learn")
+            (:h2 :class (string-append *card-title* " mt-2 group-hover:text-accent") "Start with Lisp")
+            (:p :class *card-body*
+              "Install an implementation, set up an editor, and find the books and tutorials that Lispers recommend."))
+          (:a :class *card-link* :href (the project-intro clean-url)
+            (:p :class *eyebrow* "Build")
+            (:h2 :class (string-append *card-title* " mt-2 group-hover:text-accent") "Host your project here")
+            (:p :class *card-body*
+              "Git on our own GitLab, CI that tests against every major implementation, project web pages and mailing lists, free for open source Common Lisp."))
+          (:a :class *card-link* :href (the community clean-url)
+            (:p :class *eyebrow* "Meet")
+            (:h2 :class (string-append *card-title* " mt-2 group-hover:text-accent") "Find the community")
+            (:p :class *card-body*
+              "IRC, mailing lists, Planet Lisp, the European Lisp Symposium, and the companies that build on Lisp."))))))
+
+   (features-html
+    (with-lhtml-string ()
+      (:section :class "border-y border-rule bg-band"
+        (:div :class (string-append *container* " py-14 sm:py-16")
+          (:div :class "max-w-2xl"
+            (:h2 :class *h2* "What is Common Lisp?")
+            (:p :class (string-append *lead* " mt-4")
+              "A language known for being extremely flexible, with excellent support for object-oriented programming and fast prototyping. Its macro system lets you tailor the language to your application, and its run-time environment lets you modify and debug a program while it runs."))
+          (:dl :class "mt-10 grid gap-x-10 gap-y-8 sm:grid-cols-2 lg:grid-cols-3"
+            (loop for (name text) in *front-page-features*
+                  do (htm (:div
+                            (:dt :class "flex items-center gap-2 font-semibold text-ink"
+                              (:span :class "font-mono text-accent" :aria-hidden "true" "(")
+                              (str name)
+                              (:span :class "font-mono text-accent" :aria-hidden "true" ")"))
+                            (:dd :class "mt-2 text-[0.95rem] leading-relaxed text-body"
+                              (str text))))))
+          (:p :class "mt-10"
+            (:a :class *arrow-link* :href (the features clean-url)
+              "More about the language " (:span :aria-hidden "true" "→")))))))
+
+   (news-html
+    (with-lhtml-string ()
+      (:section :class (string-append *container* " py-14 sm:py-16")
+        (:div :class "flex flex-wrap items-end justify-between gap-4"
+          (:div
+            (:p :class *eyebrow* "From the Foundation")
+            (:h2 :class (string-append *h2* " mt-2") "Latest news"))
+          (:a :class *arrow-link* :href (the news clean-url)
+            "All the news " (:span :aria-hidden "true" "→")))
+        (:div :class "mt-8 grid gap-6 lg:grid-cols-3"
+          (dolist (item (the news latest))
+            (htm (:a :class (string-append *card-link* " flex flex-col")
+                     :href (format nil "~a#~a" (the news clean-url) (getf item :id))
+                   (:p :class "font-mono text-xs text-muted"
+                     (:time :datetime (getf item :date)
+                       (str (long-date (getf item :date)))))
+                   (when (getf item :title)
+                     (htm (:h3 :class (string-append *card-title* " mt-2 group-hover:text-accent")
+                            (str (getf item :title)))))
+                   (:p :class "cl-clamp mt-2 text-[0.95rem] leading-relaxed text-body"
+                     (str (strip-tags (getf item :summary)))))))))))
+
+   ;; Two live columns; either may be missing, and the whole section
+   ;; goes when both are.
+   (live-html
+    (let ((gitlab (getf (the live) :gitlab))
+          (planet (getf (the live) :planet)))
+      (if (or gitlab planet)
+          (with-lhtml-string ()
+            (:section :class "border-y border-rule bg-band"
+              (:div :class (string-append *container* " grid gap-12 py-14 sm:py-16 lg:grid-cols-2")
+                (when gitlab
+                  (htm (:div
+                         (:p :class *eyebrow* "On our GitLab")
+                         (:h2 :class (string-append *h2* " mt-2") "Recently active projects")
+                         (:ul :class "mt-6 divide-y divide-rule"
+                           (dolist (project (getf gitlab :projects))
+                             (htm (:li :class "py-3"
+                                    (:a :class "font-mono text-sm font-semibold text-accent hover:text-accent-strong"
+                                        :href (html-escape (getf project :url))
+                                      (str (html-escape (getf project :name))))
+                                    (let ((description (getf project :description)))
+                                      (when (and (stringp description) (plusp (length description)))
+                                        (htm (:p :class "mt-0.5 line-clamp-1 text-sm text-body"
+                                               (str (html-escape description))))))))))
+                         (:p :class "mt-5 text-sm text-muted"
+                           (when (getf gitlab :total)
+                             (htm (fmt "~:d public projects. " (getf gitlab :total))))
+                           (:a :class *link* :href (format nil "~a/explore/projects" *gitlab-url*)
+                             "Explore them all")))))
+                (when planet
+                  (htm (:div
+                         (:p :class *eyebrow* "Around the community")
+                         (:h2 :class (string-append *h2* " mt-2") "Planet Lisp")
+                         (:ul :class "mt-6 divide-y divide-rule"
+                           (dolist (post planet)
+                             (htm (:li :class "py-3"
+                                    (:a :class "text-[0.95rem] font-medium text-ink hover:text-accent"
+                                        :href (html-escape (getf post :link))
+                                      (str (html-escape (getf post :title))))))))
+                         (:p :class "mt-5 text-sm text-muted"
+                           "Lisp blogs, collected at "
+                           (:a :class *link* :href "https://planet.lisp.org/" "planet.lisp.org")
+                           ".")))))))
+          "")))
+
+   (support-html
+    (with-lhtml-string ()
+      (:section :class (string-append *container* " py-14 sm:py-16")
+        (:div :class "grid gap-6 lg:grid-cols-3"
+          (:div :class *card*
+            (:h2 :class *card-title* "Services")
+            (:p :class *card-body*
+              (:a :class *link* :href (the project-intro clean-url) "Project hosting")
+              " for open source Common Lisp projects, "
+              (:a :class *link* :href (the independent-lists clean-url) "mailing lists")
+              " for Lisp topics, and "
+              (:a :class *link* :href (the foundation clean-url) "container images")
+              " of the Lisp implementations. "
+              (:a :class *link* :href (the services clean-url) "See what we run")
+              "."))
+          (:div :class *card*
+            (:h2 :class *card-title* "Support")
+            (:p :class *card-body*
+              "Stuck with something on this site or on our GitLab? Ask in "
+              (:code :class "font-mono text-sm text-ink" (str *irc-channel*))
+              " on Libera.Chat, or "
+              (:a :class *link* :href (the registration clean-url) "send the admins a request")
+              "."))
+          (:div :class *card*
+            (:h2 :class *card-title* "Keep it running")
+            (:p :class *card-body*
+              "The site is run by volunteers and paid for by the Common Lisp Foundation. "
+              (:a :class *link* :href (the donate clean-url) "Donations")
+              " and "
+              (:a :class *link* :href (the contribute clean-url) "helping hands")
+              " are both welcome.")))))))
+
+  :objects
+  (;; Learn
+   (downloads :type 'downloads)
+   ;; `documentation' is a message every Gendl object already answers,
+   ;; so the child is `docs' and says its own address
+   (docs :type 'documentation-page)
+   (features :type 'features)
+   (implementations :type 'implementations)
+   (community :type 'community)
+   (tutorials :type 'content-page :file "tutorials.html" :eyebrow "Learn"
+              :title-override "Tutorials")
+   (why :type 'content-page :file "why.html" :eyebrow "Learn"
+        :title-override "Why Common Lisp?")
+
+   ;; Projects
+   (project-intro :type 'project-intro)
+   (registration :type 'registration)
+   (phub :type 'content-page :file "phub.html" :eyebrow "Projects" :wide? t
+         :title-override "Projects hub")
+   (orphaned-projects :type 'content-page :file "orphaned-projects.html"
+                      :eyebrow "Projects")
+   (orphaned-mailing-lists :type 'content-page :file "orphaned-mailing-lists.html"
+                           :eyebrow "Projects")
+   (independent-lists :type 'content-page :file "independent-lists.html"
+                      :eyebrow "Projects" :title-override "Mailing lists")
+   (gitlab-migration-status :type 'content-page :file "gitlab-migration-status.html"
+                            :eyebrow "History")
+   (gitlab-migration-repository-mapping
+    :type 'content-page :file "gitlab-migration-repository-mapping.html"
+    :eyebrow "History" :wide? t)
+
+   ;; Resources
+   (libraries :type 'content-page :file "libraries.md" :eyebrow "Resources" :wide? t
+              :lead "A curated overview of what is available, by area. For everything else there is Quicklisp.")
+   (lisp-companies :type 'content-page :file "lisp-companies.md" :eyebrow "Resources")
+   (tools :type 'content-page :file "tools.html" :eyebrow "Resources"
+          :title-override "Tools")
+   (faq :type 'faq)
+
+   ;; Foundation
+   (news :type 'news)
+   (foundation :type 'foundation)
+   (services :type 'services)
+   (donate :type 'donate)
+   (contribute :type 'contribute)
+   (about :type 'about)))
+
+(define-object clnet-facelift-assembly (clnet-assembly)
+
+  :documentation
+  (:description "The same site with every address under
+/<*facelift-prefix*>/, for showing it on a path of a host that is busy
+serving something else at its root.")
+
+  :computed-slots
+  ((fixed-url-prefix *facelift-prefix*)))
+
+(define-object faq (content-page)
+
+  :documentation
+  (:description "The FAQ's index, and the parent of its answers.")
+
+  :computed-slots
+  ((file "faq/index.md")
+   (eyebrow "Resources"))
+
+  :objects
+  ((emailonpush :type 'content-page :file "faq/emailonpush.html" :eyebrow "FAQ"
+                :title-override "E-mail on push")
+   (using-gitlab-ci-for-cl :type 'content-page
+                           :file "faq/using-gitlab-ci-for-cl.md" :eyebrow "FAQ")
+   (using-gitlab-ci-multi-project-pipelines
+    :type 'content-page
+    :file "faq/using-gitlab-ci-multi-project-pipelines.md" :eyebrow "FAQ")
+   (using-gitlab-deploy-project-pages
+    :type 'content-page
+    :file "faq/using-gitlab-deploy-project-pages.md" :eyebrow "FAQ")))
clnet/source/base-site-page.lispaddedview

@@ -1,0 +1,196 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;; The page's one script, inline at the top of every <head> so that a
+;;; remembered theme is on <html> before anything paints.  It marks
+;;; <html class="js">, wires the theme switch, closes the navigation
+;;; menus (they are <details> and open without script), and puts a
+;;; random quotation in the footer.  Single quotes throughout: it rides
+;;; in a Lisp string.
+(defparameter *site-script*
+  "(function(){
+var r=document.documentElement,m=window.matchMedia?window.matchMedia('(prefers-color-scheme: dark)'):null;
+r.classList.add('js');
+try{var t=localStorage.getItem('cl-theme');if(t==='dark'||t==='light')r.setAttribute('data-theme',t);}catch(e){}
+function shown(){return r.getAttribute('data-theme')||(m&&m.matches?'dark':'light');}
+document.addEventListener('DOMContentLoaded',function(){
+var i,bs=document.querySelectorAll('.cl-theme-toggle'),ms=document.querySelectorAll('details.cl-menu');
+function flip(){var next=shown()==='dark'?'light':'dark';r.setAttribute('data-theme',next);
+try{localStorage.setItem('cl-theme',next);}catch(e){}}
+for(i=0;i<bs.length;i++)bs[i].addEventListener('click',flip);
+function closeAll(except){for(var j=0;j<ms.length;j++)if(ms[j]!==except)ms[j].open=false;}
+for(i=0;i<ms.length;i++)(function(d){d.addEventListener('toggle',function(){if(d.open)closeAll(d);});})(ms[i]);
+document.addEventListener('click',function(e){for(var j=0;j<ms.length;j++)if(!ms[j].contains(e.target))ms[j].open=false;});
+document.addEventListener('keydown',function(e){if(e.key==='Escape')closeAll(null);});
+var qs=document.querySelectorAll('#cl-quotes > div'),q=document.getElementById('cl-quote');
+if(qs.length&&q)q.innerHTML=qs[Math.floor(Math.random()*qs.length)].innerHTML;
+});})();")
+
+(define-object base-site-page (base-html-page)
+
+  :documentation
+  (:description "A page of the common-lisp.net site: the shared head,
+the navigation bar, a title band, the page's own `main-html', and the
+footer.  A page says its `page-title' and `main-html' and, if it likes,
+a `lead', an `eyebrow' and the `source-path' its Edit link points at.")
+
+  :input-slots
+  (("String. The page's name: its h1 and the front half of its <title>."
+    page-title "Common-Lisp.net")
+   ("String of HTML or nil. A sentence or two under the title."
+    lead nil)
+   ("String or nil. The small label over the title: the part of the
+site the page belongs to."
+    eyebrow nil)
+   ("String or nil. The page's <meta name=description>."
+    meta-description nil)
+   ("String or nil. The file that holds the page's words, relative to
+the repository root; the 'Edit this page' link points at it."
+    source-path nil)
+   ("Boolean. True for a page that search engines and the sitemap
+should leave out: a thank-you page, a form's error page."
+    hide-from-sitemap? nil)
+   ("Boolean. True to give `main-html' the wide column rather than the
+reading column."
+    wide? nil))
+
+  :computed-slots
+  ((title (format nil "~a · Common-Lisp.net" (the page-title)))
+
+   ;; A page of this site needs none of the 3D, ajax or icon-font
+   ;; assets a Gendl page can load, and brings its own stylesheet.
+   (use-x3dom? nil)
+   (use-svgpanzoom? nil)
+   (use-tailwind? nil)
+   (use-fontawesome? nil)
+   (use-anyresize? nil)
+   (use-ajax? nil)
+
+   (favicon-type "image/x-icon")
+   (favicon-path (the (asset "clnet-static" "imgs/favicon.ico")))
+
+   ("String. What every address of this page tree begins with: empty
+for a tree served at a host's root, \"/facelift\" for the prefixed one."
+    url-prefix (let ((prefix (the fixed-url-prefix)))
+                 (if prefix (format nil "/~a" prefix) "")))
+
+   ("String. The address of the front page of this page tree."
+    home-url (format nil "~a/" (the url-prefix)))
+
+   ("String. The page's address without the index.html: the form the
+links use.  Asking for it publishes the page."
+    clean-url (let* ((url (the url))
+                     (tail "index.html")
+                     (cut (- (length url) (length tail))))
+                (if (and (plusp cut) (string= tail url :start2 cut))
+                    (subseq url 0 cut)
+                    url)))
+
+   (additional-header-content
+    (string-append
+     (call-next-method)
+     (with-lhtml-string ()
+       (:script (str *site-script*))
+       (:link :rel "stylesheet"
+              :href (the (asset "clnet-assets" "css/style.css" :versioned? t)))
+       (:link :rel "alternate" :type "application/atom+xml"
+              :title "Common-Lisp.net news"
+              :href (the (path "/news.atom")))
+       (:meta :property "og:title" :content (the page-title))
+       (:meta :property "og:site_name" :content "Common-Lisp.net")
+       (when (the meta-description)
+         (htm (:meta :name "description" :content (the meta-description))
+              (:meta :property "og:description" :content (the meta-description))))
+       (if (or *preview?* (the hide-from-sitemap?))
+           (htm (:meta :name "robots" :content "noindex"))
+           (htm (:link :rel "canonical"
+                       :href (string-append *canonical-origin* (the clean-url))))))))
+
+   ("String or nil. *admin-email*, unless the mail exchanger was last
+seen rejecting it (live.lisp): a page prints the address only while
+mail to it arrives."
+    admin-email (deliverable-address (the root live) *admin-email*))
+
+   ("String or nil. *account-request-email*, on the same condition."
+    account-email (deliverable-address (the root live) *account-request-email*))
+
+   ("String of Markdown. How to reach the admins, for a sentence that
+ends in it: the request form, and the address while it works."
+    ask-the-admins
+    (format nil "[send the admins a request](/registration/#request)~@[ or write to [~a](mailto:~:*~a)~]"
+            (the admin-email)))
+
+   ("String of HTML. The page's own content; it goes into the reading
+column under the title band."
+    main-html "")
+
+   ("String of HTML. Everything between the navigation bar and the
+footer.  The default is the title band and `main-html'; the front page
+replaces it whole."
+    page-content
+    (with-lhtml-string ()
+      (:div :class "border-b border-rule bg-band"
+        (:div :class (string-append *container* " py-10 sm:py-14")
+          (when (the eyebrow)
+            (htm (:p :class *eyebrow* (str (the eyebrow)))))
+          (:h1 :class "mt-2 text-3xl font-semibold tracking-tight text-ink sm:text-4xl"
+            (str (the page-title)))
+          (when (the lead)
+            (htm (:p :class "mt-4 max-w-3xl text-lg leading-relaxed text-body"
+                   (str (the lead)))))))
+      (:div :class (string-append *container* " py-10 sm:py-14")
+        (:div :class (if (the wide?) "" "max-w-3xl")
+          (str (the main-html))
+          (str (the edit-link-html))))))
+
+   (edit-link-html
+    (if (the source-path)
+        (with-lhtml-string ()
+          (:p :class "mt-14 border-t border-rule pt-6 text-sm text-muted"
+            "Something wrong or missing here? "
+            (:a :class *link*
+                :href (format nil "~a/-/blob/~a/~a" *repo-url* *repo-branch*
+                              (the source-path))
+              "Edit this page")
+            " on GitLab."))
+        ""))
+
+   (body
+    (with-lhtml-string ()
+      (:a :href "#main"
+          :class "sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[70] focus:rounded-lg focus:bg-surface focus:px-4 focus:py-2 focus:text-ink"
+        "Skip to the content")
+      (when *preview?*
+        (htm (:div :class "bg-accent-fill px-4 py-2 text-center text-sm text-on-accent"
+               (:strong "Preview.")
+               " This is a proposed new common-lisp.net. The "
+               (:a :class "underline" :href *canonical-origin* "live site")
+               " is unchanged. "
+               (:a :class "underline" :href (format nil "~a/-/issues" *repo-url*)
+                 "Tell us what you think")
+               ".")))
+      (str (the root nav-html))
+      (:main :id "main" (str (the page-content)))
+      (str (the root footer-html)))))
+
+  :functions
+  (("String. The site-relative address RELATIVE (\"/news/\") as this
+page tree answers it."
+    path (relative) (string-append (the url-prefix) relative))
+
+   ("String. The address of the file PATH in the asset tree TREE."
+    asset (tree path &key versioned?)
+          (asset-url (the url-prefix) tree path :versioned? versioned?))
+
+   ("String of HTML. MARKDOWN rendered, with its site-relative links
+made right for this page tree."
+    md (markdown)
+       (rewrite-links (render-markdown markdown) (the url-prefix)))
+
+   ("String of HTML. MARKDOWN rendered and wrapped as prose."
+    prose (markdown)
+          (string-append "<div class=\"cl-prose mt-4\">" (the (md markdown)) "</div>"))))
clnet/source/content-page.lispaddedview

@@ -1,0 +1,56 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+(defun humanize (name)
+  "String. A file's name as a title of last resort."
+  (string-capitalize (substitute #\Space #\- name)))
+
+(define-object content-page (base-site-page)
+
+  :documentation
+  (:description "A page whose words live in a file under content/:
+Markdown (.md) or an HTML fragment (.html), optionally beginning with a
+'title: ...' header closed by a line of three hyphens.  The file is
+read when the page is first asked for; after editing one on a running
+site, call (clnet:refresh!).  This is what keeps the long, reference
+pages of the site -- the libraries list, the companies list, the FAQ --
+editable by anyone who can edit a text file on GitLab.")
+
+  :input-slots
+  (("String. The file, relative to the content/ directory."
+    file)
+   ("String or nil. A title that overrides the file's own."
+    title-override nil))
+
+  :computed-slots
+  ((pathname* (merge-pathnames (the file) *content-directory*))
+
+   (kind (if (string-equal (pathname-type (the pathname*)) "md") :md :html))
+
+   ;; (header-alist body)
+   (parsed (multiple-value-list
+            (split-header (read-text-file (the pathname*)))))
+
+   ;; (html h1-text headings)
+   (rendered (multiple-value-list
+              (render-content (second (the parsed)) (the kind) (the url-prefix))))
+
+   ;; the header's title, less the suffix the FAQ files gave theirs
+   ;; for the old site's <title>
+   (page-title (ppcre:regex-replace
+                "\\s+--\\s+Common-Lisp\\.net FAQ$"
+                (or (the title-override)
+                    (cdr (assoc "title" (first (the parsed)) :test #'string=))
+                    (second (the rendered))
+                    (humanize (pathname-name (the pathname*))))
+                ""))
+
+   (source-path (format nil "content/~a" (the file)))
+
+   (main-html (string-append "<article class=\"cl-prose\">"
+                             (first (the rendered))
+                             "</article>"))))
clnet/source/content.lispaddedview

@@ -1,0 +1,224 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; Text in, HTML out: reading the files under content/, their small
+;;;; "key: value" headers, Markdown, and the repairs the older
+;;;; hand-written HTML needs before it can sit in a page of this site.
+;;;; Plain functions; the page objects call them.
+
+(defun read-text-file (path)
+  "String. The contents of the UTF-8 file at PATH."
+  (with-open-file (in path :external-format :utf-8)
+    (let ((text (make-string (file-length in))))
+      (subseq text 0 (read-sequence text in)))))
+
+(defun html-escape (string)
+  "String. STRING with the characters HTML cares about escaped."
+  (cl-who:escape-string (or string "")))
+
+(defun strip-tags (html)
+  "String. HTML with its tags removed and its white space collapsed."
+  (string-trim " "
+               (ppcre:regex-replace-all
+                "\\s+" (ppcre:regex-replace-all "<[^>]+>" html "") " ")))
+
+(defun slugify (text)
+  "String. TEXT as a fragment identifier: lower case, runs of anything
+else turned into single hyphens."
+  (let ((slug (string-trim "-" (ppcre:regex-replace-all
+                                "[^a-z0-9]+" (string-downcase text) "-"))))
+    (if (zerop (length slug)) "section" slug)))
+
+;;; Headers.  A content file may begin with lines of "key: value",
+;;; closed by a line of three hyphens.
+
+(defun split-lines (text)
+  (mapcar (lambda (line) (string-right-trim '(#\Return) line))
+          (ppcre:split "\\n" text)))
+
+(defun header-line-p (line)
+  (or (zerop (length (string-trim " " line)))
+      (ppcre:scan "^[A-Za-z][A-Za-z0-9_-]*:" line)
+      (ppcre:scan "^\\s+-\\s" line)))
+
+(defun split-header (text)
+  "Two values: the header of TEXT as an alist of (key . value) strings,
+and the body that follows it.  A text without a header is all body."
+  (let* ((lines (split-lines text))
+         (rule (position "---" lines :test #'string=)))
+    (if (and rule (plusp rule) (every #'header-line-p (subseq lines 0 rule)))
+        (values (loop for line in (subseq lines 0 rule)
+                      for colon = (position #\: line)
+                      when (and colon (ppcre:scan "^[A-Za-z]" line))
+                        collect (cons (string-downcase (subseq line 0 colon))
+                                      (string-trim " " (subseq line (1+ colon)))))
+                (format nil "~{~a~%~}" (nthcdr (1+ rule) lines)))
+        (values nil text))))
+
+;;; Links.  The content files write site-relative addresses as the old
+;;; static site had them; here they are given the prefix the page tree
+;;; answers under, /static/ is sent to the published image tree, and
+;;; /project/<name> -- which the front-end web server redirects to
+;;; <name>.common-lisp.dev -- is sent to the public host.
+
+(defun rewrite-links (html url-prefix)
+  "String. HTML with its root-relative href and src attributes made
+right for a page tree answering under URL-PREFIX."
+  (ppcre:regex-replace-all
+   "(href|src)=\"/(?!/)([^\"]*)\""
+   html
+   (lambda (match attribute path)
+     (declare (ignore match))
+     (format nil "~a=\"~a\"" attribute
+             (cond ((ppcre:scan "^static/" path)
+                    (format nil "~a/clnet-static/~a" url-prefix (subseq path 7)))
+                   ((ppcre:scan "^project/" path)
+                    (format nil "~a/~a" *canonical-origin* path))
+                   (t (format nil "~a/~a" url-prefix path)))))
+   :simple-calls t))
+
+;;; Headings: every one gets an id, and the list of them is the raw
+;;; material of a table of contents.
+
+(defun identify-headings (html)
+  "Two values: HTML with an id on each heading that lacked one, and the
+headings as a list of (level id text), in order."
+  (let ((seen (make-hash-table :test #'equal))
+        (headings nil))
+    (values
+     (ppcre:regex-replace-all
+      "(?s)<h([1-6])([^>]*)>(.*?)</h\\1>"
+      html
+      (lambda (match level attributes inner)
+        (declare (ignore match))
+        (let* ((text (strip-tags inner))
+               (given (nth-value
+                       1 (ppcre:scan-to-strings "id=\"([^\"]+)\"" attributes)))
+               (id (if given
+                       (aref given 0)
+                       (let* ((base (slugify text))
+                              (count (incf (gethash base seen 0))))
+                         (if (= count 1) base (format nil "~a-~a" base count))))))
+          (push (list (parse-integer level) id text) headings)
+          (if given
+              (format nil "<h~a~a>~a</h~a>" level attributes inner level)
+              (format nil "<h~a id=\"~a\"~a>~a</h~a>" level id attributes inner level))))
+      :simple-calls t)
+     (nreverse headings))))
+
+(defun toc-html (headings from to &key (label "On this page"))
+  "String of HTML, or nil: a folding table of contents of the HEADINGS
+whose level lies from FROM to TO."
+  (let ((entries (remove-if-not (lambda (h) (<= from (first h) to)) headings)))
+    (when (> (length entries) 2)
+      (with-lhtml-string ()
+        (:details :class "cl-toc" :open t
+          (:summary (str (html-escape label)))
+          (:ul
+           (dolist (entry entries)
+             (destructuring-bind (level id text) entry
+               (htm (:li :class (if (> level from) "cl-toc-sub" "")
+                      (:a :href (format nil "#~a" id)
+                        (str (html-escape text)))))))))))))
+
+(defun take-single-h1 (html)
+  "Two values: HTML without its h1 when it has exactly one, and that
+heading's text (nil when the HTML was left alone).  The page shell
+prints the title, so a lone h1 in the content would say it twice."
+  (let ((count (length (ppcre:all-matches-as-strings "<h1[ >]" html))))
+    (if (= count 1)
+        (let ((text nil))
+          (values (ppcre:regex-replace
+                   "(?s)<h1[^>]*>(.*?)</h1>" html
+                   (lambda (match inner)
+                     (declare (ignore match))
+                     (setq text (strip-tags inner))
+                     "")
+                   :simple-calls t)
+                  text))
+        (values html nil))))
+
+;;; The older HTML files were written for Bootstrap and Font Awesome.
+;;; Neither is loaded any more: the icons go, and the few classes that
+;;; still mean something are styled in the stylesheet's prose section.
+
+(defun repair-legacy-html (html)
+  (let* ((html (ppcre:regex-replace-all
+                "<i class=\"fa[^\"]*\"[^>]*></i>(?:&nbsp;|\\s)*" html ""))
+         (html (ppcre:regex-replace-all "</?main[^>]*>" html "")))
+    html))
+
+;;; Markdown.  The pages that ask for a table of contents do it with
+;;; the directive the old generator's Markdown library understood.
+
+(defparameter *toc-directive* "\\{table-of-contents([^}]*)\\}")
+
+(defun directive-number (arguments key default)
+  (let ((found (nth-value 1 (ppcre:scan-to-strings
+                             (format nil ":~a\\s+(\\d+)" key) arguments))))
+    (if found (parse-integer (aref found 0)) default)))
+
+(defun render-content (text kind url-prefix)
+  "Three values for the body TEXT of a content file of KIND (:md or
+:html): its HTML ready for a page under URL-PREFIX, the text of the
+h1 that was taken out of it (or nil), and its headings."
+  (let* ((directive (nth-value 1 (ppcre:scan-to-strings *toc-directive* text)))
+         (text (ppcre:regex-replace-all *toc-directive* text ""))
+         ;; the one emoji shortcode the content files use
+         (text (ppcre:regex-replace-all ":star:" text "★"))
+         (html (ecase kind
+                 (:md (render-markdown text))
+                 (:html (repair-legacy-html text)))))
+    (multiple-value-bind (html h1) (take-single-h1 html)
+      (multiple-value-bind (html headings) (identify-headings html)
+        (let ((toc (when directive
+                     (let ((start (directive-number (aref directive 0) "start" 2))
+                           (depth (directive-number (aref directive 0) "depth" 3)))
+                       (toc-html headings start (max start depth))))))
+          (values (concatenate 'string (or toc "") (rewrite-links html url-prefix))
+                  h1
+                  headings))))))
+
+;;; Dates, as the news writes them.
+
+(defparameter *month-names*
+  #("January" "February" "March" "April" "May" "June" "July" "August"
+    "September" "October" "November" "December"))
+
+(defun parse-iso-date (string)
+  "List (year month day), or nil when STRING is not YYYY-MM-DD."
+  (let ((parts (nth-value 1 (ppcre:scan-to-strings
+                             "^(\\d{4})-(\\d{2})-(\\d{2})$" string))))
+    (when parts (map 'list #'parse-integer parts))))
+
+(defun long-date (string)
+  "String. The ISO date STRING as '29 September 2026'; STRING itself
+when it is not a date."
+  (let ((date (parse-iso-date string)))
+    (if (and date (<= 1 (second date) 12))
+        (format nil "~d ~a ~d" (third date)
+                (aref *month-names* (1- (second date))) (first date))
+        string)))
+
+(defun iso-timestamp (universal-time)
+  "String. UNIVERSAL-TIME as 2026-09-30T14:05:00Z."
+  (multiple-value-bind (second minute hour day month year)
+      (decode-universal-time universal-time 0)
+    (format nil "~4,'0d-~2,'0d-~2,'0dT~2,'0d:~2,'0d:~2,'0dZ"
+            year month day hour minute second)))
+
+(defun parse-iso-timestamp (string)
+  "Universal time of an ISO 8601 timestamp such as GitLab and Docker
+Hub send (2026-09-29T16:03:11.123Z), or nil."
+  (let ((parts (nth-value 1 (ppcre:scan-to-strings
+                             "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})"
+                             (or string "")))))
+    (when parts
+      (destructuring-bind (year month day hour minute second)
+          (map 'list #'parse-integer parts)
+        (ignore-errors
+         (encode-universal-time second minute hour day month year 0))))))
clnet/source/donate.lispaddedview

@@ -1,0 +1,630 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; Donations: the fundraising pages that used to be a site of their
+;;;; own (payments.common-lisp.net, the clf-apps repository), now pages
+;;;; of this one.
+;;;;
+;;;; The donor never leaves the site.  A campaign page is an ordinary
+;;;; cached page with one plain form.  Pressing its button asks the
+;;;; handler below for a Stripe Checkout session in embedded mode, and
+;;;; Stripe's payment form then opens in the page, where the form was.
+;;;; The card details go from the donor's browser straight to Stripe;
+;;;; this site's server never sees them, and Stripe's script is fetched
+;;;; only once the donor has pressed the button.
+;;;;
+;;;; When the payment is done Stripe sends the browser to /donate/return
+;;;; with the session's id; the handler there asks Stripe what became of
+;;;; the session and journals a completed donation.  The webhook does
+;;;; the same from Stripe's side, for the donor who closes the window
+;;;; too early, and is optional.
+;;;;
+;;;; The old pages kept a server-side session per visitor and collected
+;;;; the card in a form of their own; donors found that flow confusing
+;;;; and it did not work well on a phone.  Without script the form
+;;;; still works: it then sends the donor to the same Checkout on
+;;;; Stripe's own page.  Without credentials on the box the form says
+;;;; that card payments are not switched on.
+
+(defvar *live-mode?* nil
+  "Boolean. True to use the live Stripe keys and take real money; nil
+uses the test keys, and the pages say so.")
+
+(defparameter *stripe-credentials-file* (data-file "stripe-credentials.txt")
+  "Pathname. Lines of 'Name: value', the names beginning 'Test secret',
+'Test pub', 'Live secret', 'Live pub' and, for the optional webhook,
+'Test webhook' and 'Live webhook' -- the format of the payments
+application's file.  Read at each use, so the file can be put in
+place, or changed, under a running site.")
+
+(defparameter *stripe-api-version* "2024-06-20"
+  "String. The version of Stripe's API every request names, so that
+the parameters sent here mean what they meant when this was written,
+whatever the account's default has moved on to.")
+
+(defparameter *paypal-url*
+  "https://www.paypal.com/xclick/business=treasurer%40cl-foundation.org&currency_code=EUR&item_name=Donation%20common-lisp%2Enet&lc=GB"
+  "String. The PayPal donation link the old Contribute page carried.")
+
+(defparameter *currencies*
+  '(("eur" "EUR (€)" 100 1 "€15, €42 or €70")
+    ("usd" "USD (US$)" 100 1 "$20, $50 or $80")
+    ("jpy" "JPY (¥)" 1 100 "¥2,000, ¥4,200 or ¥7,000"))
+  "List of (code label minor-units-per-unit least-amount typical-gifts).")
+
+(defparameter *campaigns*
+  '(("hosting" "Hosting"
+     "The servers that common-lisp.net, its GitLab, its mailing lists and the project sites run on, and the machines that build the container images.")
+    ("domains" "Domain names"
+     "The yearly fees for the names the community's sites live under: common-lisp.net, cliki.net and the Foundation's own.")
+    ("general" "The general fund"
+     "Whatever the Foundation needs next: the costs above first, then accounting, transaction fees and the rest of keeping a foundation in good standing."))
+  "List of (key name blurb).  The key is the campaign's address under
+/donate/ and what Stripe and the journal know it by.  These are the
+Foundation's own costs; the appreciation fundraisers for individual
+projects are paused (see the git history of this file for their
+entries).")
+
+(defun campaign (key) (assoc key *campaigns* :test #'string=))
+
+;;; Credentials
+
+(defun stripe-credentials ()
+  "Plist of what *stripe-credentials-file* holds, or nil without it."
+  (let ((file (probe-file *stripe-credentials-file*)))
+    (when file
+      (loop for line in (split-lines (read-text-file file))
+            for colon = (position #\: line)
+            for name = (and colon (string-downcase (string-trim " " (subseq line 0 colon))))
+            for value = (and colon (string-trim " " (subseq line (1+ colon))))
+            for key = (and name
+                           (loop for (prefix key) in '(("test secret" :test-secret)
+                                                       ("live secret" :live-secret)
+                                                       ("test pub" :test-publishable)
+                                                       ("live pub" :live-publishable)
+                                                       ("test webhook" :test-webhook-secret)
+                                                       ("live webhook" :live-webhook-secret))
+                                 when (eql 0 (search prefix name)) return key))
+            when (and key (plusp (length value)))
+              append (list key value)))))
+
+(defun stripe-secret ()
+  (getf (stripe-credentials) (if *live-mode?* :live-secret :test-secret)))
+
+(defun stripe-publishable-key ()
+  (getf (stripe-credentials) (if *live-mode?* :live-publishable :test-publishable)))
+
+(defun stripe-webhook-secret ()
+  (getf (stripe-credentials)
+        (if *live-mode?* :live-webhook-secret :test-webhook-secret)))
+
+;;; Talking to Stripe
+
+(defun stripe-request (method path &optional parameters)
+  "Hash table or nil: the parsed answer of Stripe's API to METHOD on
+PATH with the form PARAMETERS, whatever its status; nil when there is
+no secret key or no answer."
+  (let ((secret (stripe-secret)))
+    (when secret
+      (ignore-errors
+       (bt:with-timeout (30)
+         (yason:parse
+          (flexi-streams:octets-to-string
+           (drakma:http-request (string-append "https://api.stripe.com" path)
+                                :method method
+                                :basic-authorization (list secret "")
+                                :additional-headers
+                                (list (cons "Stripe-Version" *stripe-api-version*))
+                                :user-agent *user-agent*
+                                :connection-timeout 10
+                                :force-binary t
+                                :parameters parameters)
+           :external-format :utf-8)))))))
+
+(defun request-origin (req)
+  "String. Scheme and host the visitor used, for the addresses Stripe
+sends them back to."
+  (let* ((host (or (net.aserve:header-slot-value req :x-forwarded-host)
+                   (net.aserve:header-slot-value req :host)
+                   "common-lisp.net"))
+         (proto (or (net.aserve:header-slot-value req :x-forwarded-proto)
+                    (if (ppcre:scan "(^localhost|\\.localhost|\\.test)(:\\d+)?$|:\\d+$" host)
+                        "http"
+                        "https"))))
+    (format nil "~a://~a" proto host)))
+
+(defun create-checkout-session (&key campaign amount currency monthly? embedded?
+                                     return-url cancel-url)
+  "Hash table or nil: a new Stripe Checkout session for AMOUNT (an
+integer of CURRENCY's smallest unit) to CAMPAIGN.  EMBEDDED? asks for
+the kind that opens inside our own page; otherwise Stripe hosts the
+page and CANCEL-URL is where its back link leads."
+  (stripe-request
+   :post "/v1/checkout/sessions"
+   (append
+    `(("mode" . ,(if monthly? "subscription" "payment"))
+      ("line_items[0][quantity]" . "1")
+      ("line_items[0][price_data][currency]" . ,currency)
+      ("line_items[0][price_data][unit_amount]" . ,(princ-to-string amount))
+      ("line_items[0][price_data][product_data][name]"
+       . ,(format nil "Donation to the Common Lisp Foundation: ~a"
+                  (second (campaign campaign))))
+      ("metadata[campaign]" . ,campaign))
+    (if monthly?
+        '(("line_items[0][price_data][recurring][interval]" . "month"))
+        '(("submit_type" . "donate")))
+    (if embedded?
+        `(("ui_mode" . "embedded")
+          ("return_url" . ,return-url))
+        `(("success_url" . ,return-url)
+          ("cancel_url" . ,cancel-url))))))
+
+(defun json-object (&rest plist)
+  "String. A JSON object of the string keys and string values of PLIST."
+  (with-output-to-string (out)
+    (write-char #\{ out)
+    (loop for (key value . more) on plist by #'cddr
+          do (yason:encode key out)
+             (write-char #\: out)
+             (yason:encode value out)
+             (when more (write-char #\, out)))
+    (write-char #\} out)))
+
+(defun write-json (req ent response json)
+  (net.aserve:with-http-response (req ent :response response
+                                          :content-type "application/json; charset=utf-8")
+    (setf (net.aserve:reply-header-slot-value req :cache-control) "no-store")
+    (net.aserve:with-http-body (req ent)
+      (write-string json (net.aserve:request-reply-stream req)))))
+
+(defun checkout-handler (url-prefix)
+  "The handler every campaign's form posts to.  Asked by the page's
+script (embedded=1) it answers in JSON with what the script needs to
+open Stripe's form in the page.  Asked by a plain form submission, from
+a browser without script, it answers with a redirect: to Stripe's own
+page, or to one of this tree's pages when something is wrong."
+  (lambda (req ent)
+    (let* ((query (net.aserve:request-query req :external-format :utf-8))
+           (embedded? (plusp (length (field query "embedded"))))
+           (key (field query "campaign"))
+           (currency (assoc (field query "currency") *currencies* :test #'string=))
+           (amount (ignore-errors (parse-integer (field query "amount"))))
+           (monthly? (string= (field query "frequency") "monthly"))
+           (origin (request-origin req))
+           (page (lambda (name) (format nil "~a/donate/~a/" url-prefix name)))
+           (problem
+             (cond ((not (and (campaign key) currency amount
+                              (<= (fourth currency) amount 1000000)))
+                    "Please give an amount and a currency.")
+                   ((zerop (length (field query "terms")))
+                    "Please accept the terms and conditions to go on.")
+                   ((or (null (stripe-secret))
+                        (and embedded? (null (stripe-publishable-key))))
+                    :unavailable)))
+           (session
+             (unless problem
+               (create-checkout-session
+                :campaign key
+                :amount (* amount (third currency))
+                :currency (first currency)
+                :monthly? monthly?
+                :embedded? embedded?
+                :return-url (format nil "~a~a/donate/return?session_id={CHECKOUT_SESSION_ID}"
+                                    origin url-prefix)
+                :cancel-url (string-append origin (funcall page key))))))
+      (cond
+        (embedded?
+         (let ((secret (and (hash-table-p session) (gethash "client_secret" session))))
+           (cond ((stringp secret)
+                  (write-json req ent net.aserve:*response-ok*
+                              (json-object "clientSecret" secret
+                                           "publishableKey" (stripe-publishable-key))))
+                 ((eq problem :unavailable)
+                  (write-json req ent net.aserve:*response-ok*
+                              (json-object "error" "Card payments are not switched on here yet. Nothing was charged.")))
+                 (t
+                  (write-json req ent net.aserve:*response-ok*
+                              (json-object "error"
+                                           (or problem
+                                               "The payment service did not answer. Nothing was charged; please try again.")))))))
+        ((eq problem :unavailable)
+         (redirect req ent (funcall page "unavailable")))
+        ((and (hash-table-p session) (stringp (gethash "url" session)))
+         (redirect req ent (gethash "url" session)))
+        (t (redirect req ent (funcall page "problem")))))))
+
+;;; Recording a donation
+
+(defun donations-file () (data-file "donations.sexp"))
+
+(defun money (amount currency)
+  "String. AMOUNT of CURRENCY's smallest unit, as a person writes it."
+  (let ((entry (assoc (string-downcase (or currency "")) *currencies* :test #'string=)))
+    (if (and entry (integerp amount))
+        (if (= (third entry) 1)
+            (format nil "~:d ~:@(~a~)" amount currency)
+            (format nil "~:d.~2,'0d ~:@(~a~)"
+                    (floor amount (third entry)) (mod amount (third entry)) currency))
+        (format nil "~a ~a" amount currency))))
+
+(defun record-donation (session live?)
+  "Journal the completed Checkout SESSION (a hash table), once however
+often it is reported, and tell the admins.  Returns true when the
+session is one of a completed donation."
+  (when (and (hash-table-p session)
+             (equal (gethash "status" session) "complete")
+             (member (gethash "payment_status" session) '("paid" "no_payment_required")
+                     :test #'equal))
+    (let ((id (gethash "id" session))
+          (metadata (gethash "metadata" session)))
+      (unless (find id (read-journal (donations-file))
+                    :key (lambda (record) (getf record :session)) :test #'equal)
+        (let ((campaign (and (hash-table-p metadata) (gethash "campaign" metadata))))
+          (append-journal
+           (donations-file)
+           (list :time (get-universal-time)
+                 :session id
+                 :campaign campaign
+                 :amount (gethash "amount_total" session)
+                 :currency (gethash "currency" session)
+                 :mode (gethash "mode" session)
+                 :live? live?))
+          (notify-admins
+           (format nil "~a~:[~; (monthly)~] to ~a~:[ -- TEST MODE~;~]"
+                   (money (gethash "amount_total" session) (gethash "currency" session))
+                   (equal (gethash "mode" session) "subscription")
+                   (or (second (campaign campaign)) campaign)
+                   live?)
+           :title "common-lisp.net: a donation" :tags "heart")))
+      t)))
+
+(defun return-handler (url-prefix)
+  "Where Stripe sends the donor's browser when the payment form is
+done with: ask Stripe what became of the session, record a completed
+donation, and show the donor the page that fits."
+  (lambda (req ent)
+    (let* ((query (net.aserve:request-query req :external-format :utf-8))
+           (id (field query "session_id"))
+           (session (and (ppcre:scan "^cs_[A-Za-z0-9_]+$" id)
+                         (stripe-request :get (string-append "/v1/checkout/sessions/" id))))
+           (page (lambda (name) (format nil "~a/donate/~a/" url-prefix name))))
+      (redirect req ent
+                (cond ((record-donation session (and (hash-table-p session)
+                                                     (gethash "livemode" session)))
+                       (funcall page "thanks"))
+                      ;; not paid: back to where the form is
+                      ((and (hash-table-p session)
+                            (equal (gethash "status" session) "open"))
+                       (format nil "~a/donate/" url-prefix))
+                      (t (funcall page "problem")))))))
+
+;;; Hearing from Stripe: the webhook (optional)
+
+(defun hmac-sha256-hex (key octets)
+  (let ((hmac (ironclad:make-hmac (ironclad:ascii-string-to-byte-array key) :sha256)))
+    (ironclad:update-hmac hmac octets)
+    (ironclad:byte-array-to-hex-string (ironclad:hmac-digest hmac))))
+
+(defun stripe-signature-ok? (payload-octets header secret
+                             &key (now (get-universal-time)) (tolerance 300))
+  "True when HEADER, a Stripe-Signature header, signs PAYLOAD-OCTETS
+with SECRET and is no older than TOLERANCE seconds.  The scheme is
+Stripe's documented one: the header carries t=<unix time> and one or
+more v1=<hex>; the signed text is the time, a full stop, and the
+payload; the signature is its HMAC-SHA256 under the endpoint's secret."
+  (let ((time nil) (signatures nil))
+    (dolist (part (ppcre:split "," (or header "")))
+      (let ((equals (position #\= part)))
+        (when equals
+          (let ((name (string-trim " " (subseq part 0 equals)))
+                (value (subseq part (1+ equals))))
+            (cond ((string= name "t") (setq time value))
+                  ((string= name "v1") (push value signatures)))))))
+    (and time signatures secret
+         (let ((unix (ignore-errors (parse-integer time))))
+           (and unix
+                ;; Unix time counts from 1970, universal time from 1900
+                (<= (abs (- (- now 2208988800) unix)) tolerance)))
+         (let ((expected (hmac-sha256-hex
+                          secret
+                          (concatenate '(vector (unsigned-byte 8))
+                                       (ironclad:ascii-string-to-byte-array
+                                        (format nil "~a." time))
+                                       payload-octets))))
+           (some (lambda (signature)
+                   (and (= (length signature) (length expected))
+                        (ironclad:constant-time-equal
+                         (ironclad:ascii-string-to-byte-array signature)
+                         (ironclad:ascii-string-to-byte-array expected))))
+                 signatures)))))
+
+(defun webhook-handler ()
+  (lambda (req ent)
+    ;; Asked for no external format, the server hands the body over as
+    ;; it read it, one character per octet; the signature is over the
+    ;; octets, so they are taken back out of the characters unchanged.
+    (let* ((body (or (net.aserve:get-request-body req) ""))
+           (octets (if (every (lambda (char) (< (char-code char) 256)) body)
+                       (map '(vector (unsigned-byte 8)) #'char-code body)
+                       (make-array 0 :element-type '(unsigned-byte 8))))
+           (ok? (stripe-signature-ok?
+                 octets
+                 (net.aserve:header-slot-value req :stripe-signature)
+                 (stripe-webhook-secret))))
+      (when ok?
+        (let* ((event (ignore-errors
+                       (yason:parse (flexi-streams:octets-to-string
+                                     octets :external-format :utf-8))))
+               (object (and (hash-table-p event)
+                            (hash-table-p (gethash "data" event))
+                            (gethash "object" (gethash "data" event)))))
+          (when (and (hash-table-p object)
+                     (equal (gethash "type" event) "checkout.session.completed"))
+            (record-donation object (gethash "livemode" event)))))
+      (net.aserve:with-http-response
+          (req ent :response (if ok?
+                                 net.aserve:*response-ok*
+                                 net.aserve:*response-bad-request*))
+        (net.aserve:with-http-body (req ent)
+          (write-string (if ok? "ok" "bad signature")
+                        (net.aserve:request-reply-stream req)))))))
+
+;;; The pages
+
+;;; The campaign form's script.  It takes over the form's submission:
+;;; posts the same fields to the same handler with embedded=1, and with
+;;; the answer fetches Stripe's script (only now, never before the donor
+;;; has asked to pay) and opens the payment form where the donation form
+;;; was.  Single quotes throughout: it rides in a Lisp string.
+(defparameter *donate-script*
+  "(function(){
+var f=document.getElementById('dn-form'),box=document.getElementById('dn-checkout'),msg=document.getElementById('dn-message'),back=document.getElementById('dn-back');
+if(!f||!box||!window.fetch||!window.URLSearchParams)return;
+var checkout=null,button=f.querySelector('button[type=submit]');
+function say(t){msg.textContent=t;msg.hidden=false;button.disabled=false;}
+function withStripe(then){
+if(window.Stripe)return then();
+var s=document.createElement('script');s.src='https://js.stripe.com/v3/';
+s.onload=then;s.onerror=function(){say('The payment form could not be loaded. Nothing was charged.');};
+document.head.appendChild(s);}
+function close(){if(checkout){checkout.destroy();checkout=null;}box.hidden=true;f.hidden=false;button.disabled=false;}
+f.addEventListener('submit',function(e){
+e.preventDefault();
+var data=new URLSearchParams(new FormData(f));data.set('embedded','1');
+button.disabled=true;msg.hidden=true;
+fetch(f.action,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:data.toString()})
+.then(function(r){return r.json();})
+.then(function(j){
+if(!j.clientSecret){say(j.error||'Something went wrong. Nothing was charged.');return;}
+withStripe(function(){
+window.Stripe(j.publishableKey).initEmbeddedCheckout({fetchClientSecret:function(){return Promise.resolve(j.clientSecret);}})
+.then(function(c){checkout=c;f.hidden=true;box.hidden=false;c.mount('#dn-checkout-mount');
+box.scrollIntoView({behavior:'smooth',block:'start'});})
+.catch(function(){say('The payment form could not be opened. Nothing was charged.');});});})
+.catch(function(){say('The payment service did not answer. Nothing was charged; please try again.');});});
+if(back)back.addEventListener('click',close);
+})();")
+
+(defun payments-notice-html ()
+  "String of HTML: what a donor should know about the state of the
+payment gateway on this box, or the empty string when it is live."
+  (cond ((null (stripe-secret))
+         (with-lhtml-string ()
+           (:p :class "mb-8 rounded-xl border border-warn/40 bg-warn-soft px-5 py-4 text-sm text-warn"
+             (:strong "Card payments are not switched on here yet.")
+             " The form below is shown as it will work; until then, a donation can be made by PayPal.")))
+        ((not *live-mode?*)
+         (with-lhtml-string ()
+           (:p :class "mb-8 rounded-xl border border-warn/40 bg-warn-soft px-5 py-4 text-sm text-warn"
+             (:strong "Test mode.")
+             " No money moves. Use a Stripe test card such as 4242 4242 4242 4242, any future date and any three digits.")))
+        (t "")))
+
+(define-object donate (base-site-page)
+
+  :computed-slots
+  ((page-title "Donate")
+   (eyebrow "Common Lisp Foundation")
+   (lead "Everything here is run by volunteers, but servers and domain names cost money. This is where it comes from.")
+   (meta-description "Donate to the Common Lisp Foundation: the hosting and the domain names behind common-lisp.net.")
+   (source-path "clnet/source/donate.lisp")
+   (wide? t)
+
+   (main-html
+    (with-lhtml-string ()
+      (str (payments-notice-html))
+      (:div :class "grid gap-6 md:grid-cols-3"
+        (loop for (key name blurb) in *campaigns*
+              do (htm (:a :class (string-append *card-link* " flex flex-col")
+                          :href (the (evaluate (make-keyword key)) clean-url)
+                        (:h2 :class (string-append *card-title* " group-hover:text-accent")
+                          (str name))
+                        (:p :class (string-append *card-body* " flex-1") (str blurb))
+                        (:p :class (string-append *arrow-link* " mt-4")
+                          "Give " (:span :aria-hidden "true" "→"))))))
+      (:div :class "mt-12 max-w-3xl"
+        (str (the (prose (format nil "
+## How a donation is used
+
+A donation pays for what it says: the hosting, the domain names, or,
+through the general fund, whatever the Foundation's running costs
+need next. All work for the Foundation is unpaid, so none of it goes to
+salaries. The full [terms and conditions](/donate/terms/) say the rest.
+
+The appreciation fundraisers for individual projects are paused while
+the Foundation raises what its own services cost.
+
+## Other ways to give
+
+- [By PayPal](~a), in euros.
+- By bank transfer or for a larger gift, write to the board at
+  [~a](mailto:~:*~a).
+- [Time, instead of money](/contribute/)."
+                                 *paypal-url* *board-email*))))))))
+
+  :objects
+  ((hosting :type 'campaign-page :campaign-key "hosting")
+   (domains :type 'campaign-page :campaign-key "domains")
+   (general :type 'campaign-page :campaign-key "general")
+   (terms :type 'donation-terms)
+   (thanks :type 'donation-thanks)
+   (unavailable :type 'donation-unavailable)
+   (problem :type 'donation-problem)))
+
+(define-object campaign-page (base-site-page)
+
+  :documentation
+  (:description "One campaign's page: what it is for, and the form
+that starts a donation to it.  The payment form opens in the same
+page.")
+
+  :input-slots
+  (("String. The campaign's key in *campaigns*." campaign-key))
+
+  :computed-slots
+  ((entry (campaign (the campaign-key)))
+   (page-title (second (the entry)))
+   (eyebrow "Donate")
+   (lead (third (the entry)))
+   (source-path "clnet/source/donate.lisp")
+
+   (main-html
+    (with-lhtml-string ()
+      (str (payments-notice-html))
+      (:form :id "dn-form" :method "post" :action (the (path "/donate/checkout"))
+             :class (string-append *card* " space-y-6")
+        (:input :type "hidden" :name "campaign" :value (the campaign-key))
+        (:div :class "grid gap-5 sm:grid-cols-[1fr_12rem]"
+          (:div
+            (:label :class *label* :for "dn-amount" "Amount")
+            (:input :id "dn-amount" :name "amount" :type "number" :required t
+                    :min "1" :max "1000000" :step "1" :value "42"
+                    :inputmode "numeric" :class "cl-field mt-1.5 text-lg font-semibold"))
+          (:div
+            (:label :class *label* :for "dn-currency" "Currency")
+            (:select :id "dn-currency" :name "currency" :class "cl-field mt-1.5"
+              (loop for (code label) in *currencies*
+                    do (htm (:option :value code (str label)))))))
+        (:p :class "-mt-3 text-sm text-muted"
+          "Any amount is welcome. Typical gifts: "
+          (str (format nil "~{~a~^; ~}" (mapcar #'fifth *currencies*))) ".")
+        (:fieldset
+          (:legend :class *label* "How often")
+          (:div :class "mt-2 flex flex-wrap gap-3"
+            (:label :class "cl-chip"
+              (:input :type "radio" :name "frequency" :value "once" :checked t)
+              (:span "Once"))
+            (:label :class "cl-chip"
+              (:input :type "radio" :name "frequency" :value "monthly")
+              (:span "Monthly"))))
+        (:label :class "flex items-start gap-3 text-sm text-body"
+          (:input :type "checkbox" :name "terms" :value "yes" :required t
+                  :class "mt-1 size-4 accent-[var(--cl-accent-fill)]")
+          (:span "I accept the "
+            (:a :class *link* :href (the parent terms clean-url)
+              "donation terms and conditions")
+            "."))
+        (:p :id "dn-message" :hidden t :role "alert"
+            :class "rounded-lg border border-warn/40 bg-warn-soft px-4 py-3 text-sm text-warn"
+          "")
+        (:div :class "flex flex-wrap items-center gap-4"
+          (:button :type "submit" :class *button* "Donate")
+          (:p :class "text-sm text-muted"
+            "The card form opens right here. Your card details go straight to Stripe, our payment processor; this site never sees them.")))
+      (:div :id "dn-checkout" :hidden t :class *card*
+        (:div :class "mb-4 flex items-center justify-between gap-4"
+          (:p :class *card-title* (str (second (the entry))))
+          (:button :id "dn-back" :type "button" :class *button-quiet* "Change the amount"))
+        (:div :id "dn-checkout-mount"))
+      (:script (str *donate-script*))
+      (:p :class "mt-8 text-sm text-muted"
+        (:a :class *link* :href (the parent clean-url) "Other ways to give")
+        " · "
+        (:a :class *link* :href *paypal-url* "Give by PayPal instead"))))))
+
+(define-object donation-terms (base-site-page)
+
+  :computed-slots
+  ((page-title "Terms and conditions for donations")
+   (eyebrow "Donate")
+   (lead "For donations, campaigns and shops hosted by the Common Lisp Foundation's payments gateway.")
+   (source-path "clnet/source/donate.lisp")
+   (main-html
+    (the (prose "
+Donations to Open Source Projects are meant to reflect the donor's
+appreciation for past project contributions and shall not be treated as
+a payment for, or entitlement to, work or products yet to be produced by
+the Open Source Project.
+
+In general, CLF retains 10% of incoming funds in order to ensure that
+its finances remain in the black, and for the possibility of building up
+a capital fund which can be invested according to the directives of the
+CLF. From the remaining 90%, overhead expenses will be covered, and the
+remaining funds will be disbursed to the respective campaign or shop
+contact entity.
+
+General, non-earmarked donations to CLF will be used first to cover CLF
+operating expenses and overheads, e.g. hosting costs, financial
+transactions fees, accounting & reporting costs, with the remainder
+being disbursed to active campaigns and shops according to a
+proportional formula to be determined by the CLF Board.
+
+Your email address will be retained by the Common Lisp Foundation for
+purposes of providing you with a receipt and thank-you note for your
+donation. If you opt-in, we may also reach out in the future to let you
+know about CLF-affiliated campaigns, activities, and events.
+
+Your email address will not be shared with other third parties.
+
+[Back to Donate](/donate/)")))))
+
+(define-object donation-thanks (base-site-page)
+
+  :computed-slots
+  ((page-title "Thank you")
+   (eyebrow "Donate")
+   (hide-from-sitemap? t)
+   (main-html
+    (the (prose "
+Your donation has been received. Stripe sends the receipt to the
+address you gave with your card.
+
+Thank you for supporting Common Lisp.
+
+[Back to the front page](/) · [The news](/news/)")))))
+
+(define-object donation-unavailable (base-site-page)
+
+  :computed-slots
+  ((page-title "Card payments are not switched on yet")
+   (eyebrow "Donate")
+   (hide-from-sitemap? t)
+   (main-html
+    (the (prose (format nil "
+Nothing was charged. This site's payment gateway is not connected yet.
+
+Until it is, a donation can be made [by PayPal](~a), and the board can
+be reached at [~a](mailto:~:*~a) for anything else.
+
+[Back to Donate](/donate/)" *paypal-url* *board-email*))))))
+
+(define-object donation-problem (base-site-page)
+
+  :computed-slots
+  ((page-title "That did not go through")
+   (eyebrow "Donate")
+   (hide-from-sitemap? t)
+   (main-html
+    (the (prose "
+Nothing was recorded as paid. Either the form was incomplete (an
+amount, a currency, and the box accepting the terms are all needed),
+the payment was not finished, or the payment service did not answer.
+
+If your card was charged all the same, the receipt from Stripe is your
+proof; write to the board and we will sort it out.
+
+[Back to Donate](/donate/) to try again.")))))
clnet/source/file-ordering.iscaddedview

@@ -1,0 +1 @@
+("package" "parameters" "content" "base-site-page" "site-chrome" "content-page" "news" "live" "requests" "donate" "learn-pages" "project-pages" "foundation-pages" "assembly" "sitemap" "initialize")
clnet/source/foundation-pages.lispaddedview

@@ -1,0 +1,437 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; The Foundation's pages: what it is doing, what it could offer as
+;;;; services, how to help, and about this site.
+
+;;; ------------------------------------------------------------------
+;;; What the Foundation is doing
+;;; ------------------------------------------------------------------
+
+(define-object foundation (base-site-page)
+
+  :computed-slots
+  ((page-title "What the Foundation is doing")
+   (eyebrow "Common Lisp Foundation")
+   (lead "The Common Lisp Foundation is the non-profit behind this site. Its board and volunteers keep the services here running, and build a few things the whole community can use.")
+   (meta-description "The current work of the Common Lisp Foundation: container images of the Lisp implementations, the common-lisp.net services, fundraising for Lisp projects.")
+   (source-path "clnet/source/foundation-pages.lisp")
+   (wide? t)
+
+   ("List of plists. The images table: what the refresher last read from
+Docker Hub, or the snapshot in live.lisp until it has."
+    images (or (getf (the root live) :images) *image-snapshot*))
+
+   (images-live? (and (getf (the root live) :images) t))
+
+   (images-table-html
+    (with-lhtml-string ()
+      (:div :class "mt-8 overflow-x-auto rounded-2xl border border-rule bg-surface"
+        (:table :class "w-full text-left text-[0.95rem]"
+          (:thead
+            (:tr :class "border-b border-rule-strong text-sm text-ink"
+              (:th :class "px-5 py-3 font-semibold" "Implementation")
+              (:th :class "px-5 py-3 font-semibold" "Image")
+              (:th :class "px-5 py-3 font-semibold" "Versions and architectures")
+              (:th :class "px-5 py-3 font-semibold" "Last built")
+              (:th :class "px-5 py-3 text-right font-semibold" "Pulls")))
+          (:tbody
+            (loop for (repo name description) in *image-repos*
+                  for status = (find repo (the images) :test #'string=
+                                                       :key (lambda (s) (getf s :repo)))
+                  when status
+                    do (htm
+                        (:tr :class "border-b border-rule last:border-0"
+                          (:td :class "px-5 py-3.5"
+                            (:p :class "font-semibold text-ink" (str name))
+                            (:p :class "text-sm text-muted" (str (html-escape description))))
+                          (:td :class "px-5 py-3.5"
+                            (:a :class "font-mono text-sm text-accent hover:text-accent-strong"
+                                :href (format nil "https://hub.docker.com/r/clfoundation/~a" repo)
+                              "clfoundation/" (str repo)))
+                          (:td :class "px-5 py-3.5"
+                            (loop for (version . arches) in (getf status :versions)
+                                  do (htm (:p :class "whitespace-nowrap"
+                                            (:span :class "font-mono text-sm text-ink"
+                                              (str (html-escape version)))
+                                            (dolist (arch arches)
+                                              (htm " "
+                                                   (:span :class (if (string= arch "arm64")
+                                                                     *badge-ok* *badge*)
+                                                     (str (html-escape arch)))))))))
+                          (:td :class "whitespace-nowrap px-5 py-3.5 font-mono text-sm text-muted"
+                            (str (html-escape (getf status :updated))))
+                          (:td :class "px-5 py-3.5 text-right font-mono text-sm text-muted"
+                            (when (getf status :pulls)
+                              (htm (fmt "~:d" (getf status :pulls)))))))))))
+      (:p :class "mt-3 text-sm text-muted"
+        (if (the images-live?)
+            (htm "Read from Docker Hub by this site, every half hour.")
+            (htm "As Docker Hub had it on 30 September 2026.")))))
+
+   (main-html
+    (with-lhtml-string ()
+      ;; ---- the images ----
+      (:section :id "images"
+        (:div :class "max-w-3xl"
+          (:p :class *eyebrow* "New in September 2026")
+          (:h2 :class (string-append *h2* " mt-2")
+            "Fresh container images of the Lisp implementations")
+          (str (the (prose "
+The Foundation builds and publishes container images of the Common Lisp
+implementations, under [clfoundation on Docker Hub](https://hub.docker.com/u/clfoundation).
+They are what our own CI runs on, and they are for anyone who wants a
+known-good Lisp in a container: to try an implementation, to test a
+library on all of them, or as the base of an application image.
+
+In September 2026 every one of them was rebuilt on the current release
+of its implementation and on current Debian."))))
+        (str (the images-table-html))
+        (:div :class "mt-10 grid gap-8 lg:grid-cols-2"
+          (:div
+            (:h3 :class *h3* "Clozure CL on arm64, for the first time")
+            (str (the (prose "
+CCL's port to 64-bit ARM Linux was merged into its development branch
+on 2 September 2026 and has no release yet. The `1.13.1-rc` images are
+built from that branch, for both `amd64` and `arm64`, so that anyone
+with an ARM machine can try it today. It is experimental: please report
+what breaks [to the CCL project](https://github.com/Clozure/ccl/issues).")))
+            (:pre :class *code-panel*
+              (:code "docker run --rm -it clfoundation/ccl:1.13.1-rc-trixie")))
+          (:div
+            (:h3 :class *h3* "Built natively, on both architectures")
+            (str (the (prose "
+The SBCL images, and CCL's release candidates, are built on real
+`amd64` and `arm64` machines, not under emulation, and published as
+multi-architecture tags: the same tag runs on a laptop with an ARM
+processor and on an x86 server. The images come on Debian (full and
+slim), some on Alpine as well, and the full ones carry a script that
+installs Quicklisp.")))
+            (:pre :class *code-panel*
+              (:code "docker run --rm -it clfoundation/sbcl"))))
+        (:div :class "mt-6 max-w-3xl"
+          (str (the (prose "
+The sources of the images are in the
+[cl-docker-images group](https://gitlab.common-lisp.net/cl-docker-images)
+on our GitLab, one repository per implementation; that is also where to
+report a problem with an image. To use them in your own project's CI,
+see [Using GitLab CI to test CL projects](/faq/using-gitlab-ci-for-cl/).")))))
+
+      ;; ---- the rest of the work ----
+      (:section :class "mt-16"
+        (:h2 :class *h2* "The standing work")
+        (:div :class "mt-8 grid gap-6 md:grid-cols-2"
+          (:div :class *card*
+            (:h3 :class *card-title* "Running common-lisp.net")
+            (:div :class "cl-prose mt-3 text-[0.95rem]"
+              (str (the (md "
+A GitLab instance kept on current releases, with CI runners; the
+mailing lists; the project sites at `common-lisp.dev`; and the backups
+behind all of it. In 2025 everything moved to a new server. A
+proof-of-work gate now stands in front of GitLab and the list archives,
+so that the swarms of crawlers that were taking the services down no
+longer can.")))))
+          (:div :class *card*
+            (:h3 :class *card-title* "Hosting Lisp projects")
+            (:div :class "cl-prose mt-3 text-[0.95rem]"
+              (str (the (md "
+[Free hosting](/project-intro/) for open source Common Lisp projects,
+as since 2003: several hundred public repositories, among them ASDF,
+Alexandria, CMUCL and Iterate.")))))
+          (:div :class *card*
+            (:h3 :class *card-title* "Raising money for Lisp projects")
+            (:div :class "cl-prose mt-3 text-[0.95rem]"
+              (str (the (md "
+The Foundation collects donations on behalf of Common Lisp projects and
+passes them on to their maintainers, as it did in the appreciation
+fundraisers for ASDF and ABCL. Those are paused for now, while the
+Foundation raises what its own hosting and domain names cost.
+[Donate](/donate/).")))))
+          (:div :class *card*
+            (:h3 :class *card-title* "Keeping the commons")
+            (:div :class "cl-prose mt-3 text-[0.95rem]"
+              (str (the (md "
+Stewardship of the community's domain names, `common-lisp.net` and
+`cliki.net` among them, and hosting for [CLiki](https://cliki.net),
+the Common Lisp wiki.")))))
+          (:div :class *card*
+            (:h3 :class *card-title* "This site")
+            (:div :class "cl-prose mt-3 text-[0.95rem]"
+              (str (the (md "
+The pages you are reading are a Common Lisp program: one page tree in
+one Lisp image, [its source](https://gitlab.common-lisp.net/clo/cl-site)
+open to merge requests like any other project here.")))))
+          (:div :class *card*
+            (:h3 :class *card-title* "Meeting, monthly")
+            (:div :class "cl-prose mt-3 text-[0.95rem]"
+              (str (the (md (format nil "
+The board meets every month, usually on the first Wednesday, and
+welcomes members of the community. Ask for an invitation at
+[~a](mailto:~:*~a)." *board-email*))))))))
+
+      (:section :class "mt-16 max-w-3xl"
+        (:h2 :class *h2* "About the Foundation")
+        (str (the (prose (format nil "
+The Stichting Common Lisp Foundation is a non-profit foundation
+registered in the Netherlands. Its purpose, in the words of its bylaws,
+is to promote the use and development of the Common Lisp programming
+language: by promoting and facilitating meetings, conferences and
+networking; by promoting the development of de-facto standard
+functionality for Common Lisp with publicly available source code; and
+by allied activities consistent with those aims.
+
+All of its work is done by volunteers. The board, the bylaws' purpose
+and the payment policy are at [cl-foundation.org](~a).
+~a
+[How you can help](/contribute/) · [Services](/services/) · [News](/news/)"
+                                 *foundation-url*
+                                 (sponsors-markdown))))))))))
+
+(defun sponsors-markdown ()
+  "String of Markdown: a paragraph naming the sponsors, or the empty
+string while there are none."
+  (if (null *sponsors*)
+      ""
+      (format nil "~%The Foundation's running costs are helped by its sponsors: ~{[~a](~a)~^, ~}.~%"
+              (loop for (name url) in *sponsors* append (list name url)))))
+
+;;; ------------------------------------------------------------------
+;;; Services
+;;; ------------------------------------------------------------------
+
+(defparameter *free-services*
+  '(("Project hosting" "/project-intro/"
+     "Git on GitLab, CI, a project web site and mailing lists, for open source Common Lisp projects.")
+    ("Container images" "/foundation/#images"
+     "Current images of SBCL, Clozure CL, ECL, ABCL, Clasp, CLISP, CMUCL and Allegro CL Express on Docker Hub.")
+    ("Mailing lists" "/independent-lists/"
+     "Lists for Lisp topics that do not need a whole project: user groups, working groups, regional meetings."))
+  "List of (name site-path text): what is offered today, at no charge.")
+
+(defparameter *proposed-services*
+  '(("Supported images"
+     "For teams that ship Lisp in containers"
+     "The same images, with a commitment behind them: a rebuild within days of a security fix in the base system, tags that stay put for as long as you subscribe, advance notice of changes, and someone who answers when a build breaks."
+     ("Security rebuilds on a stated schedule"
+      "Long-lived tags for the versions you depend on"
+      "amd64 and arm64"
+      "A private channel for notices and questions"))
+    ("CI on every implementation"
+     "For libraries and products that must run everywhere"
+     "Your test suite, run on each release of each implementation and on both architectures, on our runners. You get a report of what passed where and a notice the day a new SBCL or CCL breaks you, before your users find out."
+     ("A scheduled matrix: implementations × versions × architectures"
+      "Native arm64 runners"
+      "Works for projects hosted here, on GitHub or on GitLab.com"))
+    ("Custom images"
+     "For a fixed environment, built once and reproducible"
+     "An image made to order: an implementation at the version you name, your Quicklisp dist or your own libraries loaded, system packages installed, a saved core ready to start. Built by the pipelines that build the public images, and rebuilt when its inputs change."
+     ("Pinned dependencies"
+      "Pre-loaded cores for fast start-up"
+      "Delivered to your registry or ours"))
+    ("Private hosting"
+     "For companies that work in Common Lisp"
+     "Private groups and repositories on gitlab.common-lisp.net, with CI on the Lisp images, for teams that would rather pay the community's infrastructure than a general-purpose forge."
+     ("Private groups with CI"
+      "Mirrors to and from other forges"
+      "The same admins that run the public service"))
+    ("Sponsorship"
+     "For organisations that depend on Common Lisp"
+     "A yearly contribution to the running costs of the services on this page, acknowledged here. Not a purchase of anything: the way to keep the shared infrastructure of the language in good health."
+     ("Named on this site"
+      "An invitation to the board's meetings"
+      "A say in what gets built next")))
+  "List of (name audience text points): the services under discussion.")
+
+(define-object services (base-site-page)
+
+  :computed-slots
+  ((page-title "Services")
+   (eyebrow "Common Lisp Foundation")
+   (lead "What the Foundation runs for the community today, and what it could run for you.")
+   (meta-description "Services of the Common Lisp Foundation: free project hosting and container images, and proposed paid services built on them.")
+   (source-path "clnet/source/foundation-pages.lisp")
+   (wide? t)
+
+   (main-html
+    (with-lhtml-string ()
+      (:h2 :class *h2* "Free, for the community")
+      (:div :class "mt-8 grid gap-6 md:grid-cols-3"
+        (loop for (name site-path text) in *free-services*
+              do (htm (:a :class *card-link* :href (the (path site-path))
+                        (:h3 :class (string-append *card-title* " group-hover:text-accent")
+                          (str name))
+                        (:p :class *card-body* (str text))))))
+
+      (:div :class "mt-16 max-w-3xl"
+        (:p (:span :class *badge-warn* "A proposal, under discussion"))
+        (:h2 :class (string-append *h2* " mt-3") "Paid services, built on the same machinery")
+        (str (the (prose "
+The pipelines that build the images, and the runners they build on,
+are now dependable enough to do work for others. The Foundation is
+considering offering that work as paid services, with the income going
+to the running costs of the free ones.
+
+**None of what follows can be ordered yet.** It is an outline: what
+each service would be, and for whom. If one of them would be worth
+paying for where you work, tell us; that is what decides which are
+built first, and at what price."))))
+
+      (:div :class "mt-10 grid gap-6 lg:grid-cols-2"
+        (loop for (name audience text points) in *proposed-services*
+              do (htm (:div :class (string-append *card* " flex flex-col")
+                        (:div :class "flex flex-wrap items-center gap-3"
+                          (:h3 :class *card-title* (str name))
+                          (:span :class *badge* "Proposed"))
+                        (:p :class "mt-1 text-sm font-medium text-muted" (str audience))
+                        (:p :class *card-body* (str text))
+                        (:ul :class "mt-4 space-y-1.5 text-sm text-body"
+                          (dolist (point points)
+                            (htm (:li :class "flex gap-2"
+                                   (:span :class "font-mono text-accent" :aria-hidden "true" "·")
+                                   (:span (str point))))))))))
+
+      (:div :class (string-append *card* " mt-12")
+        (:div :class "max-w-3xl"
+          (:h2 :id "interest" :class *card-title* "Interested?")
+          (:p :class *card-body*
+            "Say which service, and what you would need from it. The board reads these; nothing is promised on either side. You can also write to "
+            (:a :class *link* :href (format nil "mailto:~a" *board-email*) (str *board-email*))
+            "."))
+        (:div :class "mt-6 max-w-2xl"
+          (str (request-form-html
+                (the url-prefix)
+                :kinds (list (assoc "services" *request-kinds* :test #'string=)
+                             (assoc "other" *request-kinds* :test #'string=))
+                :default-kind "services"
+                :username? nil
+                :message-label "Which service, and what would you use it for?"
+                :message-hint "Your organisation, the implementations and architectures you care about, and roughly how much you build."))))))))
+
+;;; ------------------------------------------------------------------
+;;; Contribute
+;;; ------------------------------------------------------------------
+
+(define-object contribute (base-site-page)
+
+  :computed-slots
+  ((page-title "Contribute")
+   (eyebrow "Common Lisp Foundation")
+   (lead "The site is run by volunteers. Money keeps the servers on; time keeps everything else going.")
+   (meta-description "How to contribute to common-lisp.net: donations and volunteering.")
+   (source-path "clnet/source/foundation-pages.lisp")
+
+   (main-html
+    (with-lhtml-string ()
+      (:div :class "grid gap-6 sm:grid-cols-2"
+        (:div :class *card*
+          (:h2 :class *card-title* "Give money")
+          (:p :class *card-body*
+            "The costs of running the site are covered by the Common Lisp Foundation, which lives on donations.")
+          (:p :class "mt-5"
+            (:a :class *button* :href (the root donate clean-url) "Donate")))
+        (:div :class *card*
+          (:h2 :class *card-title* "Give time")
+          (:p :class *card-body*
+            "There is always more to do than hands to do it, and we can almost always use help.")
+          (:p :class "mt-5"
+            (:a :class *button-quiet* :href *irc-webchat-url* "Say hello on IRC"))))
+      (str (the (prose (format nil "
+## Work waiting for volunteers
+
+- **This site.** Its pages are
+  [a repository](https://gitlab.common-lisp.net/clo/cl-site) like any
+  other here. Fix what is out of date, add what is missing, send a merge
+  request. Every page has an *Edit this page* link at the bottom.
+- **The news.** One paragraph about something that changed is a
+  contribution. [How the news is written](~a/-/blob/~a/clnet/README.md).
+- **The images.** Each implementation's image has
+  [its own repository](https://gitlab.common-lisp.net/cl-docker-images);
+  a new release of an implementation is a small merge request there.
+- **A sustainable way to list and find projects**, to replace the
+  [projects hub](/phub/).
+- **A community forum.**
+- **System administration.** If you know your way around GitLab,
+  Postfix, Mailman or Apache and would like to help run the services,
+  the admins would like to hear from you.
+
+If you have an idea for the site, or would like to help with any of the
+above, come to `~a` on Libera.Chat, or ~a."
+                               *repo-url* *repo-branch* *irc-channel* (the ask-the-admins)))))))))
+
+;;; ------------------------------------------------------------------
+;;; About
+;;; ------------------------------------------------------------------
+
+(defparameter *maintainers*
+  '(("Dave Cooper" nil)
+    ("Georgiy Tugai" nil)
+    ("Erik Huelsmann" nil))
+  "List of (name home-page-or-nil): who maintains the site today.  A
+name with a home page is printed as a link to it.")
+
+(defparameter *previous-maintainers*
+  '(("Mark Evenson" nil)
+    ("Mariano Montone" nil)
+    ("Erik Enge" nil)
+    ("Drew Crampsie" nil)
+    ("Hans Hübner" nil)
+    ("C. Yang" nil)
+    ("Mario S. Mommer" nil))
+  "List of (name home-page-or-nil): who maintained it before.")
+
+(defun people-markdown (people)
+  "String of Markdown: PEOPLE as a list, one to a line."
+  (with-output-to-string (out)
+    (loop for (name home-page) in people
+          do (if home-page
+                 (format out "- [~a](~a)~%" name home-page)
+                 (format out "- ~a~%" name)))))
+
+(define-object about (base-site-page)
+
+  :computed-slots
+  ((page-title "About Common-Lisp.net")
+   (eyebrow "About")
+   (lead "A home for Common Lisp projects and user groups since 2003.")
+   (meta-description "About common-lisp.net, its governance and its maintainers.")
+   (source-path "clnet/source/foundation-pages.lisp")
+
+   (main-html
+    (the (prose (format nil "
+Common-Lisp.net provides hosting for projects and user groups related
+to the programming language Common Lisp.
+
+The site is run under the governance of the
+[Common Lisp Foundation](~a) and maintained mostly by a committee of
+volunteers. [Please consider donating](/donate/).
+
+Its source is
+[on our GitLab](https://gitlab.common-lisp.net/clo/cl-site). The site is
+written in Common Lisp and served by [Gendl](https://gitlab.common-lisp.net/gendl/gendl).
+
+If you want to host a project, read about [project hosting](/project-intro/).
+If you have questions, suggestions or comments about this site, or
+simply wish to reach the current maintainers, ~a.
+
+## Maintenance
+
+Common-Lisp.net has been run for many years by a number of volunteers.
+
+### Currently active maintainers
+
+~a
+### Previous maintainers
+
+~a
+## Licence
+
+The text of this site is licensed under a
+[Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/).
+The program that serves it is under the MIT licence."
+                        *foundation-url* (the ask-the-admins)
+                        (people-markdown *maintainers*)
+                        (people-markdown *previous-maintainers*)))))))
clnet/source/initialize.lispaddedview

@@ -1,0 +1,124 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+(defun all-hosts ()
+  "List of the host names in *host-map*."
+  (loop for (host nil) on *host-map* by #'cddr collect host))
+
+(defun facelift-path ()
+  "String or nil. \"/facelift\": what the prefixed page tree's
+addresses begin with."
+  (and *facelift-prefix* (format nil "/~a" *facelift-prefix*)))
+
+(defun publish-tree-handlers (server url-prefix type hosts)
+  "Publish, on SERVER, everything a page tree of TYPE needs besides its
+pages: its asset directories, the form handlers, the feed.  URL-PREFIX
+is \"\" for the tree served by host (HOSTS, a list) and \"/facelift\"
+for the prefixed one (HOSTS nil: any host)."
+  (flet ((at (path) (string-append url-prefix path)))
+    (loop for (segment directory) in *asset-trees*
+          do (publish-directory :prefix (at (format nil "/~a/" segment))
+                                :server server
+                                :host hosts
+                                :destination (namestring directory)))
+    (net.aserve:publish :path (at "/news.atom") :server server :host hosts
+                        :content-type "application/atom+xml; charset=utf-8"
+                        :function (feed-handler type))
+    (net.aserve:publish :path (at "/request-submit") :server server :host hosts
+                        :content-type "text/html; charset=utf-8"
+                        :function (request-handler url-prefix))
+    (net.aserve:publish :path (at "/admin/requests") :server server :host hosts
+                        :content-type "text/html; charset=utf-8"
+                        :function (queue-handler url-prefix))
+    (net.aserve:publish :path (at "/admin/requests/handled") :server server :host hosts
+                        :content-type "text/html; charset=utf-8"
+                        :function (handled-handler url-prefix))
+    (net.aserve:publish :path (at "/admin/requests/test-push") :server server :host hosts
+                        :content-type "text/html; charset=utf-8"
+                        :function (test-push-handler url-prefix))
+    (net.aserve:publish :path (at "/donate/checkout") :server server :host hosts
+                        :content-type "text/html; charset=utf-8"
+                        :function (checkout-handler url-prefix))
+    (net.aserve:publish :path (at "/donate/return") :server server :host hosts
+                        :content-type "text/html; charset=utf-8"
+                        :function (return-handler url-prefix))
+    (net.aserve:publish :path (at "/donate/webhook") :server server :host hosts
+                        :content-type "text/plain; charset=utf-8"
+                        :function (webhook-handler))))
+
+(defun retired-prefix-handler (prefix)
+  "A handler that answers every address under /PREFIX with a permanent
+redirect to the same address without it, query and all."
+  (let ((cut (1+ (length prefix))))
+    (lambda (req ent)
+      (let* ((uri (net.aserve:request-uri req))
+             (path (or (net.uri:uri-path uri) "/"))
+             (rest (if (> (length path) cut) (subseq path cut) "/"))
+             (query (net.uri:uri-query uri)))
+        (net.aserve:with-http-response
+            (req ent :response net.aserve:*response-moved-permanently*)
+          (setf (net.aserve:reply-header-slot-value req :location)
+                (format nil "~a~@[?~a~]" (if (char= (char rest 0) #\/) rest (string-append "/" rest))
+                        query))
+          (net.aserve:with-http-body (req ent)))))))
+
+(defun initialize! (&key (refresher? t))
+  "Publish the site on every server of this image and warm its pages.
+Safe to call again: after a change to *host-map* or to what is
+published, call it again (and see `refresh!')."
+  (with-all-servers (server)
+    (publish-tree-handlers server "" 'clnet-assembly (all-hosts))
+    (net.aserve:publish :path "/robots.txt" :server server :host (all-hosts)
+                        :content-type "text/plain; charset=utf-8"
+                        :function (robots-handler))
+    (net.aserve:publish :path "/sitemap.xml" :server server :host (all-hosts)
+                        :content-type "application/xml; charset=utf-8"
+                        :function (sitemap-handler 'clnet-assembly))
+    (net.aserve:publish :path "/clnet-health" :server server
+                        :content-type "text/plain; charset=utf-8"
+                        :function (health-handler))
+    (when (facelift-path)
+      (publish-tree-handlers server (facelift-path) 'clnet-facelift-assembly nil))
+    ;; The prefixes of earlier days send their visitors to the same
+    ;; page at the root, for good.
+    (dolist (prefix *retired-prefixes*)
+      (unless (equal prefix *facelift-prefix*)
+        (net.aserve:publish-prefix
+         :prefix (format nil "/~a" prefix) :server server
+         :function (retired-prefix-handler prefix))))
+    (publish-virtual-hosts
+     :server server
+     ;; the front page says its encoding in the header like every
+     ;; other page, not only in its <meta>
+     :content-type "text/html; charset=UTF-8"
+     :host-map *host-map*
+     :path-map (when (facelift-path)
+                 (list (facelift-path) 'clnet-facelift-assembly))
+     :paths (append (list "/")
+                    (when (facelift-path)
+                      (list (facelift-path)
+                            (string-append (facelift-path) "/"))))))
+  (warm-shared-instances!
+   :types (append (list 'clnet-assembly)
+                  (when (facelift-path) (list 'clnet-facelift-assembly))))
+  (when refresher? (start-refresher))
+  :initialized)
+
+(defun refresh! ()
+  "Re-read everything the pages are made from: call it on a running
+site after editing a file under content/, after rebuilding the
+stylesheet, or after loading changed page definitions.  The trees'
+cached slots are emptied and the pages are rendered afresh on demand;
+what the refresher has learned is kept."
+  (dolist (root (site-instances))
+    (bt2:with-lock-held ((the-object root response-lock))
+      (the-object root update!)
+      (the-object root (set-slot! :live *live*))))
+  (warm-shared-instances!
+   :types (append (list 'clnet-assembly)
+                  (when (facelift-path) (list 'clnet-facelift-assembly))))
+  :refreshed)
clnet/source/learn-pages.lispaddedview

@@ -1,0 +1,399 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; The pages a newcomer reads: getting started, documentation, the
+;;;; features of the language, the implementations, the community.
+;;;; Their lists are data at the top of each page; to add a book or an
+;;;; implementation, add an entry.
+
+;;; ------------------------------------------------------------------
+;;; The sponsors' cards
+;;; ------------------------------------------------------------------
+
+(defun sponsor-cards-html (&key (heading "Sponsored"))
+  "String of HTML: one card per entry of *sponsors*, each marked as a
+sponsored link (and told to search engines as one); the empty string
+while there are no sponsors."
+  (if (null *sponsors*)
+      ""
+      (with-lhtml-string ()
+        (:div :class "mt-10"
+          (:p :class "flex items-center gap-2 text-sm text-muted"
+            (:span :class *badge* (str heading))
+            "Sponsors help pay for the servers this site runs on.")
+          (:div :class "mt-3 grid gap-5 sm:grid-cols-2"
+            (loop for (name url tagline) in *sponsors*
+                  do (htm (:a :class *card-link* :href url :rel "sponsored"
+                            (:h3 :class (string-append *card-title* " group-hover:text-accent")
+                              (str name))
+                            (:p :class *card-body* (str tagline))))))))))
+
+;;; ------------------------------------------------------------------
+;;; Getting started (the address is /downloads, as it always was)
+;;; ------------------------------------------------------------------
+
+(define-object downloads (base-site-page)
+
+  :computed-slots
+  ((page-title "Getting started")
+   (eyebrow "Learn")
+   (lead "From nothing to a running Lisp and an editor that talks to it.")
+   (meta-description "How to get started with Common Lisp: install an implementation, set up an editor, find the libraries.")
+   (source-path "clnet/source/learn-pages.lisp")
+
+   (main-html
+    (with-lhtml-string ()
+      (:h2 :class *h2* "Download and install")
+      (str (the (prose "
+If you are new, or want to be up and running as fast as possible,
+[Portacle](https://portacle.github.io/) is probably your best option. It
+is a complete, multiplatform IDE for Common Lisp: Emacs, SBCL, Git and
+Quicklisp, configured and ready to use.")))
+      (:p :class "mt-5"
+        (:a :class *button* :href "https://portacle.github.io/"
+          "Download Portacle, the all-in-one Common Lisp"))
+
+      (str (the (prose "
+Otherwise, Common Lisp comes in many [implementations](/implementations/).
+Two popular open source ones are Steel Bank Common Lisp and Clozure
+Common Lisp:")))
+      (:div :class "mt-6 grid gap-5 sm:grid-cols-2"
+        (:div :class *card*
+          (:h3 :class *card-title* "Steel Bank Common Lisp (SBCL)")
+          (:ul :class "mt-3 space-y-1.5 text-[0.95rem]"
+            (:li (:a :class *link* :href "https://www.sbcl.org/" "Main website"))
+            (:li (:a :class *link* :href "https://www.sbcl.org/platform-table.html" "Download"))
+            (:li (:a :class *link* :href "https://www.sbcl.org/getting.html" "Install"))))
+        (:div :class *card*
+          (:h3 :class *card-title* "Clozure Common Lisp (CCL)")
+          (:ul :class "mt-3 space-y-1.5 text-[0.95rem]"
+            (:li (:a :class *link* :href "https://ccl.clozure.com/" "Main website"))
+            (:li (:a :class *link* :href "https://ccl.clozure.com/download.html" "Download and install")))))
+      (str (the (prose "
+The [implementations page](/implementations/) lists the other compilers,
+including the powerful commercial ones.")))
+      (str (sponsor-cards-html))
+
+      (:h3 :class (string-append *h3* " mt-10") "Or run one in a container")
+      (str (the (prose "
+The Common Lisp Foundation publishes container images of the open source
+implementations on Docker Hub. With Docker or Podman installed, this is
+a Lisp prompt with nothing else to set up:")))
+      (:pre :class *code-panel* (:code "docker run --rm -it clfoundation/sbcl"))
+      (str (the (prose "
+Replace `sbcl` with `ccl`, `ecl`, `abcl`, `clasp`, `clisp` or `cmucl`.
+[What the images are and how they are built](/foundation/#images).")))
+
+      (:h3 :class (string-append *h3* " mt-10") "Or try it in your browser")
+      (str (the (prose "
+[JSCL](https://jscl-project.github.io/) is a Lisp that runs on the web.
+It is not a complete Common Lisp, but it is enough for a taste.")))
+
+      (:h2 :class (string-append *h2* " mt-14") "Set up your environment")
+      (str (the (prose "
+A Lisp setup has three parts: a running Lisp, a text editor connected to
+that running Lisp, and a way to set up projects and libraries.
+
+The most common choice is [Emacs](https://www.gnu.org/software/emacs/)
+with [SLIME](https://slime.common-lisp.dev/) as editor, and
+[ASDF](https://asdf.common-lisp.dev/) with
+[Quicklisp](https://www.quicklisp.org/) for projects and libraries.
+
+- **SLIME** is an extension to Emacs that connects the editor to the
+  running Lisp image and interacts with it. It provides evaluation,
+  compilation and macroexpansion, online documentation, code
+  navigation, an inspector, a debugger, and much more.
+- **ASDF** is Lisp's counterpart to Make. It defines projects (called
+  systems) and their dependencies, and compiles and loads them.
+- **Quicklisp** is a library manager. It downloads, installs and loads
+  any of over 1,500 libraries with a few simple commands.
+
+The Common Lisp Cookbook has
+[a guide to setting them all up](https://lispcookbook.github.io/cl-cookbook/getting-started.html),
+and there is [an introductory video](https://www.youtube.com/watch?v=VnWVu8VVDbI).")))
+
+      (:h2 :class (string-append *h2* " mt-14") "Read about Lisp")
+      (str (the (prose "
+A good way to learn Lisp is to work through
+[Practical Common Lisp](https://gigamonkeys.com/book/). Our
+[documentation page](/documentation/) lists more books, the language
+standard and other references.")))
+
+      (:h2 :class (string-append *h2* " mt-14") "Start a project")
+      (str (the (prose "
+The [Common Lisp Cookbook](https://lispcookbook.github.io/cl-cookbook/getting-started.html)
+shows how to start a project of your own, and there is
+[a video](https://www.youtube.com/watch?v=SPgjgybGb5o) of the same.
+When the project wants a home, [we host open source Lisp projects](/project-intro/).")))
+
+      (:h2 :class (string-append *h2* " mt-14") "Libraries")
+      (str (the (prose "
+[Our overview](/libraries/) lists Lisp libraries by area. The easiest way
+to download and load them is [Quicklisp](https://www.quicklisp.org/);
+[these instructions](https://lispcookbook.github.io/cl-cookbook/getting-started.html#install-quicklisp)
+get you started.")))
+
+      (:h2 :class (string-append *h2* " mt-14") "Other guides")
+      (str (the (prose "
+- [Getting Started With Common Lisp](https://lispmethods.com/development-environment.html) at lispmethods.com
+- [Articulate Common Lisp](http://articulate-lisp.com)
+- [The CLiki guide](https://cliki.net/Getting%20Started)")))
+
+      (:p :class "mt-10"
+        (:a :class *button* :href (the root docs clean-url) "On to the documentation"))))))
+
+;;; ------------------------------------------------------------------
+;;; Documentation
+;;; ------------------------------------------------------------------
+
+(defparameter *books*
+  '(("Practical Common Lisp" "pcl.jpg" "https://gigamonkeys.com/book/" "Read online"
+     "Lisp is often thought of as an academic language, but it need not be. This is the first book that introduces Lisp as a language for the real world: a thorough introduction to Common Lisp, its features and how they work.")
+    ("On Lisp" "on-lisp.jpg" "http://www.paulgraham.com/onlisp.html" "Read online"
+     "A comprehensive study of advanced Lisp techniques, with bottom-up programming as the unifying theme. It gives the first complete description of macros and macro applications.")
+    ("Successful Lisp" "successful.jpg"
+     "http://dept-info.labri.fr/~strandh/Teaching/MTP/Common/David-Lamkins/cover.html" "Read online"
+     "How to understand and use Common Lisp.")
+    ("Lisp Hackers" "lh.jpg" "https://leanpub.com/lisphackers/read" "Read online"
+     "Interviews with Common Lisp programmers.")
+    ("Lisp Web Tales" "lwt.png" "https://leanpub.com/lispwebtales" "Read"
+     "Develop web applications in Common Lisp.")
+    ("Common Lisp Recipes" "clr.jpg" "http://weitz.de/cl-recipes" "Buy"
+     "Solutions to the problems you are likely to meet when writing real-world applications: web programming, databases, graphical user interfaces, other languages, multi-threading, debugging and optimization.")
+    ("Land of Lisp" "lol.png" "http://landoflisp.com" "Website"
+     "Learn to program in Lisp, one game at a time!")
+    ("Programming Algorithms" "pa.png" "https://leanpub.com/progalgs" "Read"
+     "A comprehensive guide to writing efficient programs, with examples in Lisp.")
+    ("Let Over Lambda" "letoverlambda.png" "https://letoverlambda.com" "Read"
+     "One of the most hardcore computer programming books out there. Starting with the fundamentals, it describes the most advanced features of the most advanced language.")
+    ("Loving Common Lisp, or the Savvy Programmer's Secret Weapon" "lovingcl.jpeg"
+     "https://leanpub.com/lovinglisp" "Read"
+     "A quick introduction to Common Lisp, followed by many fun and useful examples, from an author who has used the language professionally since 1982."))
+  "List of (title cover-image link link-label blurb).  The covers are
+files in content/static/imgs/.")
+
+(define-object documentation-page (base-site-page)
+
+  :computed-slots
+  ((compute-urls (list "documentation/index.html" "documentation/" "documentation"))
+   (page-title "Documentation")
+   (eyebrow "Learn")
+   (lead "Books, the language standard, and where to look things up.")
+   (meta-description "Common Lisp books, the ANSI standard and other references.")
+   (source-path "clnet/source/learn-pages.lisp")
+   (wide? t)
+
+   (main-html
+    (with-lhtml-string ()
+      (:h2 :class *h2* "Books")
+      (:div :class "mt-8 grid gap-6 md:grid-cols-2"
+        (loop for (title cover link label blurb) in *books*
+              do (htm (:div :class (string-append *card* " flex gap-5")
+                        (:img :class "h-36 w-24 flex-none rounded-md object-cover shadow-sm ring-1 ring-black/10"
+                              :alt "" :loading "lazy"
+                              :src (the (asset "clnet-static" (string-append "imgs/" cover))))
+                        (:div :class "flex min-w-0 flex-col"
+                          (:h3 :class *card-title* (str title))
+                          (:p :class "mt-2 flex-1 text-sm leading-relaxed text-body" (str blurb))
+                          (:p :class "mt-3"
+                            (:a :class *arrow-link* :href link
+                              (str label) " " (:span :aria-hidden "true" "→"))))))))
+      (:div :class "mt-10 max-w-3xl"
+        (str (the (prose "
+Also [ANSI Common Lisp](http://www.paulgraham.com/acl.html) and
+[Common Lisp in the Wild: Deploying Common Lisp Applications](https://www.darkchestnut.com/book-common-lisp-application-deployment).
+CLiki keeps [a more complete list of books](https://cliki.net/Lisp+Books).
+
+## The ANSI Common Lisp specification
+
+- The [Common Lisp HyperSpec](http://www.lispworks.com/documentation/HyperSpec/Front/index.htm)
+  (CLHS), a hyperlinked, non-free version of the ANSI standard.
+- The [Common Lisp standard draft](http://cvberry.com/tech_writings/notes/common_lisp_standard_draft.html) (PDF).
+
+## Other resources
+
+- [The Common Lisp Cookbook](https://lispcookbook.github.io/cl-cookbook/)
+- [CLiki](https://cliki.net), the Common Lisp wiki
+- [Quickdocs](http://quickdocs.org), documentation for all the libraries in Quicklisp
+- [Quickref](https://quickref.common-lisp.net/), reference manuals for Quicklisp libraries
+- [State of the Common Lisp Ecosystem](http://borretti.me/article/common-lisp-sotu-2015), as of 2015
+
+## Getting help
+
+If you need help, the [mailing lists and IRC](/community/) are where to ask.
+New to all of this? Begin with [getting started](/downloads/)."))))))))
+
+;;; ------------------------------------------------------------------
+;;; Features of the language
+;;; ------------------------------------------------------------------
+
+(defparameter *features*
+  '(("Interactive development"
+     "Development in Common Lisp is interactive. There is no separate compile, run and debug cycle: the program is developed while it runs. Compilation is incremental, and functions can be created and updated on the fly. All objects are available and can be inspected all the time. This is much more than a REPL; the whole environment, from the IDE to the language, is prepared for this way of working.")
+    ("Robust"
+     "Common Lisp is a carefully designed language with a long history. It was standardized by ANSI in 1994 and the language has not changed since, yet it keeps up with the times through its malleability and its metaprogramming. Your program is very likely to keep running unmodified for years.")
+    ("Expressive"
+     "Lisp's approach to problem solving is to define idioms, small domain-specific languages, as close to the problem as possible. The result is succinct and declarative, at a level that is difficult to reach in other languages. Its flexibility also makes bottom-up programming natural.")
+    ("Fast"
+     "Most Common Lisp compilers produce fast code. Programs can be annotated with types, and the compilers optimize on them. The language supports different levels of debugging, safety and speed. Performance is usually better than that of interpreted languages such as Python and Ruby, and close to C in some cases.")
+    ("Uniform"
+     "Uniformity is present in both syntax and data structures. Everything is an S-expression, so there are few syntactic oddities to think about, and the main data structure of Lisp is the list.")
+    ("Multi-paradigm"
+     "Lisp implements and adapts to several paradigms. It has first-class functions, closures and destructuring, as functional languages do, and it comes with CLOS, one of the most powerful object systems in existence. Other paradigms can often be added as a library, as has been done for logic programming.")
+    ("Advanced"
+     "Method combinations, multiple dispatch, multiple values, the condition system with restarts, and the metaobject protocol."))
+  "List of (title text).")
+
+(define-object features (base-site-page)
+
+  :computed-slots
+  ((page-title "Features of Common Lisp")
+   (eyebrow "Learn")
+   (lead "What sets the language apart, in a few paragraphs.")
+   (meta-description "The features of Common Lisp: interactive development, stability, expressiveness, speed.")
+   (source-path "clnet/source/learn-pages.lisp")
+   (wide? t)
+
+   (main-html
+    (with-lhtml-string ()
+      (:div :class "grid gap-6 md:grid-cols-2 lg:grid-cols-3"
+        (loop for (title text) in *features*
+              do (htm (:div :class *card*
+                        (:h2 :class *card-title* (str title))
+                        (:p :class *card-body* (str text))))))
+      (:div :class "mt-10 max-w-3xl"
+        (str (the (prose "
+For a more detailed tour, see
+[Features of Common Lisp](http://random-state.net/features-of-common-lisp.html)
+by Abhishek Reddy. Ready to try it? [Get started](/downloads/)."))))))))
+
+;;; ------------------------------------------------------------------
+;;; Implementations
+;;; ------------------------------------------------------------------
+
+(defparameter *implementations*
+  '((:open
+     ("SBCL" "https://www.sbcl.org/" "sbcl"
+      "Steel Bank Common Lisp is a high performance Common Lisp compiler. It is open source, with a permissive license. Besides the compiler and runtime system for ANSI Common Lisp, it provides an interactive environment with a debugger, a statistical profiler, a code coverage tool and many other extensions. SBCL runs on a number of POSIX platforms, and on Windows.")
+     ("Clozure CL" "https://ccl.clozure.com/" "ccl"
+      "Clozure CL (CCL for short) is a free Common Lisp implementation with a long history. It is known for its fast compilation, native threads, a precise, generational, compacting garbage collector and a convenient foreign-function interface. It is distributed under the Apache License, version 2.0.")
+     ("ECL" "https://ecl.common-lisp.dev/" "ecl"
+      "Embeddable Common Lisp implements the language with an interpreter and a translator to C that can produce standalone executables and libraries to embed in other programs. It runs on Linux, the BSDs, macOS, Solaris and Windows, on Intel, ARM and other processors.")
+     ("ABCL" "https://abcl.org/" "abcl"
+      "Armed Bear Common Lisp is a full implementation of the language with both an interpreter and a compiler, running on the JVM. It supports JSR-223, so it can be the scripting engine of any Java application, and its Java integration lets parts of an application be written in Lisp.")
+     ("Clasp" "https://github.com/clasp-developers/clasp" "clasp"
+      "Clasp interoperates seamlessly with C++ libraries and programs, using LLVM to compile to native code. It opens a vast array of existing libraries, such as those of scientific computing, to Lisp's rapid prototyping and incremental development.")
+     ("CLISP" "https://clisp.sourceforge.io/" "clisp"
+      "CLISP is a Common Lisp implementation by Bruno Haible and Michael Stoll. It implements the language described in the ANSI standard, with many extensions.")
+     ("CMUCL" "https://cmucl.org/" "cmucl"
+      "CMU Common Lisp is a free, high-performance implementation that runs on most major Unix platforms. Its compiler, Python, is the ancestor of SBCL's."))
+    (:commercial
+     ("Allegro CL" "https://franz.com/products/allegro-common-lisp/" nil
+      "Allegro CL® is a powerful dynamic object-oriented development system, especially suited to enterprise-wide, complex application development, with applications of billions of objects in fields from the life sciences to manufacturing to financial analytics.")
+     ("LispWorks" "http://www.lispworks.com/products/lispworks.html" nil
+      "LispWorks® is an implementation of ANSI Common Lisp for Windows, Linux, macOS and other platforms, source-compatible across all of them. It offers an advanced compiler, the CAPI portable GUI toolkit and the LispWorks IDE.")))
+  "Plist of :open and :commercial lists of (name url image-repository
+text); image-repository names the container image under clfoundation/
+on Docker Hub, when there is one.")
+
+(define-object implementations (base-site-page)
+
+  :computed-slots
+  ((page-title "Common Lisp implementations")
+   (eyebrow "Resources")
+   (lead "One standard, many compilers. Code written to the standard runs on all of them.")
+   (meta-description "The Common Lisp implementations, open source and commercial.")
+   (source-path "clnet/source/learn-pages.lisp")
+   (wide? t)
+
+   (main-html
+    (flet ((cards (entries)
+             (with-lhtml-string ()
+               (:div :class "mt-6 grid gap-6 md:grid-cols-2 lg:grid-cols-3"
+                 (loop for (name url image text) in entries
+                       do (htm (:div :class (string-append *card* " flex flex-col")
+                                 (:h3 :class *card-title*
+                                   (:a :class "hover:text-accent" :href url (str name)))
+                                 (:p :class (string-append *card-body* " flex-1") (str text))
+                                 (when image
+                                   (htm (:p :class "mt-4 font-mono text-xs text-muted"
+                                          "docker run --rm -it clfoundation/" (str image)))))))))))
+      (with-lhtml-string ()
+        (:h2 :class *h2* "Open source")
+        (str (cards (getf *implementations* :open)))
+        (:p :class "mt-4 text-sm text-muted"
+          "The command under each one starts it from "
+          (:a :class *link* :href (the (path "/foundation/#images"))
+            "the container images the Foundation publishes")
+          ".")
+        (:h2 :class (string-append *h2* " mt-14") "Commercial")
+        (str (cards (getf *implementations* :commercial)))
+        (str (sponsor-cards-html))
+        (:div :class "mt-10 max-w-3xl"
+          (str (the (prose "
+For a complete list, see
+[Wikipedia's list of Common Lisp implementations](https://en.wikipedia.org/wiki/Common_Lisp#List_of_implementations).")))))))))
+
+;;; ------------------------------------------------------------------
+;;; Community
+;;; ------------------------------------------------------------------
+
+(define-object community (base-site-page)
+
+  :computed-slots
+  ((page-title "The Lisp community")
+   (eyebrow "Community")
+   (lead "Where Lispers talk, write and meet.")
+   (meta-description "Where the Common Lisp community meets: IRC, mailing lists, news, conferences.")
+   (source-path "clnet/source/learn-pages.lisp")
+   (wide? t)
+
+   (main-html
+    (flet ((box (title markdown)
+             (with-lhtml-string ()
+               (:div :class *card*
+                 (:h2 :class *card-title* (str title))
+                 (:div :class "cl-prose mt-3 text-[0.95rem]" (str (the (md markdown))))))))
+      (with-lhtml-string ()
+        (:div :class "grid gap-6 md:grid-cols-2 lg:grid-cols-3"
+          (str (box "IRC and chat" "
+There are several Lisp channels on [Libera.Chat](https://libera.chat):
+
+- [#commonlisp](https://irclog.tymoon.eu/libera/commonlisp), the main Common Lisp channel
+- `#lisp`, for Lisp dialects of all shapes and forms
+- `#lispcafe`, a less formal channel
+- `#common-lisp.net`, about this site and its services
+
+[More IRC channels](https://www.cliki.net/IRC). There is also a
+[Lisp Discord server](https://discord.gg/hhk46CE)."))
+          (str (box "Mailing lists" "
+- [SBCL mailing lists](https://sourceforge.net/p/sbcl/mailman)
+- [Clozure CL mailing list](https://lists.clozure.com/mailman/listinfo/openmcl-devel)
+- [The lists hosted here](https://mailman.common-lisp.net/listinfo), for hosted projects and
+  for [Lisp topics at large](/independent-lists/)"))
+          (str (box "News" "
+- [Planet Lisp](https://planet.lisp.org), a meta blog that collects Lisp-related blogs
+- [The Lisp subreddit](https://www.reddit.com/r/lisp)
+- [Our own news](/news/), about this site and the Foundation"))
+          (str (box "Documentation" "
+- [The Common Lisp Cookbook](https://lispcookbook.github.io/cl-cookbook/)
+- [CLiki](https://cliki.net), the Common Lisp wiki
+- [Quickdocs](http://quickdocs.org)
+- [Quickref](https://quickref.common-lisp.net/)
+
+[More on the documentation page](/documentation/)."))
+          (str (box "Conferences" "
+- The [European Lisp Symposium](https://european-lisp-symposium.org), every spring"))
+          (str (box "More" "
+- [The Association of Lisp Users](http://www.alu.org)
+- Success stories on [lisp-lang.org](http://lisp-lang.org/success/)
+- [Companies using Lisp](/lisp-companies/)
+- [Awesome Lisp software](https://github.com/azzamsa/awesome-cl-software)
+- [Lisp videos](https://www.cliki.net/lisp+videos) on CLiki")))
+        (:p :class "mt-10"
+          (:a :class *button* :href (the root project-intro clean-url)
+            "Host your project with us")))))))
clnet/source/live.lispaddedview

@@ -1,0 +1,377 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; The parts of the site that keep themselves current.
+;;;;
+;;;; A background thread asks four outside sources every half hour and
+;;;; hands what it learned to the page trees, which re-render only the
+;;;; pages that show it:
+;;;;
+;;;;   Planet Lisp      -- the aggregated Lisp blogs (front page)
+;;;;   GitLab           -- the projects most recently worked on
+;;;;                       (front page), if a token is on hand
+;;;;   Docker Hub       -- the clfoundation images, their versions and
+;;;;                       architectures (Foundation page)
+;;;;   the mail server  -- whether the addresses the pages print are
+;;;;                       still deliverable (a page stops printing
+;;;;                       one that is not)
+;;;;
+;;;; Every fetch is in process, bounded by a timeout, and allowed to
+;;;; fail: a source that does not answer keeps its last good answer,
+;;;; and a block with no answer at all is simply not drawn.  Nothing is
+;;;; fetched from a visitor's browser, so the pages ask no third party
+;;;; for anything.
+
+(defparameter *user-agent* "clnet-site (+https://common-lisp.net)")
+
+(defparameter *refresh-interval* 1800
+  "Integer. Seconds between two rounds of the refresher.")
+
+(defparameter *mail-check-interval* (* 6 3600)
+  "Integer. Seconds between two checks of the admin address.")
+
+(defparameter *gitlab-token-file* (data-file "gitlab-token")
+  "Pathname. One line: a GitLab token with the read_api scope.  The
+GitLab instance sits behind a proof-of-work gate that an anonymous API
+call does not pass, so without this file the 'recently active' block is
+left out.")
+
+(defvar *live* nil
+  "Plist. What the refresher last learned: :planet :gitlab :images
+:mail.  New page trees start from it.")
+
+(defvar *live-refreshed* nil
+  "Universal time of the last round, or nil.")
+
+;;; HTTP
+
+(defun http-get (url &key headers (timeout 25))
+  "Three values: the body as a string, the status, the headers; all nil
+when the request failed or ran past TIMEOUT seconds."
+  (handler-case
+      (bt:with-timeout (timeout)
+        (multiple-value-bind (body status response-headers)
+            (drakma:http-request url
+                                 :additional-headers headers
+                                 :user-agent *user-agent*
+                                 :connection-timeout 10
+                                 :force-binary t)
+          (values (flexi-streams:octets-to-string body :external-format :utf-8)
+                  status
+                  response-headers)))
+    (error () (values nil nil nil))))
+
+(defun json-get (url &key headers)
+  "Two values: the parsed JSON body of URL (objects as hash tables,
+arrays as lists) when it answered 200, else nil; and the headers."
+  (multiple-value-bind (body status response-headers) (http-get url :headers headers)
+    (values (and body (eql status 200)
+                 (ignore-errors (yason:parse body)))
+            response-headers)))
+
+;;; Planet Lisp
+
+(defun unescape-xml (string)
+  (let ((string (ppcre:regex-replace-all "(?s)<!\\[CDATA\\[(.*?)\\]\\]>" string "\\1")))
+    (dolist (pair '(("&lt;" . "<") ("&gt;" . ">") ("&quot;" . "\"")
+                    ("&#39;" . "'") ("&apos;" . "'") ("&amp;" . "&"))
+                  string)
+      (setq string (ppcre:regex-replace-all (car pair) string (cdr pair))))))
+
+(defun xml-element-text (name xml)
+  (let ((found (nth-value 1 (ppcre:scan-to-strings
+                             (format nil "(?s)<~a[^>]*>(.*?)</~a>" name name) xml))))
+    (and found (string-trim '(#\Space #\Tab #\Newline #\Return)
+                            (unescape-xml (aref found 0))))))
+
+(defun fetch-planet (&key (count 6))
+  "List of plists (:title :link), the newest posts on Planet Lisp; nil
+when it did not answer."
+  (let ((xml (http-get "https://planet.lisp.org/rss20.xml")))
+    (when xml
+      (loop for item in (ppcre:all-matches-as-strings "(?s)<item>.*?</item>" xml)
+            for title = (xml-element-text "title" item)
+            for link = (xml-element-text "link" item)
+            when (and title link (ppcre:scan "^https?://" link))
+              collect (list :title title :link link) into posts
+            until (= (length posts) count)
+            finally (return posts)))))
+
+;;; GitLab
+
+(defun gitlab-token ()
+  (let ((file (probe-file *gitlab-token-file*)))
+    (when file
+      (let ((token (string-trim '(#\Space #\Tab #\Newline #\Return)
+                                (read-text-file file))))
+        (when (plusp (length token)) token)))))
+
+(defun fetch-gitlab-activity (&key (count 6))
+  "Plist (:total n :projects (plist ...)) of the public projects most
+recently worked on, or nil without a token or an answer."
+  (let ((token (gitlab-token)))
+    (when token
+      (multiple-value-bind (projects headers)
+          (json-get (format nil "~a/api/v4/projects?visibility=public&order_by=last_activity_at&simple=true&per_page=~d"
+                            *gitlab-url* count)
+                    :headers (list (cons "PRIVATE-TOKEN" token)))
+        (when (listp projects)
+          (list :total (ignore-errors
+                        (parse-integer (cdr (assoc :x-total headers))))
+                :projects
+                (loop for project in projects
+                      when (hash-table-p project)
+                        collect (list :name (gethash "path_with_namespace" project)
+                                      :url (gethash "web_url" project)
+                                      :description (gethash "description" project)
+                                      :updated (gethash "last_activity_at" project)))))))))
+
+;;; The clfoundation images on Docker Hub
+
+(defparameter *image-repos*
+  '(("sbcl" "SBCL" "Steel Bank Common Lisp")
+    ("ccl" "Clozure CL" "Clozure Common Lisp")
+    ("ecl" "ECL" "Embeddable Common Lisp")
+    ("abcl" "ABCL" "Armed Bear Common Lisp, on the JVM")
+    ("clasp" "Clasp" "Common Lisp on LLVM, interoperating with C++")
+    ("clisp" "CLISP" "GNU CLISP")
+    ("cmucl" "CMUCL" "CMU Common Lisp")
+    ("allegro" "Allegro CL Express" "Franz Inc.'s free Express edition"))
+  "List of (hub-repository short-name description) for the images
+under clfoundation/ on Docker Hub.")
+
+(defparameter *image-snapshot*
+  '((:repo "sbcl" :versions (("2.6.9" "amd64" "arm64") ("2.6.10-rc" "amd64" "arm64"))
+     :updated "2026-09-29" :pulls 313990)
+    (:repo "ccl" :versions (("1.13" "amd64") ("1.13.1-rc" "amd64" "arm64"))
+     :updated "2026-09-28" :pulls 17820)
+    (:repo "ecl" :versions (("26.5.5" "amd64")) :updated "2026-09-30" :pulls 48314)
+    (:repo "abcl" :versions (("1.9.3" "amd64")) :updated "2026-09-29" :pulls 40378)
+    (:repo "clasp" :versions (("3.0.1" "amd64")) :updated "2026-09-29" :pulls 2758)
+    (:repo "clisp" :versions (("2.49.92" "amd64")) :updated "2026-09-28" :pulls 3932)
+    (:repo "cmucl" :versions (("21f" "amd64")) :updated "2026-09-28" :pulls 2975)
+    (:repo "allegro" :versions (("11.0express" "amd64")) :updated "2026-09-28" :pulls 1016))
+  "What Docker Hub said on 2026-09-30: the table the Foundation page
+shows until the refresher has an answer of its own.")
+
+(defun tag-version (name)
+  "String or nil. The version a tag belongs to: 2.6.9 for 2.6.9 and
+for 2.6.9-trixie-slim, 1.13.1-rc for 1.13.1-rc-bookworm.  Nil for a tag
+that is no version's (latest, slim) and for one architecture's leg of
+a multi-architecture tag (2.6.9-trixie-arm64)."
+  (unless (ppcre:scan "-(amd64|arm64|armhf|arm)$" name)
+    (let ((found (nth-value 1 (ppcre:scan-to-strings
+                               "^([0-9][0-9a-z.]*(?:-rc)?)(?:-|$)" name))))
+      (and found (aref found 0)))))
+
+(defun fetch-image-status-1 (repo)
+  (let* ((base (format nil "https://hub.docker.com/v2/repositories/clfoundation/~a/" repo))
+         (answer (json-get (string-append base "tags?page_size=100&ordering=last_updated")))
+         (tags (loop for tag in (and (hash-table-p answer) (gethash "results" answer))
+                     for updated = (parse-iso-timestamp (gethash "last_updated" tag))
+                     when updated
+                       collect (list (gethash "name" tag) updated tag)))
+         (newest (reduce #'max tags :key #'second :initial-value 0))
+         ;; Only what was pushed in the last round of builds counts:
+         ;; a repository keeps tags of years ago under the same version.
+         (recent (remove-if (lambda (tag) (< (second tag) (- newest (* 45 86400)))) tags))
+         (latest (find "latest" recent :key #'first :test #'string=))
+         (latest-digest (and latest (gethash "digest" (third latest))))
+         ;; the release is the version `latest' is another name for ...
+         (release (loop for (name nil tag) in recent
+                        for version = (tag-version name)
+                        when (and version latest-digest
+                                  (equal (gethash "digest" tag) latest-digest))
+                          return version))
+         ;; ... and the newest -rc line, if there is one, rides along
+         (candidate (loop for (name) in recent
+                          for version = (tag-version name)
+                          when (and version (search "-rc" version))
+                            return version)))
+    (flet ((architectures (version)
+             (let ((found nil))
+               (loop for (name nil tag) in recent
+                     when (equal (tag-version name) version)
+                       do (dolist (image (gethash "images" tag))
+                            (let ((arch (gethash "architecture" image)))
+                              (when (and (stringp arch) (string/= arch "unknown"))
+                                (pushnew arch found :test #'string=)))))
+               (sort found #'string<))))
+      (when (or release candidate)
+        (let ((info (json-get base)))
+          (list :repo repo
+                :versions (loop for version in (list release candidate)
+                                when version
+                                  collect (cons version (architectures version)))
+                :updated (subseq (iso-timestamp newest) 0 10)
+                :pulls (and (hash-table-p info) (gethash "pull_count" info))))))))
+
+(defun fetch-image-status ()
+  "List of plists (:repo :versions ((name arch ...) ...) :updated
+iso-date :pulls n), one per repository that answered."
+  (loop for (repo) in *image-repos*
+        for status = (fetch-image-status-1 repo)
+        when status collect status))
+
+;;; News drafts: when an image appears in a version the site has not
+;;; seen before, a line is added to data/news-drafts.md for whoever
+;;; writes the news next.  A draft is a nudge, not a publication:
+;;; nothing reaches content/news.md unless a person puts it there.
+
+(defun seen-versions-file () (data-file "seen-image-versions.sexp"))
+(defun news-drafts-file () (data-file "news-drafts.md"))
+
+(defun read-seen-versions ()
+  (let ((file (probe-file (seen-versions-file))))
+    (when file
+      (with-open-file (in file :external-format :utf-8)
+        (let ((*read-eval* nil)) (read in nil nil))))))
+
+(defun note-new-image-versions (images)
+  "Record the versions in IMAGES; append a draft news line for each
+one not seen before.  The first run only records."
+  (let* ((file (seen-versions-file))
+         (first-run? (not (probe-file file)))
+         (seen (read-seen-versions))
+         (now (loop for image in images
+                    append (loop for (version) in (getf image :versions)
+                                 collect (format nil "~a:~a" (getf image :repo) version))))
+         (new (set-difference now seen :test #'string=)))
+    (when new
+      (ensure-directories-exist file)
+      (with-open-file (out file :direction :output :if-exists :supersede
+                                :external-format :utf-8)
+        (let ((*print-pretty* nil)) (prin1 (union seen now :test #'string=) out)))
+      (unless first-run?
+        (with-open-file (out (news-drafts-file) :direction :output
+                                                :if-exists :append
+                                                :if-does-not-exist :create
+                                                :external-format :utf-8)
+          (format out "~%### ~a *New image~p on Docker Hub*~%~%~{`clfoundation/~a`~^, ~} ~
+                       ~:[is~;are~] now available.~%"
+                  (subseq (iso-timestamp (get-universal-time)) 0 10)
+                  (length new) new (rest new)))))
+    new))
+
+;;; The addresses the pages print.  A recipient check against the mail
+;;; exchanger: the conversation stops before any message is sent.
+
+(defun smtp-reply (stream)
+  "String. One SMTP reply from STREAM: its last line, which carries the
+code without a hyphen after it."
+  (loop for line = (string-right-trim '(#\Return) (read-line stream))
+        until (or (< (length line) 4) (char/= (char line 3) #\-))
+        finally (return line)))
+
+(defun check-mail-lines (&key (addresses (list *account-request-email* *admin-email*))
+                              (host *mail-exchanger*))
+  "Alist of (address :status :accepted | :rejected | :unknown :detail
+string), one entry per address.  :unknown means the exchanger could not
+be asked from here, which says nothing about the address."
+  (handler-case
+      (bt:with-timeout (40)
+        (let ((socket (usocket:socket-connect host 25 :timeout 10)))
+          (unwind-protect
+               (let ((stream (usocket:socket-stream socket)))
+                 (flet ((say (line)
+                          (write-string line stream)
+                          (write-char #\Return stream)
+                          (write-char #\Linefeed stream)
+                          (force-output stream)
+                          (smtp-reply stream)))
+                   (smtp-reply stream)
+                   (say "EHLO common-lisp.net")
+                   (say "MAIL FROM:<>")
+                   (prog1
+                       (loop for address in addresses
+                             for reply = (say (format nil "RCPT TO:<~a>" address))
+                             collect (list address
+                                           :status (case (char reply 0)
+                                                     (#\2 :accepted)
+                                                     (#\5 :rejected)
+                                                     (t :unknown))
+                                           :detail reply))
+                     (ignore-errors (say "QUIT")))))
+            (ignore-errors (usocket:socket-close socket)))))
+    (error (condition)
+      (loop for address in addresses
+            collect (list address :status :unknown
+                                  :detail (princ-to-string condition))))))
+
+(defun mail-line (live address)
+  "Plist (:status ... :detail ...): what LIVE knows of ADDRESS."
+  (rest (assoc address (getf live :mail) :test #'string=)))
+
+(defun deliverable-address (live address)
+  "ADDRESS, unless the exchanger was last seen rejecting it."
+  (unless (eq (getf (mail-line live address) :status) :rejected)
+    address))
+
+;;; Handing the data to the page trees
+
+(defun site-instances ()
+  "List of the live roots of the page trees this system serves."
+  (loop for key in '(:clnet-assembly :clnet-facelift-assembly)
+        for root = (first (gethash key gwl:*instance-hash-table*))
+        when (and root (typep root 'clnet-assembly))
+          collect root))
+
+(defun push-live-data (root)
+  "Give ROOT the current *live*, under the lock its responses take, so
+no page of the tree is half-way through a render while the slot moves."
+  (unless (equalp (the-object root live) *live*)
+    (bt2:with-lock-held ((the-object root response-lock))
+      (the-object root (set-slot! :live *live*)))))
+
+(defvar *mail-checked* 0)
+
+(defun refresh-live-data! (&key check-mail?)
+  "One round: ask every source, keep the last good answer of one that
+fails, and give the result to the page trees.  Returns the new *live*."
+  (let* ((planet (or (fetch-planet) (getf *live* :planet)))
+         (gitlab (or (fetch-gitlab-activity) (getf *live* :gitlab)))
+         (fetched-images (fetch-image-status))
+         (images (or fetched-images (getf *live* :images)))
+         (mail (if (or check-mail?
+                       (> (- (get-universal-time) *mail-checked*) *mail-check-interval*))
+                   (let ((answer (check-mail-lines)))
+                     (setq *mail-checked* (get-universal-time))
+                     ;; an exchanger that could not be asked does not
+                     ;; overwrite what it last said
+                     (if (and (getf *live* :mail)
+                              (every (lambda (entry)
+                                       (eq (getf (rest entry) :status) :unknown))
+                                     answer))
+                         (getf *live* :mail)
+                         answer))
+                   (getf *live* :mail))))
+    (when fetched-images
+      (ignore-errors (note-new-image-versions fetched-images)))
+    (setq *live* (list :planet planet :gitlab gitlab :images images :mail mail)
+          *live-refreshed* (get-universal-time))
+    (dolist (root (site-instances))
+      (push-live-data root))
+    *live*))
+
+(defvar *refresher* nil)
+
+(defun start-refresher ()
+  "Start the background thread, unless it is already running."
+  (unless (and *refresher* (bt:thread-alive-p *refresher*))
+    (setq *refresher*
+          (bt:make-thread
+           (lambda ()
+             (loop
+               (ignore-errors (refresh-live-data!))
+               (sleep *refresh-interval*)))
+           :name "clnet live-data refresher")))
+  *refresher*)
+
+(defun stop-refresher ()
+  (when (and *refresher* (bt:thread-alive-p *refresher*))
+    (bt:destroy-thread *refresher*))
+  (setq *refresher* nil))
clnet/source/news.lispaddedview

@@ -1,0 +1,152 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; The news.  One file, content/news.md, holds every item: a level-3
+;;;; heading that begins with the date (### 2026-09-29) and may go on
+;;;; with a title (### 2026-09-29 *Fresh images*), then the item's
+;;;; Markdown.  Writing news is adding one such block at the top of
+;;;; that file.  From it come the news page, the three cards on the
+;;;; front page and the Atom feed.
+
+(defparameter *news-file* "news.md")
+
+(defparameter *news-open-years* 3
+  "Integer. How many of the most recent years the news page shows
+unfolded; older years fold away under their year.")
+
+(defun clean-news-title (text)
+  "String or nil. The part of a news heading after the date, without
+the emphasis marks it was usually written in."
+  (let ((title (string-trim " *_" text)))
+    (when (plusp (length title)) title)))
+
+(defun parse-news (text url-prefix)
+  "List of plists, newest first as the file has them: :date (ISO
+string) :title (string or nil) :id :html :summary (the first paragraph,
+as HTML)."
+  (let ((items nil) (date nil) (title nil) (lines nil)
+        (seen (make-hash-table :test #'equal)))
+    (flet ((close-item ()
+             (when date
+               (let* ((html (rewrite-links
+                             (render-markdown (format nil "~{~a~%~}" (nreverse lines)))
+                             url-prefix))
+                      (count (incf (gethash date seen 0))))
+                 (push (list :date date
+                             :title title
+                             :id (if (= count 1) date (format nil "~a-~a" date count))
+                             :html html
+                             :summary (or (ppcre:scan-to-strings "(?s)<p>.*?</p>" html) ""))
+                       items)))
+             (setq lines nil)))
+      (dolist (line (split-lines (nth-value 1 (split-header text))))
+        (multiple-value-bind (match parts)
+            (ppcre:scan-to-strings "^###\\s+(\\d{4}-\\d{2}-\\d{2})\\s*(.*)$" line)
+          (cond (match
+                 (close-item)
+                 (setq date (aref parts 0)
+                       title (clean-news-title (aref parts 1))))
+                (date (push line lines)))))
+      (close-item))
+    (nreverse items)))
+
+(defun news-year (item) (subseq (getf item :date) 0 4))
+
+(defun news-item-html (item)
+  (with-lhtml-string ()
+    (:article :id (getf item :id)
+              :class "cl-news-item grid gap-x-8 gap-y-2 py-7 sm:grid-cols-[9.5rem_1fr]"
+      (:p :class "pt-0.5 font-mono text-sm text-muted"
+        (:a :class "hover:text-accent" :href (format nil "#~a" (getf item :id))
+          (:time :datetime (getf item :date) (str (long-date (getf item :date))))))
+      (:div
+        (when (getf item :title)
+          (htm (:h3 :class "mb-2 text-lg font-semibold text-ink"
+                 (str (getf item :title)))))
+        (:div :class "cl-prose cl-news-body" (str (getf item :html)))))))
+
+(defun atom-feed (items self-url site-url)
+  "String. ITEMS as an Atom feed; SITE-URL is the origin the items'
+links hang from."
+  (with-output-to-string (out)
+    (format out "<?xml version=\"1.0\" encoding=\"utf-8\"?>~%")
+    (format out "<feed xmlns=\"http://www.w3.org/2005/Atom\">~%")
+    (format out "  <title>Common-Lisp.net news</title>~%")
+    (format out "  <id>~a</id>~%" (html-escape self-url))
+    (format out "  <link rel=\"self\" href=\"~a\"/>~%" (html-escape self-url))
+    (format out "  <link href=\"~a\"/>~%" (html-escape site-url))
+    (format out "  <updated>~aT00:00:00Z</updated>~%"
+            (if items (getf (first items) :date) "2003-01-01"))
+    (dolist (item items)
+      (let ((link (format nil "~a#~a" site-url (getf item :id))))
+        (format out "  <entry>~%")
+        (format out "    <title>~a</title>~%"
+                (html-escape (or (getf item :title)
+                                 (long-date (getf item :date)))))
+        (format out "    <id>~a</id>~%" (html-escape link))
+        (format out "    <link href=\"~a\"/>~%" (html-escape link))
+        (format out "    <updated>~aT00:00:00Z</updated>~%" (getf item :date))
+        (format out "    <author><name>Common-Lisp.net maintainers</name></author>~%")
+        (format out "    <content type=\"html\">~a</content>~%"
+                (html-escape (getf item :html)))
+        (format out "  </entry>~%")))
+    (format out "</feed>~%")))
+
+(define-object news (base-site-page)
+
+  :documentation
+  (:description "The news page, and the keeper of the parsed news for
+the rest of the page tree: the front page asks it for `latest', the
+feed handler for `feed-xml'.")
+
+  :computed-slots
+  ((page-title "News")
+   (eyebrow "Foundation")
+   (lead "What has changed on common-lisp.net and at the Common Lisp Foundation, newest first.")
+   (meta-description "News from common-lisp.net and the Common Lisp Foundation.")
+   (source-path (format nil "content/~a" *news-file*))
+   (wide? t)
+
+   ("List of plists. Every news item, newest first."
+    items (parse-news (read-text-file (merge-pathnames *news-file* *content-directory*))
+                      (the url-prefix)))
+
+   ("List of plists. The items the front page shows."
+    latest (subseq (the items) 0 (min 3 (length (the items)))))
+
+   (years (remove-duplicates (mapcar #'news-year (the items))
+                             :test #'string= :from-end t))
+
+   ("String. The Atom feed of the thirty most recent items."
+    feed-xml (atom-feed (subseq (the items) 0 (min 30 (length (the items))))
+                        (string-append *canonical-origin* (the (path "/news.atom")))
+                        (string-append *canonical-origin* (the clean-url))))
+
+   (main-html
+    (with-lhtml-string ()
+      (:div :class "flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted"
+        (:span "Jump to")
+        (dolist (year (the years))
+          (htm (:a :class *link* :href (format nil "#y~a" year) (str year))))
+        (:a :class "ml-auto inline-flex items-center gap-1 rounded-full border border-rule-strong px-3 py-1 font-medium text-body hover:border-accent hover:text-accent"
+            :href (the (path "/news.atom"))
+          "Atom feed"))
+      (loop for year in (the years)
+            for index from 0
+            for items = (remove year (the items) :key #'news-year :test-not #'string=)
+            do (if (< index *news-open-years*)
+                   (htm (:section :id (format nil "y~a" year) :class "mt-10"
+                          (:h2 :class "border-b border-rule-strong pb-2 font-mono text-xl font-semibold text-ink"
+                            (str year))
+                          (dolist (item items) (str (news-item-html item)))))
+                   (htm (:details :id (format nil "y~a" year)
+                                  :class "mt-6 rounded-xl border border-rule bg-surface px-5"
+                          (:summary :class "cursor-pointer py-4 font-mono text-lg font-semibold text-ink"
+                            (str year)
+                            (:span :class "ml-3 font-sans text-sm font-normal text-muted"
+                              (fmt "~d item~:p" (length items))))
+                          (dolist (item items) (str (news-item-html item)))))))))))
clnet/source/package.lispaddedview

@@ -1,0 +1,22 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :gdl-user)
+
+(gwl:define-package :clnet
+  (:export #:initialize!
+           #:refresh!
+           #:refresh-live-data!
+           #:start-refresher
+           #:stop-refresher
+           #:clnet-assembly
+           #:clnet-facelift-assembly
+           #:*host-map*
+           #:*facelift-prefix*
+           #:*preview?*
+           #:*data-directory*
+           #:*admin-email*
+           #:*gitlab-token-file*
+           #:*live-mode?*))
clnet/source/parameters.lispaddedview

@@ -1,0 +1,173 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;; Where things are on disk.
+
+(defparameter *system-directory*
+  (truename
+   (make-pathname :defaults (merge-pathnames "../" (glisp:source-pathname))
+                  :name nil :type nil))
+  "Pathname. The clnet/ directory of the repository.")
+
+(defparameter *repo-directory*
+  (truename (merge-pathnames "../" *system-directory*))
+  "Pathname. The root of the cl-site repository.")
+
+(defparameter *content-directory*
+  (merge-pathnames "content/" *repo-directory*)
+  "Pathname. The Markdown and HTML files the content pages read, and
+the news.")
+
+(defparameter *data-directory*
+  (let ((env (uiop:getenv "CLNET_DATA_DIR")))
+    (if (and env (plusp (length env)))
+        (uiop:ensure-directory-pathname env)
+        (merge-pathnames "data/" *system-directory*)))
+  "Pathname. Runtime state and local secrets: the request journal, the
+donation journal, tokens and credentials.  Never in git.  Set
+CLNET_DATA_DIR in the environment to keep it outside the checkout.")
+
+(defun data-file (name)
+  "Pathname of NAME under *data-directory*."
+  (merge-pathnames name *data-directory*))
+
+;;; Where the site answers.
+;;;
+;;; One image can serve the site two ways at once:
+;;;
+;;;  - by HOST: every name in *host-map* gets the site at its root.
+;;;    This is how facelift.common-lisp.net previews it, and how
+;;;    common-lisp.net itself will be served after the switch (add the
+;;;    two names below when that day comes);
+;;;
+;;;  - by PATH: under /<*facelift-prefix*>/ on any host, from a second
+;;;    instance of the same page tree whose every URL carries the
+;;;    prefix.  A front-end web server can then forward just that path
+;;;    while it keeps serving everything else as before; this is how
+;;;    the site was shown at common-lisp.net/facelift/ before it took
+;;;    over the root.
+
+(defparameter *host-map*
+  '("common-lisp.net"          clnet-assembly
+    "www.common-lisp.net"      clnet-assembly
+    "facelift.common-lisp.net" clnet-assembly
+    "clnet.localhost"          clnet-assembly
+    ;; Chromium-family browsers resolve *.localhost by themselves and
+    ;; never read the hosts file; .test (RFC 6761) they leave alone.
+    "clnet.test"               clnet-assembly)
+  "Plist of host name and the type of page tree it is served.")
+
+(defparameter *facelift-prefix* nil
+  "String or nil. The first path segment under which the prefixed copy
+of the site answers on any host; nil for none.  It was \"facelift\"
+while the site was a preview on a path of the old one.")
+
+(defparameter *retired-prefixes* '("facelift")
+  "List of strings. Path prefixes the site once answered under and now
+redirects to the same path at the root, so that the links handed out
+during the preview keep working.")
+
+(defparameter *preview?* nil
+  "Boolean. True while the site is a proposal: every page asks not to
+be indexed and wears a ribbon saying what it is.")
+
+(defparameter *canonical-origin* "https://common-lisp.net"
+  "String. Scheme and host of the public site, for canonical links, the
+news feed and links to what the old web server still answers.")
+
+;;; Who and where.
+
+(defparameter *gitlab-url* "https://gitlab.common-lisp.net")
+(defparameter *repo-url* "https://gitlab.common-lisp.net/clo/cl-site")
+(defparameter *repo-branch* "master"
+  "String. The branch the 'Edit this page' links point into.")
+(defparameter *foundation-url* "https://cl-foundation.org")
+(defparameter *board-email* "clf@cl-foundation.org")
+(defparameter *irc-channel* "#common-lisp.net")
+(defparameter *irc-webchat-url* "https://web.libera.chat/#common-lisp.net")
+
+(defparameter *admin-email* "admin@common-lisp.net"
+  "String. The address the site gives for requests to the admins.  The
+site checks it against the mail exchanger every few hours (live.lisp)
+and stops printing it while the exchanger rejects it.")
+
+(defparameter *account-request-email* "account-request@common-lisp.net"
+  "String. The address for asking for an account by mail; checked like
+*admin-email*.")
+
+(defparameter *mail-exchanger* "mail.common-lisp.net"
+  "String. The host that takes mail for *admin-email*.")
+
+;;; Sponsors.  A sponsored link is a card marked as such on Getting
+;;; Started and on the Implementations page, and a name on the
+;;; Foundation page.  Nothing is drawn while the list is empty.
+
+(defparameter *sponsors*
+  '()
+  "List of (name url tagline): the Foundation's sponsors, each with the
+address its link leads to and a line of the sponsor's own words.  For
+example:
+  (\"Franz Inc.\" \"https://franz.com/products/allegro-common-lisp/\"
+   \"Allegro CL: the enterprise Common Lisp, free to try as Allegro CL Express.\")")
+
+;;; Assets.  Two trees are published, each under its own prefix so that
+;;; the site can share a server with other applications.
+
+(defparameter *asset-trees*
+  (list (list "clnet-assets" (merge-pathnames "static/" *system-directory*))
+        (list "clnet-static" (merge-pathnames "static/" *content-directory*)))
+  "List of (url-segment directory): clnet-assets is the stylesheet and
+the logo, clnet-static the images the content files refer to.")
+
+(defun asset-url (url-prefix tree path &key versioned?)
+  "String. The address of PATH in the asset tree named TREE, under
+URL-PREFIX (\"\" or \"/facelift\").  With VERSIONED?, the file's write
+date rides along as a query, so a rebuilt stylesheet is a new address
+to every cache on the way."
+  (let ((directory (second (assoc tree *asset-trees* :test #'string=))))
+    (format nil "~a/~a/~a~@[?v=~a~]" url-prefix tree path
+            (and versioned? directory
+                 (ignore-errors
+                  (file-write-date (merge-pathnames path directory)))))))
+
+;;; Shared class strings.  Tailwind finds them here as it finds any
+;;; other; a page says :class *button* and so on.
+
+(defparameter *container* "mx-auto max-w-6xl px-5 sm:px-8")
+(defparameter *container-narrow* "mx-auto max-w-3xl px-5 sm:px-8")
+
+(defparameter *button*
+  "inline-flex items-center justify-center gap-2 rounded-lg bg-accent-fill px-4 py-2.5 text-sm font-semibold text-on-accent shadow-sm transition-colors hover:bg-accent-fill-hover")
+(defparameter *button-quiet*
+  "inline-flex items-center justify-center gap-2 rounded-lg border border-rule-strong bg-surface px-4 py-2.5 text-sm font-semibold text-ink transition-colors hover:border-accent hover:text-accent")
+(defparameter *link*
+  "font-medium text-accent underline decoration-accent/30 underline-offset-4 hover:text-accent-strong hover:decoration-current")
+(defparameter *arrow-link*
+  "inline-flex items-center gap-1 text-sm font-semibold text-accent hover:text-accent-strong")
+
+(defparameter *card*
+  "rounded-2xl border border-rule bg-surface p-6 shadow-sm")
+(defparameter *card-link*
+  "group block rounded-2xl border border-rule bg-surface p-6 shadow-sm transition hover:border-accent hover:shadow-md")
+(defparameter *card-title* "text-lg font-semibold text-ink")
+(defparameter *card-body* "mt-2 text-[0.95rem] leading-relaxed text-body")
+
+(defparameter *eyebrow*
+  "font-mono text-xs font-semibold uppercase tracking-widest text-accent")
+(defparameter *h2* "text-2xl font-semibold tracking-tight text-ink sm:text-3xl")
+(defparameter *h3* "text-lg font-semibold text-ink")
+(defparameter *lead* "text-lg leading-relaxed text-body")
+
+(defparameter *badge*
+  "inline-flex items-center rounded-full bg-accent-soft px-2.5 py-0.5 text-xs font-semibold text-accent")
+(defparameter *badge-ok*
+  "inline-flex items-center rounded-full bg-ok-soft px-2.5 py-0.5 text-xs font-semibold text-ok")
+(defparameter *badge-warn*
+  "inline-flex items-center rounded-full bg-warn-soft px-2.5 py-0.5 text-xs font-semibold text-warn")
+(defparameter *code-panel*
+  "overflow-x-auto rounded-xl bg-code p-4 font-mono text-sm leading-relaxed text-code-ink")
+(defparameter *label* "block text-sm font-semibold text-ink")
clnet/source/project-pages.lispaddedview

@@ -1,0 +1,246 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; Hosting a project here, and getting the account it takes.
+
+;;; ------------------------------------------------------------------
+;;; Project hosting (the address is /project-intro, as it always was)
+;;; ------------------------------------------------------------------
+
+(define-object project-intro (base-site-page)
+
+  :computed-slots
+  ((page-title "Project hosting")
+   (eyebrow "Projects")
+   (lead "Free hosting for open source Common Lisp projects: a Git repository with CI, a web site, and mailing lists.")
+   (meta-description "Common-Lisp.net hosts open source Common Lisp projects: Git on GitLab with CI, project web pages and mailing lists.")
+   (source-path "clnet/source/project-pages.lisp")
+
+   (main-html
+    (with-lhtml-string ()
+      (:div :class "grid gap-5 sm:grid-cols-3"
+        (:div :class *card*
+          (:h2 :class *card-title* "Git and CI")
+          (:p :class *card-body*
+            "A repository on our GitLab, with issues, merge requests and pipelines that can test on every major implementation."))
+        (:div :class *card*
+          (:h2 :class *card-title* "A web site")
+          (:p :class *card-body*
+            "GitLab Pages publishes your project's site at "
+            (:code :class "font-mono text-sm" "<project>.common-lisp.dev") "."))
+        (:div :class *card*
+          (:h2 :class *card-title* "Mailing lists")
+          (:p :class *card-body*
+            "A " (:code :class "font-mono text-sm" "<project>-devel") " list on request, and more if the project needs them.")))
+      (:p :class "mt-8 flex flex-wrap gap-3"
+        (:a :class *button* :href (the root registration clean-url) "Get an account")
+        (:a :class *button-quiet* :href (the root phub clean-url) "See the hosted projects"))
+
+      (str (the (prose (format nil "
+## What you get
+
+Hosted projects get a Git repository on
+[our GitLab](https://gitlab.common-lisp.net/explore/projects/), web
+space, and mailing lists. Projects from before the move to Git may still
+use Subversion with Trac.
+
+<span id=\"project-pages\"></span>A project on GitLab can use
+[GitLab Pages](https://docs.gitlab.com/ee/user/project/pages/) to
+publish its site. The
+[Boston Lisp site repository](https://gitlab.common-lisp.net/boston-lisp/boston-lisp.common-lisp.dev/)
+is an example; its
+[.gitlab-ci.yml](https://gitlab.common-lisp.net/boston-lisp/boston-lisp.common-lisp.dev/-/blob/master/.gitlab-ci.yml)
+shows how, and the FAQ has
+[step-by-step instructions](/faq/using-gitlab-deploy-project-pages/).
+To test on several implementations, see
+[Using GitLab CI to test CL projects](/faq/using-gitlab-ci-for-cl/).
+
+## Joining an existing project
+
+Anyone can read and clone a public repository over https; no account is
+needed for that.
+
+To contribute, [get an account](/registration/) and ask for access on
+the project's GitLab page; the project's owners are notified and can add
+you themselves. You can also reach them on the project's mailing list,
+usually `<project>-devel@common-lisp.net`. If the project is orphaned or
+has no list, ~a.
+
+## Starting a project
+
+1. [Get an account](/registration/) and tell the admins that you want
+   to create projects. Because of spam this permission is given by
+   hand.
+2. Create the project on GitLab. You can create up to ten in your own
+   namespace, but a **group** for the project is strongly encouraged:
+   it gives the project's site a nicer address and gives you better
+   control over who can do what when several people work on related
+   repositories. Ask for a group with the same request.
+3. Set up a simple web page for the project with GitLab Pages, as
+   described above.
+4. If you want commits announced on a mailing list, turn on
+   [e-mail on push](/faq/emailonpush/).
+
+## House rules
+
+There are no hard limits on what a project may use. You are sharing
+the system with others, so please:
+
+- run no CPU- or memory-intensive tasks, and no long-running ones
+  (no chat clients, no `screen` sessions);
+- use shell access, if you have it, only to administer your projects;
+- store only data that is meant to be public;
+- ask the admins before hosting video or other large files, before
+  setting up cron jobs, and if you would like your project mirrored.
+
+## Mailing lists
+
+When a project is set up, one list, `<project>-devel@common-lisp.net`,
+can be created for it, with the requester subscribed. Later members
+subscribe themselves through
+[the list server](https://mailman.common-lisp.net/). More lists are
+available on request.
+
+## Backups
+
+All data is backed up regularly, on site and off, against both accident
+and disaster. There is no self-service restore, though, so please be
+careful before deleting anything in your project.
+
+## Subversion and Trac
+
+Projects that still use Subversion keep their repository at
+`/project/<project>/svn`; anonymous read-only access is at
+`https://svn.common-lisp.net/<project>`, and commits are mailed to
+`<project>-cvs@common-lisp.net`. A Subversion account is separate from a
+GitLab account and is requested from the admins with an SSH public key.
+Such a project's web site is `/project/<project>/public_html`, published
+as `https://<project>.common-lisp.dev/`. Trac is available to these
+projects on request; see [Tools](/tools/#trac).
+
+## Asking the admins
+
+For adding or removing project members, new groups, more mailing lists
+and anything else that needs an admin, ~a, with the name of the project
+and the details." (the ask-the-admins) (the ask-the-admins)))))))))
+
+;;; ------------------------------------------------------------------
+;;; Getting an account (the address is /registration, as it always was)
+;;; ------------------------------------------------------------------
+
+(define-object registration (base-site-page)
+
+  :computed-slots
+  ((page-title "Get an account")
+   (eyebrow "Projects")
+   (lead "How to join gitlab.common-lisp.net, and how to ask the admins for anything else.")
+   (meta-description "How to register on gitlab.common-lisp.net, and how to reach the common-lisp.net admins.")
+   (source-path "clnet/source/project-pages.lisp")
+
+   (main-html
+    (flet ((signup-step (number title html)
+             (with-lhtml-string ()
+               (:li :class "flex gap-5"
+                 (:span :class "flex size-9 flex-none items-center justify-center rounded-full bg-accent-soft font-mono text-sm font-semibold text-accent"
+                   (str number))
+                 (:div :class "min-w-0 flex-1 pb-2"
+                   (:h3 :class *h3* (str title))
+                   (:div :class "cl-prose mt-2 text-base" (str html)))))))
+      (with-lhtml-string ()
+        (:div :class (string-append *card* " border-ok/40")
+          (:h2 :class *card-title* "You may not need one")
+          (:p :class *card-body*
+            "Browsing " (:a :class *link* :href *gitlab-url* "our GitLab")
+            " and cloning any public repository over https need no account. You need one to open issues and merge requests, to join a project, or to host a project of your own."))
+
+        (:h2 :class (string-append *h2* " mt-12") "Signing up, step by step")
+        (:p :class "mt-3 text-body"
+          "Spammers register accounts by the thousand wherever registration is open, so ours is not. The registration page sits behind a link that we hand out to people who ask.")
+        (:ol :class "mt-8 space-y-8"
+          (str (signup-step 1 "Ask for the registration link"
+                     (the (md (format nil "
+Fill in [the form below](#request). Your request reaches the admins'
+phones the moment you send it, and one of them mails you the link.
+
+If you are on IRC, there is also a bot: in `~a` on
+[Libera.Chat](https://libera.chat) (the [web chat](~a) needs no
+client), say `minion: registration, please?`
+
+If you already have an account here, or sign in through GitHub, you do
+not need the link." *irc-channel* *irc-webchat-url*)))))
+          (str (signup-step 2 "Register"
+                     (the (md "
+Open the link and fill in the registration form. The same link is also
+what lets you sign in with a Google account for the first time."))))
+          (str (signup-step 3 "Turn on two-factor authentication"
+                     (the (md "
+Every account must use two-factor authentication; GitLab asks you to
+set it up at your first sign-in. Any authenticator app that speaks TOTP
+will do, and so will a hardware key."))))
+          (str (signup-step 4 "Join a project, or ask to host one"
+                     (the (md "
+To **join** an existing project, ask for access on its GitLab page; its
+owners are notified and can add you.
+
+To **host** a project, tell the admins with [the form below](#request).
+New accounts cannot create projects until an admin has said yes, again
+because of spam. Say what the project is, and whether you would like a
+group for it. [Project hosting](/project-intro/) explains what you get.")))))
+
+        (:h2 :id "request" :class (string-append *h2* " mt-14") "Ask the admins")
+        (:p :class "mt-3 text-body"
+          "For the registration link, permission to create projects, a project group, a mailing list, or a problem with your account. Your request is logged, pushed to the admins' phones, and stays on their queue until one of them has dealt with it, so it cannot get lost in a mailbox.")
+        (:div :class (string-append *card* " mt-6")
+          (str (request-form-html (the url-prefix))))
+
+        (:h2 :class (string-append *h2* " mt-14") "Other ways to reach us")
+        (str (the (prose (format nil "
+- **IRC:** `~a` on Libera.Chat, where the admins and other users of the
+  site can often help at once.
+~@[- **Mail:** [~a](mailto:~:*~a).
+~]- **The Foundation's board**, for anything about the Common Lisp
+  Foundation itself: [~a](mailto:~:*~a).
+
+If you have sent a request and heard nothing after a few days, please
+ask on IRC." *irc-channel* (the account-email) *board-email*))))))))
+
+  :objects
+  ((received :type 'request-received)
+   (problem :type 'request-problem)))
+
+(define-object request-received (base-site-page)
+
+  :computed-slots
+  ((page-title "Request received")
+   (eyebrow "Thank you")
+   (hide-from-sitemap? t)
+   (main-html
+    (the (prose "
+Your request is logged and the admins have been told. A person reads
+every one and answers at the address you gave.
+
+If it is urgent, or if a few days pass without an answer, ask in
+`#common-lisp.net` on [Libera.Chat](https://libera.chat).
+
+[Back to the front page](/)")))))
+
+(define-object request-problem (base-site-page)
+
+  :computed-slots
+  ((page-title "That did not go through")
+   (eyebrow "Sorry")
+   (hide-from-sitemap? t)
+   (main-html
+    (the (prose "
+The request was not recorded. Please go back and check that:
+
+- your name, a valid email address and the message are filled in;
+- the answer to the last question is the number that `(+ 2 3)`
+  evaluates to;
+- you have not sent several requests within the last hour.
+
+[Back to the form](/registration/#request)")))))
clnet/source/requests.lispaddedview

@@ -1,0 +1,440 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; The request line: what a visitor sends the admins from the site.
+;;;;
+;;;; For years the way to ask for an account, a project group or a
+;;;; mailing list was a mail to an address on a page, and a mail to an
+;;;; address can be lost without anyone knowing.  A request made here
+;;;; is instead written to a journal on disk the moment it arrives,
+;;;; pushed to the admins' phones through ntfy (https://ntfy.sh, or a
+;;;; server of our own), and kept on a queue page until one of them
+;;;; marks it handled.  Nothing is created automatically: an account is
+;;;; still an admin's decision.  The log, the push and the queue are
+;;;; all this program's; no mailbox and no chat bot stands between a
+;;;; request and the people who answer it.
+;;;;
+;;;; The forms are plain HTML forms posting to the handlers below, so
+;;;; the pages that carry them stay cached and shared.
+
+(defparameter *request-kinds*
+  '(("account" . "A GitLab account")
+    ("project" . "Permission to create projects, or a project group")
+    ("list" . "A mailing list")
+    ("services" . "A Foundation service")
+    ("other" . "Something else"))
+  "Alist of the kinds of request the forms send and their labels.")
+
+(defparameter *request-field-limit* 200)
+(defparameter *request-message-limit* 4000)
+
+(defparameter *requests-per-hour* 5
+  "Integer. Requests accepted from one address per hour.")
+
+(defparameter *human-answer* "5"
+  "String. The answer to the question the forms ask: what (+ 2 3)
+evaluates to.")
+
+(defparameter *admin-token-file* (data-file "admin-token")
+  "Pathname. One line: the token that opens the queue page.  Without
+the file there is no queue page at all.")
+
+(defparameter *ntfy-file* (data-file "ntfy-topics")
+  "Pathname. One line per ntfy topic to publish to: the topic's address
+(https://ntfy.example.org/some-topic), optionally followed by an access
+token for a topic that is protected.  Whoever subscribes to the topic,
+in the ntfy app or in a browser, hears of a request as it arrives.
+Without the file nothing is pushed and the queue page is the only way
+to see a request.")
+
+(defvar *requests-lock* (bt:make-lock "clnet requests"))
+
+(defvar *request-times* (make-hash-table :test #'equal)
+  "Address -> universal times of its recent requests.")
+
+;;; The journal: one plist per line.  A request is (:id ... :kind ...);
+;;; a later change to it is (:event :handled :id ... :time ...).
+
+(defun requests-file () (data-file "requests.sexp"))
+
+(defun append-journal (file record)
+  (ensure-directories-exist file)
+  (bt:with-lock-held (*requests-lock*)
+    (with-open-file (out file :direction :output :if-exists :append
+                              :if-does-not-exist :create :external-format :utf-8)
+      (let ((*print-pretty* nil) (*print-readably* nil))
+        (prin1 record out))
+      (terpri out)))
+  record)
+
+(defun read-journal (file)
+  (when (probe-file file)
+    (bt:with-lock-held (*requests-lock*)
+      (with-open-file (in file :external-format :utf-8)
+        (let ((*read-eval* nil))
+          (loop for record = (ignore-errors (read in nil nil))
+                while record collect record))))))
+
+(defun read-requests ()
+  "List of request plists, oldest first, each with :handled set to the
+time it was marked handled or nil."
+  (let ((requests nil) (handled (make-hash-table :test #'equal)))
+    (dolist (record (read-journal (requests-file)))
+      (if (getf record :event)
+          (setf (gethash (getf record :id) handled) (getf record :time))
+          (push record requests)))
+    (mapcar (lambda (request)
+              (append request (list :handled (gethash (getf request :id) handled))))
+            (nreverse requests))))
+
+;;; Reading a request off the wire
+
+(defun field (query name &optional (limit *request-field-limit*))
+  "String. The form field NAME of QUERY, trimmed and cut to LIMIT."
+  (let ((value (string-trim '(#\Space #\Tab #\Newline #\Return)
+                            (or (cdr (assoc name query :test #'string-equal)) ""))))
+    (subseq value 0 (min limit (length value)))))
+
+(defun client-address (req)
+  "String. Where the request came from: the first hop a front-end
+proxy reports, or the socket's peer."
+  (let ((forwarded (or (net.aserve:header-slot-value req :x-forwarded-for)
+                       (net.aserve:header-slot-value req :x-real-ip))))
+    (if (and forwarded (plusp (length forwarded)))
+        (string-trim " " (first (ppcre:split "," forwarded)))
+        (or (ignore-errors
+             (socket:ipaddr-to-dotted
+              (socket:remote-host (net.aserve:request-socket req))))
+            "unknown"))))
+
+(defun too-many-requests? (address)
+  "True when ADDRESS has used up its hour; otherwise the request is
+counted."
+  (bt:with-lock-held (*requests-lock*)
+    (let* ((now (get-universal-time))
+           (recent (remove-if (lambda (time) (< time (- now 3600)))
+                              (gethash address *request-times*))))
+      (cond ((>= (length recent) *requests-per-hour*)
+             (setf (gethash address *request-times*) recent)
+             t)
+            (t (setf (gethash address *request-times*) (cons now recent))
+               nil)))))
+
+(defun plausible-email? (string)
+  (ppcre:scan "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" string))
+
+(defun redirect (req ent location)
+  (net.aserve:with-http-response (req ent :response net.aserve:*response-see-other*)
+    (setf (net.aserve:reply-header-slot-value req :location) location)
+    (setf (net.aserve:reply-header-slot-value req :cache-control) "no-store")
+    (net.aserve:with-http-body (req ent))))
+
+(defun not-found (req ent)
+  (net.aserve:with-http-response (req ent :response net.aserve:*response-not-found*)
+    (net.aserve:with-http-body (req ent)
+      (write-string "Not found." (net.aserve:request-reply-stream req)))))
+
+;;; Announcing
+
+(defun ntfy-topics ()
+  "List of (address token-or-nil): the lines of *ntfy-file*."
+  (let ((file (probe-file *ntfy-file*)))
+    (when file
+      (loop for line in (split-lines (read-text-file file))
+            for parts = (ppcre:split "\\s+" (string-trim " " line))
+            when (and parts (ppcre:scan "^https?://" (first parts)))
+              collect (list (first parts) (second parts))))))
+
+(defun publish-to-ntfy (address token text &key title tags click)
+  "True when the ntfy topic at ADDRESS took TEXT.  TITLE and TAGS must
+be ASCII (they travel as HTTP headers); CLICK is what a tap on the
+notification opens."
+  (ignore-errors
+   (bt:with-timeout (20)
+     (eql 200
+          (nth-value
+           1 (drakma:http-request
+              address
+              :method :post
+              :content (flexi-streams:string-to-octets text :external-format :utf-8)
+              :content-type "text/plain; charset=utf-8"
+              :user-agent *user-agent*
+              :connection-timeout 10
+              :additional-headers
+              (append (when title (list (cons "Title" title)))
+                      (when tags (list (cons "Tags" tags)))
+                      (when click (list (cons "Click" click)))
+                      (when token
+                        (list (cons "Authorization" (format nil "Bearer ~a" token)))))))))))
+
+(defun notify-admins (text &key (title "common-lisp.net") tags click wait?)
+  "Publish TEXT to every topic of *ntfy-file*.  From a thread of its
+own, so that a slow ntfy server never holds up a visitor, unless WAIT?,
+which returns the list of what each topic answered."
+  (let ((topics (ntfy-topics)))
+    (flet ((publish ()
+             (loop for (address token) in topics
+                   collect (publish-to-ntfy address token text
+                                            :title title :tags tags :click click))))
+      (cond ((null topics) nil)
+            (wait? (publish))
+            (t (bt:make-thread #'publish :name "clnet ntfy") nil)))))
+
+;;; The handler behind every request form.  It answers with a redirect
+;;; to one of two fixed pages of the tree the form was on, never to an
+;;; address taken from the request.
+
+(defun request-handler (url-prefix)
+  (lambda (req ent)
+    (let* ((query (net.aserve:request-query req :external-format :utf-8))
+           (kind (field query "kind"))
+           (name (field query "name"))
+           (email (field query "email"))
+           (username (field query "username"))
+           (message (field query "message" *request-message-limit*))
+           (address (client-address req))
+           (received (format nil "~a/registration/received/" url-prefix))
+           (problem (format nil "~a/registration/problem/" url-prefix)))
+      (cond
+        ;; the hidden field a person never sees: answer as if all went
+        ;; well and keep nothing
+        ((plusp (length (field query "website")))
+         (redirect req ent received))
+        ((or (not (assoc kind *request-kinds* :test #'string=))
+             (zerop (length name))
+             (not (plausible-email? email))
+             (zerop (length message))
+             (string/= (field query "answer") *human-answer*)
+             (too-many-requests? address))
+         (redirect req ent problem))
+        (t
+         (let ((record (list :id (format nil "~36r-~36r" (get-universal-time) (random (expt 36 4)))
+                             :time (get-universal-time)
+                             :kind kind :name name :email email
+                             :username username :message message
+                             :from address)))
+           (append-journal (requests-file) record)
+           ;; The push carries the whole request, so an admin can act
+           ;; on it from the notification; a tap opens a mail to the
+           ;; person who asked.
+           (notify-admins
+            (format nil "~a~%~a <~a>~@[~%username: ~a~]~%~%~a"
+                    (cdr (assoc kind *request-kinds* :test #'string=))
+                    name email (and (plusp (length username)) username)
+                    (subseq message 0 (min 1000 (length message))))
+            :title (format nil "common-lisp.net: ~a request" kind)
+            :tags (if (string= kind "account") "bust_in_silhouette" "incoming_envelope")
+            :click (when (every (lambda (char) (< 32 (char-code char) 127)) email)
+                     (format nil "mailto:~a" email)))
+           (redirect req ent received)))))))
+
+;;; The queue page, for the admins.  Its address carries the token; a
+;;; wrong or missing token is a 404, as is a site with no token file.
+
+(defun admin-token ()
+  (let ((file (probe-file *admin-token-file*)))
+    (when file
+      (let ((token (string-trim '(#\Space #\Tab #\Newline #\Return)
+                                (read-text-file file))))
+        (when (>= (length token) 16) token)))))
+
+(defun token-ok? (query)
+  (let ((token (admin-token))
+        (given (cdr (assoc "token" query :test #'string-equal))))
+    (and token given (string= token given))))
+
+(defun queue-row-html (request token url-prefix)
+  (with-lhtml-string ()
+    (:article :class (string-append *card* " mt-4")
+      (:div :class "flex flex-wrap items-baseline justify-between gap-2"
+        (:p :class "font-semibold text-ink"
+          (str (html-escape (cdr (assoc (getf request :kind) *request-kinds*
+                                        :test #'string=)))))
+        (:p :class "font-mono text-xs text-muted"
+          (str (iso-timestamp (getf request :time)))
+          " · " (str (html-escape (getf request :from)))))
+      (:p :class "mt-2 text-sm text-body"
+        (str (html-escape (getf request :name)))
+        " &lt;"
+        (:a :class *link* :href (format nil "mailto:~a" (html-escape (getf request :email)))
+          (str (html-escape (getf request :email))))
+        "&gt;"
+        (when (plusp (length (getf request :username)))
+          (htm " · username wanted: "
+               (:code (str (html-escape (getf request :username)))))))
+      (:p :class "mt-3 whitespace-pre-wrap text-sm text-body"
+        (str (html-escape (getf request :message))))
+      (if (getf request :handled)
+          (htm (:p :class "mt-3 text-xs text-muted"
+                 "Handled " (str (iso-timestamp (getf request :handled)))))
+          (htm (:form :method "post" :class "mt-4"
+                      :action (format nil "~a/admin/requests/handled" url-prefix)
+                 (:input :type "hidden" :name "token" :value (html-escape token))
+                 (:input :type "hidden" :name "id" :value (html-escape (getf request :id)))
+                 (:button :type "submit" :class *button-quiet* "Mark handled")))))))
+
+(defun queue-page-html (token url-prefix &optional (pushed ""))
+  (let* ((requests (reverse (read-requests)))
+         (open (remove-if (lambda (r) (getf r :handled)) requests))
+         (done (remove-if-not (lambda (r) (getf r :handled)) requests))
+         (drafts (let ((file (probe-file (news-drafts-file))))
+                   (and file (read-text-file file)))))
+    (with-lhtml-string ()
+      "<!DOCTYPE html>"
+      (:html :lang "en"
+        (:head
+          (:meta :charset "UTF-8")
+          (:meta :name "viewport" :content "width=device-width, initial-scale=1")
+          (:meta :name "robots" :content "noindex")
+          (:title "Requests · Common-Lisp.net admin")
+          (:script (str *site-script*))
+          (:link :rel "stylesheet"
+                 :href (asset-url url-prefix "clnet-assets" "css/style.css" :versioned? t)))
+        (:body
+          (:main :class (string-append *container-narrow* " py-10")
+            (:p :class *eyebrow* "Admin")
+            (:h1 :class "mt-2 text-3xl font-semibold tracking-tight text-ink" "Requests")
+
+            (:h2 :class (string-append *h3* " mt-8") "The mail lines")
+            (:p :class "mt-1 text-sm text-muted"
+              "As " (:code (str (html-escape *mail-exchanger*))) " last answered.")
+            (dolist (address (list *account-request-email* *admin-email*))
+              (let ((line (mail-line *live* address)))
+                (htm (:p :class "mt-2 text-sm text-body"
+                       (:code (str (html-escape address))) " "
+                       (case (getf line :status)
+                         (:accepted (htm (:span :class *badge-ok* "accepted")))
+                         (:rejected (htm (:span :class *badge-warn* "REJECTED")))
+                         (t (htm (:span :class *badge* "not checked from here"))))
+                       (when (getf line :detail)
+                         (htm " " (:span :class "font-mono text-xs text-muted"
+                                    (str (html-escape (getf line :detail))))))))))
+
+            (:h2 :class (string-append *h3* " mt-8") "The push line")
+            (:form :method "post" :class "mt-2 flex flex-wrap items-center gap-3 text-sm text-body"
+                   :action (format nil "~a/admin/requests/test-push" url-prefix)
+              (:input :type "hidden" :name "token" :value (html-escape token))
+              (:span (fmt "~d ntfy topic~:p configured." (length (ntfy-topics))))
+              (when (ntfy-topics)
+                (htm (:button :type "submit" :class *button-quiet* "Send a test push")))
+              (cond ((string= pushed "ok") (htm (:span :class *badge-ok* "test push accepted")))
+                    ((string= pushed "failed") (htm (:span :class *badge-warn* "TEST PUSH FAILED")))))
+
+            (:h2 :class (string-append *h3* " mt-10")
+              (fmt "Waiting (~d)" (length open)))
+            (if open
+                (dolist (request open)
+                  (str (queue-row-html request token url-prefix)))
+                (htm (:p :class "mt-2 text-sm text-muted" "Nothing is waiting.")))
+
+            (when drafts
+              (htm (:h2 :class (string-append *h3* " mt-10") "News drafts")
+                   (:p :class "mt-2 text-sm text-muted"
+                     "Noticed by the site; copy what is worth saying into content/news.md.")
+                   (:pre :class (string-append *code-panel* " mt-3 whitespace-pre-wrap")
+                     (str (html-escape drafts)))))
+
+            (when done
+              (htm (:h2 :class (string-append *h3* " mt-10")
+                     (fmt "Handled (~d)" (length done)))
+                   (dolist (request (subseq done 0 (min 50 (length done))))
+                     (str (queue-row-html request token url-prefix)))))))))))
+
+(defun queue-handler (url-prefix)
+  (lambda (req ent)
+    (let ((query (net.aserve:request-query req :external-format :utf-8)))
+      (if (token-ok? query)
+          (net.aserve:with-http-response (req ent)
+            (setf (net.aserve:reply-header-slot-value req :cache-control) "no-store")
+            (net.aserve:with-http-body (req ent)
+              (write-string (queue-page-html (admin-token) url-prefix
+                                             (field query "pushed"))
+                            (net.aserve:request-reply-stream req))))
+          (not-found req ent)))))
+
+(defun test-push-handler (url-prefix)
+  "Behind the queue page's button: publish a test message to every
+topic and say on the queue page whether each took it."
+  (lambda (req ent)
+    (let ((query (net.aserve:request-query req :external-format :utf-8)))
+      (if (token-ok? query)
+          (let ((answers (notify-admins "A test push from the requests queue. If you can read this, you will hear of the next request."
+                                        :title "common-lisp.net: test" :tags "white_check_mark"
+                                        :wait? t)))
+            (redirect req ent (format nil "~a/admin/requests?token=~a&pushed=~a"
+                                      url-prefix (admin-token)
+                                      (if (and answers (every #'identity answers))
+                                          "ok" "failed"))))
+          (not-found req ent)))))
+
+(defun handled-handler (url-prefix)
+  (lambda (req ent)
+    (let ((query (net.aserve:request-query req :external-format :utf-8)))
+      (cond ((token-ok? query)
+             (let ((id (field query "id")))
+               (when (find id (read-requests) :key (lambda (r) (getf r :id))
+                                              :test #'string=)
+                 (append-journal (requests-file)
+                                 (list :event :handled :id id
+                                       :time (get-universal-time)))))
+             (redirect req ent (format nil "~a/admin/requests?token=~a"
+                                       url-prefix (admin-token))))
+            (t (not-found req ent))))))
+
+;;; The form itself, for the pages that carry one.
+
+(defun request-form-html (url-prefix &key (kinds *request-kinds*) (default-kind "account")
+                                       (username? t)
+                                       (message-label "What do you plan to do here?")
+                                       (message-hint "A sentence or two is enough: the project you want to contribute to or host, or what you need."))
+  "String of HTML: the request form, posting to the request handler of
+the page tree under URL-PREFIX."
+  (with-lhtml-string ()
+    (:form :method "post" :action (format nil "~a/request-submit" url-prefix)
+           :class "space-y-5"
+      (:div
+        (:label :class *label* :for "rq-kind" "What are you asking for?")
+        (:select :id "rq-kind" :name "kind" :class "cl-field mt-1.5"
+          (loop for (value . label) in kinds
+                do (htm (:option :value value
+                                 :selected (and (string= value default-kind) "selected")
+                          (str label))))))
+      (:div :class "grid gap-5 sm:grid-cols-2"
+        (:div
+          (:label :class *label* :for "rq-name" "Your name")
+          (:input :id "rq-name" :name "name" :type "text" :required t
+                  :maxlength "200" :autocomplete "name" :class "cl-field mt-1.5"))
+        (:div
+          (:label :class *label* :for "rq-email" "Email address")
+          (:input :id "rq-email" :name "email" :type "email" :required t
+                  :maxlength "200" :autocomplete "email" :class "cl-field mt-1.5")))
+      (when username?
+        (htm (:div
+               (:label :class *label* :for "rq-username"
+                 "GitLab username "
+                 (:span :class "font-normal text-muted" "(the one you have, or would like)"))
+               (:input :id "rq-username" :name "username" :type "text"
+                       :maxlength "200" :autocomplete "username" :class "cl-field mt-1.5"))))
+      (:div
+        (:label :class *label* :for "rq-message" (str message-label))
+        (:p :class "mt-1 text-sm text-muted" (str message-hint))
+        (:textarea :id "rq-message" :name "message" :rows "5" :required t
+                   :maxlength "4000" :class "cl-field mt-1.5"))
+      (:div :class "cl-hp" :aria-hidden "true"
+        (:label :for "rq-website" "Leave this field empty")
+        (:input :id "rq-website" :name "website" :type "text" :tabindex "-1"
+                :autocomplete "off"))
+      (:div
+        (:label :class *label* :for "rq-answer"
+          "One for the robots: what does " (:code :class "font-mono" "(+ 2 3)")
+          " evaluate to?")
+        (:input :id "rq-answer" :name "answer" :type "text" :required t
+                :inputmode "numeric" :maxlength "10" :class "cl-field mt-1.5 max-w-[8rem]"))
+      (:div :class "flex flex-wrap items-center gap-4"
+        (:button :type "submit" :class *button* "Send the request")
+        (:p :class "text-sm text-muted"
+          "A person reads every request. We use your address to answer you and for nothing else.")))))
clnet/source/site-chrome.lispaddedview

@@ -1,0 +1,227 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; The navigation bar and the footer.  Both are made once, by the
+;;;; root of a page tree, and every page of the tree prints the root's.
+
+(defparameter *icon-moon*
+  "<svg class='cl-moon size-[1.1rem]' viewBox='0 0 24 24' aria-hidden='true' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z'/></svg>")
+
+(defparameter *icon-sun*
+  "<svg class='cl-sun size-[1.1rem]' viewBox='0 0 24 24' aria-hidden='true' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='4'/><path d='M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4'/></svg>")
+
+(defparameter *icon-caret*
+  "<svg class='cl-caret size-4' viewBox='0 0 20 20' aria-hidden='true' fill='currentColor'><path d='M5.2 7.3a1 1 0 0 1 1.4 0L10 10.6l3.4-3.3a1 1 0 1 1 1.4 1.4l-4.1 4a1 1 0 0 1-1.4 0l-4.1-4a1 1 0 0 1 0-1.4z'/></svg>")
+
+(defparameter *icon-menu*
+  "<svg class='size-6' viewBox='0 0 24 24' aria-hidden='true' fill='none' stroke='currentColor' stroke-width='1.8' stroke-linecap='round'><path d='M4 7h16M4 12h16M4 17h16'/></svg>")
+
+(defparameter *theme-toggle-html*
+  (format nil "<button type='button' class='cl-theme-toggle inline-flex size-9 flex-none items-center justify-center rounded-full border border-rule bg-surface text-body hover:border-accent hover:text-accent' aria-label='Switch between the light and the dark theme' title='Light or dark'>~a~a</button>"
+          *icon-moon* *icon-sun*))
+
+;;; The quotations the old site's footer chose from, one at random per
+;;; page view.  The first is what a reader without script sees.
+(defparameter *quotes*
+  '(("Lisp is a programmable programming language." "John Foderaro, CACM, September 1991")
+    ("Programs must be written for people to read, and only incidentally for machines to execute."
+     "Abelson &amp; Sussman, SICP, preface to the first edition")
+    ("That language is an instrument of human reason, and not merely a medium for the expression of thought, is a truth generally admitted."
+     "George Boole, quoted in Iverson's Turing Award Lecture")
+    ("One of the most important and fascinating of all computer languages is Lisp (standing for &quot;List Processing&quot;), which was invented by John McCarthy around the time Algol was invented."
+     "Douglas Hofstadter, Gödel, Escher, Bach")
+    ("Lisp isn't a language, it's a building material." "Alan Kay")
+    ("Any sufficiently complicated C or Fortran program contains an ad hoc informally-specified bug-ridden slow implementation of half of Common Lisp."
+     "Philip Greenspun (Greenspun's Tenth Rule of Programming)")
+    ("Lisp is worth learning for the profound enlightenment experience you will have when you finally get it; that experience will make you a better programmer for the rest of your days, even if you never actually use Lisp itself a lot."
+     "Eric Raymond, &quot;How to Become a Hacker&quot;")
+    ("Lisp is a programmer amplifier." "Martin Rodgers")
+    ("Common Lisp, a happy amalgam of the features of previous Lisps." "Winston &amp; Horn, Lisp")
+    ("Lisp doesn't look any deader than usual to me." "David Thornley")
+    ("SQL, Lisp, and Haskell are the only programming languages that I've seen where one spends more time thinking than typing."
+     "Philip Greenspun")
+    ("Don't worry about what anybody else is going to do. The best way to predict the future is to invent it."
+     "Alan Kay")
+    ("The greatest single programming language ever designed." "Alan Kay, on Lisp")
+    ("I object to doing things that computers can do." "Olin Shivers")
+    ("Lisp is a language for doing what you've been told is impossible." "Kent Pitman")
+    ("Lisp is the red pill." "John Fraser")
+    ("Within a couple weeks of learning Lisp I found programming in any other language unbearably constraining."
+     "Paul Graham")
+    ("Programming in Lisp is like playing with the primordial forces of the universe. It feels like lightning between your fingertips. No other language even feels close."
+     "Glenn Ehrlich")
+    ("A Lisp programmer knows the value of everything, but the cost of nothing." "Alan Perlis")
+    ("Lisp is the most sophisticated programming language I know. It is literally decades ahead of the competition ... it is not possible (as far as I know) to actually use Lisp seriously before reaching the point of no return."
+     "Christian Lynbech, Road to Lisp")
+    ("[Lisp] has assisted a number of our most gifted fellow humans in thinking previously impossible thoughts."
+     "Edsger Dijkstra, CACM, 15:10")
+    ("The limits of my language are the limits of my world."
+     "Ludwig Wittgenstein, Tractatus Logico-Philosophicus 5.6, 1918"))
+  "List of (quotation attribution), both strings of HTML.")
+
+(defun quote-html (entry)
+  (with-lhtml-string ()
+    (:p :class "italic text-body" "“" (str (first entry)) "”")
+    (:p :class "mt-1 text-sm text-muted" "— " (str (second entry)))))
+
+(defun implementation-credit ()
+  "String of HTML: the Lisp this site is running on, as a link."
+  (let ((type (lisp-implementation-type)))
+    (cond ((search "Clozure" type) "<a class='underline' href='https://ccl.clozure.com/'>Clozure CL</a>")
+          ((search "SBCL" type) "<a class='underline' href='https://www.sbcl.org/'>SBCL</a>")
+          ((search "Allegro" type) "<a class='underline' href='https://franz.com/products/allegro-common-lisp/'>Allegro CL</a>")
+          (t (html-escape type)))))
+
+(define-object site-chrome-mixin ()
+
+  :documentation
+  (:description "Mixed into the root of a page tree: the navigation bar
+and the footer, which every page of the tree prints.")
+
+  :computed-slots
+  ((nav-html
+    (let ((link "whitespace-nowrap rounded-md px-2.5 py-2 text-sm font-medium text-body hover:text-accent")
+          (summary "flex items-center gap-1 whitespace-nowrap rounded-md px-2.5 py-2 text-sm font-medium text-body hover:text-accent"))
+      (with-lhtml-string ()
+        (:header :class "sticky top-0 z-50 border-b border-rule bg-ground/90 backdrop-blur"
+          (:nav :class (string-append *container* " flex h-16 items-center gap-2")
+                :aria-label "Main"
+            (:a :href (the home-url)
+                :class "mr-2 flex flex-none items-center gap-2.5 text-ink"
+              (:img :class "cl-logo size-9" :alt "" :width "36" :height "36"
+                    :src (the (asset "clnet-assets" "imgs/lisplogo.png")))
+              (:span :class "text-[1.05rem] font-semibold tracking-tight"
+                "Common-Lisp.net"))
+
+            ;; the desktop row
+            (:div :class "hidden flex-1 items-center lg:flex"
+              (:a :class link :href (the downloads clean-url) "Get started")
+              (:a :class link :href (the docs clean-url) "Documentation")
+              (:a :class link :href (the community clean-url) "Community")
+              (:details :class "cl-menu"
+                (:summary :class summary "Projects" (str *icon-caret*))
+                (:div :class "cl-menu-panel"
+                  (:a :href (the project-intro clean-url) "Project hosting"
+                    (:small "What a hosted project gets, and how to start one"))
+                  (:a :href (the registration clean-url) "Get an account"
+                    (:small "How to join gitlab.common-lisp.net"))
+                  (:a :href (the phub clean-url) "Projects hub"
+                    (:small "The projects that live here"))
+                  (:a :href *gitlab-url* "GitLab"
+                    (:small "gitlab.common-lisp.net"))
+                  (:a :href (the independent-lists clean-url) "Mailing lists"
+                    (:small "Lists for Lisp topics, and the archives"))))
+              (:details :class "cl-menu"
+                (:summary :class summary "Resources" (str *icon-caret*))
+                (:div :class "cl-menu-panel"
+                  (:a :href (the implementations clean-url) "Implementations")
+                  (:a :href (the libraries clean-url) "Libraries")
+                  (:a :href (the features clean-url) "Features of the language")
+                  (:a :href (the tools clean-url) "Tools")
+                  (:a :href (the faq clean-url) "FAQ")
+                  (:a :href (the lisp-companies clean-url) "Companies")))
+              (:details :class "cl-menu"
+                (:summary :class summary "Foundation" (str *icon-caret*))
+                (:div :class "cl-menu-panel"
+                  (:a :href (the foundation clean-url) "What we are doing"
+                    (:small "The Common Lisp Foundation's current work"))
+                  (:a :href (the news clean-url) "News")
+                  (:a :href (the services clean-url) "Services"
+                    (:small "What we run, and what we could run for you"))
+                  (:a :href (the donate clean-url) "Donate"
+                    (:small "Fundraisers and the general fund"))
+                  (:a :href (the contribute clean-url) "Contribute")
+                  (:a :href (the about clean-url) "About this site"))))
+
+            (:div :class "ml-auto flex items-center gap-2"
+              (:a :class "hidden whitespace-nowrap rounded-lg border border-rule-strong bg-surface px-3 py-2 text-sm font-semibold text-ink hover:border-accent hover:text-accent sm:inline-flex"
+                  :href *gitlab-url* "GitLab")
+              (:a :class "hidden whitespace-nowrap rounded-lg bg-accent-fill px-3 py-2 text-sm font-semibold text-on-accent hover:bg-accent-fill-hover sm:inline-flex"
+                  :href (the registration clean-url) "Get an account")
+              (str *theme-toggle-html*)
+
+              ;; the phone menu
+              (:details :class "cl-menu lg:hidden"
+                (:summary :class "flex size-9 items-center justify-center rounded-md text-body hover:text-accent"
+                          :aria-label "Open the menu"
+                  (str *icon-menu*))
+                (:div :class "cl-menu-panel cl-right max-h-[75vh] w-64 overflow-y-auto"
+                  (:a :href (the downloads clean-url) "Get started")
+                  (:a :href (the docs clean-url) "Documentation")
+                  (:a :href (the community clean-url) "Community")
+                  (:a :href (the project-intro clean-url) "Project hosting")
+                  (:a :href (the registration clean-url) "Get an account")
+                  (:a :href (the phub clean-url) "Projects hub")
+                  (:a :href *gitlab-url* "GitLab")
+                  (:a :href (the implementations clean-url) "Implementations")
+                  (:a :href (the libraries clean-url) "Libraries")
+                  (:a :href (the faq clean-url) "FAQ")
+                  (:a :href (the foundation clean-url) "The Foundation")
+                  (:a :href (the news clean-url) "News")
+                  (:a :href (the services clean-url) "Services")
+                  (:a :href (the donate clean-url) "Donate")
+                  (:a :href (the about clean-url) "About")))))))))
+
+   (footer-html
+    (let ((heading "text-sm font-semibold text-ink")
+          (item "text-sm text-body hover:text-accent"))
+      (with-lhtml-string ()
+        (:footer :class "mt-16 border-t border-rule bg-band"
+          (:div :class (string-append *container* " py-12")
+            (:div :class "grid gap-10 lg:grid-cols-[1.4fr_2fr]"
+              (:div
+                (:div :id "cl-quote" :class "max-w-md"
+                  (str (quote-html (first *quotes*))))
+                (:div :id "cl-quotes" :hidden t
+                  (dolist (entry *quotes*)
+                    (htm (:div (str (quote-html entry)))))))
+              (:div :class "grid grid-cols-2 gap-8 sm:grid-cols-4"
+                (:div
+                  (:p :class heading "Learn")
+                  (:ul :class "mt-3 space-y-2"
+                    (:li (:a :class item :href (the downloads clean-url) "Get started"))
+                    (:li (:a :class item :href (the docs clean-url) "Documentation"))
+                    (:li (:a :class item :href (the features clean-url) "Features"))
+                    (:li (:a :class item :href (the implementations clean-url) "Implementations"))
+                    (:li (:a :class item :href (the libraries clean-url) "Libraries"))))
+                (:div
+                  (:p :class heading "Build")
+                  (:ul :class "mt-3 space-y-2"
+                    (:li (:a :class item :href (the project-intro clean-url) "Project hosting"))
+                    (:li (:a :class item :href (the registration clean-url) "Get an account"))
+                    (:li (:a :class item :href (the phub clean-url) "Projects hub"))
+                    (:li (:a :class item :href (the faq clean-url) "FAQ"))
+                    (:li (:a :class item :href *gitlab-url* "GitLab"))))
+                (:div
+                  (:p :class heading "Community")
+                  (:ul :class "mt-3 space-y-2"
+                    (:li (:a :class item :href (the community clean-url) "Community"))
+                    (:li (:a :class item :href (the independent-lists clean-url) "Mailing lists"))
+                    (:li (:a :class item :href (the lisp-companies clean-url) "Companies"))
+                    (:li (:a :class item :href "https://planet.lisp.org/" "Planet Lisp"))
+                    (:li (:a :class item :href *irc-webchat-url* "IRC"))))
+                (:div
+                  (:p :class heading "Foundation")
+                  (:ul :class "mt-3 space-y-2"
+                    (:li (:a :class item :href (the foundation clean-url) "What we are doing"))
+                    (:li (:a :class item :href (the news clean-url) "News"))
+                    (:li (:a :class item :href (the services clean-url) "Services"))
+                    (:li (:a :class item :href (the donate clean-url) "Donate"))
+                    (:li (:a :class item :href (the about clean-url) "About"))))))
+            (:div :class "mt-10 flex flex-col gap-2 border-t border-rule pt-6 text-sm text-muted sm:flex-row sm:items-center sm:justify-between"
+              (:p "© 2003–2026 "
+                (:a :class "underline" :href *foundation-url* "Common-Lisp.net maintainers")
+                ". Text under "
+                (:a :class "underline" :rel "license"
+                    :href "https://creativecommons.org/licenses/by/4.0/" "CC BY 4.0")
+                ".")
+              (:p "Served by " (str (implementation-credit)) " and "
+                (:a :class "underline" :href "https://gitlab.common-lisp.net/gendl/gendl" "Gendl")
+                ". "
+                (:a :class "underline" :href *repo-url* "Source")
+                " · "
+                (:a :class "underline" :href (the (path "/news.atom")) "News feed"))))))))))
clnet/source/sitemap.lispaddedview

@@ -1,0 +1,73 @@
+;;;; -*- coding: utf-8 -*-
+;;;;
+;;;; Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see
+;;;; LICENSE at the root of this repository.
+
+(in-package :clnet)
+
+;;;; What the site says to machines: robots.txt, sitemap.xml and the
+;;;; news feed.
+
+(defun site-root (type)
+  "The shared root instance of the page tree of TYPE."
+  (virtual-host-instance type *virtual-host-registry*))
+
+(defun request-host (req)
+  "String. The host the request names, lower case, without a port."
+  (let ((header (or (net.aserve:header-slot-value req :host) "")))
+    (string-downcase (first (ppcre:split ":" (string-trim " " header))))))
+
+(defun page-urls (root)
+  "List of strings: the address of every page under ROOT that belongs
+in a sitemap, depth first."
+  (labels ((walk (page)
+             (when (typep page 'base-site-page)
+               (append (unless (the-object page hide-from-sitemap?)
+                         (list (the-object page clean-url)))
+                       (loop for child in (the-object page children)
+                             append (walk child))))))
+    (remove-duplicates (walk root) :test #'string= :from-end t)))
+
+(defun write-text (req ent text)
+  (net.aserve:with-http-response (req ent)
+    (net.aserve:with-http-body (req ent)
+      (write-string text (net.aserve:request-reply-stream req)))))
+
+(defun robots-handler ()
+  "While the site is a preview it asks every robot to stay out; after
+the switch it serves content/robots.txt with the sitemap's address."
+  (lambda (req ent)
+    (write-text
+     req ent
+     (if *preview?*
+         (format nil "User-agent: *~%Disallow: /~%")
+         (format nil "~a~&Sitemap: https://~a/sitemap.xml~%"
+                 (read-text-file (merge-pathnames "robots.txt" *content-directory*))
+                 (request-host req))))))
+
+(defun sitemap-handler (type)
+  (lambda (req ent)
+    (write-text
+     req ent
+     (format nil "<?xml version=\"1.0\" encoding=\"UTF-8\"?>~%<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">~%~{  <url><loc>~a</loc></url>~%~}</urlset>~%"
+             (unless *preview?*
+               (loop for url in (page-urls (site-root type))
+                     collect (format nil "https://~a~a" (request-host req) url)))))))
+
+(defun health-handler ()
+  "For whatever watches the container: 200 and 'ok' while a page tree
+is up in this image, 503 otherwise.  Cheap on purpose; it is asked
+every minute."
+  (lambda (req ent)
+    (let ((up? (and (site-instances) t)))
+      (net.aserve:with-http-response
+          (req ent :response (if up?
+                                 net.aserve:*response-ok*
+                                 net.aserve:*response-service-unavailable*))
+        (setf (net.aserve:reply-header-slot-value req :cache-control) "no-store")
+        (net.aserve:with-http-body (req ent)
+          (write-string (if up? "ok" "no site") (net.aserve:request-reply-stream req)))))))
+
+(defun feed-handler (type)
+  (lambda (req ent)
+    (write-text req ent (the-object (site-root type) news feed-xml))))
clnet/static/css/style.cssaddedview

@@ -1,0 +1,2 @@
+/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
+@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{background-color:var(--cl-ground);scroll-padding-top:5rem}body{background-color:var(--cl-ground);color:var(--cl-body);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;line-height:1.6}:focus-visible{outline:3px solid var(--cl-accent);outline-offset:2px;border-radius:.25rem}::selection{background:var(--cl-accent-soft);color:var(--cl-ink)}@media (prefers-reduced-motion:no-preference){html{scroll-behavior:smooth}}}@layer components{.cl-menu{position:relative}.cl-menu>summary{cursor:pointer;-webkit-user-select:none;user-select:none;list-style:none}.cl-menu>summary::-webkit-details-marker{display:none}.cl-menu>summary .cl-caret{transition:transform .15s}.cl-menu[open]>summary .cl-caret{transform:rotate(180deg)}.cl-menu-panel{z-index:60;border:1px solid var(--cl-rule);background:var(--cl-surface);border-radius:.75rem;min-width:15rem;padding:.5rem;position:absolute;top:calc(100% + .5rem);left:0;box-shadow:0 12px 32px -8px #140a3240}.cl-menu-panel.cl-right{left:auto;right:0}.cl-menu-panel a{color:var(--cl-body);border-radius:.5rem;padding:.5rem .75rem;font-size:.925rem;text-decoration:none;display:block}.cl-menu-panel a:hover{background:var(--cl-band);color:var(--cl-accent)}.cl-menu-panel a small{color:var(--cl-muted);font-size:.78rem;line-height:1.3;display:block}html:not(.js) .cl-theme-toggle,.cl-theme-toggle .cl-sun,:root[data-theme=dark] .cl-theme-toggle .cl-moon{display:none}:root[data-theme=dark] .cl-theme-toggle .cl-sun{display:block}@media (prefers-color-scheme:dark){:root:not([data-theme=light]) .cl-theme-toggle .cl-moon{display:none}:root:not([data-theme=light]) .cl-theme-toggle .cl-sun{display:block}}.cl-logo{background:#fff;border-radius:9999px;padding:2px}.cl-prose{color:var(--cl-body);overflow-wrap:break-word;font-size:1.0625rem;line-height:1.7}.cl-prose>:first-child{margin-top:0}.cl-prose h1,.cl-prose h2,.cl-prose h3,.cl-prose h4{color:var(--cl-ink);letter-spacing:-.01em;font-weight:650;line-height:1.25}.cl-prose h1{border-bottom:1px solid var(--cl-rule);margin:2.75rem 0 1rem;padding-bottom:.4rem;font-size:1.7rem}.cl-prose h2{margin:2.5rem 0 .85rem;font-size:1.45rem}.cl-prose h3{margin:2rem 0 .6rem;font-size:1.18rem}.cl-prose h4{margin:1.5rem 0 .5rem;font-size:1.05rem}.cl-prose p,.cl-prose ul,.cl-prose ol,.cl-prose dl,.cl-prose table,.cl-prose pre,.cl-prose blockquote{margin:0 0 1.1rem}.cl-prose ul{padding-left:1.4rem;list-style:outside}.cl-prose ol{padding-left:1.5rem;list-style:decimal}.cl-prose li{margin:.3rem 0}.cl-prose li>ul,.cl-prose li>ol{margin:.3rem 0 .4rem}.cl-prose li::marker{color:var(--cl-muted)}.cl-prose a{color:var(--cl-accent);text-decoration:underline;-webkit-text-decoration-color:var(--cl-accent);-webkit-text-decoration-color:var(--cl-accent);text-decoration-color:var(--cl-accent)}@supports (color:color-mix(in lab, red, red)){.cl-prose a{-webkit-text-decoration-color:color-mix(in oklab, var(--cl-accent) 35%, transparent);-webkit-text-decoration-color:color-mix(in oklab, var(--cl-accent) 35%, transparent);text-decoration-color:color-mix(in oklab, var(--cl-accent) 35%, transparent)}}.cl-prose a{text-underline-offset:.18em}.cl-prose a:hover{color:var(--cl-accent-strong);text-decoration-color:currentColor}.cl-prose strong,.cl-prose b{color:var(--cl-ink);font-weight:650}.cl-prose code,.cl-prose tt{font-family:var(--font-mono);background:var(--cl-band);color:var(--cl-ink);border-radius:.3rem;padding:.12em .36em;font-size:.88em}.cl-prose pre{font-family:var(--font-mono);background:var(--cl-code);color:var(--cl-code-ink);border-radius:.75rem;padding:1rem 1.15rem;font-size:.875rem;line-height:1.55;overflow-x:auto}.cl-prose pre code{color:inherit;font-size:inherit;background:0 0;padding:0}.cl-prose blockquote{border-left:3px solid var(--cl-rule-strong);color:var(--cl-muted);padding:.2rem 0 .2rem 1rem}.cl-prose hr{border:0;border-top:1px solid var(--cl-rule);margin:2rem 0}.cl-prose img{border-radius:.5rem;max-width:100%;height:auto}.cl-prose iframe{border:0;border-radius:.5rem;max-width:100%}.cl-prose table{border-collapse:collapse;width:100%;font-size:.95rem;display:block;overflow-x:auto}.cl-prose caption{color:var(--cl-ink);text-align:left;padding:.5rem 0;font-weight:650}.cl-prose th,.cl-prose td{border-bottom:1px solid var(--cl-rule);text-align:left;vertical-align:top;padding:.45rem .8rem}.cl-prose th{color:var(--cl-ink);border-bottom-color:var(--cl-rule-strong);white-space:nowrap;font-weight:650}.cl-prose abbr[title]{cursor:help;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.cl-prose a.btn{background:var(--cl-accent-fill);color:var(--cl-on-accent);border-radius:.5rem;margin:0 0 1rem;padding:.45rem .9rem;font-size:.9rem;font-weight:600;text-decoration:none;display:inline-block}.cl-prose a.btn:hover{background:var(--cl-accent-fill-hover);color:var(--cl-on-accent)}@media (min-width:640px){.cl-prose a.btn.float-right{float:right;margin-left:1rem}}.cl-prose .lisp-return-value{color:var(--cl-code-dim)}.cl-toc{border:1px solid var(--cl-rule);background:var(--cl-surface);border-radius:.75rem;margin:0 0 2rem;padding:1rem 1.25rem;font-size:.95rem}.cl-toc>summary{cursor:pointer;color:var(--cl-ink);font-weight:650}.cl-toc ul{columns:16rem;column-gap:2rem;margin:.6rem 0 0;padding:0;list-style:none}.cl-toc li{break-inside:avoid;margin:.15rem 0}.cl-toc li.cl-toc-sub{padding-left:1rem;font-size:.9rem}.cl-toc a{color:var(--cl-body);text-decoration:none}.cl-toc a:hover{color:var(--cl-accent);text-decoration:underline}.cl-news-item+.cl-news-item{border-top:1px solid var(--cl-rule)}.cl-news-body.cl-prose{font-size:1rem}.cl-news-body.cl-prose ul{columns:11rem;column-gap:1.5rem}.cl-clamp{-webkit-line-clamp:5;line-clamp:5;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.cl-field{border:1px solid var(--cl-rule-strong);background:var(--cl-surface);width:100%;color:var(--cl-ink);font:inherit;border-radius:.5rem;padding:.55rem .75rem;display:block}.cl-field::placeholder{color:var(--cl-muted)}.cl-field:focus{outline:3px solid var(--cl-accent)}@supports (color:color-mix(in lab, red, red)){.cl-field:focus{outline:3px solid color-mix(in oklab, var(--cl-accent) 45%, transparent)}}.cl-field:focus{outline-offset:0;border-color:var(--cl-accent)}.cl-hp{width:1px;height:1px;position:absolute;left:-10000px;overflow:hidden}.cl-chip input{opacity:0;pointer-events:none;position:absolute}.cl-chip span{border:1px solid var(--cl-rule-strong);background:var(--cl-surface);min-width:4.5rem;color:var(--cl-ink);text-align:center;cursor:pointer;border-radius:.5rem;padding:.5rem .9rem;font-weight:600;display:inline-block}.cl-chip input:checked+span{border-color:var(--cl-accent-fill);background:var(--cl-accent-fill);color:var(--cl-on-accent)}.cl-chip input:focus-visible+span{outline:3px solid var(--cl-accent);outline-offset:2px}}@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-0{top:0}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.-mt-3{margin-top:calc(var(--spacing) * -3)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-14{margin-top:calc(var(--spacing) * 14)}.mt-16{margin-top:calc(var(--spacing) * 16)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-\[1\.1rem\]{width:1.1rem;height:1.1rem}.h-16{height:calc(var(--spacing) * 16)}.h-36{height:calc(var(--spacing) * 36)}.max-h-\[75vh\]{max-height:75vh}.w-24{width:calc(var(--spacing) * 24)}.w-64{width:calc(var(--spacing) * 64)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-\[8rem\]{max-width:8rem}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:0}.flex-1{flex:1}.flex-none{flex:none}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}.gap-12{gap:calc(var(--spacing) * 12)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-x-10{column-gap:calc(var(--spacing) * 10)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-8{row-gap:calc(var(--spacing) * 8)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-rule>:not(:last-child)){border-color:var(--cl-rule)}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-ok\/40{border-color:var(--cl-ok)}@supports (color:color-mix(in lab, red, red)){.border-ok\/40{border-color:color-mix(in oklab, var(--cl-ok) 40%, transparent)}}.border-rule{border-color:var(--cl-rule)}.border-rule-strong{border-color:var(--cl-rule-strong)}.border-warn\/40{border-color:var(--cl-warn)}@supports (color:color-mix(in lab, red, red)){.border-warn\/40{border-color:color-mix(in oklab, var(--cl-warn) 40%, transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-accent-fill{background-color:var(--cl-accent-fill)}.bg-accent-soft{background-color:var(--cl-accent-soft)}.bg-band{background-color:var(--cl-band)}.bg-code{background-color:var(--cl-code)}.bg-ground\/90{background-color:var(--cl-ground)}@supports (color:color-mix(in lab, red, red)){.bg-ground\/90{background-color:color-mix(in oklab, var(--cl-ground) 90%, transparent)}}.bg-ok-soft{background-color:var(--cl-ok-soft)}.bg-surface{background-color:var(--cl-surface)}.bg-warn-soft{background-color:var(--cl-warn-soft)}.bg-white\/20{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.bg-white\/20{background-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.object-cover{object-fit:cover}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-7{padding-block:calc(var(--spacing) * 7)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-14{padding-block:calc(var(--spacing) * 14)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Noto Sans,Arial,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.95rem\]{font-size:.95rem}.text-\[1\.05rem\]{font-size:1.05rem}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent{color:var(--cl-accent)}.text-body{color:var(--cl-body)}.text-code-dim{color:var(--cl-code-dim)}.text-code-ink{color:var(--cl-code-ink)}.text-ink{color:var(--cl-ink)}.text-muted{color:var(--cl-muted)}.text-ok{color:var(--cl-ok)}.text-on-accent{color:var(--cl-on-accent)}.text-warn{color:var(--cl-warn)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline{text-decoration-line:underline}.decoration-accent\/30{-webkit-text-decoration-color:var(--cl-accent);-webkit-text-decoration-color:var(--cl-accent);text-decoration-color:var(--cl-accent)}@supports (color:color-mix(in lab, red, red)){.decoration-accent\/30{-webkit-text-decoration-color:color-mix(in oklab, var(--cl-accent) 30%, transparent);-webkit-text-decoration-color:color-mix(in oklab, var(--cl-accent) 30%, transparent);text-decoration-color:color-mix(in oklab, var(--cl-accent) 30%, transparent)}}.underline-offset-4{text-underline-offset:4px}.accent-\[var\(--cl-accent-fill\)\]{accent-color:var(--cl-accent-fill)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-black\/10{--tw-ring-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.ring-black\/10{--tw-ring-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.group-hover\:text-accent:is(:where(.group):hover *){color:var(--cl-accent)}}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}@media (hover:hover){.hover\:border-accent:hover{border-color:var(--cl-accent)}.hover\:bg-accent-fill-hover:hover{background-color:var(--cl-accent-fill-hover)}.hover\:text-accent:hover{color:var(--cl-accent)}.hover\:text-accent-strong:hover{color:var(--cl-accent-strong)}.hover\:decoration-current:hover{text-decoration-color:currentColor}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:not-sr-only:focus{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.focus\:absolute:focus{position:absolute}.focus\:top-4:focus{top:calc(var(--spacing) * 4)}.focus\:left-4:focus{left:calc(var(--spacing) * 4)}.focus\:z-\[70\]:focus{z-index:70}.focus\:rounded-lg:focus{border-radius:var(--radius-lg)}.focus\:bg-surface:focus{background-color:var(--cl-surface)}.focus\:px-4:focus{padding-inline:calc(var(--spacing) * 4)}.focus\:py-2:focus{padding-block:calc(var(--spacing) * 2)}.focus\:text-ink:focus{color:var(--cl-ink)}@media (min-width:40rem){.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[1fr_12rem\]{grid-template-columns:1fr 12rem}.sm\:grid-cols-\[9\.5rem_1fr\]{grid-template-columns:9.5rem 1fr}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:px-8{padding-inline:calc(var(--spacing) * 8)}.sm\:py-14{padding-block:calc(var(--spacing) * 14)}.sm\:py-16{padding-block:calc(var(--spacing) * 16)}.sm\:py-20{padding-block:calc(var(--spacing) * 20)}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:64rem){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-\[1\.4fr_2fr\]{grid-template-columns:1.4fr 2fr}.lg\:grid-cols-\[1\.05fr_1fr\]{grid-template-columns:1.05fr 1fr}.lg\:text-\[3\.4rem\]{font-size:3.4rem}.lg\:leading-\[1\.08\]{--tw-leading:1.08;line-height:1.08}}}:root{color-scheme:light;--cl-ground:oklch(97.2% .006 285);--cl-band:oklch(94.6% .009 285);--cl-surface:oklch(99.2% .003 285);--cl-ink:oklch(23% .035 285);--cl-body:oklch(36% .025 285);--cl-muted:oklch(48% .02 285);--cl-rule:oklch(89% .012 285);--cl-rule-strong:oklch(80% .016 285);--cl-accent:oklch(46% .19 292);--cl-accent-strong:oklch(38% .18 292);--cl-accent-fill:oklch(50% .2 292);--cl-accent-fill-hover:oklch(44% .2 292);--cl-accent-soft:oklch(93.5% .035 292);--cl-on-accent:#fff;--cl-ok:oklch(42% .12 158);--cl-ok-soft:oklch(94% .045 158);--cl-warn:oklch(44% .11 70);--cl-warn-soft:oklch(95% .05 88);--cl-code:oklch(24% .03 285);--cl-code-ink:oklch(92% .015 285);--cl-code-dim:oklch(72% .03 285)}@media (prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--cl-ground:oklch(20% .018 285);--cl-band:oklch(23.5% .02 285);--cl-surface:oklch(26.5% .022 285);--cl-ink:oklch(93% .012 285);--cl-body:oklch(82% .015 285);--cl-muted:oklch(70% .018 285);--cl-rule:oklch(33% .022 285);--cl-rule-strong:oklch(42% .026 285);--cl-accent:oklch(80% .11 292);--cl-accent-strong:oklch(87% .08 292);--cl-accent-fill:oklch(56% .19 292);--cl-accent-fill-hover:oklch(62% .18 292);--cl-accent-soft:oklch(31% .07 292);--cl-on-accent:#fff;--cl-ok:oklch(80% .13 158);--cl-ok-soft:oklch(29% .05 158);--cl-warn:oklch(84% .11 85);--cl-warn-soft:oklch(30% .045 80);--cl-code:oklch(15.5% .018 285);--cl-code-ink:oklch(92% .015 285);--cl-code-dim:oklch(70% .03 285)}}:root[data-theme=dark]{color-scheme:dark;--cl-ground:oklch(20% .018 285);--cl-band:oklch(23.5% .02 285);--cl-surface:oklch(26.5% .022 285);--cl-ink:oklch(93% .012 285);--cl-body:oklch(82% .015 285);--cl-muted:oklch(70% .018 285);--cl-rule:oklch(33% .022 285);--cl-rule-strong:oklch(42% .026 285);--cl-accent:oklch(80% .11 292);--cl-accent-strong:oklch(87% .08 292);--cl-accent-fill:oklch(56% .19 292);--cl-accent-fill-hover:oklch(62% .18 292);--cl-accent-soft:oklch(31% .07 292);--cl-on-accent:#fff;--cl-ok:oklch(80% .13 158);--cl-ok-soft:oklch(29% .05 158);--cl-warn:oklch(84% .11 85);--cl-warn-soft:oklch(30% .045 80);--cl-code:oklch(15.5% .018 285);--cl-code-ink:oklch(92% .015 285);--cl-code-dim:oklch(70% .03 285)}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}
clnet/static/imgs/lisplogo.pngaddedview
Binary file
clnet/tailwind/input.cssaddedview

@@ -1,0 +1,553 @@
+/*
+ * Copyright 2026, Common-Lisp.Net maintainers.  MIT license; see LICENSE
+ * at the root of this repository.
+ *
+ * tailwind/input.css -- the one stylesheet of the common-lisp.net site.
+ * Build it into ../static/css/style.css with `npm run build` (see
+ * ../README.md).  The build scans the Lisp sources for class names.
+ *
+ * How the colours work.  The pages never name a palette colour.  They
+ * name ROLES (ground, surface, ink, body, muted, rule, accent ...), each
+ * a CSS custom property --cl-*, and the @theme block below turns every
+ * role into ordinary Tailwind utilities: bg-ground, text-ink,
+ * border-rule, bg-accent and so on.  A theme is then nothing but a set
+ * of values for the roles.  The light set is on :root; the dark set is
+ * written twice on purpose, once under the reader's OS preference and
+ * once under an explicit choice stamped on <html data-theme> by the
+ * switch in the navigation bar.
+ */
+
+@import "tailwindcss";
+
+@source "../source/**/*.lisp";
+
+@theme inline {
+  --color-ground: var(--cl-ground);
+  --color-band: var(--cl-band);
+  --color-surface: var(--cl-surface);
+  --color-ink: var(--cl-ink);
+  --color-body: var(--cl-body);
+  --color-muted: var(--cl-muted);
+  --color-rule: var(--cl-rule);
+  --color-rule-strong: var(--cl-rule-strong);
+  --color-accent: var(--cl-accent);
+  --color-accent-strong: var(--cl-accent-strong);
+  --color-accent-fill: var(--cl-accent-fill);
+  --color-accent-fill-hover: var(--cl-accent-fill-hover);
+  --color-accent-soft: var(--cl-accent-soft);
+  --color-on-accent: var(--cl-on-accent);
+  --color-ok: var(--cl-ok);
+  --color-ok-soft: var(--cl-ok-soft);
+  --color-warn: var(--cl-warn);
+  --color-warn-soft: var(--cl-warn-soft);
+  --color-code: var(--cl-code);
+  --color-code-ink: var(--cl-code-ink);
+  --color-code-dim: var(--cl-code-dim);
+
+  --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
+    "Helvetica Neue", "Noto Sans", Arial, sans-serif;
+  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
+    "Liberation Mono", monospace;
+}
+
+/* ------------------------------------------------------------------
+   The themes
+   ------------------------------------------------------------------ */
+
+:root {
+  color-scheme: light;
+  /* grounds: a soft paper, never #fff */
+  --cl-ground: oklch(97.2% 0.006 285);
+  --cl-band: oklch(94.6% 0.009 285);
+  --cl-surface: oklch(99.2% 0.003 285);
+  /* text */
+  --cl-ink: oklch(23% 0.035 285);
+  --cl-body: oklch(36% 0.025 285);
+  --cl-muted: oklch(48% 0.02 285);
+  /* lines */
+  --cl-rule: oklch(89% 0.012 285);
+  --cl-rule-strong: oklch(80% 0.016 285);
+  /* the accent: the violet of the Lisp flag */
+  --cl-accent: oklch(46% 0.19 292);
+  --cl-accent-strong: oklch(38% 0.18 292);
+  --cl-accent-fill: oklch(50% 0.2 292);
+  --cl-accent-fill-hover: oklch(44% 0.2 292);
+  --cl-accent-soft: oklch(93.5% 0.035 292);
+  --cl-on-accent: #fff;
+  /* status */
+  --cl-ok: oklch(42% 0.12 158);
+  --cl-ok-soft: oklch(94% 0.045 158);
+  --cl-warn: oklch(44% 0.11 70);
+  --cl-warn-soft: oklch(95% 0.05 88);
+  /* code panels are dark in both themes */
+  --cl-code: oklch(24% 0.03 285);
+  --cl-code-ink: oklch(92% 0.015 285);
+  --cl-code-dim: oklch(72% 0.03 285);
+}
+
+@media (prefers-color-scheme: dark) {
+  :root:not([data-theme="light"]) {
+    color-scheme: dark;
+    --cl-ground: oklch(20% 0.018 285);
+    --cl-band: oklch(23.5% 0.02 285);
+    --cl-surface: oklch(26.5% 0.022 285);
+    --cl-ink: oklch(93% 0.012 285);
+    --cl-body: oklch(82% 0.015 285);
+    --cl-muted: oklch(70% 0.018 285);
+    --cl-rule: oklch(33% 0.022 285);
+    --cl-rule-strong: oklch(42% 0.026 285);
+    --cl-accent: oklch(80% 0.11 292);
+    --cl-accent-strong: oklch(87% 0.08 292);
+    --cl-accent-fill: oklch(56% 0.19 292);
+    --cl-accent-fill-hover: oklch(62% 0.18 292);
+    --cl-accent-soft: oklch(31% 0.07 292);
+    --cl-on-accent: #fff;
+    --cl-ok: oklch(80% 0.13 158);
+    --cl-ok-soft: oklch(29% 0.05 158);
+    --cl-warn: oklch(84% 0.11 85);
+    --cl-warn-soft: oklch(30% 0.045 80);
+    --cl-code: oklch(15.5% 0.018 285);
+    --cl-code-ink: oklch(92% 0.015 285);
+    --cl-code-dim: oklch(70% 0.03 285);
+  }
+}
+
+:root[data-theme="dark"] {
+  color-scheme: dark;
+  --cl-ground: oklch(20% 0.018 285);
+  --cl-band: oklch(23.5% 0.02 285);
+  --cl-surface: oklch(26.5% 0.022 285);
+  --cl-ink: oklch(93% 0.012 285);
+  --cl-body: oklch(82% 0.015 285);
+  --cl-muted: oklch(70% 0.018 285);
+  --cl-rule: oklch(33% 0.022 285);
+  --cl-rule-strong: oklch(42% 0.026 285);
+  --cl-accent: oklch(80% 0.11 292);
+  --cl-accent-strong: oklch(87% 0.08 292);
+  --cl-accent-fill: oklch(56% 0.19 292);
+  --cl-accent-fill-hover: oklch(62% 0.18 292);
+  --cl-accent-soft: oklch(31% 0.07 292);
+  --cl-on-accent: #fff;
+  --cl-ok: oklch(80% 0.13 158);
+  --cl-ok-soft: oklch(29% 0.05 158);
+  --cl-warn: oklch(84% 0.11 85);
+  --cl-warn-soft: oklch(30% 0.045 80);
+  --cl-code: oklch(15.5% 0.018 285);
+  --cl-code-ink: oklch(92% 0.015 285);
+  --cl-code-dim: oklch(70% 0.03 285);
+}
+
+@layer base {
+  html {
+    background-color: var(--cl-ground);
+    scroll-padding-top: 5rem;
+  }
+  body {
+    background-color: var(--cl-ground);
+    color: var(--cl-body);
+    font-family: var(--font-sans);
+    line-height: 1.6;
+    -webkit-font-smoothing: antialiased;
+  }
+  :focus-visible {
+    outline: 3px solid var(--cl-accent);
+    outline-offset: 2px;
+    border-radius: 0.25rem;
+  }
+  ::selection {
+    background: var(--cl-accent-soft);
+    color: var(--cl-ink);
+  }
+  @media (prefers-reduced-motion: no-preference) {
+    html {
+      scroll-behavior: smooth;
+    }
+  }
+}
+
+/* ------------------------------------------------------------------
+   The navigation bar: dropdowns are <details>, so they open without
+   script; the page's small script only closes them again.
+   ------------------------------------------------------------------ */
+
+@layer components {
+  .cl-menu {
+    position: relative;
+  }
+  .cl-menu > summary {
+    list-style: none;
+    cursor: pointer;
+    user-select: none;
+  }
+  .cl-menu > summary::-webkit-details-marker {
+    display: none;
+  }
+  .cl-menu > summary .cl-caret {
+    transition: transform 150ms;
+  }
+  .cl-menu[open] > summary .cl-caret {
+    transform: rotate(180deg);
+  }
+  .cl-menu-panel {
+    position: absolute;
+    top: calc(100% + 0.5rem);
+    left: 0;
+    z-index: 60;
+    min-width: 15rem;
+    padding: 0.5rem;
+    border-radius: 0.75rem;
+    border: 1px solid var(--cl-rule);
+    background: var(--cl-surface);
+    box-shadow: 0 12px 32px -8px rgb(20 10 50 / 0.25);
+  }
+  .cl-menu-panel.cl-right {
+    left: auto;
+    right: 0;
+  }
+  .cl-menu-panel a {
+    display: block;
+    padding: 0.5rem 0.75rem;
+    border-radius: 0.5rem;
+    color: var(--cl-body);
+    font-size: 0.925rem;
+    text-decoration: none;
+  }
+  .cl-menu-panel a:hover {
+    background: var(--cl-band);
+    color: var(--cl-accent);
+  }
+  .cl-menu-panel a small {
+    display: block;
+    color: var(--cl-muted);
+    font-size: 0.78rem;
+    line-height: 1.3;
+  }
+
+  /* the theme switch: moon on the light theme, sun on the dark */
+  html:not(.js) .cl-theme-toggle {
+    display: none;
+  }
+  .cl-theme-toggle .cl-sun {
+    display: none;
+  }
+  :root[data-theme="dark"] .cl-theme-toggle .cl-moon {
+    display: none;
+  }
+  :root[data-theme="dark"] .cl-theme-toggle .cl-sun {
+    display: block;
+  }
+  @media (prefers-color-scheme: dark) {
+    :root:not([data-theme="light"]) .cl-theme-toggle .cl-moon {
+      display: none;
+    }
+    :root:not([data-theme="light"]) .cl-theme-toggle .cl-sun {
+      display: block;
+    }
+  }
+
+  /* the logo is black ink on a transparent ground */
+  .cl-logo {
+    border-radius: 9999px;
+    background: #fff;
+    padding: 2px;
+  }
+
+  /* ----------------------------------------------------------------
+     Prose: everything that arrives as Markdown or as one of the
+     hand-written HTML files under content/.  The markup there carries
+     no classes of ours, so it is styled by element.
+     ---------------------------------------------------------------- */
+
+  .cl-prose {
+    color: var(--cl-body);
+    font-size: 1.0625rem;
+    line-height: 1.7;
+    overflow-wrap: break-word;
+  }
+  .cl-prose > :first-child {
+    margin-top: 0;
+  }
+  .cl-prose h1,
+  .cl-prose h2,
+  .cl-prose h3,
+  .cl-prose h4 {
+    color: var(--cl-ink);
+    font-weight: 650;
+    line-height: 1.25;
+    letter-spacing: -0.01em;
+  }
+  .cl-prose h1 {
+    font-size: 1.7rem;
+    margin: 2.75rem 0 1rem;
+    padding-bottom: 0.4rem;
+    border-bottom: 1px solid var(--cl-rule);
+  }
+  .cl-prose h2 {
+    font-size: 1.45rem;
+    margin: 2.5rem 0 0.85rem;
+  }
+  .cl-prose h3 {
+    font-size: 1.18rem;
+    margin: 2rem 0 0.6rem;
+  }
+  .cl-prose h4 {
+    font-size: 1.05rem;
+    margin: 1.5rem 0 0.5rem;
+  }
+  .cl-prose p,
+  .cl-prose ul,
+  .cl-prose ol,
+  .cl-prose dl,
+  .cl-prose table,
+  .cl-prose pre,
+  .cl-prose blockquote {
+    margin: 0 0 1.1rem;
+  }
+  .cl-prose ul {
+    list-style: disc;
+    padding-left: 1.4rem;
+  }
+  .cl-prose ol {
+    list-style: decimal;
+    padding-left: 1.5rem;
+  }
+  .cl-prose li {
+    margin: 0.3rem 0;
+  }
+  .cl-prose li > ul,
+  .cl-prose li > ol {
+    margin: 0.3rem 0 0.4rem;
+  }
+  .cl-prose li::marker {
+    color: var(--cl-muted);
+  }
+  .cl-prose a {
+    color: var(--cl-accent);
+    text-decoration: underline;
+    text-decoration-color: color-mix(in oklab, var(--cl-accent) 35%, transparent);
+    text-underline-offset: 0.18em;
+  }
+  .cl-prose a:hover {
+    color: var(--cl-accent-strong);
+    text-decoration-color: currentColor;
+  }
+  .cl-prose strong,
+  .cl-prose b {
+    color: var(--cl-ink);
+    font-weight: 650;
+  }
+  .cl-prose code,
+  .cl-prose tt {
+    font-family: var(--font-mono);
+    font-size: 0.88em;
+    padding: 0.12em 0.36em;
+    border-radius: 0.3rem;
+    background: var(--cl-band);
+    color: var(--cl-ink);
+  }
+  .cl-prose pre {
+    font-family: var(--font-mono);
+    font-size: 0.875rem;
+    line-height: 1.55;
+    padding: 1rem 1.15rem;
+    border-radius: 0.75rem;
+    background: var(--cl-code);
+    color: var(--cl-code-ink);
+    overflow-x: auto;
+  }
+  .cl-prose pre code {
+    padding: 0;
+    background: none;
+    color: inherit;
+    font-size: inherit;
+  }
+  .cl-prose blockquote {
+    padding: 0.2rem 0 0.2rem 1rem;
+    border-left: 3px solid var(--cl-rule-strong);
+    color: var(--cl-muted);
+  }
+  .cl-prose hr {
+    margin: 2rem 0;
+    border: 0;
+    border-top: 1px solid var(--cl-rule);
+  }
+  .cl-prose img {
+    max-width: 100%;
+    height: auto;
+    border-radius: 0.5rem;
+  }
+  .cl-prose iframe {
+    max-width: 100%;
+    border: 0;
+    border-radius: 0.5rem;
+  }
+  .cl-prose table {
+    display: block;
+    width: 100%;
+    overflow-x: auto;
+    border-collapse: collapse;
+    font-size: 0.95rem;
+  }
+  .cl-prose caption {
+    padding: 0.5rem 0;
+    color: var(--cl-ink);
+    font-weight: 650;
+    text-align: left;
+  }
+  .cl-prose th,
+  .cl-prose td {
+    padding: 0.45rem 0.8rem;
+    border-bottom: 1px solid var(--cl-rule);
+    text-align: left;
+    vertical-align: top;
+  }
+  .cl-prose th {
+    color: var(--cl-ink);
+    font-weight: 650;
+    border-bottom-color: var(--cl-rule-strong);
+    white-space: nowrap;
+  }
+  .cl-prose abbr[title] {
+    text-decoration: underline dotted;
+    cursor: help;
+  }
+
+  /* What is left of the 2018 Bootstrap markup in the content files:
+     a call-to-action link and the beginner-slide classes. */
+  .cl-prose a.btn {
+    display: inline-block;
+    margin: 0 0 1rem;
+    padding: 0.45rem 0.9rem;
+    border-radius: 0.5rem;
+    background: var(--cl-accent-fill);
+    color: var(--cl-on-accent);
+    font-size: 0.9rem;
+    font-weight: 600;
+    text-decoration: none;
+  }
+  .cl-prose a.btn:hover {
+    background: var(--cl-accent-fill-hover);
+    color: var(--cl-on-accent);
+  }
+  @media (min-width: 640px) {
+    .cl-prose a.btn.float-right {
+      float: right;
+      margin-left: 1rem;
+    }
+  }
+  .cl-prose .lisp-return-value {
+    color: var(--cl-code-dim);
+  }
+
+  /* the table of contents a Markdown page may ask for */
+  .cl-toc {
+    margin: 0 0 2rem;
+    padding: 1rem 1.25rem;
+    border: 1px solid var(--cl-rule);
+    border-radius: 0.75rem;
+    background: var(--cl-surface);
+    font-size: 0.95rem;
+  }
+  .cl-toc > summary {
+    cursor: pointer;
+    color: var(--cl-ink);
+    font-weight: 650;
+  }
+  .cl-toc ul {
+    margin: 0.6rem 0 0;
+    padding: 0;
+    list-style: none;
+    columns: 16rem;
+    column-gap: 2rem;
+  }
+  .cl-toc li {
+    margin: 0.15rem 0;
+    break-inside: avoid;
+  }
+  .cl-toc li.cl-toc-sub {
+    padding-left: 1rem;
+    font-size: 0.9rem;
+  }
+  .cl-toc a {
+    color: var(--cl-body);
+    text-decoration: none;
+  }
+  .cl-toc a:hover {
+    color: var(--cl-accent);
+    text-decoration: underline;
+  }
+
+  /* the news timeline */
+  .cl-news-item + .cl-news-item {
+    border-top: 1px solid var(--cl-rule);
+  }
+  .cl-news-body.cl-prose {
+    font-size: 1rem;
+  }
+  .cl-news-body.cl-prose ul {
+    columns: 11rem;
+    column-gap: 1.5rem;
+  }
+  .cl-clamp {
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 5;
+    line-clamp: 5;
+    overflow: hidden;
+  }
+
+  /* forms */
+  .cl-field {
+    display: block;
+    width: 100%;
+    padding: 0.55rem 0.75rem;
+    border: 1px solid var(--cl-rule-strong);
+    border-radius: 0.5rem;
+    background: var(--cl-surface);
+    color: var(--cl-ink);
+    font: inherit;
+  }
+  .cl-field::placeholder {
+    color: var(--cl-muted);
+  }
+  .cl-field:focus {
+    outline: 3px solid color-mix(in oklab, var(--cl-accent) 45%, transparent);
+    outline-offset: 0;
+    border-color: var(--cl-accent);
+  }
+  /* the honeypot: off the page for people, on it for robots */
+  .cl-hp {
+    position: absolute;
+    left: -10000px;
+    width: 1px;
+    height: 1px;
+    overflow: hidden;
+  }
+  /* a radio shown as a chip: the input is hidden, its label is the chip */
+  .cl-chip input {
+    position: absolute;
+    opacity: 0;
+    pointer-events: none;
+  }
+  .cl-chip span {
+    display: inline-block;
+    min-width: 4.5rem;
+    padding: 0.5rem 0.9rem;
+    border: 1px solid var(--cl-rule-strong);
+    border-radius: 0.5rem;
+    background: var(--cl-surface);
+    color: var(--cl-ink);
+    font-weight: 600;
+    text-align: center;
+    cursor: pointer;
+  }
+  .cl-chip input:checked + span {
+    border-color: var(--cl-accent-fill);
+    background: var(--cl-accent-fill);
+    color: var(--cl-on-accent);
+  }
+  .cl-chip input:focus-visible + span {
+    outline: 3px solid var(--cl-accent);
+    outline-offset: 2px;
+  }
+}
clnet/tailwind/package-lock.jsonaddedview

@@ -1,0 +1,1131 @@
+{
+  "name": "clnet-tailwind",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "clnet-tailwind",
+      "version": "1.0.0",
+      "license": "MIT",
+      "devDependencies": {
+        "@tailwindcss/cli": "^4.1.0",
+        "tailwindcss": "^4.1.0"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+      "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@parcel/watcher": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
+      "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "detect-libc": "^1.0.3",
+        "is-glob": "^4.0.3",
+        "micromatch": "^4.0.5",
+        "node-addon-api": "^7.0.0"
+      },
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "@parcel/watcher-android-arm64": "2.5.1",
+        "@parcel/watcher-darwin-arm64": "2.5.1",
+        "@parcel/watcher-darwin-x64": "2.5.1",
+        "@parcel/watcher-freebsd-x64": "2.5.1",
+        "@parcel/watcher-linux-arm-glibc": "2.5.1",
+        "@parcel/watcher-linux-arm-musl": "2.5.1",
+        "@parcel/watcher-linux-arm64-glibc": "2.5.1",
+        "@parcel/watcher-linux-arm64-musl": "2.5.1",
+        "@parcel/watcher-linux-x64-glibc": "2.5.1",
+        "@parcel/watcher-linux-x64-musl": "2.5.1",
+        "@parcel/watcher-win32-arm64": "2.5.1",
+        "@parcel/watcher-win32-ia32": "2.5.1",
+        "@parcel/watcher-win32-x64": "2.5.1"
+      }
+    },
+    "node_modules/@parcel/watcher-android-arm64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
+      "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-darwin-arm64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
+      "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-darwin-x64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
+      "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-freebsd-x64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
+      "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm-glibc": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
+      "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm-musl": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
+      "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm64-glibc": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
+      "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm64-musl": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
+      "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-x64-glibc": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
+      "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-x64-musl": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
+      "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-arm64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
+      "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-ia32": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
+      "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-x64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
+      "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@tailwindcss/cli": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz",
+      "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@parcel/watcher": "2.5.1",
+        "@tailwindcss/node": "4.3.3",
+        "@tailwindcss/oxide": "4.3.3",
+        "enhanced-resolve": "^5.24.1",
+        "mri": "^1.2.0",
+        "picocolors": "^1.1.1",
+        "tailwindcss": "4.3.3"
+      },
+      "bin": {
+        "tailwindcss": "dist/index.mjs"
+      }
+    },
+    "node_modules/@tailwindcss/node": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+      "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.5",
+        "enhanced-resolve": "^5.24.1",
+        "jiti": "^2.7.0",
+        "lightningcss": "1.32.0",
+        "magic-string": "^0.30.21",
+        "source-map-js": "^1.2.1",
+        "tailwindcss": "4.3.3"
+      }
+    },
+    "node_modules/@tailwindcss/oxide": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+      "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 20"
+      },
+      "optionalDependencies": {
+        "@tailwindcss/oxide-android-arm64": "4.3.3",
+        "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+        "@tailwindcss/oxide-darwin-x64": "4.3.3",
+        "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+        "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+        "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+        "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+        "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+        "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+        "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+        "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-android-arm64": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+      "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-arm64": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+      "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-x64": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+      "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-freebsd-x64": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+      "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+      "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+      "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+      "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+      "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+      "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+      "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+      "bundleDependencies": [
+        "@napi-rs/wasm-runtime",
+        "@emnapi/core",
+        "@emnapi/runtime",
+        "@tybys/wasm-util",
+        "@emnapi/wasi-threads",
+        "tslib"
+      ],
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "^1.11.1",
+        "@emnapi/runtime": "^1.11.1",
+        "@emnapi/wasi-threads": "^1.2.2",
+        "@napi-rs/wasm-runtime": "^1.1.4",
+        "@tybys/wasm-util": "^0.10.2",
+        "tslib": "^2.8.1"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+      "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+      "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+      "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "detect-libc": "bin/detect-libc.js"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/enhanced-resolve": {
+      "version": "5.26.0",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz",
+      "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.3.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/jiti": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+      "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jiti": "lib/jiti-cli.mjs"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+      "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.32.0",
+        "lightningcss-darwin-arm64": "1.32.0",
+        "lightningcss-darwin-x64": "1.32.0",
+        "lightningcss-freebsd-x64": "1.32.0",
+        "lightningcss-linux-arm-gnueabihf": "1.32.0",
+        "lightningcss-linux-arm64-gnu": "1.32.0",
+        "lightningcss-linux-arm64-musl": "1.32.0",
+        "lightningcss-linux-x64-gnu": "1.32.0",
+        "lightningcss-linux-x64-musl": "1.32.0",
+        "lightningcss-win32-arm64-msvc": "1.32.0",
+        "lightningcss-win32-x64-msvc": "1.32.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+      "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+      "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+      "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+      "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+      "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+      "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+      "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+      "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+      "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+      "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+      "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss/node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/mri": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+      "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/node-addon-api": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+      "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+      "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+      "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tapable": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+      "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    }
+  }
+}
clnet/tailwind/package.jsonaddedview

@@ -1,0 +1,15 @@
+{
+  "name": "clnet-tailwind",
+  "version": "1.0.0",
+  "description": "Tailwind CSS build for the common-lisp.net site (see ../README.md)",
+  "private": true,
+  "license": "MIT",
+  "scripts": {
+    "build": "tailwindcss -i ./input.css -o ../static/css/style.css --minify",
+    "dev": "tailwindcss -i ./input.css -o ../static/css/style.css --watch"
+  },
+  "devDependencies": {
+    "tailwindcss": "^4.1.0",
+    "@tailwindcss/cli": "^4.1.0"
+  }
+}
content/about.lispdeleted

@@ -1,61 +1,0 @@
-(in-package :cl-site-content)
-
-
-(add-content
- :headers-plist (list :|title| "Common-Lisp.net | About")
- :html-string
- (with-cl-who-string (:indent t)
-   (:h1 ((:i :class "far fa-question-circle")) " About Common-Lisp.net")
-
-   (wmd "
-  
-Common-Lisp.net provides hosting for projects and user groups related to the programming language Common Lisp.
-
-
-The site is run under the governance of the [Common Lisp
-Foundation](http://cl.foundation) and maintained mostly by a
-committee of volunteers.  [Please consider donating](/contribute).
-
-
-Its source is hosted on
-[gitlab.common-lisp.net](https://gitlab.common-lisp.net/clo/cl-site).
-
-
-If you want to request a project, read [this
-document](/project-intro).
-
-
-If you have questions, suggestions or comments about this site or you
-simply wish to reach the current maintainers, email [this mailing
-list](http://mailman.common-lisp.net/cgi-bin/mailman/listinfo/clo-devel).
-
-The documentation, procedures, feature requests etc are located in our
-Trac instance: [clo's Trac](http://trac.common-lisp.net/clo).")
-
-   (:h2 "Maintainance")
-
-   (wmd "
-
-Since many years [Common-Lisp.net](/) has been run by a number of
-volunteers.")
-  
-   (:h3 "Currently active maintainers")
-
-   (wmd "
-
-* Mark Evenson
-* Erik Huelsmann
-* Mariano Montone")
-
-   (:h3 "Previous maintainers")
-
-   (wmd "
-
-* Erik Enge
-* Drew Crampsie
-* Hans H&uuml;bner
-* C. Yang
-* Mario S. Mommer")))
-
-
-
content/community.htmldeleted

@@ -1,110 +1,0 @@
-<a class="btn btn-primary float-right" href="/project-intro"><i class="fa fa-arrow-right"></i>&nbsp;&nbsp;Host your project</a>
-<h1><i class="fa fa-users"></i> The Lisp Community</h1>
-
-<div class="row">
-  <div class="community-box col-lg-4 col-md-6">
-    <h2><i class="far fa-comments"></i>  IRC and Chats</h2>
-    <p>There are several Lisp related <a href="https://en.wikipedia.org/wiki/Internet_Relay_Chat">IRC</a> channels at <a href="https://libera.chat">Libera.chat</a>:
-    <ul>
-      <li><a href="https://irclog.tymoon.eu/libera/commonlisp">#commonlisp</a> - Main Common Lisp channel</li>
-      <li><a href="#">#lisp</a> - To discuss general lisp dialects of all shapes and forms</li>
-      <li><a href="#">#lispcafe</a> - A less formal channel for lisp-oriented discussion</li>
-    </ul>
-    </p>
-    <p><a href="https://www.cliki.net/IRC">More IRC channels</a></p>
-    <p>There is also a <a href="https://discord.gg/hhk46CE">Lisp Discord server</a></p>
-  </div>
-  <div class="community-box col-lg-4 col-md-6">
-    <h2><i class="far fa-envelope"></i>  Mailing Lists</h2>
-    <p>You can subscribe to several Lisp related mailing lists:</p>
-    <ul>
-      <li><a href="https://sourceforge.net/p/sbcl/mailman">SBCL mailing lists</a></li>
-      <li><a href="https://lists.clozure.com/mailman/listinfo/openmcl-devel">Clozure CL mailing list</a></li>
-    </ul>
-    <p><a href="https://mailman.common-lisp.net/listinfo">Read more</a></p>
-  </div>
-  <div class="community-box col-lg-4 col-md-6">
-    <h2><i class="far fa-newspaper"></i>  News</h2>
-    <p>Follow the news at:</p>
-    <ul>
-      <li><a href="http://planet.lisp.org">Planet Lisp</a> - A meta blog that collects the contents of various Lisp-related blogs.</li>
-      <li><a href="https://www.reddit.com/r/lisp">Lisp Subreddit</a></li>
-      <li><a href="http://groups.google.com/group/comp.lang.lisp">comp.lang-lisp</a> - Google newsgroup (not very active anymore)</li>
-    </ul>
-  </div>
-  <div class="community-box col-lg-4 col-md-6">
-    <h2><i class="fa fa-book"></i> Docs</h2>
-    <ul>
-      <li><a href="https://lispcookbook.github.io/cl-cookbook/">the Common Lisp Cookbook</a></li>
-      <li><a href="https://cliki.net">CLiki</a> - The Common Lisp wiki</li>
-      <li><a href="http://quickdocs.org">Quickdocs</a> - Documentation for All Common Lisp Projects</li>
-      <li><a href="http://quickref.common-lisp.net/">Quickref</a> - Reference manuals for Quicklisp libraries</li>
-    </ul>
-    <p><a href="/documentation">Read more</a></p>
-  </div>
-  <div class="community-box col-lg-4 col-md-6">
-    <h2><i class="fa fa-users"></i> Conferences</h2>
-    <p>Attend the conferences:</p>
-    <ul>
-      <li><a href="https://european-lisp-symposium.org">European Lisp Symposium</a></li>
-    </ul>
-  </div>
-  <div class="community-box col-lg-4 col-md-6">
-    <h2><i class="fa fa-plus"></i> More</h2>
-    <ul>
-      <li><a href="http://www.alu.org">The Association of Lisp Users</a></li>
-      <li>Success stories on <a href="http://lisp-lang.org/success/">lisp-lang.org</a></li>
-      <li><a href="https://github.com/azzamsa/awesome-cl-software">Awesome Lisp Software</a></li>
-      <li><a href="https://www.cliki.net/lisp+videos">Media</a></li>
-    </ul>
-  </div>
-</div>
-<h1 style="padding-top: 30px;"><i class="fa fa-video"></i> Media</h1>
-<div id="media_carousel" class="carousel slide" data-ride="carousel" data-interval="">
-  <ol class="carousel-indicators">
-    <li data-target="#media_carousel" data-slide-to="0" class="active"></li>
-    <li data-target="#media_carousel" data-slide-to="1"></li>
-  </ol>
-  <div class="carousel-inner" style="padding-bottom: 70px;">
-    <div class="carousel-item active">
-      <div class="row">
-        <div class="col-md-6">
-          <iframe width="560" height="315" src="https://www.youtube.com/embed/8X69_42Mj-g" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
-        </div>
-        <div class="col-md-6">
-          <iframe width="560" height="315" src="https://www.youtube.com/embed/VeAdryYZ7ak" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
-        </div>
-        <div class="col-md-6">
-          <iframe width="560" height="315" src="https://www.youtube.com/embed/videoseries?list=PLaGBIclRpkSXbU3keo6Rfd-KBVy63V5G2" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
-        </div>
-        <div class="col-md-6">
-          <iframe width="560" height="315" src="https://www.youtube.com/embed/videoseries?list=PL2VAYZE_4wRITJBv6saaKouj4sWSG1FcS" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
-        </div>
-      </div>
-    </div>
-    <div class="carousel-item">
-      <div class="row">
-        <div class="col-md-6">
-          <iframe width="560" height="315" src="https://www.youtube.com/embed/svmPz5oxMlI" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
-        </div>
-        <div class="col-md-6">
-          <iframe width="560" height="315" src="https://www.youtube.com/embed/T1QlEJ-2Gho" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
-        </div>
-        <div class="col-md-6">
-          <iframe width="560" height="315" src="https://www.youtube.com/embed/xzTH_ZqaFKI" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
-        </div>
-        <div class="col-md-6">
-          <iframe width="560" height="315" src="https://www.youtube.com/embed/_B_4vhsmRRI" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
-        </div>
-      </div>
-    </div>
-  </div>
-  <!-- <a class="carousel-control-prev" href="#media_carousel" role="button" data-slide="prev">
-       <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-       <span class="sr-only">Previous</span>
-       </a>
-       <a class="carousel-control-next" href="#media_carousel" role="button" data-slide="next">
-       <span class="carousel-control-next-icon" aria-hidden="true"></span>
-       <span class="sr-only">Next</span>
-       </a> -->
-</div>
content/contribute.htmldeleted

@@ -1,29 +1,0 @@
-<h1><i class="fa fa-heart"></i>&nbsp;Contribute</h1>
-
-<h2>Donations</h2>
-
-<p>The easiest way to contribute is to donate. The costs of running
-  the site are covered by the <a href="http://cl-foundation.org">Common
-  Lisp Foundation</a>.</p>
-
-<p><a
-     href="https://www.paypal.com/xclick/business=treasurer%40cl-foundation.org&amp;currency_code=EUR&amp;item_name=Donation%20common-lisp%2Enet&amp;lc=GB" class="btn btn-primary"><i class="fab fa-cc-paypal" style="font-size: 30px;vertical-align:middle;"></i>&nbsp;&nbsp;&nbsp;Donate to Common-Lisp.net</a>
-</p>
-
-<p><b>Thank you for your support!</b></p>
-
-<h2>Projects waiting for volunteers</h2>
-
-<p>If you would like to contribute by donating your time, please let
-  us know (through a mail to <a href="http://mailman.common-lisp.net/cgi-bin/mailman/listinfo/clo-devel">this mailinglist</a>, for example). There are currently a few projects that need attention, and we almost always can use a helping hand.</p>
-
-<p>Two projects waiting for volunteers are</p>
-
-<ul>
-  <li>Writing a sustainable framework for listing and finding
-    projects</li>
-  <li>A community forum</li>
-</ul>
-
-<p>Also, if you have a cool idea for the site, feel free to let us
-  know.</p>
content/documentation.htmldeleted

@@ -1,235 +1,0 @@
-<a class="btn btn-primary float-right" href="/community"><i class="fa fa-arrow-right"></i>&nbsp;&nbsp;The Lisp Community</a>
-
-<h1><i class="fa fa-book-open"></i> Documentation</h1>
-
-<section>
-  <h2><i class="fa fa-walking"></i>  Getting Started</h2>
-  
-  Check out <a href="/downloads">our guide</a> to get started.
-</section>
-
-<section style="padding-top: 25px;">
-  <h2><i class="fa fa-book-open"></i>  Books</h2>
-  <div class="row">
-
-    <!-- Practical Common Lisp -->
-    <div class="col-md-6 book-box">
-      <div class="card">
-        <div class="row" style="height: 100%;">
-          <div class="col-md-4">
-            <img src="/static/imgs/pcl.jpg" class="w-100">
-          </div>
-          <div class="col-md-8 px-3">
-            <div class="card-block px-3">
-              <h4 class="card-title">Practical Common Lisp</h4>
-              <p class="card-text">Lisp is often thought of as an academic language, but it need not be. This is the first book that introduces Lisp as a language for the real world.</p>
-              <p class="card-text">
-                Practical Common Lisp presents a thorough introduction to Common Lisp, providing you with an overall understanding of the language features and how they work.</p>
-            </div>
-          </div>
-        </div>
-        <a class="read btn btn-primary" target="_blank" href="http://www.gigamonkeys.com/book">Read Online</a>
-      </div>
-    </div>
-
-    <!-- On Lisp -->
-    <div class="col-md-6 book-box">
-      <div class="card">
-        <div class="row ">
-          <div class="col-md-4">
-            <img src="/static/imgs/on-lisp.jpg" class="w-100">
-          </div>
-          <div class="col-md-8 px-3">
-            <div class="card-block px-3">
-              <h4 class="card-title">On Lisp</h4>
-              <p class="card-text">On Lisp is a comprehensive study of advanced Lisp techniques, with bottom-up programming as the unifying theme. It gives the first complete description of macros and macro applications. The book also covers important subjects related to bottom-up programming, including functional programming, rapid prototyping, interactive development, and embedded languages.</p>
-            </div>
-          </div>
-        </div>
-        <a target="_blank" href="http://www.paulgraham.com/onlisp.html" class="btn btn-primary read">Read Online</a>
-      </div>
-    </div>
-
-    <!-- Successful Lisp -->
-    <div class="col-md-6 book-box">
-      <div class="card">
-        <div class="row ">
-          <div class="col-md-4">
-            <img src="/static/imgs/successful.jpg" class="w-100">
-          </div>
-          <div class="col-md-8 px-3">
-            <div class="card-block px-3">
-              <h4 class="card-title">Successful Lisp</h4>
-              <p class="card-text">How to understand and use Common Lisp.</p>
-            </div>
-          </div>
-        </div>
-        <a target="_blank" href="http://dept-info.labri.fr/~strandh/Teaching/MTP/Common/David-Lamkins/cover.html" class="btn btn-primary read">Read Online</a>
-      </div>
-    </div>
-
-    <!-- Lisp Hackers -->
-    <div class="col-md-6 book-box">
-      <div class="card">
-        <div class="row ">
-          <div class="col-md-4">
-            <img src="/static/imgs/lh.jpg" class="w-100">
-          </div>
-          <div class="col-md-8 px-3">
-            <div class="card-block px-3">
-              <h4 class="card-title">Lisp Hackers</h4>
-              <p class="card-text">Interviews with Common Lisp programmers.</p>
-            </div>
-          </div>
-        </div>
-        <a target="_blank" href="https://leanpub.com/lisphackers/read" class="btn btn-primary read">Read Online</a>
-      </div>
-    </div>
-
-    <!-- Lisp Web Tales -->
-    <div class="col-md-6 book-box">
-      <div class="card">
-        <div class="row ">
-          <div class="col-md-4">
-            <img src="/static/imgs/lwt.png" class="w-100">
-          </div>
-          <div class="col-md-8 px-3">
-            <div class="card-block px-3">
-              <h4 class="card-title">Lisp Web Tales</h4>
-              <p class="card-text">Develop Web applications in Common Lisp.</p>
-            </div>
-          </div>
-        </div>
-        <a target="_blank" href="https://leanpub.com/lispwebtales" class="btn btn-primary read">Read</a>
-      </div>
-    </div>
-
-    <!-- Common Lisp Recipes -->
-    <div class="col-md-6 book-box">
-      <div class="card">
-        <div class="row">
-          <div class="col-md-4">
-            <img src="/static/imgs/clr.jpg" class="w-100">
-          </div>
-          <div class="col-md-8 px-3">
-            <div class="card-block px-3">
-              <h4 class="card-title">Common Lisp Recipes</h4>
-              <p class="card-text">Find solutions to problems and answers to questions you are likely to encounter when writing real-world applications in Common Lisp. This book covers areas as diverse as web programming, databases, graphical user interfaces, integration with other programming languages, multi-threading, and mobile devices as well as debugging techniques and optimization, to name just a few.</p>
-            </div>
-          </div>
-        </div>
-        <a target="_blank" href="http://weitz.de/cl-recipes" class="btn btn-primary read">Buy</a>
-      </div>
-    </div>
-
-    <!-- Land of Lisp -->
-    <div class="col-md-6 book-box">
-      <div class="card">
-        <div class="row">
-          <div class="col-md-4">
-            <img src="/static/imgs/lol.png" class="w-100">
-          </div>
-          <div class="col-md-8 px-3">
-            <div class="card-block px-3">
-              <h4 class="card-title">Land of Lisp</h4>
-              <p class="card-text">Learn to Program in Lisp, One Game at a Time!</p>
-            </div>
-          </div>
-        </div>
-        <a target="_blank" href="http://landoflisp.com" class="btn btn-primary read">Website</a>
-      </div>
-    </div>
-
-    <!-- Programming Algorithms -->
-    <div class="col-md-6 book-box">
-      <div class="card">
-        <div class="row">
-          <div class="col-md-4">
-            <img src="/static/imgs/pa.png" class="w-100">
-          </div>
-          <div class="col-md-8 px-3">
-            <div class="card-block px-3">
-              <h4 class="card-title">Programming Algorithms</h4>
-              <p class="card-text">A comprehensive guide to writing efficient programs with examples in Lisp.</p>
-            </div>
-          </div>
-        </div>
-        <a target="_blank" href="https://leanpub.com/progalgs" class="btn btn-primary read">Read</a>
-      </div>
-    </div>
-
-    <!-- Let Over Lambda -->
-    <div class="col-md-6 book-box">
-      <div class="card">
-        <div class="row">
-          <div class="col-md-4">
-            <img src="/static/imgs/letoverlambda.png" class="w-100">
-          </div>
-          <div class="col-md-8 px-3">
-            <div class="card-block px-3">
-              <h4 class="card-title">Let Over Lambda - 50 Years of Lisp</h4>
-              <p class="card-text"><b>Let Over Lambda</b> is one of the most hardcore computer programming books out there. Starting with the fundamentals, it describes the most advanced features of the most advanced language: COMMON LISP. The point of this book is to expose you to ideas that you might otherwise never be exposed to.</p>
-            </div>
-          </div>
-        </div>
-        <a target="_blank" href="https://letoverlambda.com" class="btn btn-primary read">Read</a>
-      </div>
-    </div>
-
-    <!-- Loving Common Lisp -->
-    <div class="col-md-6 book-box">
-      <div class="card">
-        <div class="row">
-          <div class="col-md-4">
-            <img src="/static/imgs/lovingcl.jpeg" class="w-100">
-          </div>
-          <div class="col-md-8 px-3">
-            <div class="card-block px-3">
-              <h4 class="card-title">Loving Common Lisp, or the Savvy Programmer's Secret Weapon</h4>
-              <p class="card-text">The purpose of this book is to provide a quick introduction to Common Lisp and then provide the user with many fun and useful examples for using Common Lisp. The author has used Common Lisp professionally since 1982..
-            </div>
-          </div>
-        </div>
-        <a target="_blank" href="https://leanpub.com/lovinglisp" class="btn btn-primary read">Read</a>
-      </div>
-    </div>
-    
-  </div>
-  <p>
-    Also:
-    <ul>
-      <li><a target="_blank" href="http://www.paulgraham.com/acl.html">ANSI Common Lisp</a></li>
-      <li><a target="_blank" href="https://www.darkchestnut.com/book-common-lisp-application-deployment">Common Lisp in the Wild - Deploying Common Lisp Applications</a></li>
-    </ul>
-  </p>
-  <p>Look <a target="_blank" href="https://cliki.net/Lisp+Books">here</a> for a more complete list of books.</p>
-</section>
-
-<section style="padding-top: 25px;">
-  <h2><i class="fa fa-book"></i>  ANSI Common Lisp Specification</h2>
-
-  <p>
-    <ul>
-      <li><a href="http://www.lispworks.com/documentation/lw50/CLHS/Front/Contents.htm">Common Lisp HyperSpec (CLHS)</a> - The Common Lisp HyperSpec (CLHS) is a non-free hyperlinked version of the ANSI Common Lisp Standard.</li>
-      <li><a href="http://cvberry.com/tech_writings/notes/common_lisp_standard_draft.html">Common Lisp Standard Draft (PDF)</a></li>
-  </p>
-</section>
-
-<section style="padding-top: 25px;">
-  <h2><i class="far fa-question-circle"></i>  Getting Help</h2>
-
-  <p>If you need help, get in contact via <a href="/community">the Lisp mailing lists and IRC</a>.
-  </p>
-</section>
-
-<section style="padding-top: 25px;">
-  <h2>Other resources</h2>
-
-  <ul>
-      <li><a href="https://lispcookbook.github.io/cl-cookbook/">the Common Lisp Cookbook</a></li>
-      <li><a href="https://cliki.net">CLiki</a> - The Common Lisp wiki</li>
-      <li><a href="http://quickdocs.org">Quickdocs</a> - Documentation for All Common Lisp Projects</li>
-      <li><a href="http://quickref.common-lisp.net/">Quickref</a> - Reference manuals for Quicklisp libraries</li>
-      <li><a href="http://borretti.me/article/common-lisp-sotu-2015">State of the Common Lisp Ecosystem</a> as of 2015</li>
-  </ul>
-</section>
content/downloads.htmldeleted

@@ -1,133 +1,0 @@
-<a class="btn btn-primary float-right" href="/documentation"><i class="fa fa-arrow-right"></i>&nbsp;&nbsp;Read the documentation</a>
-
-<h1><i class="fa fa-walking"></i> Getting started</h1>
-
-<h2>Download and Install</h2>
-
-<p>If you are a newbie or you want to get started as fast as possible, then <a href="https://portacle.github.io/">Portacle</a> is probably your best option. Portacle is a multiplatform, complete IDE for Common Lisp. It includes Emacs, SBCL, Git, Quicklisp, all configured and ready to use.</p>
-
-<p>
-    <a href="https://portacle.github.io" class="btn btn-primary"><i class="fa fa-download"></i>&nbsp;&nbsp;Download Portacle - All-In-One Common Lisp</a>
-</p>
-
-<p>Otherwise, Common Lisp comes in many different <a href="/implementations">flavors, or implementations</a>.</p>
-
-<p>Two popular <em>open source</em> ones are Steel Bank Common Lisp (SBCL) and Clozure Common Lisp (CCL):</p>
-
-<div class="row">
-
-    <div class="col-md-4">
-
-        <b>Steel Bank Common Lisp (SBCL)</b>
-        <ul>
-            <li><a href="http://www.sbcl.org/">Main website</a></li>
-            <li><a href="http://www.sbcl.org/platform-table.html">Download&nbsp;&nbsp;<i class="fa fa-download"></i></a></li>
-            <li><a href="http://www.sbcl.org/getting.html">Install</a></li>
-        </ul>
-    </div>
-
-    <div class="col-md-4">
-
-        <b>Clozure Common Lisp (CCL)</b>
-        <ul>
-            <li><a href="http://ccl.clozure.com/">Main website</a></li>
-            <li><a href="http://ccl.clozure.com/download.html">Download/Install&nbsp;&nbsp;<i class="fa fa-download"></i></a></li>
-        </ul>
-    </div>
-
-</div>
-
-
-
-<!--
-
-NOTE: Franz say they will pick this up again in Fall 2020. 
-
-  <div class="row">
-
-    <div class="col-md-4">
-
-        <b>Allegro CL</b><br/>
-        <a href="https://franz.com/products/allegro-common-lisp/">Allegro CL ®</a> <span class="small"> (sponsored link)</span> is a very powerful dynamic object-oriented development system especially suited to enterprise-wide, complex application development.
-        <ul>
-            <li><a href="https://franz.com/products/allegro-common-lisp/">Main website</a></li>
-            <li><a href="http://www.franz.com/">Company website</a></li>
-            <li><a href="https://franz.com/downloads/clp/survey">Free download&nbsp;&nbsp;<i class="fa fa-download"></i></a></li>
-        </ul>
-    </div>
-</div>  -->
-
-
-<p> Look <a href="/implementations">here</a> for other Common Lisp compilers, including powerful <em>commercial</em> implementations.</p>
-
-<p>You can also try Common Lisp online:</p>
-<p>
-    <button class="btn btn-primary" type="button" data-toggle="collapse" data-target="#try_lisp" aria-expanded="false" aria-controls="collapseExample">
-        <i class="fa fa-play"></i>&nbsp;&nbsp;&nbsp;Try Lisp Online
-    </button>
-</p>
-<div class="collapse" id="try_lisp">
-    <p>This is a <a href="https://github.com/jscl-project/jscl">JSCL</a> Lisp REPL running on the Web. Note that it is not a complete Common Lisp implementation, but enough to have a taste of it:</p>
-    <iframe style="width:100%; height: 400px;"  src="https://jscl-project.github.io/"></iframe>
-</div>
-
-<h2>Setup your environment</h2>
-
-<p>A Lisp setup consists of three parts: a running Lisp instance, a text editor connected to that running Lisp instance, and project setup and libraries.</p>
-
-<p>The most common way is to use <a href="http://www.emacs.org">Emacs</a> and <a href="https://common-lisp.net/project/slime/">SLIME</a> as text editor/IDE and <a href="https://common-lisp.net/project/asdf/">ASDF</a> + <a href="http://www.quicklisp.org">Quicklisp</a> for project setup and libraries.</p>
-
-<p><a href="https://common-lisp.net/project/slime/">SLIME</a> is an extension to the Emacs text editor that connects the editor to the running Lisp image (called *inferior-lisp*) and interacts with it. It provides lisp code evaluation, compilation, and macroexpansion, online documentation, code navigation, objects inspection, debugger, and much much more.</p>
-
-<p><a href="https://common-lisp.net/project/asdf/">ASDF</a> is the Lisp version of Make. It is used to define projects (called systems), its dependencies, and load and compile the project.</p>
-
-<p><a href="https://www.quicklisp.org">Quicklisp</a> is a library manager for Common Lisp. Use it to download, install, and load any of over 1,500 libraries with a few simple commands.</p>
-
-<p>For an introduction on how to set them all up, you can have a look at <a href="https://lispcookbook.github.io/cl-cookbook/getting-started.html">this guide</a>.</p>
-
-<p>You can also watch this video tutorial:</p>
-
-<p>
-    <button class="btn btn-primary" type="button" data-toggle="collapse" data-target="#setup_video" aria-expanded="false" aria-controls="collapseExample">
-        <i class="fa fa-play"></i>&nbsp;&nbsp;&nbsp;Watch an introductory video
-    </button>
-</p>
-<div class="collapse" id="setup_video">
-    <iframe width="560" height="315" src="https://www.youtube.com/embed/VnWVu8VVDbI" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
-</div>
-
-<h2>Read about Lisp</h2>
-
-<p>A good way to learn Lisp is to go through <a href="http://www.gigamonkeys.com/book/">Practical Common Lisp book</a>.
-</p>
-
-<p>Read <a href="/documentation">our documentation</a> for more options.</p>
-
-<h2>Start a new project</h2>
-
-<p>You may want check out <a href="https://lispcookbook.github.io/cl-cookbook/getting-started.html">the Common Lisp Cookbook</a> for getting started on Common Lisp ecosystem.</p>
-
-<p>
-    <button class="btn btn-primary" type="button" data-toggle="collapse" data-target="#start_project_video" aria-expanded="false" aria-controls="collapseExample">
-        <i class="fa fa-play"></i>&nbsp;&nbsp;&nbsp;Watch an introductory video
-    </button>
-</p>
-<div class="collapse" id="start_project_video">
-    <iframe width="560" height="315" src="https://www.youtube.com/embed/SPgjgybGb5o" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
-</div>
-
-<h2>Libraries</h2>
-
-<p>Check out <a href="/libraries">this list</a> of Lisp libraries.</p>
-
-<p>The easiest way to download and load libraries is via <a href="http://www.quicklisp.org">Quicklisp</a>.  <a href="https://lispcookbook.github.io/cl-cookbook/getting-started.html#install-quicklisp">Follow these instructions</a> to get started.</p>
-
-<h2>Resources</h2>
-
-<p>Here's a list of alternative guides for getting started:
-    <ul>
-        <li><a target="_blank" href="https://lispmethods.com/development-environment.html">Getting Started With Common Lisp</a> at lispmethods.com.</li>
-        <li><a href="http://articulate-lisp.com">Articulate Lisp site</a>.</li>
-        <li><a target="_blank" href="https://cliki.net/Getting%20Started">CLiki guide</a>.</li>
-    </ul>
-</p>
content/features.htmldeleted

@@ -1,37 +1,0 @@
-<h1>Features of Common Lisp</h1>
-
-<div class="row">
-  <div class="col-lg-4 col-md-6">
-    <h2>Interactive development</h2>
-    <p>Development in Common Lisp is interactive. There's no separate compile/run/debug cycle. Instead of that, the program is developed while it runs. Compilation is incremental, and functions can be created and updated on the fly. As the program is running, all objects are available and can be inspected all the time. This is much more than a simple REPL; the whole environment, from the IDE to the language is prepared for this type of development.</p>
-  </div>
-  <div class="col-lg-4 col-md-6">
-    <h2>Robust</h2>
-    <p>Common Lisp is a carefully designed language with a long history. It was standarized in 1980 and the language has not changed since but it keeps up with the times thanks to its modability and metaprogramming capabilities. This makes it very likely that your program will continue to run unmodified for several years.</p>
-  </div>
-
-  <div class="col-lg-4 col-md-6">
-    <h2>Expressive</h2>
-    <p>Being a meta language, Lisp approach to problem solving is to define idioms (small Domain Specific Languages) as close to the problem domain as possible. This results in very succint and declarative solutions, at a level which is difficult to achieve in other programming languages. CL flexibility also facilitates a bottom-up programming approach.</p>
-  </div>
-  <div class="col-lg-4 col-md-6">
-    <h2>Fast</h2>
-    <p>Most Common Lisp compilers produce fast code. Programs can be type annotated and the compilers can apply optimizations based on them. CL also supports different levels of debugging, safety and speed. Performance is usually better than interpreted languages like Python and Ruby, and close to C in some cases.</p>
-  </div>
-  <div class="col-lg-4 col-md-6">
-    <h2>Uniform</h2>
-    <p>Uniformity is present in both syntax and data structures. Everything being an S-expression, there are not many syntax oddities to think about. And Lisp main data structure is the list.</p>
-  </div>
-  <div class="col-lg-4 col-md-6">
-    <h2>Multiparadigm</h2>
-    <p>Lisp implements and can adapt to several programming paradigms. It supports first class functions, closures and destructuring (among other things), commonly present in functional languages. And comes with CLOS, one of the most powerful object systems in existence. Support for other paradigms can often be implemented as a library as has been done for the logic paradigm.</p>
-  </div>
-  <div class="col-lg-4 col-md-6">
-    <h2>Innovative / Advanced </h2>
-    Method combinations. Multiple dispatch. Multiple-values. Meta Object protocol.
-  </div>
-</div>
-
-<div style="padding-top: 30px;">
-  <p>See <a href="http://random-state.net/features-of-common-lisp.html">Features of Common Lisp</a> by Abhishek Reddy for a more detailed overview of the language features.</p>
-</div>
content/implementations.htmldeleted

@@ -1,64 +1,0 @@
-<h1>Common Lisp Implementations</h1>
-
-<h2 style="margin-bottom: 20px;">Open source</h2>
-
-<div class="row">
-    <div class="col-lg-4 col-md-6">
-        <h3>SBCL</h3>
-        <p>
-            <a href="http://www.sbcl.org">Steel Bank Common Lisp (SBCL)</a> is a high performance Common Lisp compiler. It is open source / free software, with a permissive license. In addition to the compiler and runtime system for ANSI Common Lisp, it provides an interactive environment including a debugger, a statistical profiler, a code coverage tool, and many other extensions. SBCL runs on a number of POSIX platforms, and experimentally on Windows.
-        </p>
-    </div>
-    <div class="col-lg-4 col-md-6">
-        <h3>Clozure CL</h3>
-        <p><a href="https://ccl.clozure.com/">Clozure CL</a> (often called CCL for short) is a free Common Lisp implementation with a long history. Some distinguishing features of the implementation include fast compilation speed, native threads, a precise, generational, compacting garbage collector, and a convenient foreign-function interface.</p>
-        <p>
-            Clozure CL is distributed under the terms of the Apache License, version 2.0.
-        </p>
-    </div>
-    <div class="col-lg-4 col-md-6">
-        <h3>Embeddable CL</h3>
-        <p><a href="https://common-lisp.net/project/ecl/main.html">ECL (Embeddable Common-Lisp)</a> is an interpreter of the Common-Lisp language as described in the X3J13 Ansi specification, featuring CLOS (Common-Lisp Object System), conditions, loops, etc, plus a translator to C, which can produce standalone executables.</p>
-        <p>ECL supports the operating systems Linux, FreeBSD, NetBSD, OpenBSD, OS X, Solaris and Windows, running on top of the Intel, Sparc, Alpha, PowerPC and ARM processors.</p>
-    </div>
-    <div class="col-lg-4 col-md-6">
-        <h3>ABCL</h3>
-        <p><a href="https://common-lisp.net/project/armedbear/">Armed Bear Common Lisp (ABCL)</a> is a full implementation of the Common Lisp language featuring both an interpreter and a compiler, running in the JVM. Originally started to be a scripting language for the J editor, it now supports JSR-223 (Java scripting API): it can be a scripting engine in any Java application. Additionally, it can be used to implement (parts of) the application using Java to Lisp integration APIs.</p>
-    </div>
-    <div class="col-lg-4 col-md-6">
-        <h3>Clasp</h3>
-        <p><a href="https://github.com/clasp-developers/clasp">Clasp</a> is a new Common Lisp implementation that seamlessly interoperates with C++ libraries and programs using LLVM for compilation to native code. This allows Clasp to take advantage of a vast array of preexisting libraries and programs, such as out of the scientific computing ecosystem. Embedding them in a Common Lisp environment allows you to make use of rapid prototyping, incremental development, and other capabilities that make it a powerful language.</p>
-    </div>
-    <div class="col-lg-4 col-md-6">
-        <h3>CLISP</h3>
-        <p><a href="https://clisp.sourceforge.io/">CLISP</a> is a Common Lisp implementation by Bruno Haible, then of Karlsruhe University, and Michael Stoll, then of Munich University, both in Germany. It implements the language described in the ANSI Common Lisp standard with many extensions.</p>        
-    </div>
-</div>
-
-<h2 style="margin-bottom: 20px;">Commercial</h2>
-
-<div class="row">
-    <div class="col-lg-6 col-md-6">
-        <img src="/static/imgs/acl-128.png" class="float-right">
-        <h3>Allegro CL</h3>
-        <p>
-            <a href="https://franz.com/products/allegro-common-lisp/">Allegro CL ®</a> is a powerful dynamic object-oriented development, especially suited to enterprise-wide, complex application development. Complex applications with billions of objects are made easy with Allegro CL. The complexity of today's software applications and the explosion of data size are pervasive in all fields ranging from Life Sciences to Manufacturing to Financial Analytics. Allegro CL is the most effective system for developing and deploying applications to solve these complex problems in the real world.
-        </p>
-    </div>
-
-    <div class="col-lg-6 col-md-6">
-        <h3>LispWorks</h3>
-
-        <p>
-            <a href="http://www.lispworks.com/products/lispworks.html"> LispWorks ® </a> is an implementation of ANSI Common Lisp running on Windows, x86_64 Linux, ARM Linux, Mac OS X, FreeBSD, AIX, x86/x64 Solaris and SPARC/Solaris.
-
-            LispWorks is source code compatible across all supported platforms.
-
-            LispWorks offers many features including an advanced Common Lisp compiler, interpreter, runtime system, language extensions, CAPI portable GUI toolkit and the LispWorks IDE.
-        </p>
-    </div>
-</div>
-
-<p>
-    For a complete list, see <a href="http://en.wikipedia.org/wiki/Common_Lisp#List_of_implementations">Wikipedia's big list of CL implementations</a>
-</p>
content/index.htmldeleted

@@ -1,87 +1,0 @@
-title: Welcome to Common-Lisp.net!
-depends-on:
-  - news.md

-<main id="bodyText">
-    <a class="btn btn-primary float-right" href="/downloads"><i class="fa fa-arrow-right"></i>&nbsp;&nbsp;Get started</a>
-    <h1>Welcome to Common-Lisp.net</h1>
-    <section id="introduction">
-        <img src="/static/imgs/lisplogo_flag2_128.png" style="float:right;">
-        <h2>Introduction</h2>
-        <p>Welcome to the amazing world of <b>Common Lisp</b>, the
-            programmable programming language.</p>
-
-        <p>This site is one among many gateways to <b>Common Lisp</b>.  Its
-            goal is to provide the <b>Common Lisp</b> community with development
-            resources and to work as a starting point for new
-            programmers.</p>
-    </section>
-
-    <article id="news">
-        <div id="clnet-news">
-            <h2><a href="/news">Latest Common-Lisp.net news</a></h2>
-
-            {{{newsbox}}}
-            <p><a href="/news" style="font-size: 80%">Read more...</a></p>
-        </div>
-
-        <h2 style="padding-top: 10px;">Latest Lisp news</h2>
-        <div id="widgetmain" style="text-align:left;overflow-y:auto;overflow-x:hidden;width:331px;background-color:#transparent; border:1px solid #333333;padding: 8px;">
-            <div id="rsswidget" style="height:300px;">
-               <script src="//rss.bloople.net/?url=https%3A%2F%2Fplanet.lisp.org%2Frss20.xml&detail=-1&limit=6&type=js"></script>
-            </div>
-        </div>
-
-        <div style="padding-top: 10px;"></div>
-        <script src="https://www.reddit.com/r/lisp/.embed?limit=5&t=all" type="text/javascript"></script>
-    </article>
-
-    <article id="description">
-        <h2>What is Common Lisp?</h2>
-
-        <p><b>Common Lisp</b> is the <a href="/features">modern</a>, <a href="/features">multi-paradigm</a>, <a href="/features">high-performance</a>,
-          <a href="/features">compiled</a>, <a href="http://www.lispworks.com/documentation/HyperSpec/Front/index.htm">ANSI-standardized</a>, most prominent (along with <a href="http://www.schemers.org/">Scheme</a>) descendant of the long-running family of Lisp programming languages.</p>
-        </p>
-
-        <p><b>Common Lisp</b> is known for being extremely flexible, having
-            excellent support for object oriented programming, and fast
-            prototyping capabilities. It also sports an extremely powerful
-            macro system that allows you to tailor the language to your
-            application, and a flexible run-time environment that allows
-            modification and debugging of running applications (excellent
-            for server-side development and long-running critical
-            software). It is a multi-paradigm programming language that
-            allows you to choose the approach and paradigm according to
-            your application domain.</p>
-        <!-- <p><a href="/features" class="emphasize"> Read more about <b>Common Lisp</b> features here.</a></p> -->
-
-        <p>Curious? &nbsp;&nbsp; <a class="btn btn-primary" href="/downloads"> Get started!</a>
-
-            <!-- <p>Read about the <a class="emphasize" href="http://www.paulgraham.com/lisphistory.html">language history here.</a></p> -->
-    </article>
-
-    <section id="services">
-        <h2>Services</h2>
-
-        <ul>
-            <li><a href="/project-intro">Project hosting</a> for open
-                source / free software <b>Common Lisp</b> projects.</li>
-            <li><a href="/independent-lists">Mailing lists</a> for
-                <b>Common Lisp</b> related topics.</li>
-        </ul>
-    </section>
-
-    <section id="support">
-        <h2>Support</h2>
-
-        <p>If you need technical support, please contact <a
-                                                             href="http://mailman.common-lisp.net/cgi-bin/mailman/listinfo/clo-devel">our
-            admins</a>.</p>
-    </section>
-
-    <section id="about">
-        <h2>About this site</h2>
-
-        <p>Check out our <a href="/about">about page</a>.</p>
-    </section>
-</main>
content/news.mdmodifiedview

@@ -4,6 +4,33 @@
 
 # Latest Common-Lisp.net news
 
+### 2026-09-30 *A new common-lisp.net*
+
+This site has been rebuilt. It is now a single Common Lisp program
+serving every page from one Lisp image, restyled from top to bottom,
+readable on a phone and in the dark. There is a clearer path to
+[getting an account](/registration/), with a request form that puts
+your question on the admins' queue; a page on
+[what the Foundation is doing](/foundation/); and the fundraising pages
+have moved in under [Donate](/donate/). Every address of the old site
+still works. It was shown to `#common-lisp.net` and `#commonlisp` as a
+preview first; tell us what is still wrong with it in the
+[issue tracker](https://gitlab.common-lisp.net/clo/cl-site/-/issues).
+
+### 2026-09-29 *Fresh container images, and Clozure CL on arm64*
+
+The [container images](/foundation/#images) of the Common Lisp
+implementations that the Foundation publishes as `clfoundation/*` on
+Docker Hub have all been rebuilt: SBCL 2.6.9 (and a preview of 2.6.10),
+Clozure CL 1.13, ECL 26.5.5, ABCL 1.9.3, Clasp 3.0.1, CLISP 2.49.92,
+CMUCL 21f and Allegro CL 11.0 Express, on Debian trixie and bookworm.
+New this time: the `1.13.1-rc` images of Clozure CL are built for
+`linux/arm64` as well as `amd64`, from the development branch that
+received CCL's port to 64-bit ARM Linux on September 2nd. That port has
+no release yet, so treat it as experimental and please
+[report what breaks](https://github.com/Clozure/ccl/issues). To try it:
+`docker run --rm -it clfoundation/ccl:1.13.1-rc-trixie`.
+
 ### 2025-05-20
 
 European Lisp Symposium has just concluded and the Common Lisp
content/project-intro.htmldeleted

@@ -1,182 +1,0 @@
-title: Project Hosting

-<a style="clear:both;" class="btn btn-primary float-right" href="/phub"><i class="fa fa-arrow-right"></i>&nbsp;&nbsp;Common-Lisp.net projects</a>
-
-<div style="clear:both;margin-top: 20px;" class="table-of-contents">
-  <h4 class="bg-primary" style="color:white;">Table of contents</h4>
-  <ul>
-    <li><a href="#whatyouget">What you get</a></li>
-    <li><a href="#creproj">Creating a project</a></li>
-    <li><a href="#svn">Subversion access</a></li>
-    <li><a href="#website">Website</a></li>
-    <li><a href="#mailinglist">Mailinglists</a></li>
-    <li><a href="#admin">Administrative requests</a></li>
-    <li><a href="#restore">Backups</a></li>
-    <li><a href="#trac">Trac</a></li>
-  </ul>
-</div>
-
-<h1 style="padding-bottom: 30px;"><i class="fa fa-project-diagram"></i>&nbsp;Common-Lisp.net project hosting</h1>
-
-<div id="bodyTextWide">
-  <h2 id="whatyouget">What you get</h2>
-
-  <p>Hosted projects get web space, mailing lists, and disk space
-    for a version control system. We offer git through
-    <a href="https://gitlab.common-lisp.net/explore/projects/">GitLab</a>
-    and Subversion, augmented with Trac.
-  </p>
-
-  <p id="project-pages">Projects using GitLab for their version control, can take advantage of
-    <a href="https://docs.gitlab.com/ee/user/project/pages/">GitLab Pages</a>
-    to host their project site. An example of such a project is the <a
-      href="https://gitlab.common-lisp.net/boston-lisp/boston-lisp.common-lisp.dev/">Boston Lisp
-        site repository</a>; see the <a 
-      href="https://gitlab.common-lisp.net/boston-lisp/boston-lisp.common-lisp.dev/-/blob/master/.gitlab-ci.yml">.gitlab-ci.yml</a>
-    in that project for an example.
-  </p>
-
-  <h2>Requesting access to an existing project</h2>
-  <p>If you want to join an existing project, please contact the
-    project owners through the project mailing list (usually
-    &lt;project&gt;-devel@common-lisp.net). Project owners can add
-    new members to GitLab projects themselves. For Subversion projects,
-    one of the project members will contact the site admins to ask the
-    user to be added to their project.</p>
-  <p>If the project is orphaned, or has no mailing list, please
-    contact admin@common-lisp.net directly.</p>
-
-  <p>Please note that anonymous access is available for
-    public Git repositories through https -- repositories can be downloaded
-    without requesting access!</p>
-
-
-  <h2 id="your-access">Your common-lisp.net access</h2>
-  <p>To create a new Git project, a GitLab account can be created; please
-    notify admin@common-lisp.net about your intent to create projects,
-    including a fork of an existing project.  Due to SPAM, you need to be
-    granted this permission manually.
-  </p>
-  <p>To join existing Git projects, a GitLab account can be created; with
-    that account, access to the existing project can be requested through
-    the GitLab interface. Project owners will be notified of your request
-    for access.
-  </p>
-  <p>For Subversion access, a different account is required. An account for
-    Subversion access can be requested through admin@common-lisp.net by
-    sending an SSH public key. See <a
-    href="https://www.cyberciti.biz/faq/how-to-set-up-ssh-keys-on-linux-unix/">the
-    instructions by Cyberciti</a> how to set up SSH keys.</a>
-  </p>
-
-  <p>There are no hard limits on resource-use. Please exercise care; you will be
-    sharing this system with others and stick to these basic rules:</p>
-    
-  <ul>
-    <li>No CPU/RAM intensive tasks</li>
-    <li>No long-running tasks (no chat clients, <code>screen</code> sessions, etc.)</li>
-    <li>Don't use your shell access unless you need to perform administrative tasks on your project(s)</li>
-    <li>Only store publicly hosted data on the machine</li>
-    <li>To host video or other large assets, please contact admins</li>
-    <li>To run cron jobs, please consult with the admins</li>
-    <li>Mirrorring of resources on common-lisp.net can be arranged by consulting with admins</li>
-  </ul>
-
-  <h2 id="creproj">Creating a project</h2>
-
-  <p>After notifying the admins of their desire to create projects, users can
-    create Git projects (up to 10) themselves through the GitLab interface in
-    their own namespace. However, creating a GitLab group (a separate
-    namespace) for your new project is strongly encouraged. GitLab groups allow
-    nicer hosting of project web sites and better access control for related
-    projects worked on by multiple people. A group can be requested through an
-    e-mail to admin@common-lisp.net.
-  </p>
-
-  <p>To request a Subversion project, please contact admin@common-lisp.net. If you don't have
-    an account yet, please see the procedure under <a href="#your-access">Your common-lisp.net
-    access</a> above.
-  </p>
-
-  <p>As a first step, please take some time to set up a simple web-page for your
-    project. For GitLab based projects, this can be set up as described in the section
-    <a href="#project-pages">What you get</a> above. Subversion-based projects can 
-    upload it to our server (via <tt>scp</tt>). See also our section titled
-    &quot;<a href="#website">Website</a>&quot;.</p>
-
-  <p>To send e-mails on push to your project mailing list, follow these steps:</p>
-  <ol>
-    <li>Go to the project's landing page (not the group's), e.g.
-      SLIME's: <a href="https://gitlab.common-lisp.net/slime/slime"
-               >https://gitlab.common-lisp.net/slime/slime</a></li>
-    <li>Click on &quot;Settings&quot; at the bottom of the left
-      menu bar</li>
-    <li>Click on &quot;Services&quot; in the next page</li>
-    <li>From the list of services in the resulting page, select
-      &quot;E-mail on push&quot;</li>
-    <li>Enter the recipients for push mails, including the
-      mailing list name of your project (i.e. &lt;project-name&gt;-cvs@common-lisp.net)</li>
-  </ol>
-
-  <h2 id="svn">Subversion repository access</h2>
-
-  <p>Your Subversion repository is in
-    <tt>/project/&lt;project-name&gt;/svn</tt>.  By default, any
-    commits to it will be sent to
-    <tt>&lt;project-name&gt;-cvs@common-lisp.net</tt>.</p>
-
-  <p>Anonymous read-only Subversion access is enabled by default.
-    Repositories can be accessed through <a
-     href="https://svn.common-lisp.net/">https://svn.common-lisp.net/&lt;project&gt;</a>
-  </p>
-
-
-   <h2 id="website">Website</h2>
-
-    <p>When the project doesn't use GitLab Pages as described above,
-      <tt>/project/&lt;project-name&gt;/public_html</tt> is published from our
-    webservers as <tt>https://&lt;project-name&gt;.common-lisp.dev/;</tt></p>
-
-    <p>
-      Apache <a href="http://httpd.apache.org/docs/howto/ssi.html">Server-Side
-      Includes</a> are enabled.
-    </p>
-
-
-
-    <h2 id="mailinglist">Automatic and manual mailinglists</h2>
-
-    <p>When your project is set up, one mailinglist (&lt;project-name&gt;-devel@common-lisp.net)
-      is created. The project requestor is subscribed on initialization.  Any future project members
-      will have to subscribe on their own, via the Mailman interface
-      at <tt>http://mailman.common-lisp.net/cgi-bin/mailman/listinfo/&lt;project-name&gt;-devel</tt>.
-      </p>
-
-    <p>Additional mailing lists available on request.</p>
-
-
-    <h2 id="admin">Administrative requests</h2>
-
-    <p>For adding/removing project members, additional mailing
-      lists, etc, send an email to
-      <tt>admin&nbsp;(at)&nbsp;common-lisp&nbsp;(dot)&nbsp;net</tt>
-      with all the relevant details (project name, etc).</p>
-
-
-    <h2 id="restore">Restoring from backup</h2>
-
-    <p>All the data is backed up regularly both on- and off-site, to cover for scenarios
-     of user-accident as well as disaster recovery. There is, however, no provision for
-     user-driven recovery. Please be careful before deleting anything in your project.</p>
-
-    <h2 id="trac">Trac</h2>
-
-    <p>We offer a Trac instances for projects to use with their Subversion repository.
-      Please specify if want one when you set up your project or email <code>admin (at)
-      common-lisp (dot) net</code> to request it for an existing project.</p>
-
-    <p>To learn more about how Trac works at common-lisp.net, please read
-        our <a href="/tools/#trac">Trac @ clnet introduction</a> .
-
-
-</div>
content/registration.mddeleted

@@ -1,23 +1,0 @@
-title: How to register on Gitlab.common-lisp.net
-

-
-# Registration on gitlab.c-l.net made harder
-
-Due to the large amount of users that try to register for spam purposes we've 
-had to make registration (*and login via Google*) a bit harder.
-
-## One step more
-
-Please message `minion` on `#common-lisp.net` on Libera IRC about
-the registration URL; you can either use your own IRC client, or use
-[webchat](https://web.libera.chat)
-need to "Auth to services").
-
-The right question would be the string `minion: registration, please?`; you'll 
-receive answer in near-realtime.
-
-The URL you receive can be used both for registration, as well as for logging in 
-via Google; if you already have a local user or use GitHub, these restrictions 
-do not (yet) apply.
-
content/stories.htmldeleted

@@ -1,2 +1,0 @@
-<h1>Common Lisp Success Stories</h1>
-
content/tutorial/2.htmldeleted

content/tutorial/3.htmldeleted

content/tutorial/4.htmldeleted

content/tutorial/5.htmldeleted

content/tutorial/6.htmldeleted

content/tutorial/7.htmldeleted

content/tutorial/8.htmldeleted

content/tutorial/9.htmldeleted

globals.lispdeleted

@@ -1,41 +1,0 @@
-;;;; -*- Mode: Lisp; Syntax: ANSI-Common-Lisp; Base: 10 -*-
-(in-package :cl-site)
-
-(defparameter *LAYOUT-DIR* (asdf:system-relative-pathname :cl-site "layout/")
-  "Pathname for location of all layout files (including templates).")
-
-(defparameter *templates-dir* (merge-pathnames #P"templates/" *layout-dir*)
-  "Pathname for location of template files.")
-
-(defparameter *OUTPUT-DIR* (asdf:system-relative-pathname :cl-site "output/")
-  "Pathname where the generated output should be written to.")
-
-;; @TO-DO: store layout name as global, generate js/css pathnames from it. 
-;; (More template-related variables may go here in the future)
-(defparameter *DEFAULT-PAGE-TEMPLATE* "layout_2018.html"
-  "Default template to use unless another is explicitly specified.")
-
-;; General static files - images etc
-(defparameter *STATIC-DIR* (asdf:system-relative-pathname :cl-site "content/static/")
-  "Pathname for location of static files.")
-
-;; The only REQUIRED field is :content which must be the filename of the page content
-;; The :content field will NOT be sent to the template.
-;; @TO-DO: store fields within content files and parse them out.
-(defparameter *PAGES-DIR* (asdf:system-relative-pathname :cl-site "content/")
-  "Pathname for location of page content.")
-(defparameter *PRIVATE-KEYS* '(:slug :content))
-
-
-;; Initialize global context (will be appended to all individual page contexts)
-;; Used to store things like stylesheets, etc...
-(defparameter *GLOBAL-CONTEXT* ())
-
-(defparameter *computed-page-content* nil
-  "Used as dynamically bound variable in preprocess-lisp-pages, to
-  capture output when compile/loading any lispy html content files
-  wrapped with calls to add-content. Global value should always be
-  nil.")
-
-(defvar *paserve-port* 8008)
-;; FLAG add different ports for other webservers when activated
helpers.lispdeleted

@@ -1,158 +1,0 @@
-;;;; -*- Mode: Lisp; Syntax: ANSI-Common-Lisp; Base: 10 -*-

-(in-package :cl-site)

-

-

-(defun make-path (prefix suffix &optional page)

-  "Constructs a pathname from `prefix` and `suffix`.

-  `suffix` can be one of

-    1. a context (an alist) with a ':content' element

-    2. a pathname

-    3. a string identifying a path

-

-  `prefix` can be one of

-    1. a pathname

-    2. a string identifying a path

-  "

-  (let ((path (if page (cdr (assoc :content suffix)) suffix)))

-    (if (pathnamep prefix)

-        (merge-pathnames path prefix)

-        (pathname (concatenate 'string 

-                               prefix path)))))

-

-(defun file-to-string (filepath)

-  "Reads the file designated by pathname `filepath` into memory,

-   returning the string with the sequence read from the file"

-  (with-open-file (stream filepath)

-    (let ((data (make-string (file-length stream))))

-      (read-sequence data stream)

-      data)))

-

-(defun file-to-list (path)

-   (with-open-file (f path :direction :input)

-      (loop for line = (read-line f nil)

-            while line

-            collect line)))

-

-

-(defun header-lines (lines)

-   "Returns a list of lines (subset of `lines`)

-    which form the header of the document `lines` were read from

-   "

-   (loop for line in lines

-         thereis (when (string= line "---") header-lines)

-         collect line into header-lines))

-

-(defun content-lines (lines)

-   "Returns a list of lines (subset of `lines`)

-    which remain after the header (if any) has been discarded

-   "

-   (loop with found-header-p = nil

-         for line in lines

-         when found-header-p

-         collect line into post-header-lines

-         when (string= line "---")

-           do (setf found-header-p t)

-         collect line into content-lines

-         finally (return (if found-header-p post-header-lines content-lines))))

-

-(defun populate-pages ()

-  "Each page is an alist containing info to be sent to the template via the context."

-  (mapcar (lambda (p)

-            (list (cons :content (enough-namestring p *pages-dir*))

-                  (cons :title (file-namestring p))))

-	  ;;

-	  ;; At this point there could be duplicate, i.e. a .html and a .lisp file. 

-	  ;;

-	  (append

-	   (directory (merge-pathnames #P"**/*.html" *pages-dir*))

-	   (directory (merge-pathnames #P"**/*.md" *pages-dir*))

-	   (directory (merge-pathnames #P"**/*.lisp" *pages-dir*)))))

-

-

-(defvar *render-hooks* '()

-  "A list of hooks wrapping the rendering of the page.")

-

-(defmacro def-render-hook (name (&rest args) &rest body)

-   "Registers a hook to wrap the rendering of the page.

-

-    Provides a `call-next-hook` function which dispatches

-    execution to the next wrapper and ultimately to the

-    page renderer.

-

-    The wrapper should return the value received from

-    a `call-next-hook` to its caller as it is the content

-    of the rendered page.

-    "

-   (let ((next-hooks-sym (gensym)))

-     `(progn

-         (defun ,name (,@args ,next-hooks-sym)

-            (flet ((call-next-hook (content context)

-                      (funcall (pop ,next-hooks-sym) content context ,next-hooks-sym)))

-              ,@body))

-         (pushnew ',name *render-hooks*))))

-

-(defun render-page-with-hooks (content context)

-  (let ((hooks (reverse (cons #'(lambda (p c next-processors)

-                                  (process-page p c))

-                              *render-hooks*))))

-    (funcall (pop hooks) content context hooks)))

-

-

-

-(defun page-content (page) (rest (assoc :content page)))

-

-

-(defun add-content (&key headers-plist html-string)

-  (setq *computed-page-content* (list :headers headers-plist :html-string html-string)))

-

-;;

-;; FLAG -- the following are from Gendl - remove if/when we start :depending

-;; on :gendl or :gwl

-;;

-(defun plist-keys (plist)

-  "List of keyword symbols. Returns the keys from a plist.

-

-:arguments (plist \"Plist\")"

-  (when plist

-    (cons (first plist)

-          (plist-keys (rest (rest plist))))))

-

-(defun plist-values (plist)

-  "List of Lisp objects. Returns the values from a plist.

-

-:arguments (plist \"Plist\")"

-  (when plist

-    (cons (second plist)

-          (plist-values (rest (rest plist))))))

-

-(defun alist-from-hash (hash)

-   (loop for k being each hash-key of hash

-               using (hash-value v)

-     collect (cons k v)))

-

-(defmacro with-cl-who-string ((&rest args) &body body)

-

-    "Form. Sets up body to be evaluated with cl-who:with-html-output and  return the resulting string instead

-of writing to a stream."

-

-    (let ((ss (gensym)))

-      `(with-output-to-string (,ss)

-	 (cl-who:with-html-output (,ss nil ,@args) ,@body))))

-

-

-(defmacro wmd (string)

-  "Form suitable for inclusion in cl-who LHTML context. Returns an

-  `str` form after passing it through cl-markdown's markdown processor.

-"

-  ;;

-  ;; FLAG -- should we be checking for loaded markdown package at compile-time instead? 

-  ;;

-  `(cl-who:str (cond ((find-package :markdown.cl)

-		      (funcall (read-from-string "markdown.cl:parse") ,string))

-		     ((find-package :cl-markdown)

-		      (with-output-to-string (ss)

-			(funcall (read-from-string "cl-markdown:markdown") ,string :stream ss))))))

-

-;;

-;; End of Gendl utilities. 

-;;

... and 68 more files.

common-lisp.net mentioned in commit f44efac37324ad23f0a7edd2ce9f6294d3e999f5

Sign in to comment.