feat(webserver): Vendor and reimplement main pages in leptos

This commit is contained in:
Tristan Daniël Maat 2025-11-24 03:29:18 +08:00
parent aeba7301b0
commit 59fdb37222
Signed by: tlater
GPG key ID: 02E935006CF2E8E7
25 changed files with 4862 additions and 176 deletions

7
.dir-locals.el Normal file
View file

@ -0,0 +1,7 @@
((rust-mode
. ((eglot-workspace-configuration
. (:rust-analyzer
(:cargo (:features "all")
:check (:command "clippy")
:rustfmt (:overrideCommand ["leptosfmt" "--stdin" "--rustfmt"])
:linkedProjects ["./pkgs/packages/webserver/Cargo.toml"]))))))

View file

@ -8,7 +8,6 @@
imports = [
flake-inputs.disko.nixosModules.disko
flake-inputs.sops-nix.nixosModules.sops
flake-inputs.tlaternet-webserver.nixosModules.default
"${modulesPath}/profiles/minimal.nix"
../modules

View file

@ -1,4 +1,10 @@
{ config, ... }:
{
pkgs,
config,
lib,
flake-inputs,
...
}:
let
inherit (config.services.nginx) domain;
in
@ -8,11 +14,50 @@ in
443
];
services.tlaternet-webserver = {
enable = true;
listen = {
addr = "127.0.0.1";
port = 8000;
systemd.services.tlaternet-webserver = {
description = "tlater.net webserver";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
script = ''
${lib.getExe flake-inputs.self.packages.${pkgs.system}.webserver}
'';
environment = {
TLATERNET_NTFY_INSTANCE = "https://tlater.net";
LEPTOS_SITE_ADDR = "127.0.0.1:8000";
};
serviceConfig = {
Type = "exec";
LoadCredential = "ntfy-topic:/run/secrets/tlaternet/ntfy-topic";
DynamicUser = true;
ProtectHome = true; # Override the default (read-only)
PrivateDevices = true;
PrivateIPC = true;
PrivateUsers = true;
ProtectHostname = true;
ProtectClock = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged @resources @setuid @keyring"
];
};
};
@ -28,6 +73,11 @@ in
useACMEHost = "tlater.net";
enableHSTS = true;
locations."/".proxyPass = "http://${addr}:${toString port}";
locations."/".proxyPass =
"http://${config.systemd.services.tlaternet-webserver.environment.LEPTOS_SITE_ADDR}";
};
sops.secrets = {
"tlaternet/ntfy-topic" = { };
};
}

167
flake.lock generated
View file

@ -136,50 +136,6 @@
"type": "github"
}
},
"dream2nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"purescript-overlay": "purescript-overlay",
"pyproject-nix": "pyproject-nix"
},
"locked": {
"lastModified": 1735160684,
"narHash": "sha256-n5CwhmqKxifuD4Sq4WuRP/h5LO6f23cGnSAuJemnd/4=",
"owner": "nix-community",
"repo": "dream2nix",
"rev": "8ce6284ff58208ed8961681276f82c2f8f978ef4",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "dream2nix",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"tlaternet-webserver",
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1737181903,
"narHash": "sha256-lvp77MhGzSN+ICd0MugppCjQR6cmlM2iAC5cjy2ZsaA=",
"owner": "nix-community",
"repo": "fenix",
"rev": "ac79bb490b8c1af4bbc587b84c76f9527d6b14f7",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
@ -310,6 +266,19 @@
"url": "https://channels.nixos.org/nixos-25.05-small/nixexprs.tar.xz"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1763835633,
"narHash": "sha256-nzRnw0UkYQpDm0o20AKvG/5oHCXy5qEGOsFAVhB5NmA=",
"rev": "050e09e091117c3d7328c7b2b7b577492c43c134",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre900642.050e09e09111/nixexprs.tar.xz?lastModified=1763835633&rev=050e09e091117c3d7328c7b2b7b577492c43c134"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [
@ -349,50 +318,6 @@
"type": "github"
}
},
"purescript-overlay": {
"inputs": {
"flake-compat": [
"deploy-rs",
"flake-compat"
],
"nixpkgs": [
"tlaternet-webserver",
"dream2nix",
"nixpkgs"
],
"slimlock": "slimlock"
},
"locked": {
"lastModified": 1728546539,
"narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=",
"owner": "thomashoneyman",
"repo": "purescript-overlay",
"rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4",
"type": "github"
},
"original": {
"owner": "thomashoneyman",
"repo": "purescript-overlay",
"type": "github"
}
},
"pyproject-nix": {
"flake": false,
"locked": {
"lastModified": 1702448246,
"narHash": "sha256-hFg5s/hoJFv7tDpiGvEvXP0UfFvFEDgTdyHIjDVHu1I=",
"owner": "davhau",
"repo": "pyproject.nix",
"rev": "5a06a2697b228c04dd2f35659b4b659ca74f7aeb",
"type": "github"
},
"original": {
"owner": "davhau",
"ref": "dream2nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"root": {
"inputs": {
"deploy-rs": "deploy-rs",
@ -400,49 +325,9 @@
"flint": "flint",
"foundryvtt": "foundryvtt",
"nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable",
"sonnenshift": "sonnenshift",
"sops-nix": "sops-nix",
"tlaternet-webserver": "tlaternet-webserver"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1737140097,
"narHash": "sha256-m4SN8DeKzsP10EQFS7+2zgGfCrMhTfTt1H0QRNesD08=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "f61bfa4d7feb84d07538d361fe77d34a29e3b375",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"slimlock": {
"inputs": {
"nixpkgs": [
"tlaternet-webserver",
"dream2nix",
"purescript-overlay",
"nixpkgs"
]
},
"locked": {
"lastModified": 1688756706,
"narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=",
"owner": "thomashoneyman",
"repo": "slimlock",
"rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c",
"type": "github"
},
"original": {
"owner": "thomashoneyman",
"repo": "slimlock",
"type": "github"
"sops-nix": "sops-nix"
}
},
"sonnenshift": {
@ -501,28 +386,6 @@
"type": "github"
}
},
"tlaternet-webserver": {
"inputs": {
"dream2nix": "dream2nix",
"fenix": "fenix",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1737271785,
"narHash": "sha256-yVdaaawYK1/q9V5btfGpxVCQBdyQx1WcFHYO0yX5bP8=",
"ref": "refs/heads/master",
"rev": "5d3d84836101ec9b9867a5f754c9ee1b9d4dc538",
"revCount": 76,
"type": "git",
"url": "https://gitea.tlater.net/tlaternet/tlaternet.git"
},
"original": {
"type": "git",
"url": "https://gitea.tlater.net/tlaternet/tlaternet.git"
}
},
"utils": {
"inputs": {
"systems": "systems"

View file

@ -3,6 +3,7 @@
inputs = {
nixpkgs.url = "https://channels.nixos.org/nixos-25.05-small/nixexprs.tar.xz";
nixpkgs-unstable.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz";
## Nix/OS utilities
@ -30,17 +31,6 @@
## Services
tlaternet-webserver = {
url = "git+https://gitea.tlater.net/tlaternet/tlaternet.git";
inputs = {
nixpkgs.follows = "nixpkgs";
dream2nix.inputs = {
nixpkgs.follows = "nixpkgs";
purescript-overlay.inputs.flake-compat.follows = "deploy-rs/flake-compat";
};
};
};
foundryvtt = {
url = "github:reckenrode/nix-foundryvtt";
inputs.nixpkgs.follows = "nixpkgs";
@ -148,7 +138,10 @@
packages.${system} = {
default = vm.config.system.build.vm;
}
// import ./pkgs { pkgs = nixpkgs.legacyPackages.${system}; };
// import ./pkgs {
pkgs = nixpkgs.legacyPackages.${system};
flake-inputs = inputs;
};
###################
# Utility scripts #
@ -184,6 +177,8 @@
minecraft = nixpkgs.legacyPackages.${system}.mkShell {
packages = nixpkgs.lib.attrValues { inherit (nixpkgs.legacyPackages.${system}) packwiz; };
};
webserver = self.packages.${system}.webserver.devShell;
};
};
}

View file

@ -1,3 +1,5 @@
tlaternet:
ntfy-topic: ENC[AES256_GCM,data:T0wEsXso0uJD4KDe8qv8d24+vaouDrpDFxpSROn5kiYnqa1w6BSy3VrFdqFy17s7mzlAsZtN7gfDZDFjIo1S3Q==,iv:evWlOBgdnVdprzrTy5m+rcRo0OkFEIX+lCgfwLyfw4I=,tag:iq4B6VLWy0nm80ezqJ+rgw==,type:str]
porkbun:
api-key: ENC[AES256_GCM,data:A5J1sqwq6hs=,iv:77Mar3IX7mq7z7x6s9sSeGNVYc1Wv78HptJElEC7z3Q=,tag:eM/EF9TxKu+zcbJ1SYXiuA==,type:str]
secret-api-key: ENC[AES256_GCM,data:8Xv+jWYaWMI=,iv:li4tdY0pch5lksftMmfMVS729caAwfaacoztaQ49az0=,tag:KhfElBGzVH4ByFPfuQsdhw==,type:str]
@ -28,8 +30,8 @@ turn:
env: ENC[AES256_GCM,data:xjIz/AY109lyiL5N01p5T3HcYco/rM5CJSRTtg==,iv:16bW6OpyOK/QL0QPGQp/Baa9xyT8E3ZsYkwqmjuofk0=,tag:J5re3uKxIykw3YunvQWBgg==,type:str]
secret: ENC[AES256_GCM,data:eQ7dAocoZtg=,iv:fgzjTPv30WqTKlLy+yMn5MsKQgjhPnwlGFFwYEg3gWs=,tag:1ze33U1NBkgMX/9SiaBNQg==,type:str]
sops:
lastmodified: "2025-11-19T16:42:43Z"
mac: ENC[AES256_GCM,data:4YivckDS+jBX3Bkon0bTAm3SXya4v2ieZyqeBXjBUYZeCmelIng7bn2dP7791O6RK6RvSXAGhiykWgGRW/boG3QM8VLxDMSRTKovJo5k6oxtFJC8OLDJoh1EC5BQLznJDKl4So6FgYPEtdQ6rx+Q6Ah7JSMtQilxRoe/hYapT90=,iv:9BGtS585gVbvH6l96/YYZiY1DrwB565vPaNNtFC9vbk=,tag:HsZuDMqPFHTMPxQsD36LNQ==,type:str]
lastmodified: "2025-11-27T19:00:56Z"
mac: ENC[AES256_GCM,data:Q8ATaYVuToQaToVgT5JNP6NGipLnu8JifBivD5jG0h/Xb82ObkMHYejy7tcir600+XgC2UJgfRBgEtYbhjK+SQcyvyBpmPWyv1cKfcidwnfDqRMbc8/1LLl/3mK1losv7/51aZDqvu7GuIvOwXh6IyDQldSuyyPf+wcmCCENiLY=,iv:zDGmnEL2659W7JPRG9dlLjtvDtCgz+iXjJt4EPx/OCM=,tag:3X69zX1sZqrZujB7En8jqA==,type:str]
pgp:
- created_at: "2025-10-03T21:38:26Z"
enc: |-

View file

@ -1,5 +1,8 @@
{ pkgs }:
{ pkgs, flake-inputs }:
let
inherit (flake-inputs.nixpkgs-unstable.legacyPackages.${pkgs.system}) ast-grep;
in
pkgs.lib.packagesFromDirectoryRecursive {
inherit (pkgs) callPackage;
callPackage = pkgs.lib.callPackageWith (pkgs // { inherit ast-grep; });
directory = ./packages;
}

1
pkgs/packages/webserver/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

3453
pkgs/packages/webserver/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,68 @@
[package]
name = "tlaternet-webserver"
version = "0.2.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
axum = { version = "0.8.7", features = ["macros"], optional = true }
console_error_panic_hook = { version = "0.1.7", optional = true }
figment = { version = "0.10.19", features = ["toml", "env"] }
leptos = "0.8.3"
leptos_axum = { version = "0.8.3", optional = true }
leptos_meta = "0.8.3"
leptos_router = "0.8.3"
markdown_view_leptos = "0.1.3"
reqwest = "0.12.24"
serde = { version = "1.0.228", features = ["derive"] }
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["rt-multi-thread"], optional = true }
url = "2.5.7"
wasm-bindgen = { version = "=0.2.100", optional = true }
web-sys = "^0.3.77"
[features]
hydrate = [
"leptos/hydrate",
"dep:console_error_panic_hook",
"dep:wasm-bindgen",
]
ssr = [
"dep:axum",
"dep:tokio",
"dep:leptos_axum",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
]
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"
[package.metadata.leptos]
output-name = "tlaternet-webserver"
site-root = "target/site"
site-pkg-dir = "pkg"
style-file = "style/main.scss"
site-addr = "127.0.0.1:3000"
reload-port = 3001
browserquery = "defaults"
env = "DEV"
bin-features = ["ssr"]
bin-default-features = false
lib-features = ["hydrate"]
lib-default-features = false
lib-profile-release = "wasm-release"
watch-additional-files = ["config.toml"]
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
# [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx
end2end-cmd = "npx playwright test"
end2end-dir = "end2end"

View file

@ -0,0 +1,3 @@
# Can be specified with systemd creds instead
credentials_directory = "dev-creds"
ntfy_instance = "https://ntfy.sh"

View file

@ -0,0 +1,5 @@
max_width = 100
tab_spaces = 2
attr_value_brace_style = "WhenRequired"
macro_names = [ "leptos::view", "view" ]
closing_tag_style = "SelfClosing"

View file

@ -0,0 +1,295 @@
{
lib,
stdenvNoCC,
fetchFromGitHub,
fetchurl,
symlinkJoin,
makeBinaryWrapper,
pkg-config,
openssl,
cargo-leptos,
dart-sass,
rustPlatform,
wasm-bindgen-cli_0_2_100,
binaryen,
inkscape,
mkShell,
clangStdenv,
rust-analyzer,
rustc,
rustfmt,
leptosfmt,
cargo,
clippy,
writers,
ast-grep,
nix-prefetch-github,
}:
let
cargoMetadata = lib.pipe ./Cargo.toml [
builtins.readFile
builtins.fromTOML
];
sass-dependencies = {
bulma = stdenvNoCC.mkDerivation (drv: {
pname = "bulma";
version = "1.0.4";
src = fetchFromGitHub {
owner = "jgthms";
repo = "bulma";
rev = drv.version;
hash = "sha256-hlejqBI6ayzhm15IymrzhTevkl3xffMfdTasZ2CmAas=";
};
installPhase = ''
mkdir -p $out/node_modules/bulma/
cp -r ./sass $out/node_modules/bulma/
'';
});
fontsource-scss = stdenvNoCC.mkDerivation {
pname = "fontsource-scss";
version = "0.2.2";
src = fetchurl {
url = "https://registry.npmjs.org/@fontsource-utils/scss/-/scss-0.2.2.tgz";
hash = "sha256-2BkCBhh01kZfMHhjHMMLDtUeesi7Uy7eMoeM1BAqX38=";
};
installPhase = ''
mkdir -p $out/node_modules/@fontsource-utils/scss/
cp -r . $out/node_modules/@fontsource-utils/scss/
'';
};
fontsource-nunito = stdenvNoCC.mkDerivation {
pname = "fontsource-nunito";
version = "5.2.7";
src = fetchurl {
url = "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.2.7.tgz";
hash = "sha256-xSt1sDpVL/hVYzffKTgN/t7uLI3JadDWtTfWow2jiPM=";
};
outputs = [
"out"
"assets"
];
installPhase = ''
mkdir -p $out/node_modules/@fontsource-variable/nunito/
cp -r . $out/node_modules/@fontsource-variable/nunito/
mkdir -p $assets/@fontsource-variable/
cp -r files/ $assets/@fontsource-variable/nunito
'';
};
fontsource-arimo = stdenvNoCC.mkDerivation {
pname = "fontsource-nunito";
version = "5.2.8";
src = fetchurl {
url = "https://registry.npmjs.org/@fontsource-variable/arimo/-/arimo-5.2.8.tgz";
hash = "sha256-jD1IGqy02j4bqMRAwbCgiIz/h97WPrTSd3eZ09nptHA=";
};
outputs = [
"out"
"assets"
];
installPhase = ''
mkdir -p $out/node_modules/@fontsource-variable/arimo/
cp -r . $out/node_modules/@fontsource-variable/arimo/
mkdir -p $assets/@fontsource-variable/
cp -r files/ $assets/@fontsource-variable/arimo
'';
};
};
other-dependencies = {
hack-font = stdenvNoCC.mkDerivation (drv: {
pname = "hack-font";
version = "3.003";
src = fetchFromGitHub {
owner = "source-foundry";
repo = "Hack";
rev = "v${drv.version}";
hash = "sha256-qGDtBvKecdfsleUBfXFezllz9Op679a030Qcj/oBs1o=";
};
dontBuild = true;
installPhase = ''
mkdir -p $out
cp -r build/web/fonts $out/hack-font/
'';
});
};
icons = stdenvNoCC.mkDerivation {
pname = "tlaternet-icons";
version = "1.0";
src = ./public/icon.svg;
dontUnpack = true;
nativeBuildInputs = [ inkscape ];
buildPhase = ''
inkscape -w 48 -h 48 $src --export-filename=favicon-48.png
'';
installPhase = ''
mkdir -p $out
cp $src $out/icon.svg
cp favicon-48.png $out/
'';
};
assets = symlinkJoin {
name = "assets-${cargoMetadata.package.name}";
paths = [
sass-dependencies.fontsource-arimo.assets
sass-dependencies.fontsource-nunito.assets
other-dependencies.hack-font
icons
];
};
# Hack to allow importing sass *without* resorting to using `npm`
# and dealing with the insanity that is `package.json` files.
#
# dart-sass *in theory* supports completely arbitrary logic for
# package importing via the `pkg:` url prefix, but unfortunately
# currently the only implementation of it that is available in the
# upstream binary *requires* using a `node_modules` file in the
# repository root. See here:
# https://sass-lang.com/documentation/at-rules/use/#node-js-package-importer
#
# This wouldn't be so bad if it supported an environment variable or
# command line arg to specify what the repo root should be, but it
# doesn't; so instead we use the load-path to specify a package
# import root.
#
# As a consequence, we cannot use the `pkg:` prefix, so package
# imports are indistinguishable from relative path imports. This
# isn't the end of the world, but:
#
# TODO(tlater): See if we can talk to upstream about an
# implementation better suited for use with nix, perhaps in
# dart-sass (add an env variable?) or in cargo-leptos (add a config
# option that can also be set with an env variable, and use the sass
# protocol instead of the raw exe?).
dart-sass-with-packages =
let
packages = symlinkJoin {
name = "sass-packages";
paths = lib.attrValues sass-dependencies;
stripPrefix = "/node_modules";
};
in
symlinkJoin {
inherit (dart-sass) version;
pname = "dart-sass-with-packages";
paths = [ dart-sass ];
nativeBuildInputs = [ makeBinaryWrapper ];
postBuild = ''
wrapProgram $out/bin/sass \
--add-flag --load-path=${packages}
'';
};
in
rustPlatform.buildRustPackage (drv: {
inherit (cargoMetadata.package) version;
pname = cargoMetadata.package.name;
cargoLock.lockFile = drv.src + /Cargo.lock;
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.fromSource (lib.sources.cleanSource ./.);
};
nativeBuildInputs = [
cargo-leptos
rustc.llvmPackages.lld
dart-sass-with-packages
makeBinaryWrapper
pkg-config
wasm-bindgen-cli_0_2_100
binaryen
];
buildInputs = [ openssl ];
LEPTOS_ASSETS_DIR = assets.outPath;
buildPhase = ''
runHook preBuild
cargo leptos build --release
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/bin $out/share
cp target/release/tlaternet-webserver $out/bin
cp -r target/site $out/share
wrapProgram $out/bin/tlaternet-webserver \
--set LEPTOS_SITE_ROOT $out/share/site \
--set LEPTOS_ASSETS_DIR ${assets.outPath}
runHook postInstall
'';
meta.mainProgram = "tlaternet-webserver";
passthru = {
dependencies = sass-dependencies;
devShell = mkShell.override { stdenv = clangStdenv; } {
packages = [
pkg-config
openssl
cargo-leptos
dart-sass-with-packages
# lld is exposed as ld by the clangStdenv, adding it
# explicitly with bintools makes it work
rustc.llvmPackages.lld
rust-analyzer
rustc
rustfmt
leptosfmt
cargo
clippy
];
LEPTOS_ASSETS_DIR = assets.outPath;
};
updateScript = writers.writeNuBin "update-${cargoMetadata.package.name}" {
makeWrapperArgs = [
"--prefix"
"PATH"
":"
(lib.makeBinPath [
ast-grep
nix-prefetch-github
])
];
} ./update.nu;
};
})

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.0"
width="260.000000pt"
height="260.000000pt"
viewBox="0 0 260.000000 260.000000"
preserveAspectRatio="xMidYMid meet"
id="svg70"
sodipodi:docname="icon.svg"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)">
<metadata
id="metadata76">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs74" />
<sodipodi:namedview
inkscape:pagecheckerboard="true"
inkscape:document-rotation="0"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1916"
inkscape:window-height="1059"
id="namedview72"
showgrid="false"
inkscape:zoom="2.2269231"
inkscape:cx="173.33333"
inkscape:cy="173.33333"
inkscape:window-x="-2"
inkscape:window-y="13"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="Background">
<rect
style="fill:#99d1ce;stroke-width:0.75;fill-opacity:1"
id="rect843"
width="260"
height="260"
x="2.0117849e-08"
y="2.0117849e-08" />
</g>
<path
style="fill:#0f0f0f;fill-opacity:1;stroke:#991dce;stroke-width:0.1"
id="path62"
d="M 2.0117848e-8,130 V 260 H 130 260 V 130 2.0117848e-8 H 130 2.0117848e-8 Z M 132.5,19.099999 c 1.4,1.300001 2,3.6 2.3,8.300001 l 0.4,6.4 8.2,0.699999 C 160.7,35.9 177.9,40.300001 182.89999,44.600002 188.9,49.6 189.1,58.399998 183.3,62.999999 180,65.6 176.6,65.5 161.7,62.499998 155,61.199999 145.99999,59.8 141.79999,59.399999 l -7.8,-0.700001 V 85.7 v 27 L 147,116.9 c 34.8,11.2 49.4,24.6 50.69999,46.39999 C 198.7,178 194.8,188.9 184.79999,199.5 174.6,210.4 162.6,216.5 144.89999,219.6 l -9.69999,1.7 -0.4,7.5 c -0.3,6.29999 -0.7,7.69999 -2.7,9.29999 -3.5,2.8 -8.5,2.5 -11,-0.69999 -1.7,-2.10001 -2.1,-4.1 -2.1,-9.4 v -6.7 l -7.1,-0.70001 c -8.8,-0.79999 -23.900001,-4 -34.400001,-7.3 -10.300001,-3.3 -15.5,-7.99999 -15.5,-14 0,-5.59999 3.5,-10.49999 8.2,-11.7 2.999999,-0.8 5.4,-0.39999 12.500002,1.7 C 93.099998,192.5 103,194.59999 112.3,195.5 l 6.7,0.7 v -29.1 c 0,-22.50001 -0.3,-29.20001 -1.20001,-29.5 C 93.099998,130.3 81.199997,124.9 72.900001,117.3 58.5,103.9 54.800001,82.499998 63.699999,64.300001 70.999998,49.399999 91.399998,37.199999 113.4,34.499999 L 119,33.8 v -5.7 c 0,-10.400001 7.1,-15.1 13.5,-9.000001 z" />
<path
style="fill:#0f0f0f;stroke:#991dce;stroke-width:0.1;fill-opacity:1"
id="path64"
d="m 113.2,60.6 c -18.4,4.7 -25.8,18.1 -17.7,32 1.3,2.2 4,5.3 6,6.8 3.7,2.9 16.4,9 17.1,8.3 0.3,-0.2 0.3,-11.2 0.2,-24.4 l -0.3,-24 z" />
<path
style="fill:#0f0f0f;stroke:#991dce;stroke-width:0.1;fill-opacity:1"
id="path66"
d="m 134,169.1 v 26.2 l 4.4,-0.5 c 9.6,-1.1 21.1,-8.9 24.2,-16.4 1.9,-4.6 1.7,-12.1 -0.5,-16.9 -2.6,-5.7 -10,-11.8 -18.6,-15.4 -4,-1.7 -7.8,-3.1 -8.4,-3.1 -0.8,0 -1.1,8.2 -1.1,26.1 z" />
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,56 @@
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet};
use leptos_router::{
components::{Route, Router, Routes},
StaticSegment,
};
mod homepage;
mod mail;
use crate::components::Navbar;
use homepage::HomePage;
use mail::Mail;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="icon" type="image/png" href="/favicon-48.png" sizes="48x48" />
<meta charset="utf-8" />
<meta name="author" content="Tristan Daniël Maat" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<AutoReload options=options.clone() />
<HydrationScripts options />
<MetaTags />
</head>
<body>
<App />
</body>
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Stylesheet href="/pkg/tlaternet-webserver.css" />
<Navbar />
// content for this welcome page
<Router>
<main>
<Routes fallback=|| "Page not found.".into_view()>
<Route path=StaticSegment("") view=HomePage />
<Route path=StaticSegment("mail") view=Mail />
</Routes>
</main>
</Router>
}
}

View file

@ -0,0 +1,70 @@
use leptos::prelude::*;
use leptos_meta::{Meta, Title};
use markdown_view_leptos::markdown_view;
#[component]
pub fn HomePage() -> impl IntoView {
view! {
<Meta name="description" content="tlater.net homepage" />
<Title text="Welcome to tlater.net!" />
<section class="section">
<div class="container">
<h1 class="title has-text-weight-normal is-family-monospace">
"$ "<span id="typed-welcome" />
</h1>
<hr />
<div class="columns">
<div class="column content">
{markdown_view!(
r#"
### About Me
Looks like you found my website. I suppose introductions are in order.
My name's Tristan, I'm an avid Dutch-South African software
engineer. You probably either met me at an open source conference, a
hackathon, a badminton session or at a roleplaying table.
"#
)}
</div>
<div class="column content">
{markdown_view!(
r#"### My Work
I'm interested in a variety of things in the open source
world. Perhaps thanks to my pursuit of the perfect Linux desktop, this
has revolved a lot around reproducible build and deployment systems
for the last few years, initially starting with
[BuildStream](https://buildstream.build/) back in ~2017. I gave a
couple of talks on it at build meetups in the UK in subsequent years,
though sadly most evidence of that appears to have disappeared.
Since then this has culminated in a strong fondness for
[NixOS](https://nixos.org/) and Nix, as its active community makes
private use cases much more feasible. As such, I have a vested
interest in making this community as large as possible - I post a lot
on the NixOS [discourse](https://discourse.nixos.org/) trying to help
newcomers out where I can.
I also just enjoy Programming, my core languages for personal work are
currently probably Rust and Python, although I have a very varied
background. This is in part due to my former work as a consultant,
which required new languages every few months. I have experience from
JavaScript over Elm to Kotlin, but eventually I hope I might only need
to write Rust ;)
If you're interested in seeing these things for yourself, visit my
[Gitlab](https://gitlab.com/tlater) and
[GitHub](https://github.com/tlater) pages.
"#
)}
</div>
</div>
</div>
</section>
}
}

View file

@ -0,0 +1,217 @@
use leptos::{html::Form, logging};
use leptos_meta::{Meta, Title};
use leptos::{prelude::*, server_fn::codec::JsonEncoding};
use markdown_view_leptos::markdown_view;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[server]
async fn submit_mail(mail: String, subject: String, message: String) -> Result<String, MailError> {
use crate::AppState;
const NTFY_TOPIC_CREDENTIAL_NAME: &str = "ntfy-topic";
let mail = format!("From: {}\nSubject: {}\n{}", mail, subject, message);
logging::log!("{}", mail);
let state = use_context::<AppState>().unwrap();
let ntfy_topic = std::fs::read_to_string(
state
.config
.credentials_directory
.join(NTFY_TOPIC_CREDENTIAL_NAME),
)?;
let ntfy_url = state.config.ntfy_instance.join(ntfy_topic.trim())?;
state
.http_client
.post(ntfy_url)
.body(mail)
.send()
.await
.map_err(|err| err.without_url())?
.error_for_status()
.map_err(|err| err.without_url())?;
leptos_axum::redirect("/mail");
Ok("Mail successfully sent!".to_owned())
}
#[component]
fn MailForm() -> impl IntoView {
let form: NodeRef<Form> = NodeRef::new();
let submit_mail = ServerAction::<SubmitMail>::new();
let pending = submit_mail.pending();
let flash_message = submit_mail.value();
Effect::new(move ||
if flash_message.get().is_some_and(|m| m.is_ok()) {
if let Some(form) = form.get() {
form.reset();
} else {
logging::warn!("Failed to reset form");
}
}
);
view! {
<Show when=move || { flash_message.get().is_some() }>
<div
class="notification is-light"
class=("is-success", move || flash_message.get().is_some_and(|m| m.is_ok()))
class=("is-danger", move || flash_message.get().is_some_and(|m| m.is_err()))
>
<button
class="delete"
aria-label="Close"
on:click=move |_| {
*flash_message.write() = None;
}
/>
<span role="alert">
{move || match flash_message.get() {
None => "".to_owned(),
Some(Ok(message)) => message,
Some(Err(error)) => format!("{}", error),
}}
</span>
</div>
</Show>
<ActionForm node_ref=form action=submit_mail>
<fieldset disabled=move || pending.get()>
<div class="field">
<label class="label" for="mail">
Email address
</label>
<div class="control">
<input
id="mail"
class="input"
type="email"
placeholder="Your address"
name="mail"
required
/>
</div>
</div>
<div class="field">
<label class="label" for="subject">
Subject
</label>
<div class="control">
<input
id="subject"
class="input"
type="text"
placeholder="E.g. There's a typo on your home page!"
name="subject"
autocomplete="off"
required
/>
</div>
</div>
<div class="field">
<label class="label" for="message">
Message
</label>
<textarea
id="message"
class="textarea"
type="text"
rows="6"
name="message"
autocomplete="off"
required
/>
</div>
<div class="control">
<div class="field">
<button
type="submit"
class="button is-link"
class=("is-loading", move || pending.get())
>
Send
</button>
</div>
</div>
</fieldset>
</ActionForm>
}
}
#[component]
pub fn Mail() -> impl IntoView {
view! {
<Meta name="description" content="tlater.net mail submission" />
<Title text="Mail submission" />
<section class="section">
<div class="container">
<h1 class="title has-text-weight-normal">Contact Me</h1>
<div class="columns">
<div class="column">
<MailForm />
</div>
<div class="column content">
{markdown_view!(
r#"
Any messages you enter here are directly forwarded to me. I aim to
respond within a day.
Don't be upset about the form, I want to avoid the spam publishing
your email address brings with it... And minimize the amount of mail
that doesn't reach me, this form is an exception in all my spam
filters, you see ;)
"#
)}
</div>
</div>
</div>
</section>
}
}
#[derive(Error, Debug, Clone, Serialize, Deserialize)]
pub enum MailError {
#[error("This form appears to currently be broken :(")]
Permanent,
#[error("Mail service appears to be down; please try again later")]
Temporary,
#[error("Server error: {0}")]
ServerFnError(ServerFnErrorErr),
}
impl From<url::ParseError> for MailError {
fn from(error: url::ParseError) -> Self {
logging::error!("Invalid ntfy URL: {error}");
Self::Permanent
}
}
impl From<std::io::Error> for MailError {
fn from(error: std::io::Error) -> Self {
logging::error!("Couldn't read ntfy topic secret: {error}");
Self::Permanent
}
}
impl From<reqwest::Error> for MailError {
fn from(error: reqwest::Error) -> Self {
logging::error!("Failed to connect to ntfy: {error}");
Self::Temporary
}
}
impl FromServerFnError for MailError {
type Encoder = JsonEncoding;
fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
Self::ServerFnError(value)
}
}

View file

@ -0,0 +1,45 @@
use leptos::prelude::*;
#[component]
pub fn Navbar() -> impl IntoView {
let (active, set_active) = signal(false);
view! {
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item has-text-primary is-uppercase" href="/">
tlater
</a>
<a
role="button"
on:click=move |_| { set_active.update(|active: &mut bool| *active = !*active) }
class="navbar-burger"
class=("is-active", move || active.get())
aria-label="menu"
aria-controls="main-navigation"
aria-expanded=move || if active.get() { "true" } else { "false" }
>
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
</a>
</div>
<div id="main-navigation" class="navbar-menu" class=("is-active", move || active.get())>
<div class="navbar-start">
<a class="navbar-item" href="/mail">
"E-Mail"
</a>
<a class="navbar-item" href="https://www.gitlab.com/tlater">
GitLab
</a>
<a class="navbar-item" href="https://www.github.com/TLATER">
GitHub
</a>
</div>
</div>
</nav>
}
}

View file

@ -0,0 +1,73 @@
pub mod app;
mod components;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::app::*;
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
}
#[cfg(feature = "ssr")]
pub use appstate::{AppState, Config};
#[cfg(feature = "ssr")]
mod appstate {
use axum::extract::FromRef;
use figment::{providers::Format, Figment};
use leptos::config::LeptosOptions;
use reqwest::Client;
use serde::Deserialize;
use std::path::PathBuf;
use url::Url;
#[derive(Deserialize, Debug, Clone)]
pub struct Config {
pub credentials_directory: PathBuf,
pub ntfy_instance: Url,
}
impl Config {
pub fn parse() -> Self {
let config_path = std::env::var_os("TLATERNET_CONFIG")
.map(PathBuf::from)
.or_else(|| {
std::env::current_dir()
.map(|dir| dir.join("config.toml"))
.ok()
});
let config: Result<Config, figment::Error> = if let Some(config_path) = config_path {
Figment::new().merge(figment::providers::Toml::file(config_path))
} else {
Figment::new()
}
.merge(figment::providers::Env::raw().only(&["CREDENTIALS_DIRECTORY"]))
.merge(figment::providers::Env::prefixed("TLATERNET_"))
.extract();
match config {
Ok(config) => {
if !config.credentials_directory.join("ntfy-topic").exists() {
leptos::logging::error!("Failed to find ntfy-topic credential");
std::process::exit(1);
}
config
}
Err(error) => {
leptos::logging::error!("Failed to parse configuration: {:?}", error);
std::process::exit(1);
}
}
}
}
#[derive(FromRef, Debug, Clone)]
pub struct AppState {
pub config: Config,
pub http_client: Client,
pub leptos_options: LeptosOptions,
}
}

View file

@ -0,0 +1,54 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_axum::{LeptosRoutes, generate_route_list};
use tlaternet_webserver::app::*;
use tlaternet_webserver::{AppState, Config};
let config = Config::parse();
let (addr, leptos_options) = {
let conf = get_configuration(None).unwrap();
let addr = conf.leptos_options.site_addr;
let leptos_options = conf.leptos_options;
(addr, leptos_options)
};
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
let http_client = reqwest::Client::new();
let state = AppState {
config,
http_client,
leptos_options,
};
let app = Router::new()
.leptos_routes(&state, routes, {
let leptos_options = state.leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback::<_, (_, _, axum::extract::State<AppState>, _)>(
leptos_axum::file_and_error_handler(shell),
)
.with_state(state);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
log!("listening on http://{}", &addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for pure client-side testing
// see lib.rs for hydration function instead
}

View file

@ -0,0 +1,57 @@
@use "bulma/sass/utilities/initial-variables" as iv with (
$black: #0f0f0f,
$grey-darker: #11151c,
$grey-light: #dddddd,
$white: #ffffff,
$orange: #d26937,
$yellow: #b58900,
$green: #2aa889,
$cyan: #99d1ce,
$blue: #195466,
$red: #dc322f,
);
iv.$family-sans-serif: "Nunito", iv.$family-sans-serif;
iv.$family-monospace: "Hack", iv.$family-monospace;
@forward "bulma/sass/utilities/functions";
@use "bulma/sass/utilities/derived-variables" with (
$link: iv.$cyan,
$primary: iv.$cyan,
);
@forward "bulma/sass/utilities/controls";
@forward "bulma/sass/form";
@forward "bulma/sass/base" with (
$body-background-color: iv.$black,
$body-color: iv.$grey-light,
$hr-background-color: iv.$grey-light,
$hr-height: 1px,
);
@forward "bulma/sass/themes";
@forward "bulma/sass/elements/button";
@use "bulma/sass/elements/content" with (
$content-heading-weight: iv.$weight-semibold,
);
@use "bulma/sass/elements/notification";
@use "bulma/sass/elements/delete";
@use "bulma/sass/elements/title" with (
$title-color: iv.$cyan,
);
@forward "bulma/sass/grid/columns";
@forward "bulma/sass/helpers/typography";
@forward "bulma/sass/helpers/color";
@forward "bulma/sass/layout/container";
@forward "bulma/sass/layout/section";
@forward "bulma/sass/components/navbar" with (
$navbar-burger-color: iv.$grey-light,
);

View file

@ -0,0 +1,48 @@
@use "@fontsource-utils/scss/src/mixins" as fontsource with (
$display: auto
);
@use "@fontsource-variable/arimo/scss/metadata.scss" as arimo;
@use "@fontsource-variable/nunito/scss/metadata.scss" as nunito;
@include fontsource.faces(
$metadata: nunito.$metadata,
$weights: (
300,
400,
500,
600,
700,
),
$subsets: latin,
$styles: (
normal,
italic,
),
$family: "Nunito",
$directory: "/@fontsource-variable/nunito"
);
@include fontsource.faces(
$metadata: arimo.$metadata,
$weights: 400,
$subsets: latin,
$styles: normal,
$family: "Arimo",
$directory: "/@fontsource-variable/arimo"
);
// Hack *does* come with its own CSS, but it's broken and hasn't seen
// a release since https://github.com/source-foundry/Hack/issues/467
// was resolved.
$variants: regular normal 400, bold normal 700, italic italic 400, bolditalic italic 700;
@each $name, $style, $weights in $variants {
@font-face {
font-family: "Hack";
font-style: $style;
font-display: auto;
font-weight: $weights;
src: url("/hack-font/hack-#{$name}-subset.woff2") format("woff2-variations");
}
}

View file

@ -0,0 +1,7 @@
@use "fonts";
@use "custom-bulma";
@use "typed";
#typed-welcome {
@include typed.typed-text(typed-welcome, "Welcome to tlater.net!", 1.2s);
}

View file

@ -0,0 +1,149 @@
@use "sass:math";
@use "sass:list";
@mixin test {
margin: 0;
}
@mixin typed-text($id, $text, $duration) {
&::before {
@include typed($id, $text, $duration);
}
&::after {
@include cursor($id, 6s);
}
}
/// Animate a blinking cursor.
@mixin cursor($id, $duration) {
$name: cursor-#{$id};
// The number of times we need to blink is = the number of full
// seconds (500ms * 2) that fit in the total duration, rounded up,
// and doubled.
$iterations: math.ceil(math.div($duration, 1s)) * 2;
animation: $name ease-in-out 500ms $iterations alternate;
content: " ";
@keyframes #{$name} {
from {
content: " ";
}
to {
content: "";
}
}
}
/// Animate a piece of text as if it was being typed by a human.
@mixin typed($id, $text, $duration) {
word-break: break-all;
// We don't want a linearly typed set of text, which makes this
// significantly more complex.
//
// CSS animations normally do not permit per-frame changes in
// duration (since the total animation time is fixed). This means we
// need to create multiple animations, and delay them so that they
// happen in the time sequence we want.
//
// We generate the raw values with _generate-animations, and then
// split up the result into the animation API.
$frames: str-length($text);
$animations: _generate-animations($id, $frames, 1.2s);
animation-name: _unzip($animations, 1);
animation-delay: _unzip($animations, 3);
animation-fill-mode: forwards;
content: "";
// We need to type each character in separate animations, see above
// comment.
@each $name, $character in $animations {
@keyframes #{$name} {
from {
content: str-slice($text, 0, $character);
}
to {
content: str-slice($text, 0, $character + 1);
}
}
}
}
/// Unzip a nested set of lists, taking the nth value of each sublist.
@function _unzip($lists, $i) {
$out: ();
$sep: comma;
@each $sublist in $lists {
$out: list.append($out, list.nth($sublist, $i), $sep);
}
@return $out;
}
/// Compute the sum of all numbers in a list.
@function _sum($list) {
$out: 0;
@each $val in $list {
$out: $out + $val;
}
@return $out;
}
/// Produce a list from a shorter list by repeating it up until size
/// $length.
@function _round-robin($base, $length) {
$out: ();
$sep: list.separator($out);
@for $i from 0 through $length {
$out: list.append($out, list.nth($base, $i % list.length($base) + 1));
}
@return $out;
}
/// Generate the actual animation values.
///
/// This generates a nested list as:
///
/// (keyframe-name, index, start time)
///
/// The duration of each frame is taken from the internal $delays in a
/// round robin fashion, to give some amount of human-like variance to
/// the duration of each frame.
///
/// Start time is set to the time at which the frame should start to
/// achieve the desired frame-by-frame duration.
@function _generate-animations($id, $number, $total_duration) {
$out: ();
$sep: list.separator($out);
// A set of "human-like" delays for each typed character. In
// practice, my typing seems to be about 20-70ms, but it looks a bit
// nicer to increase all typing by 20ms to make the effect more
// noticeable.
//
// Numbers generated once with a random number generator, rather
// than using `math.random()`, since they end up in CSS verbatim,
// and the build would be non-reproducible if we didn't do it this
// way. Using `math.random() wouldn't change this dynamically each
// time the page loads anyway, so we don't really lose anything by
// pre-generating these numbers.
$delays: 69ms, 83ms, 49ms, 48ms, 52ms, 59ms, 40ms, 71ms, 80ms, 67ms;
@for $animation from 0 through $number {
$out: list.append(
$out,
(
type-#{$id}-#{$animation},
$animation,
_sum(_round_robin($delays, $animation))
),
$sep
);
}
@return $out;
}

View file

@ -0,0 +1,88 @@
const self = "pkgs/packages/webserver/package.nix"
let tmpdir = mktemp -d webserver-update.XXXXXXXXXX
let dependencies = {
fontsource-scss: (prefetch-npm @fontsource-utils/scss)
fontsource-arimo: (prefetch-npm @fontsource-variable/arimo)
fontsource-nunito: (prefetch-npm @fontsource-variable/nunito)
bulma: (prefetch-github jgthms bulma)
hack-font: (prefetch-github source-foundry Hack)
}
cd (git rev-parse --show-toplevel)
$dependencies | items {|name, metadata| $metadata | update-dependency $name }
rm -r $tmpdir
cd (dirname $self)
cargo update
def prefetch-npm [package: string] {
let metadata = http get $'https://registry.npmjs.org/($package)'
let version = $metadata.dist-tags.latest
let url = ($metadata.versions | get $version).dist.tarball
let tarball = ($tmpdir | path join "package.tgz")
http get $url | save -f $tarball
let hash = nix hash file $tarball
{
url: $url
version: $version
hash: $hash
}
}
def prefetch-github [owner: string, repo: string] {
let metadata = http get $'https://api.github.com/repos/($owner)/($repo)/releases/latest'
let prefetch = nix-prefetch-github --rev $metadata.tag_name --json $owner $repo | from json
$prefetch | select hash | insert version ($metadata.name | str trim --left --char v)
}
def update-dependency [dependency_name: string] {
const replace_attribute_template = "
id: update-attribute
language: nix
utils:
is-attribute:
kind: string_fragment
inside:
kind: binding
stopBy: end
has:
field: attrpath
regex: '{attribute}'
rule:
matches: is-attribute
not:
regex: '{replacement}'
inside:
kind: binding
stopBy: end
has:
field: attrpath
regex: '{dependency_name}'
fix: '{replacement}'"
let template_data = (
$in | if ($in has url) {
[{attribute: url replacement: $in.url}]
} else { [] }
) ++ [
{attribute: version replacement: $in.version}
{attribute: hash replacement: $in.hash}
];
let ast_grep_rule = (
$template_data
| each { $in | insert dependency_name $dependency_name | format pattern $replace_attribute_template }
| str join "\n---\n"
)
ast-grep scan --update-all --inline-rules $ast_grep_rule $self
}