From fb64d2b9c959f8a0beffca4e236b3d556f15f2e0 Mon Sep 17 00:00:00 2001 From: Kiara Grouwstra Date: Wed, 19 Feb 2025 20:23:48 +0100 Subject: [PATCH 01/10] convert readmes from org to markdown --- deployment/README.md | 219 ++++++++++++++++++++++++++++++++++++++++++ deployment/README.org | 113 ---------------------- infra/README.md | 65 +++++++++++++ infra/README.org | 58 ----------- 4 files changed, 284 insertions(+), 171 deletions(-) create mode 100644 deployment/README.md delete mode 100644 deployment/README.org create mode 100644 infra/README.md delete mode 100644 infra/README.org diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 00000000..50fddd05 --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,219 @@ +# Provisioning VMs via Proxmox + +## Quick links + +Proxmox API doc +: + +Fediversity Proxmox +: + +## Basic terminology + +Node +: physical host + +## Fediversity Proxmox + +- It is only accessible via Procolix\'s VPN: + - Get credentials for the VPN portal and Proxmox from + [Kevin](https://git.fediversity.eu/kevin). + + - Log in to the [VPN + portal](https://vpn.fediversity.eu/vpn-user-portal/home). + + - Create a **New Configuration**: + - Select **WireGuard (UDP)** + - Enter some name, e.g. `fediversity` + - Click Download + + - Write the WireGuard configuration to a file + `fediversity-vpn.config` next to your NixOS configuration + + - Add that file's path to `.git/info/exclude` and make sure + it doesn't otherwise leak (for example, use + [Agenix](https://github.com/ryantm/agenix) to manage + secrets) + + - To your NixOS configuration, add + + ``` nix + networking.wg-quick.interfaces.fediversity.configFile = toString ./fediversity-vpn.config; + ``` +- Select "Promox VE authentication server". +- Ignore the "You do not have a valid subscription" message. + +## Automatically + +This directory contains scripts that can automatically provision or +remove a Proxmox VM. For now, they are tied to one node in the +Fediversity Proxmox, but it would not be difficult to make them more +generic. Try: + +```sh +bash proxmox/provision.sh --help +bash proxmox/remove.sh --help +``` + +## Preparing the machine configuration + +- It is nicer if the machine is a QEMU guest. On NixOS: + + ``` nix + services.qemuGuest.enable = true + ``` + +- Choose name for your machine. + +- Choose static IPs for your machine. The IPv4 and IPv6 subnets + available for Fediversity testing are: + + - `95.215.187.0/24`. Gateway is `95.215.187.1`. + - `2a00:51c0:13:1305::/64`. Gateway is `2a00:51c0:13:1305::1`. + +- I have been using id `XXX` (starting from `001`), name `fediXXX`, + `95.215.187.XXX` and `2a00:51c0:13:1305::XXX`. + +- Name servers should be `95.215.185.6` and `95.215.185.7`. + +- Check [Netbox](https://netbox.protagio.org) to see which addresses + are free. + +## Manually via the GUI + +### Upload your ISO + +- Go to Fediversity proxmox. +- In the left view, expand under the node that you want and click on + "local". +- Select "ISO Images", then click "Upload". +- Note: You can also download from URL. +- Note: You should click on "local" and not "local-zfs". + +### Creating the VM + +- Click "Create VM" at the top right corner. + +#### General + +Node +: which node will host the VM; has to be the same + +VM ID +: Has to be unique, probably best to use the `xxxx` in `vm0xxxx` + (yet to be decided) + +Name +: Usually `vm` + 5 digits, e.g. `vm02199` + +Resource pool +: Fediversity + +#### OS + +Use CD/DVD disc image file (iso) + +: + + Storage + : local, means storage of the node. + + ISO image + : select the image previously uploaded + +No need to touch anything else + +#### System + +BIOS +: OVMF (UEFI) + +EFI Storage +: `linstor_storage`; this is a storage shared by all of the Proxmox + machines. + +Pre-Enroll keys +: MUST be unchecked + +Qemu Agent +: check + +#### Disks + +- Tick "advanced" at the bottom. +- Disk size (GiB) :: 40 (depending on requirements) +- SSD emulation :: check (only visible if "Advanced" is checked) +- Discard :: check, so that blocks of removed data are cleared + +#### CPU + +Sockets +: 1 (depending on requirements) + +Cores +: 2 (depending on requirements) + +Enable NUMA +: check + +#### Memory + +Memory (MiB) +: choose what you want + +Ballooning Device +: leave checked (only visible if "Advanced" is checked) + +#### Network + +Bridge +: `vnet1306`. This is the provisioning bridge; + we will change it later. + +Firewall +: uncheck, we will handle the firewall on the VM itself + +#### Confirm + +### Install and start the VM + +- Start the VM a first time. + - Select the VM in the left panel. You might have to expand the + node on which it is hosted. + - Select "Console" and start the VM. +- Install the VM as you would any other machine. +- [*Shutdown the VM*]{.spurious-link target="Shutdown the VM"}. +- After the VM has been installed: + - Select the VM again, then go to "Hardware". + - Double click on the CD/DVD Drive line. Select "Do not use any + media" and press OK. + - Double click on Network Device, and change the bridge to + `vnet1305`, the public bridge. +- Start the VM again. + +### Remove the VM + +- [*Shutdown the VM*]{.spurious-link target="Shutdown the VM"}. +- On the top right corner, click "More", then "Remove". +- Enter the ID of the machine. +- Check "Purge from job configurations" +- Check "Destroy unreferenced disks owned by guest" +- Click "Remove". + +### Move the VM to another node + +- Make sure there is no ISO plugged in. +- Click on the VM. Click migrate. Choose target node. Go. +- Since the storage is shared, it should go pretty fast (~1 minute). + +### Shutdown the VM + +- Find the VM in the left panel. +- At the top right corner appears a "Shutdown" button with a submenu. +- Clicking "Shutdown" sends a signal to shutdown the machine. This + might not work if the machine is not listening for that signal. +- Brutal solution: in the submenu, select "Stop". +- The checkbox "Overrule active shutdown tasks" means that the machine + should be stopped even if a shutdown is currently ongoing. This is + particularly important if you have tried to shut the machine down + normally just before. diff --git a/deployment/README.org b/deployment/README.org deleted file mode 100644 index ec8acd19..00000000 --- a/deployment/README.org +++ /dev/null @@ -1,113 +0,0 @@ -#+title: Provisioning VMs via Proxmox - -* Quick links -- Proxmox API doc :: https://pve.proxmox.com/pve-docs/api-viewer -- Fediversity Proxmox :: http://192.168.51.81:8006/ -* Basic terminology -- Node :: physical host -* Fediversity Proxmox -- It is only accessible via Procolix's VPN: - - Get credentials for the VPN portal and Proxmox from [[https://git.fediversity.eu/kevin][Kevin]]. - - Log in to the [[https://vpn.fediversity.eu/vpn-user-portal/home][VPN portal]]. - - Create a *New Configuration*: - - Select *WireGuard (UDP)* - - Enter some name, e.g. ~fediversity~ - - Click Download - - Write the WireGuard configuration to a file ~fediversity-vpn.config~ next to your NixOS configuration - - Add that file's path to ~.git/info/exclude~ and make sure it doesn't otherwise leak (for example, use [[https://github.com/ryantm/agenix][Agenix]] to manage secrets) - - To your NixOS configuration, add - #+begin_src nix - networking.wg-quick.interfaces.fediversity.configFile = toString ./fediversity-vpn.config; - #+end_src -- Select “Promox VE authentication server”. -- Ignore the “You do not have a valid subscription” message. -* Automatically -This directory contains scripts that can automatically provision or remove a -Proxmox VM. For now, they are tied to one node in the Fediversity Proxmox, but -it would not be difficult to make them more generic. Try: -#+begin_src sh -sh proxmox/provision.sh --help -sh proxmox/remove.sh --help -#+end_src -* Preparing the machine configuration -- It is nicer if the machine is a QEMU guest. On NixOS: - #+begin_src nix - services.qemuGuest.enable = true - #+end_src -- Choose name for your machine. -- Choose static IPs for your machine. The IPv4 and IPv6 subnets available for - Fediversity testing are: - - ~95.215.187.0/24~. Gateway is ~95.215.187.1~. - - ~2a00:51c0:13:1305::/64~. Gateway is ~2a00:51c0:13:1305::1~. -- I have been using id ~XXX~ (starting from ~001~), name ~fediXXX~, ~95.215.187.XXX~ and - ~2a00:51c0:13:1305::XXX~. -- Name servers should be ~95.215.185.6~ and ~95.215.185.7~. -- Check [[https://netbox.protagio.org][Netbox]] to see which addresses are free. -* Manually via the GUI -** Upload your ISO -- Go to Fediversity proxmox. -- In the left view, expand under the node that you want and click on “local”. -- Select “ISO Images”, then click “Upload”. -- Note: You can also download from URL. -- Note: You should click on “local” and not “local-zfs”. -** Creating the VM -- Click “Create VM” at the top right corner. -*** General -- Node :: which node will host the VM; has to be the same -- VM ID :: Has to be unique, probably best to use the "xxxx" in "vm0xxxx" (yet to be decided) -- Name :: Usually "vm" + 5 digits, e.g. "vm02199" -- Resource pool :: Fediversity -*** OS -- Use CD/DVD disc image file (iso) :: - - Storage :: local, means storage of the node. - - ISO image :: select the image previously uploaded -No need to touch anything else -*** System -- BIOS :: OVMF (UEFI) -- EFI Storage :: ~linstor_storage~; this is a storage shared by all of the Proxmox machines. -- Pre-Enroll keys :: MUST be unchecked -- Qemu Agent :: check -*** Disks -- Tick “advanced” at the bottom. -- Disk size (GiB) :: 40 (depending on requirements) -- SSD emulation :: check (only visible if “Advanced” is checked) -- Discard :: check, so that blocks of removed data are cleared -*** CPU -- Sockets :: 1 (depending on requirements) -- Cores :: 2 (depending on requirements) -- Enable NUMA :: check -*** Memory -- Memory (MiB) :: choose what you want -- Ballooning Device :: leave checked (only visible if “Advanced” is checked) -*** Network -- Bridge :: ~vnet1306~. This is the provisioning bridge; we will change it later. -- Firewall :: uncheck, we will handle the firewall on the VM itself -*** Confirm -** Install and start the VM -- Start the VM a first time. - - Select the VM in the left panel. You might have to expand the node on which it is hosted. - - Select “Console” and start the VM. -- Install the VM as you would any other machine. -- [[Shutdown the VM]]. -- After the VM has been installed: - - Select the VM again, then go to “Hardware”. - - Double click on the CD/DVD Drive line. Select “Do not use any media” and press OK. - - Double click on Network Device, and change the bridge to ~vnet1305~, the public bridge. -- Start the VM again. -** Remove the VM -- [[Shutdown the VM]]. -- On the top right corner, click “More”, then “Remove”. -- Enter the ID of the machine. -- Check “Purge from job configurations” -- Check “Destroy unreferenced disks owned by guest” -- Click “Remove”. -** Move the VM to another node -- Make sure there is no ISO plugged in. -- Click on the VM. Click migrate. Choose target node. Go. -- Since the storage is shared, it should go pretty fast (~1 minute). -** Shutdown the VM -- Find the VM in the left panel. -- At the top right corner appears a “Shutdown” button with a submenu. -- Clicking “Shutdown” sends a signal to shutdown the machine. This might not work if the machine is not listening for that signal. -- Brutal solution: in the submenu, select “Stop”. -- The checkbox “Overrule active shutdown tasks” means that the machine should be stopped even if a shutdown is currently ongoing. This is particularly important if you have tried to shut the machine down normally just before. diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 00000000..fef93f67 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,65 @@ +# Infra + +This directory contains the definition of the VMs that host our infrastructure. + +## NixOps4 + +Their configuration can be updated via NixOps4. Run + +```sh +nixops4 deployments list +``` + +to see the available deployments. +This should be done from the root of the repository, +otherwise NixOps4 will fail with something like: + +``` + nixops4 error: evaluation: error: + … while calling the 'getFlake' builtin + + error: path '/nix/store/05nn7krhvi8wkcyl6bsysznlv60g5rrf-source/flake.nix' does not exist, evaluation: error: + … while calling the 'getFlake' builtin + + error: path '/nix/store/05nn7krhvi8wkcyl6bsysznlv60g5rrf-source/flake.nix' does not exist +``` + +Then, given a deployment (eg. `git`), run + +```sh +nixops4 apply +``` + +Alternatively, to run the `default` deployment, run + +```sh +nixops4 apply +``` + +## Deployments + +default +: Contains everything + +`git` +: Machines hosting our Git infrastructure, eg. Forgejo and its actions runners + +`web` +: Machines hosting our online content, eg. the website or the wiki + +`other` +: Machines without a specific purpose + +## Machines + +These machines are hosted on the Procolix Proxmox instance, +to which non-Procolix members of the project do not have access. +They host our stable infrastructure. + + Machine Proxmox Description Deployment + --------- ------------- ------------------------ ------------ + vm02116 Procolix Forgejo `git` + vm02179 Procolix *unused* `other` + vm02186 Procolix *unused* `other` + vm02187 Procolix Wiki `web` + fedi300 Fediversity Forgejo actions runner `git` diff --git a/infra/README.org b/infra/README.org deleted file mode 100644 index 8029f4b0..00000000 --- a/infra/README.org +++ /dev/null @@ -1,58 +0,0 @@ -#+title: Infra - -This directory contains the definition of the VMs that host our infrastructure. - -* NixOps4 - -Their configuration can be updated via NixOps4. Run - -#+begin_src sh -nixops4 deployments list -#+end_src - -to see the available deployments. This should be done from the root of the -repository, otherwise NixOps4 will fail with something like: - -#+begin_src -nixops4 error: evaluation: error: - … while calling the 'getFlake' builtin - - error: path '/nix/store/05nn7krhvi8wkcyl6bsysznlv60g5rrf-source/flake.nix' does not exist, evaluation: error: - … while calling the 'getFlake' builtin - - error: path '/nix/store/05nn7krhvi8wkcyl6bsysznlv60g5rrf-source/flake.nix' does not exist -#+end_src - -Then, given a deployment (eg. ~git~), run - -#+begin_src sh -nixops4 apply -#+end_src - -Alternatively, to run the ~default~ deployment, run - -#+begin_src sh -nixops4 apply -#+end_src - -* Deployments - -- default :: Contains everything -- ~git~ :: Machines hosting our Git infrastructure, eg. Forgejo and its actions - runners -- ~web~ :: Machines hosting our online content, eg. the website or the wiki -- ~other~ :: Machines without a specific purpose - -* Machines - -These machines are hosted on the Procolix Proxmox instance, to which -non-Procolix members of the project do not have access. They host our stable -infrastructure. - -| Machine | Proxmox | Description | Deployment | -|---------+-------------+------------------------+------------| -| vm02116 | Procolix | Forgejo | ~git~ | -| vm02179 | Procolix | /unused/ | ~other~ | -| vm02186 | Procolix | /unused/ | ~other~ | -| vm02187 | Procolix | Wiki | ~web~ | -| fedi300 | Fediversity | Forgejo actions runner | ~git~ | From 92563d387a858d97467b1f257454354f9fc7e3b2 Mon Sep 17 00:00:00 2001 From: Valentin Gagarin Date: Wed, 19 Feb 2025 23:07:51 +0100 Subject: [PATCH 02/10] test login/logout redirection (#163) this concludes https://git.fediversity.eu/Fediversity/Fediversity/issues/72 with a test covering most of the user story. test in the devshell: ``` manage test panel ``` test in full isolation: ``` nix-build -A tests ``` Reviewed-on: https://git.fediversity.eu/Fediversity/Fediversity/pulls/163 Reviewed-by: kiara Grouwstra --- panel/nix/tests.nix | 2 +- panel/src/panel/templates/base.html | 2 +- panel/src/panel/tests/__init__.py | 0 panel/src/panel/tests/test_user_stories.py | 104 +++++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 panel/src/panel/tests/__init__.py create mode 100644 panel/src/panel/tests/test_user_stories.py diff --git a/panel/nix/tests.nix b/panel/nix/tests.nix index eaa90681..a8259384 100644 --- a/panel/nix/tests.nix +++ b/panel/nix/tests.nix @@ -26,7 +26,7 @@ lib.mapAttrs (name: test: pkgs.testers.runNixOSTest (test // { inherit name; })) # run all application-level tests managed by Django # https://docs.djangoproject.com/en/5.0/topics/testing/overview/ testScript = '' - server.succeed("manage test") + server.succeed("manage test ${name}") ''; }; admin = { diff --git a/panel/src/panel/templates/base.html b/panel/src/panel/templates/base.html index 7f8b2e18..aa29745a 100644 --- a/panel/src/panel/templates/base.html +++ b/panel/src/panel/templates/base.html @@ -30,7 +30,7 @@ {% load custom_tags %}
  • {% if user.is_authenticated %} - Welcome, {{ user.username }}! Logout + Welcome, {{ user.username }}! Logout {% else %} Login {% endif %} diff --git a/panel/src/panel/tests/__init__.py b/panel/src/panel/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/panel/src/panel/tests/test_user_stories.py b/panel/src/panel/tests/test_user_stories.py new file mode 100644 index 00000000..8f45ae4c --- /dev/null +++ b/panel/src/panel/tests/test_user_stories.py @@ -0,0 +1,104 @@ +from django.test import TestCase +from django.urls import reverse +from django.contrib.auth.models import User +from django.template import Template, Context +from urllib.parse import unquote + +class Login(TestCase): + def setUp(self): + self.username = 'testuser' + self.password = 'securepassword123' + self.user = User.objects.create_user( + username=self.username, + email='test@example.com', + password=self.password + ) + + self.login = reverse('login') + self.logout = reverse('logout') + self.required_login = reverse('account_detail') + self.optional_login = reverse('service_list') + + def test_optional_login_redirects_back_to_original_page(self): + # go to a view where authentication is optional + response = self.client.get(self.optional_login) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.context['user'].is_authenticated) + + # check that the expected login URL is in the response + context = response.context[0] + template = Template("{% load custom_tags %}{% auth_url 'login' %}") + login_url = template.render(context) + self.assertIn(login_url, response.content.decode('utf-8')) + + # log in + response = self.client.get(login_url) + self.assertEqual(response.status_code, 200) + + login_data = { + 'username': self.username, + 'password': self.password, + } + response = self.client.post(login_url, login_data, follow=True) + + # check that we're back at the desired view and authenticated + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context['user'].is_authenticated) + location, status = response.redirect_chain[-1] + self.assertEqual(location, self.optional_login) + + # check that the expected logout URL is present + context = response.context[0] + template = Template("{% load custom_tags %}{% auth_url 'logout' %}") + logout_url = template.render(context) + self.assertIn(logout_url, response.content.decode('utf-8')) + + # log out again + response = self.client.get(logout_url, follow=True) + + # check that we're back at the view and logged out + self.assertEqual(response.status_code, 200) + location, status = response.redirect_chain[-1] + self.assertEqual(location, self.optional_login) + self.assertFalse(response.context['user'].is_authenticated) + + def test_required_login_redirects_back_login(self): + # go to a view that requires authentication + response = self.client.get(self.required_login) + + # check that we're redirected to the login view + self.assertEqual(response.status_code, 302) + redirect = response.url + self.assertTrue(redirect.startswith(self.login)) + + # log in + response = self.client.get(redirect) + self.assertEqual(response.status_code, 200) + login_data = { + 'username': self.username, + 'password': self.password, + } + response = self.client.post(redirect, login_data, follow=True) + + # check that we reached the desired view, authenticated + self.assertEqual(response.status_code, 200) + location, status = response.redirect_chain[-1] + self.assertEqual(location, self.required_login) + self.assertTrue(response.context['user'].is_authenticated) + + # check that the expected logout URL is present + context = response.context[0] + template = Template("{% load custom_tags %}{% auth_url 'logout' %}") + logout_url = template.render(context) + self.assertIn(logout_url, response.content.decode('utf-8')) + + # log out + response = self.client.get(logout_url, follow=True) + + # check that we're at the expected location, logged out + self.assertEqual(response.status_code, 200) + template = Template("{% load custom_tags %}{% auth_url 'login' %}") + login_url = template.render(context) + location, status = response.redirect_chain[-1] + self.assertEqual(location, unquote(login_url)) + self.assertFalse(response.context['user'].is_authenticated) From 10f3d15a98c5e4c7d1604aa501f4b4a5560d674c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=E2=80=9CNiols=E2=80=9D=20Jeannerod?= Date: Wed, 19 Feb 2025 18:34:19 +0100 Subject: [PATCH 03/10] website: format --- website/content/default.nix | 252 ++-- website/content/events.nix | 40 +- website/content/events/2024-11-zurich-zhf.nix | 36 +- .../events/owc-annual-conference-2024.nix | 42 +- .../events/publicspaces-conference-2024.nix | 32 +- .../events/waag-state-internet-2024.nix | 40 +- website/content/navigation.nix | 27 +- website/content/news.nix | 32 +- website/content/news/2024-11-zurich-zhf.nix | 36 +- website/content/news/project-launch.nix | 30 +- website/default.nix | 36 +- website/lib.nix | 171 +-- website/presentation/default.nix | 101 +- website/presentation/dom.nix | 1179 +++++++++-------- website/presentation/style.nix | 64 +- website/presentation/templates.nix | 85 +- website/structure/article.nix | 97 +- website/structure/assets.nix | 34 +- website/structure/collections.nix | 100 +- website/structure/default.nix | 135 +- website/structure/event.nix | 155 ++- website/structure/navigation.nix | 122 +- website/structure/page.nix | 58 +- website/tests.nix | 33 +- 24 files changed, 1679 insertions(+), 1258 deletions(-) diff --git a/website/content/default.nix b/website/content/default.nix index daccf448..f1d19ea7 100644 --- a/website/content/default.nix +++ b/website/content/default.nix @@ -9,124 +9,160 @@ in collections.news.type = cfg.content-types.article; collections.events.type = cfg.content-types.event; - pages.index = { config, link, ... }: { - title = "Welcome to the Fediversity project"; - description = "Fediversity web site"; - summary = '' - This web site hosts up-to-date information about the the NGI Zero Fediversity project. - ''; - body = '' - ${pages.fediversity.summary} + pages.index = + { config, link, ... }: + { + title = "Welcome to the Fediversity project"; + description = "Fediversity web site"; + summary = '' + This web site hosts up-to-date information about the the NGI Zero Fediversity project. + ''; + body = '' + ${pages.fediversity.summary} - [Learn more about Fediversity](${link pages.fediversity}) - ''; - outputs.html = (cfg.templates.html.page config).override (final: prev: { - html = { - head.title.text = "Fediversity"; - head.link.stylesheets = prev.html.head.link.stylesheets ++ [ - { href = "${link cfg.assets."index.css"}"; } - ]; - body.content = - let - to-section = { heading, body, attrs ? { } }: { - section = { - heading.content = heading; - inherit attrs; - content = [ - (cfg.templates.html.markdown { - name = "${config.name}-${lib.slug heading}"; - inherit body; - }) - ]; - }; - }; - in - [ - (lib.head prev.html.body.content) - { - section = { - attrs = { }; - heading.content = config.title; - content = [ - (cfg.templates.html.markdown { inherit (config) name body; }) - ] - ++ - (map to-section [ + [Learn more about Fediversity](${link pages.fediversity}) + ''; + outputs.html = (cfg.templates.html.page config).override ( + final: prev: { + html = { + head.title.text = "Fediversity"; + head.link.stylesheets = prev.html.head.link.stylesheets ++ [ + { href = "${link cfg.assets."index.css"}"; } + ]; + body.content = + let + to-section = { - heading = "Fediversity grants"; - body = '' - ${pages.grants.summary} - - [Learn more about Fediversity grants](${link pages.grants}) - ''; - } + heading, + body, + attrs ? { }, + }: { - heading = "Consortium"; - body = '' - The Consortium behind the Fediversity project is a cooperation between NLnet, Open Internet Discourse Foundation, NORDUnet and Tweag. + section = { + heading.content = heading; + inherit attrs; + content = [ + (cfg.templates.html.markdown { + name = "${config.name}-${lib.slug heading}"; + inherit body; + }) + ]; + }; + }; + in + [ + (lib.head prev.html.body.content) + { + section = { + attrs = { }; + heading.content = config.title; + content = + [ + (cfg.templates.html.markdown { inherit (config) name body; }) + ] + ++ (map to-section [ + { + heading = "Fediversity grants"; + body = '' + ${pages.grants.summary} - ${toString (map (partner: '' - ### ${partner.title} + [Learn more about Fediversity grants](${link pages.grants}) + ''; + } + { + heading = "Consortium"; + body = '' + The Consortium behind the Fediversity project is a cooperation between NLnet, Open Internet Discourse Foundation, NORDUnet and Tweag. - ${partner.summary} + ${toString ( + map + (partner: '' + ### ${partner.title} - [Read more about ${partner.title}](${link partner}) - '') (with pages; [ nlnet oid tweag nordunet ]))} - ''; - } - { - heading = "Fediverse explained"; - body = '' - ${toString (map (role: '' - ### ${role.title} + ${partner.summary} - ${role.summary} + [Read more about ${partner.title}](${link partner}) + '') + ( + with pages; + [ + nlnet + oid + tweag + nordunet + ] + ) + )} + ''; + } + { + heading = "Fediverse explained"; + body = '' + ${toString ( + map + (role: '' + ### ${role.title} - [Read more about ${role.title}](${link role}) - '') (with pages; [ individuals developers european-commission ]))} - ''; - } - ]); - }; - } - ] - ++ - (map to-section [ - { - heading = "News"; - attrs = { class = [ "collection" ]; }; - body = - let - sorted = with lib; reverseList (sortOn (entry: entry.date) cfg.collections.news.entry); - in - lib.join "\n" (map - (article: '' - - ${article.date} [${article.title}](${link article}) - '') - sorted); - } - { - heading = "Events"; - attrs = { class = [ "collection" ]; }; - body = - let - sorted = with lib; reverseList (sortOn (entry: entry.start-date) cfg.collections.events.entry); - in - lib.join "\n" (map - (article: '' - - ${article.start-date} [${article.title}](${link article}) - '') - sorted); - } - ]); - }; + ${role.summary} - }); - }; + [Read more about ${role.title}](${link role}) + '') + ( + with pages; + [ + individuals + developers + european-commission + ] + ) + )} + ''; + } + ]); + }; + } + ] + ++ (map to-section [ + { + heading = "News"; + attrs = { + class = [ "collection" ]; + }; + body = + let + sorted = with lib; reverseList (sortOn (entry: entry.date) cfg.collections.news.entry); + in + lib.join "\n" ( + map (article: '' + - ${article.date} [${article.title}](${link article}) + '') sorted + ); + } + { + heading = "Events"; + attrs = { + class = [ "collection" ]; + }; + body = + let + sorted = with lib; reverseList (sortOn (entry: entry.start-date) cfg.collections.events.entry); + in + lib.join "\n" ( + map (article: '' + - ${article.start-date} [${article.title}](${link article}) + '') sorted + ); + } + ]); + }; - assets."index.css".path = with lib; builtins.toFile - "index.css" - '' + } + ); + }; + + assets."index.css".path = + with lib; + builtins.toFile "index.css" '' section h1, section h2, section h3 { text-align: center; diff --git a/website/content/events.nix b/website/content/events.nix index 450c50ff..6d92ffb5 100644 --- a/website/content/events.nix +++ b/website/content/events.nix @@ -1,22 +1,26 @@ { config, lib, ... }: { - pages.events = { link, ... }: rec { - title = "Events"; - description = "Events related to the Fediverse and NixOS"; - summary = description; - body = - with lib; - let - events = map - (event: with lib; '' - ## [${event.title}](${link event}) + pages.events = + { link, ... }: + rec { + title = "Events"; + description = "Events related to the Fediverse and NixOS"; + summary = description; + body = + with lib; + let + events = map ( + event: with lib; '' + ## [${event.title}](${link event}) - ${event.start-date} ${optionalString (!isNull event.end-date && event.end-date != event.start-date) "to ${event.end-date}"} in ${event.location} - '') - config.collections.events.entry; - in - '' - ${join "\n" events} - ''; - }; + ${event.start-date} ${ + optionalString (!isNull event.end-date && event.end-date != event.start-date) "to ${event.end-date}" + } in ${event.location} + '' + ) config.collections.events.entry; + in + '' + ${join "\n" events} + ''; + }; } diff --git a/website/content/events/2024-11-zurich-zhf.nix b/website/content/events/2024-11-zurich-zhf.nix index da7f225a..ab0e5672 100644 --- a/website/content/events/2024-11-zurich-zhf.nix +++ b/website/content/events/2024-11-zurich-zhf.nix @@ -1,23 +1,25 @@ { config, lib, ... }: { - collections.events.entry = { link, ... }: { - title = "NixOS 24.11 ZHF hackathon"; - name = "zhf-24-11"; - description = "NixOS 24.11 ZHF hackathon in Zürich"; - start-date = "2024-11-23"; - end-date = "2024-11-24"; - start-time = "10:00"; - end-time = "17:00"; - location = "OST Campus Rapperswil"; - body = '' - The biannual [Zürich NixOS ZHF hackathon](https://zurich.nix.ug/) has become somewhat of an institution for maintaining the tradition of preparing the upcoming NixOS release. + collections.events.entry = + { link, ... }: + { + title = "NixOS 24.11 ZHF hackathon"; + name = "zhf-24-11"; + description = "NixOS 24.11 ZHF hackathon in Zürich"; + start-date = "2024-11-23"; + end-date = "2024-11-24"; + start-time = "10:00"; + end-time = "17:00"; + location = "OST Campus Rapperswil"; + body = '' + The biannual [Zürich NixOS ZHF hackathon](https://zurich.nix.ug/) has become somewhat of an institution for maintaining the tradition of preparing the upcoming NixOS release. - The main goal of the two-day gathering is to bring down the number of build failures on the [continuous integration system Hydra](https://status.nixos.org/) before the release: ZHF stands for *Zero Hydra Failures*. - It also presents a great opportunity to learn Nix, squash bugs together, get to know each other, discuss current events, and make plans for the future. + The main goal of the two-day gathering is to bring down the number of build failures on the [continuous integration system Hydra](https://status.nixos.org/) before the release: ZHF stands for *Zero Hydra Failures*. + It also presents a great opportunity to learn Nix, squash bugs together, get to know each other, discuss current events, and make plans for the future. - This is the greatest event in the series so far, with more than 40 participants from all over Europe, including many high-profile contributors and maintainers in the Nix ecosystem. + This is the greatest event in the series so far, with more than 40 participants from all over Europe, including many high-profile contributors and maintainers in the Nix ecosystem. - [Fediversity engineers attended](${link config.collections.news.by-name.zhf-24-11}) to present prototypes and exchange ideas with other developers. - ''; - }; + [Fediversity engineers attended](${link config.collections.news.by-name.zhf-24-11}) to present prototypes and exchange ideas with other developers. + ''; + }; } diff --git a/website/content/events/owc-annual-conference-2024.nix b/website/content/events/owc-annual-conference-2024.nix index 39d495f1..f44d5208 100644 --- a/website/content/events/owc-annual-conference-2024.nix +++ b/website/content/events/owc-annual-conference-2024.nix @@ -1,24 +1,26 @@ { ... }: { - collections.events.entry = { ... }: { - title = "OW2con 2024"; - description = "OW2con is the annual European open source conference in Paris"; - start-date = "2024-06-11"; - end-date = "2024-06-12"; - start-time = "09:00"; - end-time = "18:00"; - location = "Paris-Chatillon"; - body = '' - OW2con is the European open source conference organized by OW2. - An international meeting of developpers, IT companies, academics and non-profit organizations, OW2con brings together the entire open source community, during two days of presentations ranging from tech topics to business and ethical issues of open source. - It also offers a unique opportunity to establish contact with peers through friendly networking sessions. - OW2con is [open](https://www.ngi.eu/event/open-source-community-annual-conference-2024/) to all, the event is free and all sessions are held in English. + collections.events.entry = + { ... }: + { + title = "OW2con 2024"; + description = "OW2con is the annual European open source conference in Paris"; + start-date = "2024-06-11"; + end-date = "2024-06-12"; + start-time = "09:00"; + end-time = "18:00"; + location = "Paris-Chatillon"; + body = '' + OW2con is the European open source conference organized by OW2. + An international meeting of developpers, IT companies, academics and non-profit organizations, OW2con brings together the entire open source community, during two days of presentations ranging from tech topics to business and ethical issues of open source. + It also offers a unique opportunity to establish contact with peers through friendly networking sessions. + OW2con is [open](https://www.ngi.eu/event/open-source-community-annual-conference-2024/) to all, the event is free and all sessions are held in English. - The OW2con’24 call for presentations is open. - This year we are giving the highlight on the theme of open source funding: - What are the current solutions for innovators, start-ups or ISVs to finance their development? - Private or public financing? - Are national and European public policies up to the challenges? - ''; - }; + The OW2con’24 call for presentations is open. + This year we are giving the highlight on the theme of open source funding: + What are the current solutions for innovators, start-ups or ISVs to finance their development? + Private or public financing? + Are national and European public policies up to the challenges? + ''; + }; } diff --git a/website/content/events/publicspaces-conference-2024.nix b/website/content/events/publicspaces-conference-2024.nix index 35fbe7dd..3f2af58a 100644 --- a/website/content/events/publicspaces-conference-2024.nix +++ b/website/content/events/publicspaces-conference-2024.nix @@ -1,18 +1,20 @@ { ... }: { - collections.events.entry = { ... }: { - title = "PublicSpaces Conference 2024"; - description = "A conference by PublicSpaces, Taking Back the Internet."; - start-date = "2024-06-06"; - end-date = "2024-06-07"; - start-time = "09:00"; - end-time = "18:00"; - location = "Pakhuis de Zwijger - Amsterdam"; - body = '' - On June 6th and 7th, PublicSpaces and Waag Futurelab proudly present the fourth edition of the PublicSpaces conference under the theme 'Empowering the Internet'. - Held at Pakhuis de Zwijger, this two-day event will feature panels, keynotes, roundtable discussions, lectures, as well as art and cultural showcases, all aimed at collectively shaping the rules for a more inclusive internet. - Join us as we navigate towards a digital landscape where everyone has a voice. - For more information, check out the [website](https://publicspaces.net/2024/02/01/save-the-date-publicspaces-conferentie-2024/) - ''; - }; + collections.events.entry = + { ... }: + { + title = "PublicSpaces Conference 2024"; + description = "A conference by PublicSpaces, Taking Back the Internet."; + start-date = "2024-06-06"; + end-date = "2024-06-07"; + start-time = "09:00"; + end-time = "18:00"; + location = "Pakhuis de Zwijger - Amsterdam"; + body = '' + On June 6th and 7th, PublicSpaces and Waag Futurelab proudly present the fourth edition of the PublicSpaces conference under the theme 'Empowering the Internet'. + Held at Pakhuis de Zwijger, this two-day event will feature panels, keynotes, roundtable discussions, lectures, as well as art and cultural showcases, all aimed at collectively shaping the rules for a more inclusive internet. + Join us as we navigate towards a digital landscape where everyone has a voice. + For more information, check out the [website](https://publicspaces.net/2024/02/01/save-the-date-publicspaces-conferentie-2024/) + ''; + }; } diff --git a/website/content/events/waag-state-internet-2024.nix b/website/content/events/waag-state-internet-2024.nix index a4608bdb..0c6d607b 100644 --- a/website/content/events/waag-state-internet-2024.nix +++ b/website/content/events/waag-state-internet-2024.nix @@ -1,25 +1,27 @@ { ... }: { - collections.events.entry = { ... }: { - title = "State of the Internet 2024"; - description = "The State of the Internet 2024 by Waag"; - start-date = "2024-05-16"; - end-date = "2024-05-16"; - start-time = "18:00"; - end-time = "20:00"; - location = "OBA Oosterdok - Amsterdam"; - body = '' - Join us at the State of the Internet 2024, where Waag Futurelab, alongside the Municipality of Amsterdam and the OBA, delves into the depths of the online realm. - Featuring Kim van Sparrentak, Member of the European Parliament, discussing Europe's efforts to regulate Big Tech and enhance digital rights. - Explore the impact of pivotal European laws like the GDPR and AI Act while celebrating 30 years of Waag Futurelab's dedication to democratizing technology access for all. + collections.events.entry = + { ... }: + { + title = "State of the Internet 2024"; + description = "The State of the Internet 2024 by Waag"; + start-date = "2024-05-16"; + end-date = "2024-05-16"; + start-time = "18:00"; + end-time = "20:00"; + location = "OBA Oosterdok - Amsterdam"; + body = '' + Join us at the State of the Internet 2024, where Waag Futurelab, alongside the Municipality of Amsterdam and the OBA, delves into the depths of the online realm. + Featuring Kim van Sparrentak, Member of the European Parliament, discussing Europe's efforts to regulate Big Tech and enhance digital rights. + Explore the impact of pivotal European laws like the GDPR and AI Act while celebrating 30 years of Waag Futurelab's dedication to democratizing technology access for all. - The event takes place at: + The event takes place at: - OBA Oosterdok
    - Oosterdokskade 143
    - 1011 DK Amsterdam + OBA Oosterdok
    + Oosterdokskade 143
    + 1011 DK Amsterdam - Registration available [here](https://waag.org/nl/event/de-staat-van-het-internet-2024-met-kim-van-sparrentak/). - ''; - }; + Registration available [here](https://waag.org/nl/event/de-staat-van-het-internet-2024-met-kim-van-sparrentak/). + ''; + }; } diff --git a/website/content/navigation.nix b/website/content/navigation.nix index 770fe29f..2beb657d 100644 --- a/website/content/navigation.nix +++ b/website/content/navigation.nix @@ -6,16 +6,33 @@ in menus.main = { label = "Main"; items = [ - { page = pages.index // { title = "Start"; }; } + { + page = pages.index // { + title = "Start"; + }; + } { menu.label = "For you"; - menu.items = map (page: { inherit page; }) - (with pages; [ individuals developers european-commission ]); + menu.items = map (page: { inherit page; }) ( + with pages; + [ + individuals + developers + european-commission + ] + ); } { menu.label = "Consortium"; - menu.items = map (page: { inherit page; }) - (with pages; [ nlnet oid tweag nordunet ]); + menu.items = map (page: { inherit page; }) ( + with pages; + [ + nlnet + oid + tweag + nordunet + ] + ); } { page = pages.fediversity; } { page = pages.grants; } diff --git a/website/content/news.nix b/website/content/news.nix index b689cf60..96d7c160 100644 --- a/website/content/news.nix +++ b/website/content/news.nix @@ -1,24 +1,24 @@ { config, lib, ... }: { - pages.news = { link, ... }: rec { - title = "News"; - description = "News about Fediversity"; - summary = description; - body = - with lib; - let - news = map - (article: '' + pages.news = + { link, ... }: + rec { + title = "News"; + description = "News about Fediversity"; + summary = description; + body = + with lib; + let + news = map (article: '' ## [${article.title}](${link article}) ${article.date} by ${article.author} ${article.summary} - '') - config.collections.news.entry; - in - '' - ${join "\n\n" news} - ''; - }; + '') config.collections.news.entry; + in + '' + ${join "\n\n" news} + ''; + }; } diff --git a/website/content/news/2024-11-zurich-zhf.nix b/website/content/news/2024-11-zurich-zhf.nix index 08d12027..84a8a05d 100644 --- a/website/content/news/2024-11-zurich-zhf.nix +++ b/website/content/news/2024-11-zurich-zhf.nix @@ -1,22 +1,24 @@ { config, lib, ... }: { - collections.news.entry = { link, ... }: rec { - name = "zhf-24-11"; - title = "NixOS 24.11 release hackathon and workshop"; - description = "Fediversity engineers met in Zürich at a NixOS 24.11 ZHF hackathon"; - date = "2024-11-28"; - author = "Valentin Gagarin"; - summary = '' - Fediversity engineers met in Zürich at a [NixOS 24.11 ZHF hackathon](${link config.collections.events.by-name.zhf-24-11}) to present prototypes and exchange ideas with the Nix community. - ''; - body = '' - ${summary} + collections.news.entry = + { link, ... }: + rec { + name = "zhf-24-11"; + title = "NixOS 24.11 release hackathon and workshop"; + description = "Fediversity engineers met in Zürich at a NixOS 24.11 ZHF hackathon"; + date = "2024-11-28"; + author = "Valentin Gagarin"; + summary = '' + Fediversity engineers met in Zürich at a [NixOS 24.11 ZHF hackathon](${link config.collections.events.by-name.zhf-24-11}) to present prototypes and exchange ideas with the Nix community. + ''; + body = '' + ${summary} - Robert held a lightning talk on the design of [NixOps4](https://github.com/nixops4/nixops4), which is currently in the prototype stage of development. - Before that, Nicolas had already shown an internal demonstration that NixOps4 is capable of deploying multiple NixOS services in the Fediversity test environment. + Robert held a lightning talk on the design of [NixOps4](https://github.com/nixops4/nixops4), which is currently in the prototype stage of development. + Before that, Nicolas had already shown an internal demonstration that NixOps4 is capable of deploying multiple NixOS services in the Fediversity test environment. - In the afternoon, Robert, Valentin, and Koen got together with Eli from [Thymis](https://thymis.io) and Johannes from [Clan](https://clan.lol/) to walk each other through the architecture of their respective systems. - This was an extraordinarily fruitful encounter that helped us to identify overlaps and potential for future collaboration! - ''; - }; + In the afternoon, Robert, Valentin, and Koen got together with Eli from [Thymis](https://thymis.io) and Johannes from [Clan](https://clan.lol/) to walk each other through the architecture of their respective systems. + This was an extraordinarily fruitful encounter that helped us to identify overlaps and potential for future collaboration! + ''; + }; } diff --git a/website/content/news/project-launch.nix b/website/content/news/project-launch.nix index 63472b65..30a773c7 100644 --- a/website/content/news/project-launch.nix +++ b/website/content/news/project-launch.nix @@ -1,19 +1,21 @@ { config, lib, ... }: { - collections.news.entry = { link, ... }: { - title = "Fediversity project publicly announced"; - description = "The Fediversity project has officially been announced"; - date = "2024-01-01"; - author = "Laurens Hof"; - summary = '' - We are pleased to introduce the launch of our new website dedicated to the Fediversity project. - ''; - body = '' - The Consortium behind the Fediversity project announces that the project has officially been started. NLnet, Tweag, NorduNet and the Open Internet Discourse Foundation are working together to build a new service for cloud hosters. + collections.news.entry = + { link, ... }: + { + title = "Fediversity project publicly announced"; + description = "The Fediversity project has officially been announced"; + date = "2024-01-01"; + author = "Laurens Hof"; + summary = '' + We are pleased to introduce the launch of our new website dedicated to the Fediversity project. + ''; + body = '' + The Consortium behind the Fediversity project announces that the project has officially been started. NLnet, Tweag, NorduNet and the Open Internet Discourse Foundation are working together to build a new service for cloud hosters. - Fediversity is a comprehensive effort to bring easy-to-use, hosted cloud services with service portability and personal freedom at their core to everyone. It wants to provide everyone with high-quality, secure IT systems for everyday use. Without tracking, without exploitation, in a way that runs everywhere and scales effortlessly. Fediversity is based on NixOS, a disruptive Linux distribution with a unique approach to package and configuration management. Built on top of the Nix package manager, NixOS is completely declarative, makes upgrading systems reliable, and has many other advantages. Because it is reproducible, it is ideally suited for complex deployment scenario's where consistent behaviour, stability and configurability matter. + Fediversity is a comprehensive effort to bring easy-to-use, hosted cloud services with service portability and personal freedom at their core to everyone. It wants to provide everyone with high-quality, secure IT systems for everyday use. Without tracking, without exploitation, in a way that runs everywhere and scales effortlessly. Fediversity is based on NixOS, a disruptive Linux distribution with a unique approach to package and configuration management. Built on top of the Nix package manager, NixOS is completely declarative, makes upgrading systems reliable, and has many other advantages. Because it is reproducible, it is ideally suited for complex deployment scenario's where consistent behaviour, stability and configurability matter. - Fediversity has received funding from the European Union’s Horizon Europe research and innovation programme under grant agreement No. 101136078. - ''; - }; + Fediversity has received funding from the European Union’s Horizon Europe research and innovation programme under grant agreement No. 101136078. + ''; + }; } diff --git a/website/default.nix b/website/default.nix index f68ec041..258d1ffb 100644 --- a/website/default.nix +++ b/website/default.nix @@ -1,14 +1,16 @@ -{ sources ? import ../npins -, system ? builtins.currentSystem -, pkgs ? import sources.nixpkgs { +{ + sources ? import ../npins, + system ? builtins.currentSystem, + pkgs ? import sources.nixpkgs { inherit system; config = { }; overlays = [ ]; - } -, lib ? import "${sources.nixpkgs}/lib" + }, + lib ? import "${sources.nixpkgs}/lib", }: let - lib' = final: prev: + lib' = + final: prev: let new = import ./lib.nix { lib = final; }; in @@ -37,15 +39,21 @@ rec { let run-tests = pkgs.writeShellApplication { name = "run-tests"; - text = with pkgs; with lib; '' - ${getExe nix-unit} ${toString ./tests.nix} "$@" - ''; + text = + with pkgs; + with lib; + '' + ${getExe nix-unit} ${toString ./tests.nix} "$@" + ''; }; test-loop = pkgs.writeShellApplication { name = "test-loop"; - text = with pkgs; with lib; '' - ${getExe watchexec} -w ${toString ./.} -- ${getExe nix-unit} ${toString ./tests.nix} - ''; + text = + with pkgs; + with lib; + '' + ${getExe watchexec} -w ${toString ./.} -- ${getExe nix-unit} ${toString ./tests.nix} + ''; }; devmode = pkgs.devmode.override { buildArgs = "${toString ./.} -A build --show-trace"; @@ -62,7 +70,9 @@ rec { }; inherit sources pkgs; - tests = with pkgs; with lib; + tests = + with pkgs; + with lib; let source = fileset.toSource { root = ../.; diff --git a/website/lib.nix b/website/lib.nix index 148c1dad..f18adf2e 100644 --- a/website/lib.nix +++ b/website/lib.nix @@ -1,21 +1,25 @@ { lib }: rec { - template = g: f: x: + template = + g: f: x: let base = f x; result = g base; in - result // { - override = new: + result + // { + override = + new: let base' = - if lib.isFunction new - then lib.recursiveUpdate base (new base' base) + if lib.isFunction new then + lib.recursiveUpdate base (new base' base) else lib.recursiveUpdate base new; result' = g base'; in - result' // { + result' + // { override = new: (template g (x': base') x).override new; }; }; @@ -28,7 +32,8 @@ rec { replaceStringRec "--" "-" "hello-----world" => "hello-world" */ - replaceStringsRec = from: to: string: + replaceStringsRec = + from: to: string: let replaced = lib.replaceStrings [ from ] [ to ] string; in @@ -37,25 +42,24 @@ rec { /** Create a URL-safe slug from any string */ - slug = str: + slug = + str: let # Replace non-alphanumeric characters with hyphens - replaced = join "" - ( - builtins.map - (c: - if (c >= "a" && c <= "z") || (c >= "0" && c <= "9") - then c - else "-" - ) - (with lib; stringToCharacters (toLower str))); + replaced = join "" ( + builtins.map (c: if (c >= "a" && c <= "z") || (c >= "0" && c <= "9") then c else "-") ( + with lib; stringToCharacters (toLower str) + ) + ); # Remove leading and trailing hyphens - trimHyphens = s: + trimHyphens = + s: let matched = builtins.match "(-*)([^-].*[^-]|[^-])(-*)" s; in - with lib; optionalString (!isNull matched) (builtins.elemAt matched 1); + with lib; + optionalString (!isNull matched) (builtins.elemAt matched 1); in trimHyphens (replaceStringsRec "--" "-" replaced); @@ -64,9 +68,11 @@ rec { /** Trim trailing spaces and squash non-leading spaces */ - trim = string: + trim = + string: let - trimLine = line: + trimLine = + line: with lib; let # separate leading spaces from the rest @@ -76,8 +82,7 @@ rec { # drop trailing spaces body = head (split " *$" rest); in - if body == "" then "" else - spaces + replaceStringsRec " " " " body; + if body == "" then "" else spaces + replaceStringsRec " " " " body; in join "\n" (map trimLine (splitLines string)); @@ -85,84 +90,95 @@ rec { splitLines = s: with builtins; filter (x: !isList x) (split "\n" s); - indent = prefix: s: + indent = + prefix: s: with lib.lists; let lines = splitLines s; in - join "\n" ( - [ (head lines) ] - ++ - (map (x: if x == "" then x else "${prefix}${x}") (tail lines)) - ); + join "\n" ([ (head lines) ] ++ (map (x: if x == "" then x else "${prefix}${x}") (tail lines))); - relativePath = path1': path2': + relativePath = + path1': path2': let inherit (lib.path) subpath; - inherit (lib) lists length take drop min max; + inherit (lib) + lists + length + take + drop + min + max + ; path1 = subpath.components path1'; prefix1 = take (length path1 - 1) path1; path2 = subpath.components path2'; prefix2 = take (length path2 - 1) path2; - commonPrefixLength = with lists; - findFirstIndex (i: i.fst != i.snd) - (min (length prefix1) (length prefix2)) - (zipLists prefix1 prefix2); + commonPrefixLength = + with lists; + findFirstIndex (i: i.fst != i.snd) (min (length prefix1) (length prefix2)) ( + zipLists prefix1 prefix2 + ); depth = max 0 (length prefix1 - commonPrefixLength); - relativeComponents = with lists; + relativeComponents = + with lists; [ "." ] ++ (replicate depth "..") ++ (drop commonPrefixLength path2); in join "/" relativeComponents; /** - Recursively list all Nix files from a directory, except the top-level `default.nix` + Recursively list all Nix files from a directory, except the top-level `default.nix` - Useful for module system `imports` from a top-level module. - **/ - nixFiles = dir: with lib.fileset; - toList (difference - (fileFilter ({ hasExt, ... }: hasExt "nix") dir) - (dir + "/default.nix") - ); + Useful for module system `imports` from a top-level module. + * + */ + nixFiles = + dir: + with lib.fileset; + toList (difference (fileFilter ({ hasExt, ... }: hasExt "nix") dir) (dir + "/default.nix")); types = rec { # arbitrarily nested attribute set where the leaves are of type `type` # NOTE: this works for anything but attribute sets! - recursiveAttrs = type: with lib.types; + recursiveAttrs = + type: + with lib.types; # NOTE: due to how `either` works, the first match is significant, # so if `type` happens to be an attrset, the typecheck will consider # `type`, not `attrsOf` attrsOf (either type (recursiveAttrs type)); # collection of unnamed items that can be added to item-wise, i.e. without wrapping the item in a list - collection = elemType: + collection = + elemType: let unparenthesize = class: class == "noun"; - desc = type: - types.optionDescriptionPhrase unparenthesize type; - desc' = type: + desc = type: types.optionDescriptionPhrase unparenthesize type; + desc' = + type: let typeDesc = lib.types.optionDescriptionPhrase unparenthesize type; in - if type.descriptionClass == "noun" - then - typeDesc + "s" - else - "many instances of ${typeDesc}"; + if type.descriptionClass == "noun" then typeDesc + "s" else "many instances of ${typeDesc}"; in lib.types.mkOptionType { name = "collection"; description = "separately specified ${desc elemType} for a collection of ${desc' elemType}"; - merge = loc: defs: - map - (def: - elemType.merge (loc ++ [ "[definition ${toString def.file}]" ]) [{ inherit (def) file; value = def.value; }] - ) - defs; + merge = + loc: defs: + map ( + def: + elemType.merge (loc ++ [ "[definition ${toString def.file}]" ]) [ + { + inherit (def) file; + value = def.value; + } + ] + ) defs; check = elemType.check; getSubOptions = elemType.getSubOptions; getSubModules = elemType.getSubModules; @@ -175,29 +191,34 @@ rec { nestedTypes.elemType = elemType; }; - listOfUnique = elemType: + listOfUnique = + elemType: let baseType = lib.types.listOf elemType; in - baseType // { - merge = loc: defs: + baseType + // { + merge = + loc: defs: let # Keep track of which definition each value came from - defsWithValues = map - (def: - map (v: { inherit (def) file; value = v; }) def.value - ) - defs; + defsWithValues = map ( + def: + map (v: { + inherit (def) file; + value = v; + }) def.value + ) defs; flatDefs = lib.flatten defsWithValues; # Check for duplicates while preserving source info - seen = builtins.foldl' - (acc: def: - if lib.lists.any (v: v.value == def.value) acc - then throw "The option `${lib.options.showOption loc}` has duplicate values (${toString def.value}) defined in ${def.file}" - else acc ++ [ def ] - ) [ ] - flatDefs; + seen = builtins.foldl' ( + acc: def: + if lib.lists.any (v: v.value == def.value) acc then + throw "The option `${lib.options.showOption loc}` has duplicate values (${toString def.value}) defined in ${def.file}" + else + acc ++ [ def ] + ) [ ] flatDefs; in map (def: def.value) seen; }; diff --git a/website/presentation/default.nix b/website/presentation/default.nix index a0d679e6..8a20230d 100644 --- a/website/presentation/default.nix +++ b/website/presentation/default.nix @@ -1,4 +1,10 @@ -{ config, options, lib, pkgs, ... }: +{ + config, + options, + lib, + pkgs, + ... +}: let inherit (lib) mkOption @@ -8,13 +14,12 @@ in { imports = lib.nixFiles ./.; - options.templates = - mkOption { - description = '' - Collection of named helper functions for conversion different structured representations which can be rendered to a string - ''; - type = with types; recursiveAttrs (functionTo (either str attrs)); - }; + options.templates = mkOption { + description = '' + Collection of named helper functions for conversion different structured representations which can be rendered to a string + ''; + type = with types; recursiveAttrs (functionTo (either str attrs)); + }; options.files = mkOption { description = '' @@ -32,58 +37,64 @@ in type = types.package; default = let - script = '' - mkdir $out - '' + lib.join "\n" copy; - copy = lib.mapAttrsToList - ( - path: file: '' - mkdir -p $out/$(dirname ${path}) - cp -r ${file} $out/${path} - '' - ) - config.files; + script = + '' + mkdir $out + '' + + lib.join "\n" copy; + copy = lib.mapAttrsToList (path: file: '' + mkdir -p $out/$(dirname ${path}) + cp -r ${file} $out/${path} + '') config.files; in pkgs.runCommand "source" { } script; }; # TODO: this is an artefact of exploration; needs to be adapted to actual use - config.templates.table-of-contents = { config, ... }: + config.templates.table-of-contents = + { config, ... }: let - outline = { ... }: { - options = { - value = mkOption { - # null denotes root - type = with types; nullOr (either str (listOf (attrTag categories.phrasing))); - subsections = mkOption { - type = with types; listOf (submodule outline); - default = with lib; map - # TODO: go into depth manually here, - # we don't want to pollute the DOM implementation - (c: (lib.head (attrValues c)).outline) - (filter (c: isAttrs c && (lib.head (attrValues c)) ? outline) config.content); + outline = + { ... }: + { + options = { + value = mkOption { + # null denotes root + type = with types; nullOr (either str (listOf (attrTag categories.phrasing))); + subsections = mkOption { + type = with types; listOf (submodule outline); + default = + with lib; + map + # TODO: go into depth manually here, + # we don't want to pollute the DOM implementation + (c: (lib.head (attrValues c)).outline) + (filter (c: isAttrs c && (lib.head (attrValues c)) ? outline) config.content); + }; + }; + __toString = mkOption { + type = with types; functionTo str; + # TODO: convert to HTML + default = + self: + lib.squash '' + ${if isNull self.value then "root" else self.value} + ${if self.subsections != [ ] then " " + lib.indent " " (lib.join "\n" self.subsections) else ""} + ''; }; }; - __toString = mkOption { - type = with types; functionTo str; - # TODO: convert to HTML - default = self: lib.squash '' - ${if isNull self.value then "root" else self.value} - ${if self.subsections != [] then - " " + lib.indent " " (lib.join "\n" self.subsections) else ""} - ''; - }; }; - }; in { options.outline = mkOption { type = types.submodule outline; default = { value = null; - subsections = with lib; - map (c: (lib.head (attrValues c)).outline) - (filter (c: isAttrs c && (lib.head (attrValues c)) ? outline) config.content); + subsections = + with lib; + map (c: (lib.head (attrValues c)).outline) ( + filter (c: isAttrs c && (lib.head (attrValues c)) ? outline) config.content + ); }; }; }; diff --git a/website/presentation/dom.nix b/website/presentation/dom.nix index 1c067804..0911a7a5 100644 --- a/website/presentation/dom.nix +++ b/website/presentation/dom.nix @@ -28,36 +28,46 @@ let ]; # base type for all DOM elements - element = { ... }: { - # TODO: add fields for upstream documentation references - # TODO: programmatically generate documentation - options = with lib; { - categories = mkOption { - type = types.listOfUnique (types.enum content-categories); - }; - __toString = mkOption { - internal = true; - type = with types; functionTo str; + element = + { ... }: + { + # TODO: add fields for upstream documentation references + # TODO: programmatically generate documentation + options = with lib; { + categories = mkOption { + type = types.listOfUnique (types.enum content-categories); + }; + __toString = mkOption { + internal = true; + type = with types; functionTo str; + }; }; }; - }; # options with types for all the defined DOM elements - element-types = lib.mapAttrs - (name: value: mkOption { type = submodule value; }) - elements; + element-types = lib.mapAttrs (name: value: mkOption { type = submodule value; }) elements; # attrset of categories, where values are module options with the type of the # elements that belong to these categories - categories = with lib; - genAttrs - content-categories - (category: - (mapAttrs (_: e: mkOption { type = submodule e; }) - # HACK: don't evaluate the submodule types, just grab the config directly - # TODO: we may want to do this properly and loop `categories` through the top-level `config` - (filterAttrs (_: e: elem category (e { name = "dummy"; config = { }; }).config.categories) elements)) - ); + categories = + with lib; + genAttrs content-categories ( + category: + (mapAttrs (_: e: mkOption { type = submodule e; }) + # HACK: don't evaluate the submodule types, just grab the config directly + # TODO: we may want to do this properly and loop `categories` through the top-level `config` + ( + filterAttrs ( + _: e: + elem category + (e { + name = "dummy"; + config = { }; + }).config.categories + ) elements + ) + ) + ); global-attrs = lib.mapAttrs (name: value: mkOption value) { class = { @@ -131,7 +141,8 @@ let # https://html.spec.whatwg.org/multipage/document-sequences.html#valid-navigable-target-name-or-keyword type = let - is-valid-target = s: + is-valid-target = + s: let inherit (lib) match; has-lt = s: match ".*<.*" s != null; @@ -140,14 +151,19 @@ let in has-valid-start s && !(has-lt s && has-tab-or-newline s); in - with types; either - (enum [ "_blank" "_self" "_parent" "_top" ]) - (types.addCheck str is-valid-target) - ; + with types; + either (enum [ + "_blank" + "_self" + "_parent" + "_top" + ]) (types.addCheck str is-valid-target); }; }; - mkAttrs = attrs: with lib; + mkAttrs = + attrs: + with lib; mkOption { type = submodule { options = global-attrs // attrs; @@ -155,28 +171,33 @@ let default = { }; }; - print-attrs = with lib; attrs: + print-attrs = + with lib; + attrs: # TODO: figure out how let attributes know how to print themselves without polluting the interface let - result = trim (join " " - (mapAttrsToList - # TODO: this needs to be smarter for boolean attributes - # where the value must be written out explicitly. - # probably the attribute itself should have its own `__toString`. - (name: value: - if isBool value then - if value then name else "" - # TODO: some attributes must be explicitly empty - else optionalString (toString value != "") ''${name}="${toString value}"'' - ) - attrs) + result = trim ( + join " " ( + mapAttrsToList + # TODO: this needs to be smarter for boolean attributes + # where the value must be written out explicitly. + # probably the attribute itself should have its own `__toString`. + ( + name: value: + if isBool value then + if value then name else "" + # TODO: some attributes must be explicitly empty + else + optionalString (toString value != "") ''${name}="${toString value}"'' + ) + attrs + ) ); in - if attrs == null then throw "wat" else - optionalString (stringLength result > 0) " " + result - ; + if attrs == null then throw "wat" else optionalString (stringLength result > 0) " " + result; - print-element = name: attrs: content: + print-element = + name: attrs: content: with lib; # TODO: be smarter about content to save some space and repetition at the call sites squash (trim '' @@ -187,472 +208,575 @@ let print-element' = name: attrs: "<${name}${print-attrs attrs}>"; - toString-unwrap = e: + toString-unwrap = + e: with lib; - if isAttrs e - then toString (head (attrValues e)) - else if isList e - then toString (map toString-unwrap e) - else e; + if isAttrs e then + toString (head (attrValues e)) + else if isList e then + toString (map toString-unwrap e) + else + e; elements = rec { - document = { ... }: { - imports = [ element ]; - options = { - inherit (element-types) html; - attrs = mkAttrs { }; - }; - - config.categories = [ ]; - config.__toString = self: '' - - ${self.html} - ''; - }; - - html = { name, ... }: { - imports = [ element ]; - options = { - attrs = mkAttrs { }; - inherit (element-types) head body; - }; - - config.categories = [ ]; - config.__toString = self: print-element name self.attrs '' - ${self.head} - ${self.body} - ''; - }; - - head = { name, ... }: { - imports = [ element ]; - options = with lib; { - attrs = mkAttrs { }; - # https://html.spec.whatwg.org/multipage/semantics.html#the-head-element:concept-element-content-model - # XXX: this doesn't implement the iframe srcdoc semantics - # as those have questionable value and would complicate things a bit. - # it should be possible though, by passing a flag via module arguments. - inherit (element-types) title; - base = mkOption { - type = with types; nullOr (submodule base); - default = null; - }; - # https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-charset - meta.charset = mkOption { - # TODO: create programmatically from https://encoding.spec.whatwg.org/encodings.json - type = types.enum [ - "utf-8" - ]; - default = "utf-8"; - }; - # https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag#viewport_width_and_screen_width - # this should not exist and no one should ever have to think about it - meta.viewport = mkOption { - type = submodule ({ ... }: { - # TODO: figure out how to render only non-default values - options = { - width = mkOption { - type = with types; either - (ints.between 1 10000) - (enum [ "device-width" ]); - default = "device-width"; # not default by standard - }; - height = mkOption { - type = with types; either - (ints.between 1 10000) - (enum [ "device-height" ]); - default = "device-height"; # not default by standard (but seems to work if you don't set it) - }; - initial-scale = mkOption { - type = types.numbers.between 0.1 10; - default = 1; - }; - minimum-scale = mkOption { - type = types.numbers.between 0.1 10; - # TODO: render only as many digits as needed - default = 0.1; - }; - maximum-scale = mkOption { - type = types.numbers.between 0.1 10; - default = 10; - }; - user-scalable = mkOption { - type = types.bool; - default = true; - }; - interactive-widget = mkOption { - type = types.enum [ - "resizes-visual" - "resizes-content" - "overlays-content" - ]; - default = "resizes-visual"; - }; - }; - }); - default = { }; + document = + { ... }: + { + imports = [ element ]; + options = { + inherit (element-types) html; + attrs = mkAttrs { }; }; - meta.authors = mkOption { - type = with types; listOf str; - default = [ ]; - }; - meta.description = mkOption { - type = with types; nullOr str; - default = null; - }; - # TODO: this one has more internal structure, e.g with hreflang - # TODO: print in output - link.canonical = mkOption { - type = with types; nullOr str; - default = null; - }; - link.stylesheets = mkOption { - type = types.listOf (submodule stylesheet); - default = [ ]; - }; - - # TODO: figure out `meta` elements - # https://html.spec.whatwg.org/multipage/semantics.html#the-meta-element:concept-element-attributes - # https://html.spec.whatwg.org/multipage/semantics.html#other-metadata-names - }; - - config.categories = [ ]; - config.__toString = self: - with lib; - print-element name self.attrs '' - ${self.title} - ${with lib; optionalString (!isNull self.base) self.base} - - - ${/* https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv-x-ua-compatible */ - ""} - - - - - - ${print-element' "meta" { - name = "viewport"; - content = "${join ", " (mapAttrsToList (name: value: "${name}=${toString value}") self.meta.viewport) }"; - }} - - ${join "\n" (map - (author: print-element' "meta" { - name = "author"; - content = "${author}"; - }) - self.meta.authors) - } - - ${join "\n" (map - (stylesheet: print-element' "link" ({ rel = "stylesheet"; } // (removeAttrs stylesheet [ "categories" "__toString" ]))) - self.link.stylesheets) - } + config.categories = [ ]; + config.__toString = self: '' + + ${self.html} ''; - }; - - title = { name, ... }: { - imports = [ element ]; - options.attrs = mkAttrs { }; - options.text = mkOption { - type = types.str; }; - config.categories = [ "metadata" ]; - config.__toString = self: "<${name}${print-attrs self.attrs}>${self.text}"; - }; - - base = { name, ... }: { - imports = [ element ]; - # TODO: "A base element must have either an href attribute, a target attribute, or both." - options = global-attrs // { inherit (attrs) href target; }; - config.categories = [ "metadata" ]; - config.__toString = self: ""; - }; - - link = { name, ... }: { - imports = [ element ]; - options = global-attrs // { - # TODO: more attributes - # https://html.spec.whatwg.org/multipage/semantics.html#the-link-element:concept-element-attributes - inherit (attrs) href; - # XXX: there are variants of `rel` for `link`, `a`/`area`, and `form` - rel = mkOption { - # https://html.spec.whatwg.org/multipage/semantics.html#attr-link-rel - type = with types; listOfUnique str (enum - # TODO: work out link types in detail, there are lots of additional constraints - # https://html.spec.whatwg.org/multipage/links.html#linkTypes - [ - "alternate" - "dns-prefetch" - "expect" - "help" - "icon" - "license" - "manifest" - "modulepreload" - "next" - "pingback" - "preconnect" - "prefetch" - "preload" - "prev" - "privacy-policy" - "search" - "terms-of-service" - ] - ); + html = + { name, ... }: + { + imports = [ element ]; + options = { + attrs = mkAttrs { }; + inherit (element-types) head body; }; + + config.categories = [ ]; + config.__toString = + self: + print-element name self.attrs '' + ${self.head} + ${self.body} + ''; + }; + + head = + { name, ... }: + { + imports = [ element ]; + options = with lib; { + attrs = mkAttrs { }; + # https://html.spec.whatwg.org/multipage/semantics.html#the-head-element:concept-element-content-model + # XXX: this doesn't implement the iframe srcdoc semantics + # as those have questionable value and would complicate things a bit. + # it should be possible though, by passing a flag via module arguments. + inherit (element-types) title; + base = mkOption { + type = with types; nullOr (submodule base); + default = null; + }; + # https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-charset + meta.charset = mkOption { + # TODO: create programmatically from https://encoding.spec.whatwg.org/encodings.json + type = types.enum [ + "utf-8" + ]; + default = "utf-8"; + }; + # https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag#viewport_width_and_screen_width + # this should not exist and no one should ever have to think about it + meta.viewport = mkOption { + type = submodule ( + { ... }: + { + # TODO: figure out how to render only non-default values + options = { + width = mkOption { + type = with types; either (ints.between 1 10000) (enum [ "device-width" ]); + default = "device-width"; # not default by standard + }; + height = mkOption { + type = with types; either (ints.between 1 10000) (enum [ "device-height" ]); + default = "device-height"; # not default by standard (but seems to work if you don't set it) + }; + initial-scale = mkOption { + type = types.numbers.between 0.1 10; + default = 1; + }; + minimum-scale = mkOption { + type = types.numbers.between 0.1 10; + # TODO: render only as many digits as needed + default = 0.1; + }; + maximum-scale = mkOption { + type = types.numbers.between 0.1 10; + default = 10; + }; + user-scalable = mkOption { + type = types.bool; + default = true; + }; + interactive-widget = mkOption { + type = types.enum [ + "resizes-visual" + "resizes-content" + "overlays-content" + ]; + default = "resizes-visual"; + }; + }; + } + ); + default = { }; + }; + + meta.authors = mkOption { + type = with types; listOf str; + default = [ ]; + }; + meta.description = mkOption { + type = with types; nullOr str; + default = null; + }; + # TODO: this one has more internal structure, e.g with hreflang + # TODO: print in output + link.canonical = mkOption { + type = with types; nullOr str; + default = null; + }; + link.stylesheets = mkOption { + type = types.listOf (submodule stylesheet); + default = [ ]; + }; + + # TODO: figure out `meta` elements + # https://html.spec.whatwg.org/multipage/semantics.html#the-meta-element:concept-element-attributes + # https://html.spec.whatwg.org/multipage/semantics.html#other-metadata-names + }; + + config.categories = [ ]; + config.__toString = + self: + with lib; + print-element name self.attrs '' + ${self.title} + ${with lib; optionalString (!isNull self.base) self.base} + + + ${ + # https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv-x-ua-compatible + "" + } + + + + + + ${print-element' "meta" { + name = "viewport"; + content = "${join ", " ( + mapAttrsToList (name: value: "${name}=${toString value}") self.meta.viewport + )}"; + }} + + ${join "\n" ( + map ( + author: + print-element' "meta" { + name = "author"; + content = "${author}"; + } + ) self.meta.authors + )} + + ${join "\n" ( + map ( + stylesheet: + print-element' "link" ( + { + rel = "stylesheet"; + } + // (removeAttrs stylesheet [ + "categories" + "__toString" + ]) + ) + ) self.link.stylesheets + )} + ''; + }; + + title = + { name, ... }: + { + imports = [ element ]; + options.attrs = mkAttrs { }; + options.text = mkOption { + type = types.str; + }; + config.categories = [ "metadata" ]; + config.__toString = self: "<${name}${print-attrs self.attrs}>${self.text}"; + + }; + + base = + { name, ... }: + { + imports = [ element ]; + # TODO: "A base element must have either an href attribute, a target attribute, or both." + options = global-attrs // { + inherit (attrs) href target; + }; + config.categories = [ "metadata" ]; + config.__toString = self: ""; + }; + + link = + { name, ... }: + { + imports = [ element ]; + options = global-attrs // { + # TODO: more attributes + # https://html.spec.whatwg.org/multipage/semantics.html#the-link-element:concept-element-attributes + inherit (attrs) href; + # XXX: there are variants of `rel` for `link`, `a`/`area`, and `form` + rel = mkOption { + # https://html.spec.whatwg.org/multipage/semantics.html#attr-link-rel + type = + with types; + listOfUnique str (enum + # TODO: work out link types in detail, there are lots of additional constraints + # https://html.spec.whatwg.org/multipage/links.html#linkTypes + [ + "alternate" + "dns-prefetch" + "expect" + "help" + "icon" + "license" + "manifest" + "modulepreload" + "next" + "pingback" + "preconnect" + "prefetch" + "preload" + "prev" + "privacy-policy" + "search" + "terms-of-service" + ]); + }; + }; + # TODO: figure out how to make body-ok `link` elements + # https://html.spec.whatwg.org/multipage/semantics.html#allowed-in-the-body + config.categories = [ "metadata" ]; + config.__toString = self: ""; }; - # TODO: figure out how to make body-ok `link` elements - # https://html.spec.whatwg.org/multipage/semantics.html#allowed-in-the-body - config.categories = [ "metadata" ]; - config.__toString = self: ""; - }; # is implemented separately because it can be used both in `` and `` # semantically it's a standalone thing but syntactically happens to be subsumed under `` - stylesheet = { config, name, ... }: { - imports = [ element ]; - options = global-attrs // { - type = mkOption { - # TODO: this must be a valid MIME type string, which is a bit involved. - # the syntax is explicated here: https://mimesniff.spec.whatwg.org/#mime-type-writing - # but the spec refers to RFC9110: https://www.rfc-editor.org/rfc/rfc9110#name-media-type - # all registered MIME types: https://www.iana.org/assignments/top-level-media-types/top-level-media-types.xhtml - # XXX: if nothing is specified, "text/css" is assumed. - # https://html.spec.whatwg.org/multipage/links.html#link-type-stylesheet:link-type-stylesheet-2 - # there's no specification on what else could be there, and it's questionable whether setting anything else even makes sense. - # in practice, browsers seem to ignore anything but "text/css", so we may as well not care at all. - type = with types; nullOr str; - default = null; + stylesheet = + { config, name, ... }: + { + imports = [ element ]; + options = global-attrs // { + type = mkOption { + # TODO: this must be a valid MIME type string, which is a bit involved. + # the syntax is explicated here: https://mimesniff.spec.whatwg.org/#mime-type-writing + # but the spec refers to RFC9110: https://www.rfc-editor.org/rfc/rfc9110#name-media-type + # all registered MIME types: https://www.iana.org/assignments/top-level-media-types/top-level-media-types.xhtml + # XXX: if nothing is specified, "text/css" is assumed. + # https://html.spec.whatwg.org/multipage/links.html#link-type-stylesheet:link-type-stylesheet-2 + # there's no specification on what else could be there, and it's questionable whether setting anything else even makes sense. + # in practice, browsers seem to ignore anything but "text/css", so we may as well not care at all. + type = with types; nullOr str; + default = null; + }; + # https://html.spec.whatwg.org/multipage/semantics.html#attr-link-disabled + disabled = mkOption { + type = types.bool; + default = false; + }; + # TODO: implement the rest of the stylesheet attributes + # https://html.spec.whatwg.org/#link-type-stylesheet + inherit (link-attrs) href media integrity; }; - # https://html.spec.whatwg.org/multipage/semantics.html#attr-link-disabled - disabled = mkOption { - type = types.bool; - default = false; - }; - # TODO: implement the rest of the stylesheet attributes - # https://html.spec.whatwg.org/#link-type-stylesheet - inherit (link-attrs) href media integrity; + # https://html.spec.whatwg.org/multipage/links.html#link-type-stylesheet:body-ok + config.categories = [ + "metadata" + "phrasing" + ]; + config.__toString = + self: + print-attrs ( + removeAttrs self [ + "categories" + "__toString" + ] + ); }; - # https://html.spec.whatwg.org/multipage/links.html#link-type-stylesheet:body-ok - config.categories = [ "metadata" "phrasing" ]; - config.__toString = self: print-attrs (removeAttrs self [ "categories" "__toString" ]); - }; - body = { config, name, ... }: { - imports = [ element ]; - options = { - attrs = mkAttrs { }; - content = mkOption { - type = with types; - let - # Type check that ensures spec-compliant section hierarchy - # https://html.spec.whatwg.org/multipage/sections.html#headings-and-outlines-2:concept-heading-7 - with-section-constraints = baseType: baseType // { - merge = loc: defs: - with lib; - let - find-and-attach = def: + body = + { config, name, ... }: + { + imports = [ element ]; + options = { + attrs = mkAttrs { }; + content = mkOption { + type = + with types; + let + # Type check that ensures spec-compliant section hierarchy + # https://html.spec.whatwg.org/multipage/sections.html#headings-and-outlines-2:concept-heading-7 + with-section-constraints = + baseType: + baseType + // { + merge = + loc: defs: + with lib; let - process-with-depth = depth: content: - map - (x: - if isAttrs x && x ? section - then x // { - section = x.section // { - heading-level = depth; - content = process-with-depth (depth + 1) (x.section.content or [ ]); - }; - } - else x - ) - content; - - find-with-depth = depth: content: + find-and-attach = + def: let - sections = map (v: { inherit (def) file; value = v; depth = depth; }) - (filter (x: isAttrs x && x ? section) content); - subsections = concatMap - (x: - if isAttrs x && x ? section && x.section ? content - then find-with-depth (depth + 1) x.section.content - else [ ]) - content; + process-with-depth = + depth: content: + map ( + x: + if isAttrs x && x ? section then + x + // { + section = x.section // { + heading-level = depth; + content = process-with-depth (depth + 1) (x.section.content or [ ]); + }; + } + else + x + ) content; + + find-with-depth = + depth: content: + let + sections = map (v: { + inherit (def) file; + value = v; + depth = depth; + }) (filter (x: isAttrs x && x ? section) content); + subsections = concatMap ( + x: + if isAttrs x && x ? section && x.section ? content then + find-with-depth (depth + 1) x.section.content + else + [ ] + ) content; + in + sections ++ subsections; + in - sections ++ subsections; + { + inherit def; + processed = process-with-depth 1 def.value; + validation = find-with-depth 1 def.value; + }; + processed = map find-and-attach defs; + all-sections = flatten (map (p: p.validation) processed); + too-deep = filter (sec: sec.depth > 6) all-sections; in - { - inherit def; - processed = process-with-depth 1 def.value; - validation = find-with-depth 1 def.value; - }; + if too-deep != [ ] then + throw '' + The option `${lib.options.showOption loc}` has sections nested too deeply: + ${concatMapStrings ( + sec: " - depth ${toString sec.depth} section in ${toString sec.file}\n" + ) too-deep} + Section hierarchy must not be deeper than 6 levels.'' + else + baseType.merge loc (map (p: p.def // { value = p.processed; }) processed); + }; + in + with-section-constraints + # TODO: find a reasonable cut-off for where to place raw content + (listOf (either str (attrTag categories.flow))); + default = [ ]; + }; + }; - processed = map find-and-attach defs; - all-sections = flatten (map (p: p.validation) processed); - too-deep = filter (sec: sec.depth > 6) all-sections; - in - if too-deep != [ ] then - throw '' - The option `${lib.options.showOption loc}` has sections nested too deeply: - ${concatMapStrings (sec: " - depth ${toString sec.depth} section in ${toString sec.file}\n") too-deep} - Section hierarchy must not be deeper than 6 levels.'' - else baseType.merge loc (map (p: p.def // { value = p.processed; }) processed); - }; + config.categories = [ ]; + config.__toString = + self: with lib; print-element name self.attrs (join "\n" (map toString-unwrap self.content)); + }; + + section = + { config, name, ... }: + { + imports = [ element ]; + options = { + # setting to an attribute set will wrap the section in `
    ` + attrs = mkOption { + type = + with types; + nullOr (submodule { + options = global-attrs; + }); + default = null; + }; + heading = mkOption { + # XXX: while there are no explicit rules on whether sections should contain headers, + # sections should have content that would be listed in an outline. + # + # https://html.spec.whatwg.org/multipage/sections.html#use-div-for-wrappers + # + # such an outline is rather meaningless without headings for navigation, + # which is why we enforce headings in sections. + # arguably, and this is encoded here, a section *is defined* by its heading. + type = + with types; + submodule ( + { config, ... }: + { + imports = [ element ]; + options = { + attrs = mkAttrs { }; + # setting to an attribute set will wrap the section in `
    ` + hgroup.attrs = mkOption { + type = + with types; + nullOr (submodule { + options = global-attrs; + }); + default = with lib; if (config.before == [ ] && config.after == [ ]) then null else { }; + }; + # https://html.spec.whatwg.org/multipage/sections.html#the-hgroup-element + before = mkOption { + type = with types; listOf (attrTag ({ inherit (element-types) p; } // categories.scripting)); + default = [ ]; + }; + content = mkOption { + # https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements + type = with types; either str (listOf (attrTag categories.phrasing)); + }; + after = mkOption { + type = with types; listOf (attrTag ({ inherit (element-types) p; } // categories.scripting)); + default = [ ]; + }; + }; + } + ); + }; + # https://html.spec.whatwg.org/multipage/sections.html#headings-and-outlines + content = mkOption { + type = with types; listOf (either str (attrTag categories.flow)); + default = [ ]; + }; + }; + options.heading-level = mkOption { + # XXX: this will proudly fail if the invariant is violated, + # but the error message will be inscrutable + type = with types; ints.between 1 6; + internal = true; + }; + config = { + categories = [ + "flow" + "sectioning" + "palpable" + ]; + __toString = + self: + with lib; + let + n = toString config.heading-level; + heading = ''${self.heading.content}''; + hgroup = + with lib; + print-element "hgroup" self.heading.hgroup.attrs (squash '' + ${optionalString (!isNull self.heading.before) (toString-unwrap self.heading.before)} + ${heading} + ${optionalString (!isNull self.heading.after) (toString-unwrap self.heading.after)} + ''); + content = + (if isNull self.heading.hgroup.attrs then heading else hgroup) + + join "\n" (map toString-unwrap self.content); in - with-section-constraints - # TODO: find a reasonable cut-off for where to place raw content - (listOf (either str (attrTag categories.flow))); - default = [ ]; + if !isNull self.attrs then print-element name self.attrs content else content; }; }; - config.categories = [ ]; - config.__toString = self: with lib; - print-element name self.attrs (join "\n" (map toString-unwrap self.content)); - }; + p = + { name, ... }: + { + imports = [ element ]; + options = { + attrs = mkAttrs { }; + content = mkOption { + type = with types; either str (listOf (attrTag categories.phrasing)); + }; + }; + config.categories = [ + "flow" + "palpable" + ]; + config.__toString = self: print-element name self.attrs (toString self.content); + }; - section = { config, name, ... }: { - imports = [ element ]; - options = { - # setting to an attribute set will wrap the section in `
    ` - attrs = mkOption { - type = with types; nullOr (submodule { options = global-attrs; }); - default = null; + dl = + { config, name, ... }: + { + imports = [ element ]; + options = { + attrs = mkAttrs { }; + content = mkOption { + type = + with types; + listOf ( + submodule ( + { ... }: + { + options = { + # TODO: wrap in `
    ` if set + div.attrs = mkOption { + type = + with types; + nullOr (submodule { + options = global-attrs; + }); + default = null; + }; + before = mkOption { + type = with types; listOf (attrTag categories.scripting); + default = [ ]; + }; + terms = mkOption { + type = with types; nonEmptyListOf (submodule dt); + }; + between = mkOption { + type = with types; listOf (attrTag categories.scripting); + default = [ ]; + }; + descriptions = mkOption { + type = with types; nonEmptyListOf (submodule dd); + }; + after = mkOption { + type = with types; listOf (attrTag categories.scripting); + default = [ ]; + }; + }; + } + ) + ); + }; }; - heading = mkOption { - # XXX: while there are no explicit rules on whether sections should contain headers, - # sections should have content that would be listed in an outline. - # - # https://html.spec.whatwg.org/multipage/sections.html#use-div-for-wrappers - # - # such an outline is rather meaningless without headings for navigation, - # which is why we enforce headings in sections. - # arguably, and this is encoded here, a section *is defined* by its heading. - type = with types; submodule ({ config, ... }: { - imports = [ element ]; - options = { - attrs = mkAttrs { }; - # setting to an attribute set will wrap the section in `
    ` - hgroup.attrs = mkOption { - type = with types; nullOr (submodule { options = global-attrs; }); - default = with lib; if (config.before == [ ] && config.after == [ ]) then null else { }; - }; - # https://html.spec.whatwg.org/multipage/sections.html#the-hgroup-element - before = mkOption { - type = with types; listOf (attrTag ({ inherit (element-types) p; } // categories.scripting)); - default = [ ]; - }; - content = mkOption { - # https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements - type = with types; either str (listOf (attrTag categories.phrasing)); - }; - after = mkOption { - type = with types; - listOf (attrTag ({ inherit (element-types) p; } // categories.scripting)); - default = [ ]; - }; - }; - }); - }; - # https://html.spec.whatwg.org/multipage/sections.html#headings-and-outlines - content = mkOption { - type = with types; listOf (either str (attrTag categories.flow)); - default = [ ]; - }; - }; - options.heading-level = mkOption { - # XXX: this will proudly fail if the invariant is violated, - # but the error message will be inscrutable - type = with types; ints.between 1 6; - internal = true; - }; - config = { - categories = [ "flow" "sectioning" "palpable" ]; - __toString = self: with lib; + # XXX: here we can't express the spec requirement that `dl` is palpable if the list of term-description-pairs is nonempty. + # the reason is that we have to specify a child's *type* in the parent, but being palpable is a property of the value in this case. + # and while the module system does have some dependent typing capabilities, we can't say "the type is X but only if its value has property Y". + # but since the "palpable" category isn't used in any structural requirement in the spec, this is not a loss of fidelity on our side. + # TODO: the whole notion of content categories may be a red herring for this implementation after all, reconsider it. + # it does help to concisely express type constraints on an element's children, but it seems that most of the categories in the spec can be ignored entirely in this implementation. + # the cleanup task would be to identify which categories are really helpful, and document the rationale for using that mechanism as well as the specific choice of categories to keep. + config.categories = [ "flow" ]; + config.__toString = + self: + with lib; let - n = toString config.heading-level; - heading = ''${self.heading.content}''; - hgroup = with lib; print-element "hgroup" self.heading.hgroup.attrs (squash '' - ${optionalString (!isNull self.heading.before) (toString-unwrap self.heading.before)} - ${heading} - ${optionalString (!isNull self.heading.after) (toString-unwrap self.heading.after)} - ''); - content = - (if isNull self.heading.hgroup.attrs then heading else hgroup) - + - join "\n" (map toString-unwrap self.content); - in - if !isNull self.attrs - then print-element name self.attrs content - else content; - }; - }; - - p = { name, ... }: { - imports = [ element ]; - options = { - attrs = mkAttrs { }; - content = mkOption { - type = with types; either str (listOf (attrTag categories.phrasing)); - }; - }; - config.categories = [ "flow" "palpable" ]; - config.__toString = self: print-element name self.attrs (toString self.content); - }; - - dl = { config, name, ... }: { - imports = [ element ]; - options = { - attrs = mkAttrs { }; - content = mkOption { - type = with types; listOf (submodule ({ ... }: { - options = { - # TODO: wrap in `
    ` if set - div.attrs = mkOption { - type = with types; nullOr (submodule { options = global-attrs; }); - default = null; - }; - before = mkOption { - type = with types; listOf (attrTag categories.scripting); - default = [ ]; - }; - terms = mkOption { - type = with types; nonEmptyListOf (submodule dt); - }; - between = mkOption { - type = with types; listOf (attrTag categories.scripting); - default = [ ]; - }; - descriptions = mkOption { - type = with types; nonEmptyListOf (submodule dd); - }; - after = mkOption { - type = with types; listOf (attrTag categories.scripting); - default = [ ]; - }; - }; - })); - }; - }; - # XXX: here we can't express the spec requirement that `dl` is palpable if the list of term-description-pairs is nonempty. - # the reason is that we have to specify a child's *type* in the parent, but being palpable is a property of the value in this case. - # and while the module system does have some dependent typing capabilities, we can't say "the type is X but only if its value has property Y". - # but since the "palpable" category isn't used in any structural requirement in the spec, this is not a loss of fidelity on our side. - # TODO: the whole notion of content categories may be a red herring for this implementation after all, reconsider it. - # it does help to concisely express type constraints on an element's children, but it seems that most of the categories in the spec can be ignored entirely in this implementation. - # the cleanup task would be to identify which categories are really helpful, and document the rationale for using that mechanism as well as the specific choice of categories to keep. - config.categories = [ "flow" ]; - config.__toString = self: - with lib; - let - content = map - (entry: + content = map ( + entry: let list = squash '' ${join "\n" entry.before} @@ -662,46 +786,63 @@ let ${join "\n" entry.after} ''; in - if !isNull entry.div.attrs - then print-element "div" entry.div.attrs list - else list - ) - self.content; - in - print-element name self.attrs (join "\n" content); - }; - - dt = { config, ... }: { - imports = [ element ]; - options = { - attrs = mkAttrs { }; - dt = mkOption { - type = with types; either str (submodule (attrTag ( - # TODO: test - with lib; removeAttrs - (filterAttrs - (name: value: ! any (c: elem c [ "sectioning" "heading" ]) value.categories) - categories.flow - ) - [ "header" "footer" ] - ))); - }; + if !isNull entry.div.attrs then print-element "div" entry.div.attrs list else list + ) self.content; + in + print-element name self.attrs (join "\n" content); }; - config.categories = [ ]; - config.__toString = self: print-element "dt" self.attrs self.dt; - }; - dd = { config, ... }: { - imports = [ element ]; - options = { - attrs = mkAttrs { }; - dd = mkOption { - type = with types; either str (submodule (attrTag categories.flow)); + dt = + { config, ... }: + { + imports = [ element ]; + options = { + attrs = mkAttrs { }; + dt = mkOption { + type = + with types; + either str ( + submodule ( + attrTag ( + # TODO: test + with lib; + removeAttrs + (filterAttrs ( + name: value: + !any ( + c: + elem c [ + "sectioning" + "heading" + ] + ) value.categories + ) categories.flow) + [ + "header" + "footer" + ] + ) + ) + ); + }; }; + config.categories = [ ]; + config.__toString = self: print-element "dt" self.attrs self.dt; + }; + + dd = + { config, ... }: + { + imports = [ element ]; + options = { + attrs = mkAttrs { }; + dd = mkOption { + type = with types; either str (submodule (attrTag categories.flow)); + }; + }; + config.categories = [ ]; + config.__toString = self: print-element "dd" self.attrs self.dd; }; - config.categories = [ ]; - config.__toString = self: print-element "dd" self.attrs self.dd; - }; }; in { diff --git a/website/presentation/style.nix b/website/presentation/style.nix index 5f79fe50..df65ff33 100644 --- a/website/presentation/style.nix +++ b/website/presentation/style.nix @@ -1,27 +1,53 @@ -{ config, lib, pkgs, ... }: { +{ + config, + lib, + pkgs, + ... +}: +{ config.assets."style.css".path = ./style.css; config.assets."ngi-fediversity.svg".path = ./ngi-fediversity.svg; # TODO: auto-generate a bunch from SVG config.assets."favicon.png".path = ./favicon.png; - config.assets."fonts.css".path = with lib; builtins.toFile "fonts.css" (join "\n" (map - (font: '' - @font-face { - font-family: '${font.name}'; - font-style: normal; - font-weight: ${toString font.weight}; - src: url(/${head config.assets.${font.file}.locations}) format('woff2'); - } - '') - ( - (crossLists (name: file: weight: { inherit name file weight; }) - [ [ "Signika" ] [ "signika-extended.woff2" "signika.woff2" ] [ 500 700 ] ] + config.assets."fonts.css".path = + with lib; + builtins.toFile "fonts.css" ( + join "\n" ( + map + (font: '' + @font-face { + font-family: '${font.name}'; + font-style: normal; + font-weight: ${toString font.weight}; + src: url(/${head config.assets.${font.file}.locations}) format('woff2'); + } + '') + ( + (crossLists (name: file: weight: { inherit name file weight; }) [ + [ "Signika" ] + [ + "signika-extended.woff2" + "signika.woff2" + ] + [ + 500 + 700 + ] + ]) + ++ (crossLists (name: file: weight: { inherit name file weight; }) [ + [ "Heebo" ] + [ + "heebo-extended.woff2" + "heebo.woff2" + ] + [ + 400 + 600 + ] + ]) + ) ) - ++ - (crossLists (name: file: weight: { inherit name file weight; }) - [ [ "Heebo" ] [ "heebo-extended.woff2" "heebo.woff2" ] [ 400 600 ] ] - ) - ) - )); + ); # TODO: get directly from https://github.com/google/fonts # and compress with https://github.com/fonttools/fonttools diff --git a/website/presentation/templates.nix b/website/presentation/templates.nix index 34f6ce73..80422f93 100644 --- a/website/presentation/templates.nix +++ b/website/presentation/templates.nix @@ -1,4 +1,10 @@ -{ config, options, lib, pkgs, ... }: +{ + config, + options, + lib, + pkgs, + ... +}: let inherit (lib) mkOption @@ -7,11 +13,15 @@ let in { config.templates.html = { - dom = document: + dom = + document: let eval = lib.evalModules { class = "DOM"; - modules = [ document (import ./dom.nix) ]; + modules = [ + document + (import ./dom.nix) + ]; }; in { @@ -19,27 +29,34 @@ in value = eval.config; }; - markdown = { name, body }: + markdown = + { name, body }: let - commonmark = pkgs.runCommand "${name}.html" - { - buildInputs = [ pkgs.cmark ]; - } '' - cmark ${builtins.toFile "${name}.md" body} > $out - ''; + commonmark = + pkgs.runCommand "${name}.html" + { + buildInputs = [ pkgs.cmark ]; + } + '' + cmark ${builtins.toFile "${name}.md" body} > $out + ''; in builtins.readFile commonmark; - nav = { menu, page }: + nav = + { menu, page }: let - render-item = item: - if item ? menu then '' -
  • ${item.menu.label} - ${lib.indent " " (item.menu.outputs.html page)} -
  • - '' - else if item ? page then ''
  • ${item.page.title}
  • '' - else ''
  • ${item.link.label}
  • '' - ; + render-item = + item: + if item ? menu then + '' +
  • ${item.menu.label} + ${lib.indent " " (item.menu.outputs.html page)} +
  • + '' + else if item ? page then + ''
  • ${item.page.title}
  • '' + else + ''
  • ${item.link.label}
  • ''; in ''
    -
    +
    {{ end }} diff --git a/website/layouts/blog/single.html b/website/layouts/blog/single.html index c25421d6..975de70f 100644 --- a/website/layouts/blog/single.html +++ b/website/layouts/blog/single.html @@ -58,7 +58,7 @@ - + diff --git a/website/layouts/contact/list.html b/website/layouts/contact/list.html index f7c67ea3..f6ee6fc7 100755 --- a/website/layouts/contact/list.html +++ b/website/layouts/contact/list.html @@ -61,4 +61,4 @@ -{{ end }} \ No newline at end of file +{{ end }} \ No newline at end of file diff --git a/website/layouts/events/list.html b/website/layouts/events/list.html index 7dbb085b..f0657e17 100644 --- a/website/layouts/events/list.html +++ b/website/layouts/events/list.html @@ -14,14 +14,14 @@ {{ .Title }}
    - +
  • {{ end }} - + {{ end }} diff --git a/website/layouts/events/single.html b/website/layouts/events/single.html index 811e6a20..dcd51556 100644 --- a/website/layouts/events/single.html +++ b/website/layouts/events/single.html @@ -63,7 +63,7 @@ - + diff --git a/website/layouts/index.html b/website/layouts/index.html index ecc7437e..2667b3ee 100755 --- a/website/layouts/index.html +++ b/website/layouts/index.html @@ -45,7 +45,7 @@
    {{ range $i, $e := .Params.features3 }} - +
    @@ -89,7 +89,7 @@ >

    {{ .title | markdownify }}

    {{ .content | markdownify }}

    -
      +
        {{ range .bulletpoints }}
      • @@ -147,7 +147,7 @@ {{ if gt (len (where .Site.RegularPages "Section" "blog")) 0 }}
        -
        +

        - +
      • @@ -189,7 +189,7 @@ {{ .Title }}

    - +
    From 57d53a1d22fe0012a83cd2fb5f848b9c5ebf578d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=E2=80=9CNiols=E2=80=9D=20Jeannerod?= Date: Wed, 19 Feb 2025 18:44:12 +0100 Subject: [PATCH 09/10] Make `trim-trailing-whitespace` hook apply everywhere --- flake.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/flake.nix b/flake.nix index 96dfb893..2e9e77e8 100644 --- a/flake.nix +++ b/flake.nix @@ -57,7 +57,6 @@ }; trim-trailing-whitespace = { enable = true; - files = "\\.nix$"; inherit excludes; }; }; From c2db12a735ee1cb8068f3cd02189c7447c4f5afd Mon Sep 17 00:00:00 2001 From: Kiara Grouwstra Date: Wed, 19 Feb 2025 23:10:33 +0100 Subject: [PATCH 10/10] add simple (ngi) favicon to fedi panel, fixes 404 not found error (#167) Reviewed-on: https://git.fediversity.eu/Fediversity/Fediversity/pulls/167 Reviewed-by: Valentin Gagarin Co-authored-by: Kiara Grouwstra Co-committed-by: Kiara Grouwstra --- panel/src/panel/static/favicon.ico | Bin 0 -> 22623 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 panel/src/panel/static/favicon.ico diff --git a/panel/src/panel/static/favicon.ico b/panel/src/panel/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2bf02a9969e00407e7b067c10c8353761051a8b5 GIT binary patch literal 22623 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 HzW3sPM+eM) literal 0 HcmV?d00001