chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds 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}'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 <Owner></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 <things>", | ||
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('&', '&') | ||
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 }) | ||