chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

Social cards for the front page, repositories, pulls and issues

Pages name a 1200x630 PNG in Open Graph tags, at the page's path plus
/card.png?v=<hash of its SVG>: an SVG written like the pages, drawn in
process by resvg with Inter Display and Noto Color Emoji compiled in,
and written with a palette when it has fewer than 256 colors. Under its
version a card is immutable; cards are kept in memory and drawn on
blocking threads, one per core at most. `just bench-cards` measures
them, and the screenshots take each card once, as the `card` project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chad Miller committed on Oct 21, 2026 parent 4de1da8 commit f8d61ab
Showing 34 changed files with 1994 additions and 59 deletions
Cargo.lock+322−1
@@ -62,6 +62,12 @@ dependencies = [
"rustversion",
]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -239,6 +245,26 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -497,6 +523,12 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "defmt"
version = "0.3.101"
@@ -647,6 +679,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "euclid"
version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
dependencies = [
"num-traits",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -677,6 +718,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.1"
@@ -710,10 +760,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "fnv"
version = "1.0.7"
@@ -726,6 +782,26 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.12.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
dependencies = [
"bytemuck",
]
[[package]]
name = "fontdb"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
dependencies = [
"log",
"slotmap",
"tinyvec",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1596,6 +1672,18 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "harfrust"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
dependencies = [
"bitflags 2.13.2",
"bytemuck",
"read-fonts",
"smallvec",
]
[[package]]
name = "hash32"
version = "0.3.1"
@@ -1902,6 +1990,12 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "imagesize"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -2057,12 +2151,15 @@ dependencies = [
"futures",
"gix",
"knotless",
"png",
"pulldown-cmark",
"reqwest",
"resvg",
"rusqlite",
"serde",
"serde_json",
"sha2",
"skrifa",
"tempfile",
"tokio",
"tokio-tungstenite",
@@ -2090,6 +2187,18 @@ dependencies = [
"tree-sitter-zig",
]
[[package]]
name = "kurbo"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
"polycool",
"smallvec",
]
[[package]]
name = "libc"
version = "0.2.191"
@@ -2167,6 +2276,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -2271,6 +2390,12 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2293,6 +2418,28 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "polycool"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
"arrayvec",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -2512,6 +2659,17 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types",
"once_cell",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2611,6 +2769,21 @@ dependencies = [
"webpki-roots 1.0.10",
]
[[package]]
name = "resvg"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd"
dependencies = [
"bytemuck",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia",
"usvg",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
@@ -2621,6 +2794,15 @@ dependencies = [
"subtle",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -2673,6 +2855,15 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
@@ -2964,12 +3155,46 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]]
name = "siphasher"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "skrifa"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
dependencies = [
"bytemuck",
"read-fonts",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.16.3"
@@ -3020,6 +3245,15 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3032,6 +3266,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
dependencies = [
"kurbo",
"siphasher",
]
[[package]]
name = "syn"
version = "2.0.119"
@@ -3107,6 +3351,32 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -3550,6 +3820,12 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-bom"
version = "2.0.3"
@@ -3571,6 +3847,18 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-vo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -3589,6 +3877,33 @@ dependencies = [
"serde",
]
[[package]]
name = "usvg"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91"
dependencies = [
"base64 0.23.1",
"data-url",
"fontdb",
"harfrust",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree",
"simplecss",
"siphasher",
"skrifa",
"strict-num",
"svgtypes",
"tiny-skia-path",
"unicode-bidi",
"unicode-script",
"unicode-vo",
"xmlwriter",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3908,6 +4223,12 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "yoke"
version = "0.8.3"
Justfile+5−0
@@ -63,6 +63,11 @@ bench rtt="50":
{{cargo}} build --release
{{cargo}} run --release --example bench -- --rtt {{rtt}}
# What a social card costs to draw, stage by stage, and the PNGs, written
# to target/cards to look at.
bench-cards:
{{cargo}} run --release -p knotless-serve --example cards -- --out target/cards
serve *args:
{{cargo}} run --release -p knotless-serve -- {{args}}
README.md+8−3
@@ -56,7 +56,9 @@ agent its own branch and merge, or send commits through a gateway, which
queues them per branch and lands each queue as one push.
The web pages read a mirror's objects in process and render in about a
millisecond warm; a highlighted 40 KB source file takes a few more.
millisecond warm; a highlighted 40 KB source file takes a few more. A
page's social card is drawn in about 5 ms the first time, about 30 KB, and
served from memory after that (`just bench-cards`).
## How it works
@@ -251,7 +253,8 @@ rebuilds the index.
Code is sent as plain text and highlighted with tree-sitter ranges applied
by the CSS Custom Highlight API; links prerender on hover; pages move with
view transitions; anything named by a commit hash is cached as immutable.
Writing is for git, the CLI and agents.
The front page, repositories, pulls and issues unfurl into a social card,
drawn in process. Writing is for git, the CLI and agents.
- **An appview**: `dev.knotless.listRepos` and `dev.knotless.getForge`,
from a SQLite index of what Jetstream or the firehose reports.
- **A gateway**: `dev.knotless.pushRefs`, `dev.knotless.createCommit` and
@@ -318,6 +321,7 @@ just test-spaces # against the alpha: KNOTLESS_DEV_ENV names its dev-env
just cov # both runs, failing under 100% lines or functions
just lint # rustfmt and clippy
just bench 50 # the table above, at 50 ms RTT
just bench-cards # what a social card costs to draw, with the PNGs
```
While working, run only what a change needs:
@@ -335,7 +339,8 @@ short run. On a shared one, tests make accounts as they need them.
The screenshots ([`harness/shots`](harness/shots)) fill a reference PDS
with the same repositories, pulls and issues each time, with fixed commit
dates and record times, and take every page in light and dark, at desktop
and phone widths, with Playwright's Chromium:
and phone widths, with Playwright's Chromium, and the social cards the
pages name, which are the same in any browser, once:
```sh
just shots-up # seed once, and keep it up for the runs below
crates/knotless-serve/Cargo.toml+3−0
@@ -24,6 +24,9 @@ tower-http = { version = "0.7.1", features = ["compression-gzip", "compression-b
sha2.workspace = true
base64.workspace = true
gix = { version = "0.89", default-features = false, features = ["sha1", "max-performance-safe", "revision"] }
png = "0.18.1"
resvg = { version = "0.48.1", default-features = false, features = ["text"] }
skrifa = "0.44.0"
tree-sitter = "0.27"
tree-sitter-highlight = "0.27"
tree-sitter-rust = "0.24.2"
crates/knotless-serve/examples/cards.rsnew+171−0
@@ -0,0 +1,171 @@
//! What social cards cost: each stage of drawing one and the PNG it makes,
//! a cached card against a new one, and how many a second the cores draw
//! together.
//!
//! cargo run --release -p knotless-serve --example cards -- [--out <dir>]
//!
//! With `--out`, the cards are written there as PNGs, to look at.
use knotless_serve::card::{self, Card};
use std::time::{Duration, Instant};
/// Runs `f` until a second has passed, at least ten times; the median and
/// the slowest one in a hundred.
fn time<T>(mut f: impl FnMut() -> T) -> (Duration, Duration) {
let mut runs = Vec::new();
let start = Instant::now();
while runs.len() < 10 || start.elapsed() < Duration::from_secs(1) {
let t = Instant::now();
std::hint::black_box(f());
runs.push(t.elapsed());
}
runs.sort();
(runs[runs.len() / 2], runs[runs.len() * 99 / 100])
}
fn ms(d: Duration) -> String {
format!("{:.3}", d.as_secs_f64() * 1000.0)
}
fn cards() -> Vec<(&'static str, Card)> {
vec![
("home", Card::Home),
(
"repo",
Card::Repo {
owner: "alice.bsky.social".into(),
name: "knotless".into(),
description: Some(
"Git repositories stored natively on atproto: no git server, nothing to install on the PDS."
.into(),
),
pulls: 3,
issues: 12,
},
),
(
"issue",
Card::Item {
owner: "alice.bsky.social".into(),
name: "knotless".into(),
pull: false,
state: "open".into(),
title: "Cloning a repository with ten thousand refs takes a minute on a slow PDS"
.into(),
author: "bob.example.com".into(),
comments: 4,
},
),
(
"pull",
Card::Item {
owner: "alice.bsky.social".into(),
name: "knotless".into(),
pull: true,
state: "merged".into(),
title: "Draw social cards in process".into(),
author: "carol.test".into(),
comments: 1,
},
),
(
"emoji",
Card::Item {
owner: "alice.bsky.social".into(),
name: "knotless".into(),
pull: false,
state: "closed".into(),
title: "Titles with emoji 🎉 draw in color: 👩🏽‍💻 🇯🇵 ❤️ 🦀 not as boxes".into(),
author: "dana.test".into(),
comments: 2,
},
),
]
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let out = args
.iter()
.position(|a| a == "--out")
.map(|i| std::path::PathBuf::from(&args[i + 1]));
if let Some(dir) = &out {
std::fs::create_dir_all(dir).unwrap();
}
// The fonts load on first use; that is not what is measured.
card::render(&Card::Home.svg());
println!(
"{:<6} {:>8} {:>8} {:>8} {:>8} {:>8} {:>8}",
"card", "svg ms", "hash ms", "draw ms", "png ms", "all ms", "KB"
);
for (name, card) in cards() {
let svg = card.svg();
let (svg_ms, _) = time(|| card.svg());
let (hash_ms, _) = time(|| card::version(&svg));
let (draw_ms, _) = time(|| card::raster(&svg));
let pixmap = card::raster(&svg);
let (png_ms, _) = time(|| card::encode(&pixmap));
let (all_ms, _) = time(|| card::render(&svg));
let png = card::render(&svg);
println!(
"{name:<6} {:>8} {:>8} {:>8} {:>8} {:>8} {:>8.1}",
ms(svg_ms),
ms(hash_ms),
ms(draw_ms),
ms(png_ms),
ms(all_ms),
png.len() as f64 / 1024.0
);
if let Some(dir) = &out {
std::fs::write(dir.join(format!("{name}.png")), png).unwrap();
}
}
// A whole card as a request for it pays, new and cached; and the cores
// drawing new ones together.
let rt = tokio::runtime::Runtime::new().unwrap();
let (_, issue) = &cards()[2];
let svg = issue.svg();
let (new, new99) = time(|| card::render(&svg));
let cache = card::Cache::new(1 << 16);
let version = card::version(&svg);
rt.block_on(cache.png(&version, svg.clone()));
let (hit, hit99) = time(|| {
rt.block_on(async {
let svg = issue.svg();
let version = card::version(&svg);
cache.png(&version, svg).await
})
});
println!(
"\nan issue card, new: {} ms (p99 {}); cached, with its SVG and hash: {} ms (p99 {})",
ms(new),
ms(new99),
ms(hit),
ms(hit99)
);
let n = 256;
let start = Instant::now();
rt.block_on(futures::future::join_all((0..n).map(|i| {
let card = Card::Item {
owner: "alice.bsky.social".into(),
name: "knotless".into(),
pull: i % 2 == 0,
state: "open".into(),
title: format!("Issue number {i}, one of many drawn at once"),
author: "bob.example.com".into(),
comments: i,
};
let svg = card.svg();
let cache = &cache;
async move { cache.png(&card::version(&svg), svg).await }
})));
let took = start.elapsed();
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
println!(
"{n} new cards at once on {cores} cores: {} ms, {:.0} cards a second",
took.as_millis(),
n as f64 / took.as_secs_f64()
);
}
crates/knotless-serve/fonts/InterDisplay-Regular.ttfnew+0−0
A binary file, not shown.
crates/knotless-serve/fonts/InterDisplay-SemiBold.ttfnew+0−0
A binary file, not shown.
crates/knotless-serve/fonts/NotoColorEmoji-COLRv1.ttfnew+0−0
A binary file, not shown.
crates/knotless-serve/fonts/OFL-Inter.txtnew+92−0
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
crates/knotless-serve/fonts/OFL-Noto.txtnew+94−0
@@ -0,0 +1,94 @@
Copyright 2022 Google Inc. (https://github.com/googlefonts/noto-emoji)
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
crates/knotless-serve/src/card.rsnew+845−0
@@ -0,0 +1,845 @@
//! Social cards: the picture a link to a page unfurls into, in a chat or a
//! post. Each is an SVG written here, as the pages are, and rasterized in
//! process: no browser, and no fonts but those compiled in, Inter and Noto
//! Color Emoji for what Inter lacks, so a card looks the same on every host.
//!
//! A card's URL carries a hash of its SVG, so the picture can be cached
//! forever: when what it shows changes, so does the URL its page names.
use crate::web::{esc, icon};
use resvg::{tiny_skia, usvg};
use sha2::{Digest, Sha256};
use skrifa::MetadataProvider;
use std::collections::{HashMap, VecDeque};
use std::fmt::Write;
use std::sync::{Arc, LazyLock, Mutex};
pub const WIDTH: u32 = 1200;
pub const HEIGHT: u32 = 630;
const PAD: f32 = 80.0;
/// Where the text of the last row sits.
const FOOT: f32 = 550.0;
const FG: &str = "#1c1d20";
const MUTE: &str = "#6a6d75";
const ACCENT: &str = "#2f6af5";
static REGULAR: &[u8] = include_bytes!("../fonts/InterDisplay-Regular.ttf");
static SEMIBOLD: &[u8] = include_bytes!("../fonts/InterDisplay-SemiBold.ttf");
static EMOJI: &[u8] = include_bytes!("../fonts/NotoColorEmoji-COLRv1.ttf");
/// Read where they are compiled in, not copied.
static FONTS: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
let mut db = usvg::fontdb::Database::new();
for data in [REGULAR, SEMIBOLD, EMOJI] {
db.load_font_source(usvg::fontdb::Source::Binary(Arc::new(data)));
}
Arc::new(db)
});
fn emoji() -> skrifa::FontRef<'static> {
skrifa::FontRef::new(EMOJI).expect("the fonts compiled in parse")
}
#[derive(Clone, Copy)]
enum Weight {
Regular,
Semibold,
}
impl Weight {
fn font(self) -> skrifa::FontRef<'static> {
let data = match self {
Weight::Regular => REGULAR,
Weight::Semibold => SEMIBOLD,
};
skrifa::FontRef::new(data).expect("the fonts compiled in parse")
}
fn css(self) -> u16 {
match self {
Weight::Regular => 400,
Weight::Semibold => 600,
}
}
}
/// Whether `c` draws as part of the character before it, `prev`, rather
/// than on its own: the joiners, selectors, skin tones and tags of an emoji
/// sequence, a keycap, what a joiner joins, and the second letter of a flag
/// (`flag` being whether `prev` is a flag's first).
fn joins(prev: Option<char>, c: char, flag: bool) -> bool {
let Some(prev) = prev else {
return false;
};
matches!(
c,
'\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{20E3}' | '\u{1F3FB}'..='\u{1F3FF}' | '\u{E0020}'..='\u{E007F}'
) || prev == '\u{200D}'
|| (flag && is_flag_letter(c))
}
fn is_flag_letter(c: char) -> bool {
('\u{1F1E6}'..='\u{1F1FF}').contains(&c)
}
/// `text` split where a line may break inside a word: between the
/// characters drawn apart, each with the sequence it heads.
fn clusters(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut prev = None;
let mut flag = false;
for (i, c) in text.char_indices() {
if joins(prev, c, flag) {
flag = false;
} else {
if i > start {
out.push(&text[start..i]);
}
start = i;
flag = is_flag_letter(c);
}
prev = Some(c);
}
if start < text.len() {
out.push(&text[start..]);
}
out
}
/// How wide `text` is set at `size`: each cluster as wide as its first
/// character, in Inter, or the emoji if Inter lacks it. Kerning is left
/// out, which errs wide.
fn measure(text: &str, size: f32, weight: Weight) -> f32 {
let size = skrifa::instance::Size::new(size);
let at = skrifa::instance::LocationRef::default();
let (text_font, emoji) = (weight.font(), emoji());
let (text_map, emoji_map) = (text_font.charmap(), emoji.charmap());
let text_metrics = text_font.glyph_metrics(size, at);
let emoji_metrics = emoji.glyph_metrics(size, at);
clusters(text)
.into_iter()
.map(|cluster| {
let c = cluster.chars().next().expect("a cluster is not empty");
let advance = match (text_map.map(c), emoji_map.map(c)) {
(None, Some(glyph)) => emoji_metrics.advance_width(glyph),
// What Inter lacks and the emoji too is drawn as Inter's box.
(glyph, _) => text_metrics.advance_width(glyph.unwrap_or_default()),
};
advance.unwrap_or_default()
})
.sum()
}
/// `text` broken into at most `max` lines no wider than `width`, the last
/// ending in an ellipsis if it had to be cut.
fn wrap(text: &str, size: f32, weight: Weight, width: f32, max: usize) -> Vec<String> {
let fits = |s: &str| measure(s, size, weight) <= width;
let mut lines: Vec<String> = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if lines.len() > max {
break;
}
let joined = format!("{line} {word}");
let joined = joined.trim_start();
if fits(joined) {
line = joined.to_string();
continue;
}
if !line.is_empty() {
lines.push(std::mem::take(&mut line));
}
// A word wider than a line is broken where it overflows, between
// what is drawn apart.
for cluster in clusters(word) {
let alone = line.is_empty();
line.push_str(cluster);
if !alone && !fits(&line) {
line.truncate(line.len() - cluster.len());
lines.push(std::mem::replace(&mut line, cluster.to_string()));
}
}
}
if !line.is_empty() {
lines.push(line);
}
if lines.len() > max {
lines.truncate(max);
let last = lines.last_mut().expect("max is at least one");
while !fits(&format!("{last}…")) && !last.is_empty() {
let end = clusters(last).last().expect("not empty").len();
last.truncate(last.len() - end);
}
let kept = last.trim_end().len();
last.truncate(kept);
last.push('…');
}
lines
}
/// Whether `c` is drawn from the emoji, as Inter lacks it.
fn is_emoji(c: char) -> bool {
Weight::Regular.font().charmap().map(c).is_none() && emoji().charmap().map(c).is_some()
}
/// `text`, left on `x`, escaped: in Inter, and each run of what Inter
/// lacks in the emoji. Each run is placed on its own, so it is shaped in
/// its font alone: the renderer, given runs in two fonts to shape together,
/// drops sequences of emoji, or fails on them.
fn spans(text: &str, x: f32, size: f32, weight: Weight) -> String {
// Inter has all of ASCII.
if text.is_ascii() {
return esc(text);
}
let mut runs: Vec<(bool, String)> = Vec::new();
for cluster in clusters(text) {
let emoji = is_emoji(cluster.chars().next().expect("a cluster is not empty"));
match runs.last_mut() {
Some((was, run)) if *was == emoji => run.push_str(cluster),
_ => runs.push((emoji, cluster.to_string())),
}
}
if runs.iter().all(|(emoji, _)| !emoji) {
return esc(text);
}
let mut out = String::new();
let mut x = x;
for (emoji, run) in runs {
let family = if emoji {
r#" font-family="Noto Color Emoji""#
} else {
""
};
write!(out, r#"<tspan x="{x}"{family}>{}</tspan>"#, esc(&run)).unwrap();
x += measure(&run, size, weight);
}
out
}
/// Lines of text, `leading` apart, the first on `y`.
fn lines(
out: &mut String,
lines: &[String],
x: f32,
y: f32,
leading: f32,
(size, weight, fill): (f32, Weight, &str),
) {
for (i, line) in lines.iter().enumerate() {
write!(
out,
r#"<text x="{x}" y="{}" {}>{}</text>"#,
y + leading * i as f32,
style(size, weight, fill),
spans(line, x, size, weight)
)
.unwrap();
}
}
fn style(size: f32, weight: Weight, fill: &str) -> String {
format!(
r#"font-size="{size}" font-weight="{}" fill="{fill}""#,
weight.css()
)
}
/// A 16-unit icon, `size` across, its top left at `x`, `y`.
fn icon(out: &mut String, path: &str, x: f32, y: f32, size: f32, fill: &str) {
write!(
out,
r#"<path transform="translate({x} {y}) scale({})" fill="{fill}" d="{path}"/>"#,
size / 16.0
)
.unwrap();
}
/// The mark, `size` across, and the name beside it, its left at `x`.
fn brand(out: &mut String, x: f32, baseline: f32, size: f32) {
let top = baseline - size * 0.84;
write!(
out,
r##"<g transform="translate({x} {top}) scale({})"><rect width="24" height="24" rx="6.5" fill="{FG}"/><path d="M7 12h10" stroke="#fff" stroke-width="2" stroke-linecap="round"/><circle cx="7" cy="12" r="2.6" fill="#fff"/><circle cx="17" cy="12" r="2.6" fill="#fff"/></g>"##,
size / 24.0
)
.unwrap();
write!(
out,
r#"<text x="{}" y="{baseline}" {}>knotless</text>"#,
x + size * 1.3,
style(size * 0.78, Weight::Semibold, FG)
)
.unwrap();
}
/// How wide `brand` draws at `size`.
fn brand_width(size: f32) -> f32 {
size * 1.3 + measure("knotless", size * 0.78, Weight::Semibold)
}
/// The hue a name's mark is drawn in.
pub(crate) fn hue(name: &str) -> u32 {
name.bytes()
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32))
% 360
}
/// The letter a name's mark shows.
pub(crate) fn initial(name: &str) -> char {
name.chars().find(|c| c.is_alphanumeric()).unwrap_or('?')
}
/// A name's round mark, `r` in radius, centered on `cx`, `cy`.
fn avatar(out: &mut String, name: &str, cx: f32, cy: f32, r: f32) {
write!(
out,
r#"<circle cx="{cx}" cy="{cy}" r="{r}" fill="hsl({}, 45%, 52%)"/><text x="{cx}" y="{}" text-anchor="middle" {}>{}</text>"#,
hue(name),
cy + r * 0.36,
style(r, Weight::Semibold, "#fff"),
esc(&initial(name).to_uppercase().to_string())
)
.unwrap();
}
/// The color of an item's state, as the pages draw it.
fn state_color(state: &str) -> &'static str {
match state {
"open" => "#1d8348",
"merged" => "#7a48d6",
_ => "#c2410c",
}
}
fn plural(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
/// What a card shows.
#[derive(Debug, Clone, PartialEq)]
pub enum Card {
/// The front page.
Home,
Repo {
owner: String,
name: String,
description: Option<String>,
/// Open pulls and issues.
pulls: usize,
issues: usize,
},
/// A pull or an issue.
Item {
owner: String,
name: String,
pull: bool,
state: String,
title: String,
author: String,
comments: usize,
},
}
impl Card {
pub fn svg(&self) -> String {
let mut out = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}"><rect width="{WIDTH}" height="{HEIGHT}" fill="#fff"/>"##
);
let wide = WIDTH as f32 - PAD * 2.0;
let bar = match self {
Card::Home => {
brand(&mut out, PAD, 168.0, 88.0);
let head = wrap(
"Git that lives in your atproto account.",
80.0,
Weight::Semibold,
wide,
2,
);
let at = 330.0;
lines(&mut out, &head, PAD, at, 92.0, (80.0, Weight::Semibold, FG));
let sub = wrap(
"Repositories as records on your own PDS. Clone and push with plain git; agents read, review and merge over MCP.",
36.0,
Weight::Regular,
wide,
2,
);
lines(
&mut out,
&sub,
PAD,
at + 92.0 + 80.0,
50.0,
(36.0, Weight::Regular, MUTE),
);
ACCENT.to_string()
}
Card::Repo {
owner,
name,
description,
pulls,
issues,
} => {
top(&mut out, owner, None);
let title = wrap(name, 84.0, Weight::Semibold, wide, 2);
lines(
&mut out,
&title,
PAD,
268.0,
96.0,
(84.0, Weight::Semibold, FG),
);
let below = 268.0 + 96.0 * (title.len() - 1) as f32 + 72.0;
// As many lines as fit above the last row.
let room = ((470.0 - below) / 48.0) as usize + 1;
let about = wrap(
description.as_deref().unwrap_or_default(),
36.0,
Weight::Regular,
wide,
room,
);
lines(
&mut out,
&about,
PAD,
below,
48.0,
(36.0, Weight::Regular, MUTE),
);
brand(&mut out, PAD, FOOT, 44.0);
// The counts, from the right edge leftward.
let mut right = WIDTH as f32 - PAD;
for (path, text) in [
(icon::ISSUE, plural(*issues, "open issue", "open issues")),
(icon::PULL, plural(*pulls, "open pull", "open pulls")),
] {
let w = measure(&text, 32.0, Weight::Regular);
write!(
out,
r#"<text x="{right}" y="{FOOT}" text-anchor="end" {}>{}</text>"#,
style(32.0, Weight::Regular, MUTE),
text
)
.unwrap();
icon(&mut out, path, right - w - 42.0, FOOT - 28.0, 32.0, MUTE);
right -= w + 42.0 + 40.0;
}
format!("hsl({}, 45%, 52%)", hue(owner))
}
Card::Item {
owner,
name,
pull,
state,
title,
author,
comments,
} => {
top(&mut out, owner, Some(name));
let head = wrap(title, 72.0, Weight::Semibold, wide, 3);
lines(
&mut out,
&head,
PAD,
262.0,
86.0,
(72.0, Weight::Semibold, FG),
);
let color = state_color(state);
let label = {
let mut c = state.chars();
c.next()
.map(|f| f.to_uppercase().chain(c).collect::<String>())
.unwrap_or_default()
};
let path = match (*pull, state.as_str()) {
(true, "merged") => icon::MERGE,
(true, _) => icon::PULL,
(false, "open") => icon::ISSUE,
_ => icon::DONE,
};
let pill = 70.0 + measure(&label, 30.0, Weight::Semibold) + 28.0;
write!(
out,
r#"<rect x="{PAD}" y="{}" width="{pill}" height="60" rx="30" fill="{color}"/>"#,
FOOT - 41.0,
)
.unwrap();
let label = [label];
lines(
&mut out,
&label,
PAD + 70.0,
FOOT,
0.0,
(30.0, Weight::Semibold, "#fff"),
);
icon(&mut out, path, PAD + 26.0, FOOT - 27.0, 32.0, "#fff");
let mark = brand_width(44.0);
let x = PAD + pill + 28.0;
let said = plural(*comments, "comment", "comments");
let room = WIDTH as f32 - PAD - mark - 48.0 - x;
let by = wrap(
&format!("{author} · {said}"),
32.0,
Weight::Regular,
room,
1,
);
lines(&mut out, &by, x, FOOT, 0.0, (32.0, Weight::Regular, MUTE));
brand(&mut out, WIDTH as f32 - PAD - mark, FOOT, 44.0);
color.to_string()
}
};
write!(
out,
r#"<rect y="{}" width="{WIDTH}" height="14" fill="{bar}"/></svg>"#,
HEIGHT - 14
)
.unwrap();
out
}
}
/// The first row: the owner's mark and name, and the repository's after.
fn top(out: &mut String, owner: &str, name: Option<&str>) {
avatar(out, owner, PAD + 32.0, 112.0, 32.0);
let x = PAD + 84.0;
let room = WIDTH as f32 - PAD - x;
let Some(name) = name else {
let shown = wrap(owner, 36.0, Weight::Regular, room, 1);
lines(out, &shown, x, 125.0, 0.0, (36.0, Weight::Regular, MUTE));
return;
};
let owner = wrap(&format!("{owner}/"), 36.0, Weight::Regular, room / 2.0, 1);
let after = x + measure(&owner[0], 36.0, Weight::Regular);
let name = wrap(name, 36.0, Weight::Semibold, WIDTH as f32 - PAD - after, 1);
lines(out, &owner, x, 125.0, 0.0, (36.0, Weight::Regular, MUTE));
lines(out, &name, after, 125.0, 0.0, (36.0, Weight::Semibold, FG));
}
/// What names a card's picture: a hash of its SVG.
pub fn version(svg: &str) -> String {
let mut out = String::with_capacity(16);
for b in &Sha256::digest(svg)[..8] {
write!(out, "{b:02x}").unwrap();
}
out
}
/// The SVG drawn.
pub fn raster(svg: &str) -> tiny_skia::Pixmap {
let options = usvg::Options {
font_family: "Inter Display".into(),
fontdb: FONTS.clone(),
..Default::default()
};
let tree = usvg::Tree::from_str(svg, &options).expect("a card's SVG parses");
let mut pixmap = tiny_skia::Pixmap::new(WIDTH, HEIGHT).expect("a card has a size");
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
pixmap
}
/// The drawing as a PNG. A card is a few flat colors and the edges between
/// them, not 256 in all, so it is written exactly with a palette: a third
/// of the bytes to compress, and a fast compressor makes it smaller than
/// a thorough one makes RGB. With more colors, it is RGB.
pub fn encode(pixmap: &tiny_skia::Pixmap) -> Vec<u8> {
let (w, h) = (pixmap.width(), pixmap.height());
// Opaque, so its premultiplied colors are its colors.
let pixels = pixmap.data().chunks_exact(4).map(|p| [p[0], p[1], p[2]]);
let mut palette: Vec<u8> = Vec::new();
let mut known: HashMap<[u8; 3], u8> = HashMap::new();
let mut indexed = Vec::with_capacity((w * h) as usize);
// Neighbours are mostly the same color: it is looked up once a run.
let mut last = None;
for rgb in pixels.clone() {
let i = match last {
Some((seen, i)) if seen == rgb => i,
_ => {
let i = match known.get(&rgb) {
Some(&i) => i,
None => {
let Ok(i) = u8::try_from(known.len()) else {
let rgb: Vec<u8> = pixels.flatten().collect();
return png(w, h, png::ColorType::Rgb, None, &rgb);
};
known.insert(rgb, i);
palette.extend_from_slice(&rgb);
i
}
};
last = Some((rgb, i));
i
}
};
indexed.push(i);
}
png(w, h, png::ColorType::Indexed, Some(palette), &indexed)
}
fn png(w: u32, h: u32, color: png::ColorType, palette: Option<Vec<u8>>, data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len() / 16);
let mut encoder = png::Encoder::new(&mut out, w, h);
encoder.set_color(color);
encoder.set_depth(png::BitDepth::Eight);
if let Some(palette) = palette {
encoder.set_palette(palette);
}
encoder.set_compression(png::Compression::Fast);
encoder.set_filter(png::Filter::Adaptive);
let mut writer = encoder.write_header().expect("writing to memory");
writer.write_image_data(data).expect("writing to memory");
writer.finish().expect("writing to memory");
out
}
/// The SVG as a PNG.
pub fn render(svg: &str) -> Vec<u8> {
encode(&raster(svg))
}
/// Cards drawn, by version, the oldest dropped past a number of them; and
/// a cap on how many are drawn at once, as each takes a core.
pub struct Cache {
drawn: Mutex<Drawn>,
cap: usize,
drawing: tokio::sync::Semaphore,
}
#[derive(Default)]
struct Drawn {
by_version: HashMap<String, Arc<[u8]>>,
/// Oldest first.
order: VecDeque<String>,
}
impl Cache {
pub fn new(cap: usize) -> Cache {
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
Cache {
drawn: Mutex::default(),
cap,
drawing: tokio::sync::Semaphore::new(cores),
}
}
/// The PNG of `svg`, whose version is `version`.
pub async fn png(&self, version: &str, svg: String) -> Arc<[u8]> {
if let Some(png) = self.drawn.lock().unwrap().by_version.get(version) {
return png.clone();
}
let _turn = self.drawing.acquire().await.expect("never closed");
let png: Arc<[u8]> = tokio::task::spawn_blocking(move || render(&svg))
.await
.expect("drawing a card does not panic")
.into();
let mut drawn = self.drawn.lock().unwrap();
let Drawn { by_version, order } = &mut *drawn;
if by_version
.insert(version.to_string(), png.clone())
.is_none()
{
order.push_back(version.to_string());
}
while order.len() > self.cap {
let oldest = order.pop_front().expect("longer than the cap");
by_version.remove(&oldest);
}
png
}
pub fn len(&self) -> usize {
self.drawn.lock().unwrap().order.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrapping() {
let w = |t: &str, width: f32, max: usize| wrap(t, 40.0, Weight::Regular, width, max);
assert_eq!(w("one two three", 2000.0, 1), ["one two three"]);
assert_eq!(w("one two three", 170.0, 3), ["one two", "three"]);
let cut = w("one two three four five six", 170.0, 2);
assert_eq!(cut.len(), 2);
assert!(cut[1].ends_with('…'), "{cut:?}");
// A word longer than a line breaks where it overflows.
let long = w(&"x".repeat(40), 200.0, 5);
assert!(long.len() > 1 && long.concat().len() == 40, "{long:?}");
assert!(
long.iter()
.all(|l| measure(l, 40.0, Weight::Regular) <= 200.0)
);
assert!(w("", 100.0, 2).is_empty());
assert_eq!(w("a b c d e f g h", 1.0, 1), ["…"]);
// Narrower than one letter, a line still takes one.
assert_eq!(w("ab", 1.0, 3), ["a", "b"]);
}
#[test]
fn marks() {
assert_eq!(initial("_alice"), 'a');
assert_eq!(initial("…"), '?');
assert!(hue("alice") < 360);
assert_eq!(state_color("closed"), "#c2410c");
// Both weights are one family, so asking for 600 gets the bolder.
let faces: Vec<(String, u16)> = FONTS
.faces()
.map(|f| (f.families[0].0.clone(), f.weight.0))
.collect();
let family = || "Inter Display".to_string();
let emoji = ("Noto Color Emoji".to_string(), 400);
assert_eq!(faces, [(family(), 400), (family(), 600), emoji]);
assert!(measure("W", 40.0, Weight::Semibold) > measure("i", 40.0, Weight::Semibold));
// A letter the fonts lack is measured as the box drawn for it.
assert!(measure("漢", 40.0, Weight::Regular) > 0.0);
}
#[test]
fn emoji() {
let one = measure("😀", 40.0, Weight::Regular);
// As wide as Noto draws it, not as Inter's box.
assert!(one > measure("W", 40.0, Weight::Regular), "{one}");
assert_ne!(one, measure("漢", 40.0, Weight::Regular));
// A sequence is drawn as one, however many characters it takes.
for one_of in [
"👩‍💻",
"👋🏽",
"❤️",
"🇫🇷",
"#️⃣",
"🏴\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}",
"👨‍👩‍👧‍👦",
] {
assert_eq!(clusters(one_of), [one_of]);
}
assert_eq!(measure("👨‍👩‍👧‍👦", 40.0, Weight::Regular), one);
// Flags pair off; a letter left over stands alone.
assert_eq!(clusters("🇫🇷🇩🇪🇮"), ["🇫🇷", "🇩🇪", "🇮"]);
assert_eq!(clusters("a😀b"), ["a", "😀", "b"]);
// A selector with nothing before it stands alone.
assert_eq!(clusters("\u{FE0F}x"), ["\u{FE0F}", "x"]);
// Lines break, and are cut, between sequences, never inside one.
let family = "👨‍👩‍👧‍👦".repeat(12);
let broken = wrap(&family, 40.0, Weight::Regular, one * 5.0, 2);
assert_eq!(broken[0], "👨‍👩‍👧‍👦".repeat(5));
assert!(broken[1].ends_with('…'), "{broken:?}");
assert!(clusters(&broken[1]).iter().all(|c| *c == "👨‍👩‍👧‍👦" || *c == "…"));
let repo = |description: &str| {
render(
&Card::Repo {
owner: "alice.test".into(),
name: "party".into(),
description: Some(description.into()),
pulls: 0,
issues: 0,
}
.svg(),
)
};
// Drawn in Noto's colors, not as boxes in the text's.
let drawn = repo("Flags 🇫🇷🇯🇵, people 👩🏽‍🔬 and hearts ❤️");
assert!(drawn.len() > repo("Flags ab, people c and hearts d").len());
// What no keyboard makes draws too, rather than failing the card.
for odd in [
"😀\u{200D}a",
"a\u{200D}😀",
"\u{200D}\u{FE0F}",
"🇫 x",
"#\u{FE0F}\u{20E3}x",
"😀\u{200D}漢 é 😀",
"\u{1F3FD}\u{1F3FD}",
"<😀&>",
] {
repo(odd);
}
}
fn item(state: &str, pull: bool) -> Card {
Card::Item {
owner: "alice.test".into(),
name: "tracked".into(),
pull,
state: state.into(),
title: "Broken <thing> & more".into(),
author: "bob.test".into(),
comments: 1,
}
}
#[test]
fn every_card_draws() {
let cards = [
Card::Home,
Card::Repo {
owner: "alice.test".into(),
name: "a-repository-name-long-enough-to-wrap-onto-two-lines".into(),
description: Some("Tracks <things>.".into()),
pulls: 1,
issues: 2,
},
Card::Repo {
owner: "alice.test".into(),
name: "bare".into(),
description: None,
pulls: 0,
issues: 0,
},
item("open", true),
item("merged", true),
item("closed", true),
item("open", false),
item("closed", false),
item("", false),
];
let mut versions = std::collections::HashSet::new();
for card in &cards {
let svg = card.svg();
assert!(!svg.contains("<thing>"), "{svg}");
assert!(versions.insert(version(&svg)));
let png = render(&svg);
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
}
assert_eq!(version("x").len(), 16);
}
#[test]
fn more_than_a_palette_holds() {
let mut pixmap = tiny_skia::Pixmap::new(32, 32).unwrap();
for (i, p) in pixmap.data_mut().chunks_exact_mut(4).enumerate() {
p.copy_from_slice(&[i as u8, (i >> 8) as u8, 0, 255]);
}
let rgb = encode(&pixmap);
// The header's color type: 2 is RGB, 3 a palette.
assert_eq!(rgb[25], 2);
assert_eq!(render(&Card::Home.svg())[25], 3);
}
#[tokio::test]
async fn the_cache_keeps_the_newest() {
let cache = Cache::new(2);
assert!(cache.is_empty());
let svg = Card::Home.svg();
let first = cache.png("a", svg.clone()).await;
let again = cache.png("a", svg.clone()).await;
assert!(Arc::ptr_eq(&first, &again));
cache.png("b", svg.clone()).await;
cache.png("c", svg.clone()).await;
assert_eq!(cache.len(), 2);
let redrawn = cache.png("a", svg).await;
assert!(!Arc::ptr_eq(&first, &redrawn));
}
}
crates/knotless-serve/src/lib.rs+1−0
@@ -16,6 +16,7 @@
mod api;
mod appview;
pub mod card;
pub mod diff;
pub mod firehose;
pub mod gateway;
crates/knotless-serve/src/web.rs+257−43
@@ -7,6 +7,7 @@
//! the CSS Custom Highlight API so code arrives as plain text, and copies
//! commands to the clipboard.
use crate::card::{self, Card};
use crate::diff::{self, Kind, Status};
use crate::objects::{self, Objects};
use crate::{App, AppError, highlight, repo_name};
@@ -42,7 +43,14 @@ pub fn routes() -> Router<Arc<App>> {
.route("/{authority}/{repo}/pulls", get(pulls))
.route("/{authority}/{repo}/issues", get(issues))
.route("/{authority}/{repo}/{kind}/{did}/{rkey}", get(item))
.route("/card.png", get(home_card))
.route("/{authority}/{repo}/card.png", get(repo_card))
.route(
"/{authority}/{repo}/{kind}/{did}/{rkey}/card.png",
get(item_card_png),
)
.layer(middleware::from_fn(renamed))
.layer(middleware::from_fn(origin))
.layer(tower_http::compression::CompressionLayer::new())
}
@@ -76,6 +84,25 @@ async fn renamed(req: Request, next: Next) -> Response {
res
}
tokio::task_local! {
/// Where the request being answered was sent: `https://host`.
static ORIGIN: String;
}
/// Notes where the request was sent, for the links a page must give in
/// full. A proxy in front says with `X-Forwarded-Proto` if it took https.
async fn origin(req: Request, next: Next) -> Response {
let h = req.headers();
let host = h
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost");
let https = h.get("x-forwarded-proto").is_some_and(|v| v == "https");
let scheme = if https { "https" } else { "http" };
let at = format!("{scheme}://{host}");
ORIGIN.scope(at, next.run(req)).await
}
/// Text inlined above, past this, is a link to the raw file instead.
const INLINE: usize = 1024 * 1024;
const COMMITS_PER_PAGE: usize = 50;
@@ -293,7 +320,7 @@ static POLICY: LazyLock<String> = LazyLock::new(|| {
const LOGO: &str = r#"<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="24" height="24" rx="6.5" fill="currentColor"/><path d="M7 12h10" stroke="var(--bg)" stroke-width="2" stroke-linecap="round"/><circle cx="7" cy="12" r="2.6" fill="var(--bg)"/><circle cx="17" cy="12" r="2.6" fill="var(--bg)"/></svg>"#;
/// Icons, each a 16-unit path.
mod icon {
pub(crate) mod icon {
pub const CODE: &str = "M4.7 3.3a.75.75 0 0 1 0 1.06L1.06 8l3.64 3.64a.75.75 0 1 1-1.06 1.06L-.53 8.53a.75.75 0 0 1 0-1.06L3.64 3.3a.75.75 0 0 1 1.06 0Zm6.6 0a.75.75 0 0 1 1.06 0l4.17 4.17a.75.75 0 0 1 0 1.06l-4.17 4.17a.75.75 0 1 1-1.06-1.06L14.94 8 11.3 4.36a.75.75 0 0 1 0-1.06Z";
pub const COMMIT: &str = "M11.93 8.5a4 4 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4 4 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z";
pub const PULL: &str = "M1.5 3.25a2.25 2.25 0 1 1 3 2.12v5.26a2.25 2.25 0 1 1-1.5 0V5.37a2.25 2.25 0 0 1-1.5-2.12Zm5.72-.53L9.47.47a.75.75 0 0 1 1.06 1.06l-.97.97h1.69A2.75 2.75 0 0 1 14 5.25v5.38a2.25 2.25 0 1 1-1.5 0V5.25c0-.69-.56-1.25-1.25-1.25H9.56l.97.97a.75.75 0 0 1-1.06 1.06L7.22 3.78a.75.75 0 0 1 0-1.06ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z";
@@ -312,7 +339,7 @@ fn svg(path: &str) -> String {
format!(r#"<svg class="i" viewBox="0 0 16 16" aria-hidden="true"><path d="{path}"/></svg>"#)
}
fn esc(s: &str) -> String {
pub(crate) fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
@@ -374,14 +401,10 @@ fn subject(message: &str) -> &str {
/// A round mark with a name's first letter, in a color its name picks.
fn avatar(name: &str, class: &str) -> String {
let hue = name
.bytes()
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32))
% 360;
let letter = name.chars().find(|c| c.is_alphanumeric()).unwrap_or('?');
format!(
r#"<span class="av{class}" style="--h:{hue}" aria-hidden="true">{}</span>"#,
esc(&letter.to_string())
r#"<span class="av{class}" style="--h:{}" aria-hidden="true">{}</span>"#,
card::hue(name),
esc(&card::initial(name).to_string())
)
}
@@ -410,10 +433,58 @@ fn command(text: &str) -> String {
)
}
fn layout(title: &str, body: &str) -> String {
/// What a page says of itself to whatever unfurls a link to it.
#[derive(Default)]
struct Meta {
description: Option<String>,
/// Its card, and the path of the page it is the card of.
card: Option<(String, Card)>,
}
impl Meta {
fn tags(&self, title: &str) -> String {
let mut out = format!(
r#"<meta property="og:site_name" content="knotless"><meta property="og:title" content="{}">"#,
esc(title)
);
if let Some(about) = &self.description {
write!(
out,
r#"<meta name="description" content="{0}"><meta property="og:description" content="{0}">"#,
esc(about)
)
.unwrap();
}
if let Some((path, card)) = &self.card {
let origin = ORIGIN.try_with(String::clone).unwrap_or_default();
let url = format!("{origin}{path}/card.png?v={}", card::version(&card.svg()));
write!(
out,
r#"<meta property="og:image" content="{}"><meta property="og:image:width" content="{}"><meta property="og:image:height" content="{}"><meta name="twitter:card" content="summary_large_image">"#,
esc(&url),
card::WIDTH,
card::HEIGHT
)
.unwrap();
}
out
}
}
/// `text` on one line, cut at `n` characters.
fn summary(text: &str, n: usize) -> String {
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
match flat.char_indices().nth(n) {
Some((at, _)) => format!("{}…", flat[..at].trim_end()),
None => flat,
}
}
fn layout(title: &str, meta: &Meta, body: &str) -> String {
format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>{}</title><style>{CSS}</style><script type="speculationrules">{SPECULATION}</script></head><body><header class="top"><div class="wrap"><a class="brand" href="/">{LOGO}<span>knotless</span></a><nav><a href="/#repositories">Repositories</a><a href="/#connect">Connect</a></nav></div></header><main class="wrap">{body}</main><footer class="wrap foot">Git repositories stored in atproto accounts. This server only reads them.</footer><script>{SCRIPT}</script></body></html>"#,
esc(title)
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>{}</title>{}<style>{CSS}</style><script type="speculationrules">{SPECULATION}</script></head><body><header class="top"><div class="wrap"><a class="brand" href="/">{LOGO}<span>knotless</span></a><nav><a href="/#repositories">Repositories</a><a href="/#connect">Connect</a></nav></div></header><main class="wrap">{body}</main><footer class="wrap foot">Git repositories stored in atproto accounts. This server only reads them.</footer><script>{SCRIPT}</script></body></html>"#,
esc(title),
meta.tags(title)
)
}
@@ -427,8 +498,8 @@ fn cache_for(rev: &str) -> &'static str {
}
}
fn page(status: StatusCode, cache: &'static str, title: &str, body: &str) -> Response {
let mut res = (status, layout(title, body)).into_response();
fn page(status: StatusCode, cache: &'static str, title: &str, meta: &Meta, body: &str) -> Response {
let mut res = (status, layout(title, meta, body)).into_response();
let h = res.headers_mut();
h.insert(
header::CONTENT_TYPE,
@@ -472,7 +543,7 @@ impl IntoResponse for Shown {
self.0.as_u16(),
esc(&self.1)
);
page(self.0, "no-store", reason, &body)
page(self.0, "no-store", reason, &Meta::default(), &body)
}
}
@@ -606,7 +677,19 @@ impl Repo {
Some((open("pull"), open("issue")))
}
async fn header(&self, app: &App, tab: Tab) -> String {
/// Its card, as `record` and the counts of open pulls and issues say.
fn card(&self, record: Option<&RepoRecord>, (pulls, issues): (usize, usize)) -> Card {
Card::Repo {
owner: self.authority.clone(),
name: self.name.clone(),
description: record.and_then(|r| r.description.clone()),
pulls,
issues,
}
}
/// The top of each of its pages, and what those pages say of themselves.
async fn header(&self, app: &App, tab: Tab) -> (String, Meta) {
let record = self.record(app);
let mut out = format!(
r#"<div class="repohead"><div class="about"><h1>{}<a href="/{}">{}</a><span class="mute">/</span><a href="{}"><b>{}</b></a></h1>"#,
@@ -638,7 +721,8 @@ impl Repo {
copy(&clone)
)
.unwrap();
let (pulls, issues) = self.open_items(app).unwrap_or_default();
let open = self.open_items(app).unwrap_or_default();
let (pulls, issues) = open;
let count = |n: usize| {
if n > 0 {
format!(r#"<span class="count">{n}</span>"#)
@@ -684,7 +768,17 @@ impl Repo {
.unwrap();
}
out.push_str("</nav>");
out
let description = record
.as_ref()
.and_then(|r| r.description.clone())
.unwrap_or_else(|| {
format!("A git repository in {}'s atproto account.", self.authority)
});
let meta = Meta {
description: Some(description),
card: Some((self.href(""), self.card(record.as_ref(), open))),
};
(out, meta)
}
/// Splits `rest` into a revision and a path: the longest branch or tag
@@ -773,8 +867,12 @@ async fn home(
command("claude mcp add knotless -- knotless-mcp"),
command(mcp),
);
let meta = Meta {
description: Some("Git repositories stored in atproto accounts: clone and push with plain git, and let agents work over MCP.".into()),
card: Some((String::new(), Card::Home)),
};
let Some(index) = &app.index else {
return Ok(page(StatusCode::OK, "no-cache", "knotless", &body));
return Ok(page(StatusCode::OK, "no-cache", "knotless", &meta, &body));
};
let q = query.q.unwrap_or_default();
write!(
@@ -807,7 +905,7 @@ async fn home(
body.push_str(r#"<li class="empty">No repositories found.</li>"#);
}
body.push_str("</ul></div></section>");
Ok(page(StatusCode::OK, "no-cache", "knotless", &body))
Ok(page(StatusCode::OK, "no-cache", "knotless", &meta, &body))
}
/// An account: who it is, as its Bluesky profile says if it has one, and
@@ -886,10 +984,15 @@ async fn profile(State(app): State<Arc<App>>, Path(authority): Path<String>) ->
.unwrap();
}
body.push_str("</ul></div>");
let meta = Meta {
description: about["description"].as_str().map(|bio| summary(bio, 200)),
card: None,
};
Ok(page(
StatusCode::OK,
"no-cache",
name.unwrap_or(&shown),
&meta,
&body,
))
}
@@ -899,7 +1002,7 @@ type RestPath = Path<(String, String, String)>;
async fn code(State(app): State<Arc<App>>, Path((authority, name)): RepoPath) -> Result<Response> {
let repo = Repo::open(&app, &authority, &name).await?;
let mut body = repo.header(&app, Tab::Code).await;
let (mut body, meta) = repo.header(&app, Tab::Code).await;
if repo.objects()?.tree("HEAD", "").is_err() {
write!(
body,
@@ -907,10 +1010,10 @@ async fn code(State(app): State<Arc<App>>, Path((authority, name)): RepoPath) ->
command(&format!("git push knotless://{}/{} main", repo.authority, repo.name))
)
.unwrap();
return Ok(page(StatusCode::OK, "no-cache", &repo.name, &body));
return Ok(page(StatusCode::OK, "no-cache", &repo.name, &meta, &body));
}
let head = repo.head.clone();
listing(&repo, &head, "", body)
listing(&repo, &head, "", body, &meta)
}
async fn tree(
@@ -919,12 +1022,12 @@ async fn tree(
) -> Result<Response> {
let repo = Repo::open(&app, &authority, &name).await?;
let (rev, path) = repo.locate(&rest)?;
let body = repo.header(&app, Tab::Code).await;
listing(&repo, &rev, path, body)
let (body, meta) = repo.header(&app, Tab::Code).await;
listing(&repo, &rev, path, body, &meta)
}
/// A directory, its latest commit, and its README.
fn listing(repo: &Repo, rev: &str, path: &str, mut body: String) -> Result<Response> {
fn listing(repo: &Repo, rev: &str, path: &str, mut body: String, meta: &Meta) -> Result<Response> {
let o = repo.objects()?;
let mut entries = o.tree(rev, path).map_err(not_found)?;
entries.sort_by_key(|e| (e.kind != "tree", e.name.to_lowercase()));
@@ -1013,7 +1116,7 @@ fn listing(repo: &Repo, rev: &str, path: &str, mut body: String) -> Result<Respo
} else {
format!("{path} · {}", repo.name)
};
Ok(page(StatusCode::OK, cache_for(rev), &title, &body))
Ok(page(StatusCode::OK, cache_for(rev), &title, meta, &body))
}
fn size(bytes: u64) -> String {
@@ -1102,7 +1205,7 @@ async fn blob(
let (rev, path) = repo.locate(&rest)?;
let bytes = repo.objects()?.blob(&rev, path).map_err(not_found)?;
let raw = repo.href(&format!("/raw/{}/{}", url_path(&rev), url_path(path)));
let mut body = repo.header(&app, Tab::Code).await;
let (mut body, meta) = repo.header(&app, Tab::Code).await;
body.push_str(&repo.toolbar(&rev, path));
let text = String::from_utf8_lossy(&bytes);
let lines = text.lines().count().max(1);
@@ -1149,6 +1252,7 @@ async fn blob(
StatusCode::OK,
cache_for(&rev),
&format!("{path} · {}", repo.name),
&meta,
&body,
))
}
@@ -1200,7 +1304,7 @@ async fn commits(
.objects()?
.log(&rev, path, n * COMMITS_PER_PAGE, COMMITS_PER_PAGE)
.map_err(not_found)?;
let mut body = repo.header(&app, Tab::Commits).await;
let (mut body, meta) = repo.header(&app, Tab::Commits).await;
body.push_str(&repo.toolbar(&rev, path));
let mut days: Vec<(String, Vec<&read::Commit>)> = Vec::new();
for c in &log {
@@ -1246,6 +1350,7 @@ async fn commits(
StatusCode::OK,
cache_for(&rev),
&format!("Commits · {}", repo.name),
&meta,
&body,
))
}
@@ -1376,7 +1481,7 @@ async fn commit(
.await
.map_err(not_found)?;
let files = diff::parse(&patch);
let mut body = repo.header(&app, Tab::Commits).await;
let (mut body, meta) = repo.header(&app, Tab::Commits).await;
let (title, rest) = c.message.split_once('\n').unwrap_or((&c.message, ""));
write!(
body,
@@ -1430,6 +1535,7 @@ async fn commit(
StatusCode::OK,
cache_for(&rev),
&format!("{} · {}", subject(&c.message), repo.name),
&meta,
&body,
))
}
@@ -1506,7 +1612,7 @@ async fn items(
} else {
(Tab::Issues, "Issues")
};
let mut body = repo.header(&app, tab).await;
let (mut body, meta) = repo.header(&app, tab).await;
let current = |yes: bool| if yes { r#" aria-current="page""# } else { "" };
write!(
body,
@@ -1559,16 +1665,22 @@ async fn items(
StatusCode::OK,
"no-cache",
&format!("{label} · {}", repo.name),
&meta,
&body,
))
}
async fn item(
State(app): State<Arc<App>>,
Path((authority, name, kind, did, rkey)): Path<(String, String, String, String, String)>,
) -> Result<Response> {
let repo = Repo::open(&app, &authority, &name).await?;
let collection = match kind.as_str() {
type ItemPath = Path<(String, String, String, String, String)>;
/// A pull or an issue, by the kind its path names, and who opened it.
async fn thread(
app: &App,
repo: &Repo,
kind: &str,
did: &str,
rkey: &str,
) -> Result<(Thread, String)> {
let collection = match kind {
"pull" => knotless::records::nsid::PULL,
"issue" => knotless::records::nsid::ISSUE,
_ => {
@@ -1578,18 +1690,40 @@ async fn item(
));
}
};
let entries = assembled(&app, &repo)?;
let record = repo.record(&app).unwrap_or_default();
let entries = assembled(app, repo)?;
let record = repo.record(app).unwrap_or_default();
let uri = format!("at://{did}/{collection}/{rkey}");
let Thread { item, comments } =
forge::thread(&record, &repo.did, &entries, &uri).map_err(not_found)?;
let thread = forge::thread(&record, &repo.did, &entries, &uri).map_err(not_found)?;
let author = handle(app, &thread.item.author).await;
Ok((thread, author))
}
fn item_card(repo: &Repo, kind: &str, thread: &Thread, author: &str) -> Card {
Card::Item {
owner: repo.authority.clone(),
name: repo.name.clone(),
pull: kind == "pull",
state: thread.item.state.clone(),
title: thread.item.title.clone(),
author: author.to_string(),
comments: thread.comments.len(),
}
}
async fn item(
State(app): State<Arc<App>>,
Path((authority, name, kind, did, rkey)): ItemPath,
) -> Result<Response> {
let repo = Repo::open(&app, &authority, &name).await?;
let (thread, author) = self::thread(&app, &repo, &kind, &did, &rkey).await?;
let card = item_card(&repo, &kind, &thread, &author);
let Thread { item, comments } = thread;
let tab = if kind == "pull" {
Tab::Pulls
} else {
Tab::Issues
};
let mut body = repo.header(&app, tab).await;
let author = handle(&app, &item.author).await;
let (mut body, _) = repo.header(&app, tab).await;
write!(
body,
r#"<h2 class="item-title">{}</h2><div class="item-meta"><span class="pill {}">{}{}</span>"#,
@@ -1679,14 +1813,87 @@ async fn item(
body.push_str(r#"<div class="box"><div class="empty">Nothing said yet.</div></div>"#);
}
body.push_str("</div>");
let what = if kind == "pull" { "A pull" } else { "An issue" };
let meta = Meta {
description: Some(match &item.body {
Some(text) => summary(text, 200),
None => format!("{what} on {}/{} by {author}.", repo.authority, repo.name),
}),
card: Some((repo.href(&format!("/{kind}/{did}/{rkey}")), card)),
};
Ok(page(
StatusCode::OK,
"no-cache",
&format!("{} · {}", item.title, repo.name),
&meta,
&body,
))
}
static CARDS: LazyLock<card::Cache> = LazyLock::new(|| card::Cache::new(512));
#[derive(Deserialize, Default)]
pub struct CardQuery {
v: Option<String>,
}
/// A card as a PNG: cached for good under the version its page names, and
/// checked again under any other, as it may have changed since.
async fn card_png(card: Card, asked: Option<String>, headers: &HeaderMap) -> Response {
let svg = card.svg();
let version = card::version(&svg);
let tag = format!("\"{version}\"");
let cache = if asked.as_deref() == Some(version.as_str()) {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
let mut res = if headers
.get(header::IF_NONE_MATCH)
.is_some_and(|v| v.as_bytes() == tag.as_bytes())
{
StatusCode::NOT_MODIFIED.into_response()
} else {
let png = CARDS.png(&version, svg).await;
let mut res = axum::body::Body::from(axum::body::Bytes::from_owner(png)).into_response();
res.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
res
};
let h = res.headers_mut();
h.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
h.insert(header::ETAG, HeaderValue::from_str(&tag).unwrap());
res
}
async fn home_card(headers: HeaderMap, Query(q): Query<CardQuery>) -> Response {
card_png(Card::Home, q.v, &headers).await
}
async fn repo_card(
State(app): State<Arc<App>>,
Path((authority, name)): RepoPath,
headers: HeaderMap,
Query(q): Query<CardQuery>,
) -> Result<Response> {
let repo = Repo::open(&app, &authority, &name).await?;
let open = repo.open_items(&app).unwrap_or_default();
let card = repo.card(repo.record(&app).as_ref(), open);
Ok(card_png(card, q.v, &headers).await)
}
async fn item_card_png(
State(app): State<Arc<App>>,
Path((authority, name, kind, did, rkey)): ItemPath,
headers: HeaderMap,
Query(q): Query<CardQuery>,
) -> Result<Response> {
let repo = Repo::open(&app, &authority, &name).await?;
let (thread, author) = self::thread(&app, &repo, &kind, &did, &rkey).await?;
let card = item_card(&repo, &kind, &thread, &author);
Ok(card_png(card, q.v, &headers).await)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1719,6 +1926,13 @@ mod tests {
);
}
#[test]
fn summaries() {
assert_eq!(summary(" one\n\ntwo ", 200), "one two");
assert_eq!(summary("one two three", 8), "one two…");
assert_eq!(summary("ab", 2), "ab");
}
#[test]
fn small_things() {
assert_eq!(
crates/knotless-serve/tests/web.rs+95−0
@@ -61,6 +61,29 @@ impl Site {
}
}
/// The card a page names for itself, as a URL.
fn card_of(page: &str) -> String {
let tag = r#"<meta property="og:image" content=""#;
let at = page
.find(tag)
.unwrap_or_else(|| panic!("no card in\n{page}"))
+ tag.len();
page[at..].split('"').next().unwrap().to_string()
}
/// A card's PNG, which must be there, with its cache control.
fn card(url: &str) -> (Vec<u8>, String) {
let res = Client::new().get(url).send().unwrap();
assert_eq!(res.status().as_u16(), 200, "{url}");
assert_eq!(res.headers()["content-type"], "image/png");
let cache = res.headers()["cache-control"].to_str().unwrap().to_string();
let png = res.bytes().unwrap().to_vec();
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
// The width and height, from the header chunk.
assert_eq!(png[16..24], [0, 0, 4, 176, 0, 0, 2, 118]);
(png, cache)
}
fn person(account: Account, site: &Site) -> Sandbox {
let mut s = Sandbox::with(account);
s.set("KNOTLESS_PASSWORD", "");
@@ -152,6 +175,48 @@ fn browsing_a_repository() {
}
assert!(home.find(">docs</a>").unwrap() < home.find(">README.md</a>").unwrap());
// Each page says what it is to whatever unfurls a link to it, and
// names a card: cached for good under the version the page names,
// checked again under any other.
assert!(
home.contains(&format!(
r#"<meta property="og:title" content="browsed"><meta name="description" content="A git repository in {h}&#39;s atproto account.">"#
)),
"{home}"
);
let named = card_of(&home);
let unversioned = format!("{}{repo}/card.png", site.base);
assert!(named.starts_with(&format!("{unversioned}?v=")), "{named}");
let (png, cache) = card(&named);
assert_eq!(cache, "public, max-age=31536000, immutable");
assert_eq!(card(&unversioned), (png, "no-cache".to_string()));
assert_eq!(card_of(&site.page(&format!("{repo}/commits/main"))), named);
let res = Client::new().get(&named).send().unwrap();
let tag = res.headers()["etag"].clone();
let again = Client::new()
.get(&named)
.header("if-none-match", tag.clone())
.send()
.unwrap();
assert_eq!(again.status().as_u16(), 304);
assert_eq!(again.headers()["etag"], tag);
let front = site.page("/");
assert!(
front.contains(r#"<meta property="og:description" content="Git repositories stored in atproto accounts"#),
"{front}"
);
let (_, cache) = card(&card_of(&front));
assert_eq!(cache, "public, max-age=31536000, immutable");
// Behind a proxy that took https, the card's URL says so.
let proxied = Client::new()
.get(format!("{}{repo}", site.base))
.header("x-forwarded-proto", "https")
.send()
.unwrap()
.text()
.unwrap();
assert_eq!(card_of(&proxied), named.replace("http://", "https://"));
// Compressed for browsers that ask.
let res = Client::builder()
.no_gzip()
@@ -380,6 +445,35 @@ fn pulls_and_issues() {
404
);
// A pull's card and an issue's, and what each says of itself. Said
// more about, the issue names a new card.
let issue_page = format!("{repo}/issue/{other_did}/{}", rkey(&issue));
assert!(
page.contains(&format!(
r#"<meta name="description" content="An issue on {}/tracked by {}.">"#,
owner.account.handle, other.account.handle
)),
"{page}"
);
let before = card_of(&page);
assert!(before.starts_with(&format!("{}{issue_page}/card.png?v=", site.base)));
let (png, _) = card(&before);
let pulled = site.page(&thread);
assert!(pulled.contains(r#"<meta property="og:description" content="Adds **b**.">"#));
assert_ne!(card(&card_of(&pulled)).0, png);
ok(&owner, &["comment", &url, &issue, "Seen"]);
wait_until(|| card_of(&site.page(&issue_page)) != before);
assert_ne!(card(&card_of(&site.page(&issue_page))).0, png);
let (_, cache) = card(&before);
assert_eq!(cache, "no-cache");
for missing in [
format!("{repo}/issue/{other_did}/nothing/card.png"),
format!("{repo}/wiki/{other_did}/{}/card.png", rkey(&issue)),
format!("/{}/nothing/card.png", owner.account.handle),
] {
assert_eq!(site.status(&missing).0, 404, "{missing}");
}
// The fork says where it came from.
let forked = site.page(&format!("/{}/tracked", other.account.handle));
assert!(
@@ -436,6 +530,7 @@ fn pulls_and_issues() {
"<h1>The &lt;Owner&gt;</h1>",
&format!(r#"<p class="who">{}</p>"#, owner.account.handle),
r#"<p class="bio">Keeps things tracked.</p>"#,
r#"<meta name="description" content="Keeps things tracked.">"#,
&format!(r#"href="/{}/tracked">tracked</a>"#, owner.account.handle),
"Tracks &lt;things&gt;",
r#"<span class="count">2</span>"#,
docs/design.md+18−0
@@ -333,6 +333,24 @@ scripts, allowed by hash in a `default-src 'none'` content security policy:
header still between pages.
- **Caching.** Pages named by a commit hash are immutable; pages named by a
branch are revalidated. HTML is compressed; git responses are not.
- **Social cards.** The front page, each repository's pages, and each pull
and issue name a 1200×630 PNG in Open Graph tags, at the page's path
plus `/card.png`. A card is an SVG written like the pages, drawn by
`resvg` with fonts compiled in, so it is the same on any host: two Inter
Display faces subset to Latin, and Noto Color Emoji (COLRv1, 5 MB) for
emoji; other scripts show as boxes. Each run of emoji is placed on its
own, at an x measured here: shaped together with text in another font,
resvg drops emoji sequences, or panics on them. A card is written with
a palette when it has fewer than 256 colors, as one without emoji does:
exact, a third of the bytes to compress, and smaller with a fast
compressor than RGB with a thorough one. The URL carries `?v=` and a hash of the SVG, so under
that version it is immutable, and a page names a new URL when what its
card shows changes, which is also what makes chat apps that cache
previews by URL show the new one. Under any other version, it is the
current card, revalidated by ETag. Cards are kept in memory by version,
the oldest of 512 dropped, and drawn on blocking threads, one per core
at most. The origin comes from `Host`, and `X-Forwarded-Proto: https`
from a proxy in front.
- **Layout.** One stylesheet of custom properties, redefined for dark mode,
with a single column under 640 px. Nothing is rendered lazily with
`content-visibility`, so a full-page screenshot is the same every time.
harness/shots/baseline/darwin/home-card.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-card.sha256new+1−0
@@ -0,0 +1 @@
31df5cd16d9e52d98ca71b6a280c415faea99bc97caf2888ebc918abbfbc6c53
\ No newline at end of file
harness/shots/baseline/darwin/issue-card.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-card.sha256new+1−0
@@ -0,0 +1 @@
53ff8385339c8e827c5956e7865d8cd70cf467b00ec314313eeba5c4c2696d0a
\ No newline at end of file
harness/shots/baseline/darwin/issue-closed-card.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-closed-card.sha256new+1−0
@@ -0,0 +1 @@
6249bb425cfabc51abe3bbd9e7fc24ccbebc32cc281abd74c58ae99bd64b0623
\ No newline at end of file
harness/shots/baseline/darwin/notes-card.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/notes-card.sha256new+1−0
@@ -0,0 +1 @@
9d991ab1c0bfb8f626846139ae917029828293ae3a64827f6c7aaa20082488a7
\ No newline at end of file
harness/shots/baseline/darwin/pull-card.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-card.sha256new+1−0
@@ -0,0 +1 @@
171f0862012b43a1a84ee003d85da4d1cc511718a4ca17faa7c9b1a5609a6537
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-card.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-card.sha256new+1−0
@@ -0,0 +1 @@
36356bff6a44424b9fa18a584d0c98124d7924365bcd9e984767148a7905ddbf
\ No newline at end of file
harness/shots/baseline/darwin/repository-card.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-card.sha256new+1−0
@@ -0,0 +1 @@
52322c40b37b1e5c7c0ea5feb8f0b48f89d7ead1b459e8975511a31bcad6b2a6
\ No newline at end of file
harness/shots/cards.spec.mjsnew+32−0
@@ -0,0 +1,32 @@
// The social card each page in world.cards names, compared pixel for pixel
// with the one checked in. A card's URL carries a hash of what it shows:
// beside each baseline is a digest of it, and setup.mjs lists the cards
// whose URL is as it was, skipped as unchanged as the pages are.
import { test, expect } from '@playwright/test'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { card, digest } from './digest.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const read = (file, otherwise) => {
const path = resolve(root, 'target/shots', file)
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : otherwise
}
const unchanged = new Set(read('unchanged.json', []))
const names = ['home', 'repository', 'notes', 'pull', 'pull-open', 'issue', 'issue-closed']
for (const name of names) {
test.describe(() => {
test.skip(unchanged.has(`card:${name}`), 'unchanged')
test(name, async ({ request }, info) => {
const path = card(await (await request.get(read('cards.json', {})[name])).text())
const png = await request.get(path)
expect(png.headers()['content-type']).toBe('image/png')
expect(await png.body()).toMatchSnapshot(`${name}.png`)
writeFileSync(info.snapshotPath(`${name}.sha256`), digest(path))
})
})
}
harness/shots/digest.mjs+7−0
@@ -8,3 +8,10 @@ export const digest = (html) =>
createHash('sha256')
.update(html.replace(/did:plc:[a-z2-7]{24}/g, 'did').replace(/\/[2-7a-z]{13}(?=["/?#])/g, '/rkey'))
.digest('hex')
/// The path of the social card a page names, which carries a hash of what
/// the card shows: all its pixels follow from, but the renderer.
export const card = (html) => {
const url = html.match(/<meta property="og:image" content="([^"]+)"/)[1].replaceAll('&amp;', '&')
return new URL(url).pathname + new URL(url).search
}
harness/shots/playwright.config.mjs+5−1
@@ -1,5 +1,7 @@
// Screenshots of every page of the web interface, in light and dark, on a
// desktop and a phone, compared pixel for pixel with the ones checked in.
// desktop and a phone, compared pixel for pixel with the ones checked in;
// and the social cards the pages name, which are the same in any browser,
// so are taken once, as the `card` project.
//
// just shots take them again, replacing the baselines
// just shots-check fail on any pixel that differs
@@ -26,6 +28,7 @@ export default defineConfig({
reporter: [['list']],
expect: {
toHaveScreenshot: { maxDiffPixels: 0, threshold: 0, animations: 'disabled', caret: 'hide', scale: 'device' },
toMatchSnapshot: { maxDiffPixels: 0, threshold: 0 },
},
use: { baseURL: 'http://127.0.0.1:2593', locale: 'en-US', timezoneId: 'UTC' },
projects: [
@@ -33,5 +36,6 @@ export default defineConfig({
{ name: 'desktop-dark', use: { ...desktop, colorScheme: 'dark' } },
{ name: 'phone-light', use: { ...phone, colorScheme: 'light' } },
{ name: 'phone-dark', use: { ...phone, colorScheme: 'dark' } },
{ name: 'card', testMatch: 'cards.spec.mjs' },
],
})
harness/shots/setup.mjs+18−7
@@ -3,21 +3,24 @@
// this build in front of it. The pages are written to
// target/shots/views.json, and those whose HTML is as it was when their
// baselines were taken, in every project, to target/shots/unchanged.json:
// views.spec.mjs skips them before a browser page is opened. Returns the
// teardown.
// views.spec.mjs skips them before a browser page is opened. So too the
// pages whose cards are shot, to cards.json, and the cards whose URL is
// as it was, as `card:<name>`. Returns the teardown.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { base, create, page, root, saved, serve, until } from './world.mjs'
import { digest } from './digest.mjs'
import { card, digest } from './digest.mjs'
import config from './playwright.config.mjs'
const here = join(root, 'harness/shots')
const projects = config.projects.map((p) => p.name)
const projects = config.projects.map((p) => p.name).filter((p) => p !== 'card')
const kept = async () => {
if (!existsSync(saved)) return null
const world = JSON.parse(readFileSync(saved, 'utf8'))
// Kept up from before cards were shot, it is seeded differently.
if (!world.cards) return null
const up = await fetch(`${world.net.pds}/xrpc/com.atproto.server.describeServer`).then((r) => r.ok, () => false)
return up ? world : null
}
@@ -36,13 +39,21 @@ export default async function setup() {
}
mkdirSync(join(root, 'target/shots'), { recursive: true })
writeFileSync(join(root, 'target/shots/views.json'), JSON.stringify(world.views, null, 2))
writeFileSync(join(root, 'target/shots/cards.json'), JSON.stringify(world.cards, null, 2))
const unchanged = []
if (!process.env.SHOTS_ALL) {
const at = (name, project, ext) => join(here, 'baseline', process.platform, `${name}-${project}.${ext}`)
const same = (name, project, sum) =>
existsSync(at(name, project, 'png')) &&
existsSync(at(name, project, 'sha256')) &&
readFileSync(at(name, project, 'sha256'), 'utf8') === sum
for (const [name, path] of Object.entries(world.views)) {
const sum = digest(await (await fetch(base + path)).text())
const at = (project, ext) => join(here, 'baseline', process.platform, `${name}-${project}.${ext}`)
const same = (p) => existsSync(at(p, 'png')) && existsSync(at(p, 'sha256')) && readFileSync(at(p, 'sha256'), 'utf8') === sum
if (projects.every(same)) unchanged.push(name)
if (projects.every((p) => same(name, p, sum))) unchanged.push(name)
}
for (const [name, path] of Object.entries(world.cards)) {
const sum = digest(card(await (await fetch(base + path)).text()))
if (same(name, 'card', sum)) unchanged.push(`card:${name}`)
}
}
writeFileSync(join(root, 'target/shots/unchanged.json'), JSON.stringify(unchanged))
harness/shots/world.mjs+14−4
@@ -157,7 +157,7 @@ export async function create() {
git('alice', a, null, 'push', '-q', tally, 'main')
const notes = 'knotless://alice.test/notes'
knotless('alice', 'create', notes, '--description', 'Scratch notes')
knotless('alice', 'create', notes, '--description', 'Scratch notes 📝')
const n = join(work, 'alice/notes')
mkdirSync(n, { recursive: true })
git('alice', n, null, 'init', '-q', '-b', 'main')
@@ -195,7 +195,7 @@ export async function create() {
'-m', 'Running `tally image.png` panics:\n\n```\nthread \'main\' panicked at src/main.rs:8:56:\nstream did not contain valid UTF-8\n```\n\nwc counts bytes for any file.',
)
knotless('alice', 'comment', tally, utf8, 'Confirmed. Reading bytes and counting words lossily would fix it.')
const typo = knotless('bob', 'issue', tally, 'Typo in the README')
const typo = knotless('bob', 'issue', tally, 'Typo in the README ✏️')
await until('the issue', async () => (await page('/alice.test/tally/issues')).includes('Typo in the README'))
knotless('alice', 'close', tally, typo)
@@ -288,6 +288,16 @@ export async function create() {
empty: '/alice.test/scratch',
missing: '/alice.test/tally/blob/main/nowhere',
}
// The pages whose social cards are shot, by the names of the shots.
const cards = {
home: '/',
repository: '/alice.test/tally',
notes: '/alice.test/notes',
pull: views.pull,
'pull-open': views['pull-open'],
issue: views.issue,
'issue-closed': item('issue', typo),
}
// The index has caught up when no page shows today's date, every
// record now being dated before it, and the merge shows.
const today = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
@@ -296,7 +306,7 @@ export async function create() {
const all = pages.join('')
return !all.includes(today) && all.includes('Merged as') && all.includes('Forked from') && all.includes('1 Closed')
})
return { net, dir, views, pds, server: children[1], stop }
return { net, dir, views, cards, pds, server: children[1], stop }
} catch (e) {
stop()
throw e
@@ -307,7 +317,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
const world = await create()
world.server.kill()
mkdirSync(dirname(saved), { recursive: true })
writeFileSync(saved, JSON.stringify({ net: world.net, dir: world.dir, views: world.views }, null, 2))
writeFileSync(saved, JSON.stringify({ net: world.net, dir: world.dir, views: world.views, cards: world.cards }, null, 2))
console.log(`seeded; kept up for \`just shots\` until ctrl-c (${saved})`)
const end = () => {
rmSync(saved, { force: true })