chadtmiller.com/knotless

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

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

web: the pages drawn by a SvelteKit app, from a JSON data layer

knotless-serve answers what the pages show as JSON under /_data, and the
feed as the dev.knotless.getFeed lexicon; with --web it hands every other
page to the SvelteKit app in web/, built of atoms, molecules, organisms
and templates. The screenshots are drawn from recorded answers, without a
PDS. Search looks as it is typed, the icons are Octicons, and closed pulls
and issues have colours of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chad Miller committed on Oct 21, 2026 parent 698c4c0 commit b762f7e
Showing 299 changed files with 5145 additions and 1954 deletions
.cargo/config.tomlnew+5−0
@@ -0,0 +1,5 @@
# The TypeScript types the web pages read, written by `cargo test` from the
# Rust types the data endpoints answer with.
[env]
TS_RS_EXPORT_DIR = { value = "web/src/lib/api", relative = true }
TS_RS_LARGE_INT = "number"
CLAUDE.md+15−6
@@ -13,12 +13,21 @@ work is done.
- `just changed` names the files changed since tests last passed and the
tests that cover them; `just t-changed` runs those and marks the pass.
`just t <cargo test args>` runs any selection on the shared PDS.
- For the web pages: `just shots-up` in the background once, then
`just shots`, which retakes only pages whose HTML changed. Look at the
PNGs it writes under `harness/shots/baseline/<platform>/`.
- Done: `just lint`, `just cov` (100% lines and functions over the stock and
spaces-alpha runs; slow, so once) and, if pages changed, `just
shots-check`.
- The web pages are the SvelteKit app in `web/`; knotless-serve answers
what they show as JSON under `/_data`. `cargo test -p knotless-serve
--lib` writes the answers' TypeScript types into `web/src/lib/api`.
- For the web pages: `just shots`, which builds `web/` and retakes only
pages whose HTML changed, drawn from `harness/shots/fixtures.json`, with
no PDS. Look at the PNGs it writes under
`harness/shots/baseline/<platform>/`. After a change to what a page's
answer holds, `just shots-up` in the background once, then `just
shots-record`.
- In Svelte markup, keep a run of inline text one expression or one line:
a block or component beside it splits the text into nodes, which shapes
differently from the same text whole and moves pixels.
- Done: `just lint` (it runs svelte-check too), `just cov` (100% lines and
functions over the stock and spaces-alpha runs; slow, so once) and, if
pages changed, `just shots-check` and `just shots-live`.
`cargo fmt` can put a `?` on a line of its own, which coverage then counts
as a line never run: bind the value first, or use a named function for a
Cargo.lock+32−1
@@ -2062,7 +2062,6 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"sha2",
"tempfile",
"tokio",
"tokio-tungstenite",
@@ -2088,6 +2087,7 @@ dependencies = [
"tree-sitter-typescript",
"tree-sitter-yaml",
"tree-sitter-zig",
"ts-rs",
]
[[package]]
@@ -3087,6 +3087,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "thiserror"
version = "2.0.21"
@@ -3511,6 +3520,28 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"thiserror",
"ts-rs-macros",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"termcolor",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
Justfile+37−7
@@ -1,9 +1,10 @@
cargo := "rustup run stable cargo"
ignore := "(tests/|harness/|examples/|rustlib/)"
# Install the reference PDS harness and coverage tools.
# Install the reference PDS harness, the web pages' packages and coverage tools.
setup:
cd harness && npm install --no-audit --no-fund
cd web && npm install --no-audit --no-fund
rustup component add llvm-tools-preview --toolchain stable
command -v cargo-llvm-cov >/dev/null || cargo install cargo-llvm-cov
@@ -57,6 +58,7 @@ cov-html:
lint:
{{cargo}} fmt --all --check
{{cargo}} clippy --workspace --all-targets -- -D warnings
cd web && npm run check
# Latency of agent operations at a given round-trip time, in milliseconds.
bench rtt="50":
@@ -66,18 +68,46 @@ bench rtt="50":
serve *args:
{{cargo}} run --release -p knotless-serve -- {{args}}
# The web pages, built into web/build: a SvelteKit app that draws what
# knotless-serve answers under /_data. Its types come from `cargo test`.
web:
cd web && npm run build
# The pages, from web/build, for `just serve --web http://127.0.0.1:2591`
# to hand them to, reading from that server.
pages: web
HOST=127.0.0.1 PORT=2591 KNOTLESS_SERVE=http://127.0.0.1:2590 HOST_HEADER=x-forwarded-host PROTOCOL_HEADER=x-forwarded-proto node web/build
# The pages, redrawn as their source changes, reading from a
# `just serve` on its default port.
web-dev:
cd web && npm run dev
# Screenshots of the pages whose HTML changed since their baselines were
# taken, taken again as the baselines in harness/shots/baseline. Arguments
# go to Playwright: a page name to take only it, or --project=desktop-light.
shots *args:
{{cargo}} build --bins
# taken, taken again as the baselines in harness/shots/baseline. The pages
# are drawn from what harness/shots/fixtures.json recorded, so no PDS is
# needed. Arguments go to Playwright: a page name to take only it, or
# --project=desktop-light.
shots *args: web
cd harness && npx playwright test -c shots/playwright.config.mjs --update-snapshots=all {{args}}
# Every screenshot, failing on any pixel that differs from the baselines.
shots-check *args:
{{cargo}} build --bins
shots-check *args: web
cd harness && SHOTS_ALL=1 npx playwright test -c shots/playwright.config.mjs {{args}}
# Every screenshot of the whole stack, the pages behind knotless-serve over
# a seeded PDS, failing on any pixel that differs from the baselines.
shots-live *args: web
{{cargo}} build --bins
cd harness && SHOTS_LIVE=1 SHOTS_ALL=1 npx playwright test -c shots/playwright.config.mjs {{args}}
# Records again what knotless-serve answers for each page, from the world
# `shots-up` keeps up or a new one, into harness/shots/fixtures.json: after
# a change to what the server answers, or to the world.
shots-record: web
{{cargo}} build --bins
node harness/shots/fixtures.mjs
# The screenshots' PDS, seeded once and left running, so `shots` need only
# start the server. Stop it with ctrl-c.
shots-up:
README.md+40−15
@@ -55,8 +55,9 @@ per round, and the reference PDS serializes an account's writes. Give each
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.
What the web pages show is read from a mirror's objects in process, in
about a millisecond warm; a highlighted 40 KB source file takes a few more.
The pages themselves take a few milliseconds more to draw.
## How it works
@@ -219,6 +220,7 @@ knotless-serve --listen 0.0.0.0:2590 \
--backfill https://bsky.network \ # index everything already there
--public-url https://git.example.com \ # be a gateway, as did:web:git.example.com
--labeler did:plc:… # honour a labeler's takedowns
--web http://127.0.0.1:2591 # the web pages, from web/
```
Events come from a Jetstream v2 host or, with `--firehose wss://…`, from a
@@ -249,11 +251,24 @@ rebuilds the index.
at its clone URL. Commits show each file's diff with old and new line
numbers, syntax highlighting, and the changed part of each edited line.
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
by the CSS Custom Highlight API; links load 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.
- **An appview**: `dev.knotless.listRepos` and `dev.knotless.getForge`,
from a SQLite index of what Jetstream or the firehose reports.
The pages are a SvelteKit app in [`web/`](web), run with Node, that
`--web` names: the server hands it every request it does not answer
itself, and it draws each page from the server's JSON under `/_data` (its
TypeScript types are written by `cargo test`) and, for the front page,
`dev.knotless.getFeed`. Without `--web`, the server has no pages.
```sh
just web # build web/build
just pages # serve it on :2591
just serve --index index.db --web http://127.0.0.1:2591
just web-dev # or redraw as the source changes
```
- **An appview**: `dev.knotless.listRepos`, `dev.knotless.getForge` and
`dev.knotless.getFeed`, from a SQLite index of what Jetstream or the firehose reports.
- **A gateway**: `dev.knotless.pushRefs`, `dev.knotless.createCommit` and
`dev.knotless.putDelegation`, for collaborators.
- **A moderation service**, as an appview with a public URL:
@@ -312,11 +327,11 @@ alpha build of it for private repositories. Every line and function is
covered across the two runs, and CI fails below that.
```sh
just setup # npm install in harness/, llvm-tools for coverage
just setup # npm install in harness/ and web/, llvm-tools for coverage
just test # every test
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 lint # rustfmt, clippy and svelte-check
just bench 50 # the table above, at 50 ms RTT
```
@@ -332,23 +347,33 @@ just t -p knotless-serve --test web # or any cargo test arguments
Each test binary otherwise starts a PDS of its own, which takes most of a
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:
The screenshots ([`harness/shots`](harness/shots)) take every page in
light and dark, at desktop and phone widths, with Playwright's Chromium.
The pages are drawn by the app in `web/` from what knotless-serve answered
for them, recorded in `harness/shots/fixtures.json`, so a run needs no PDS
or server:
```sh
just shots-up # seed once, and keep it up for the runs below
just shots # retake the pages whose HTML changed, as the baselines
just shots profile --project=desktop-light # or only some
just shots-check # take every page, failing on any pixel that differs
```
The recording comes from a world: a reference PDS filled with the same
repositories, pulls and issues each time, with fixed commit dates and
record times. Record again after a change to what the server answers:
```sh
just shots-up # seed once, and keep it up for the runs below
just shots-record # record each page's answers from it
just shots-live # every page, through knotless-serve over the world
```
Each baseline keeps a digest of the HTML it was taken from, and a page
whose HTML still matches is skipped as unchanged before a browser opens
it. Without `shots-up`, each run seeds a world of its own. Baselines are
kept per platform, under `harness/shots/baseline/<platform>`; DIDs, which
the PLC directory makes new for each world, are masked.
it. Without `shots-up`, a recording or live run seeds a world of its own.
Baselines are kept per platform, under `harness/shots/baseline/<platform>`;
DIDs, which the PLC directory makes new for each world, are masked.
## Not yet
crates/knotless-serve/Cargo.toml+2−1
@@ -7,6 +7,7 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
reqwest.workspace = true
knotless.workspace = true
anyhow.workspace = true
axum = { workspace = true, features = ["ws"] }
@@ -21,7 +22,6 @@ tokio-tungstenite.workspace = true
tokio-util.workspace = true
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
tower-http = { version = "0.7.1", features = ["compression-gzip", "compression-br"] }
sha2.workspace = true
base64.workspace = true
gix = { version = "0.89", default-features = false, features = ["sha1", "max-performance-safe", "revision"] }
tree-sitter = "0.27"
@@ -44,6 +44,7 @@ tree-sitter-css = "0.25.0"
tree-sitter-lua = "0.5.0"
tree-sitter-zig = "1.1.2"
tree-sitter-language = "0.1.7"
ts-rs = "12"
[dev-dependencies]
futures.workspace = true
crates/knotless-serve/src/lib.rs+11−2
@@ -394,6 +394,7 @@ pub fn router(app: Arc<App>) -> Router {
.route("/xrpc/dev.knotless.createCommit", post(gateway::commit))
.route("/xrpc/dev.knotless.getForge", get(appview::get_forge))
.route("/xrpc/dev.knotless.listRepos", get(appview::list_repos))
.route("/xrpc/dev.knotless.getFeed", get(web::get_feed))
.route(
"/xrpc/com.atproto.moderation.createReport",
post(moderation::create_report),
@@ -446,6 +447,9 @@ pub struct Options {
pub backfill: Option<String>,
/// Labelers, by DID, whose takedowns hold here too.
pub labelers: Vec<String>,
/// The SvelteKit app that draws the web pages, which this server hands
/// every request it does not answer itself.
pub web: Option<String>,
}
pub fn parse_args(args: &[String]) -> Result<Options, String> {
@@ -458,6 +462,7 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> {
index: None,
backfill: None,
labelers: Vec::new(),
web: None,
};
let mut it = args.iter().skip(1);
while let Some(flag) = it.next() {
@@ -477,9 +482,10 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> {
"--index" => opts.index = Some(value()?.into()),
"--backfill" => opts.backfill = Some(value()?),
"--labeler" => opts.labelers.push(value()?),
"--web" => opts.web = Some(value()?),
other => {
return Err(format!(
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--labeler <did>]..."
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--labeler <did>]... [--web <http://app>]"
));
}
}
@@ -515,7 +521,10 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> {
}
let listener = tokio::net::TcpListener::bind(&opts.listen).await?;
println!("listening on http://{}", listener.local_addr()?);
axum::serve(listener, router(app))
// With no pages to hand on to, what this server does not answer is not found.
let pages = opts.web.map(web::pages).unwrap_or_default();
let routes = router(app).merge(pages);
axum::serve(listener, routes)
.with_graceful_shutdown(async {
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
crates/knotless-serve/src/web.rs+588−1173
A large diff, not shown.
crates/knotless-serve/src/web/feed.rs+170−392
@@ -1,140 +1,125 @@
//! The front page: what happened on the repositories the index knows,
//! newest first and a day at a time, and how to start.
//! `dev.knotless.getFeed`: what happened on the repositories the index
//! knows, newest first. The front page shows it.
use super::*;
use crate::index::Index;
use knotless::records::nsid;
use std::collections::HashSet;
/// How far back the feed reaches, in each kind of thing that happens.
const REACH: usize = 40;
/// Commits listed under a push; the rest are counted.
const LISTED: usize = 5;
/// Events answered when the asker names no number.
const LIMIT: usize = 40;
/// Commits a push is walked for: past this, it says only that there were
/// more.
/// at least as many.
const WALKED: usize = 100;
/// Commits a push lists; the rest are counted.
const LISTED: usize = 10;
/// Something `who` did to `repo`, shown as `handle/name`, and its page.
struct Event {
at: String,
who: String,
repo: (String, String),
what: What,
#[derive(Serialize, TS)]
#[ts(export, rename = "Feed")]
pub struct Output {
events: Vec<Event>,
}
enum What {
/// Something an account did to a repository.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename = "FeedEvent")]
pub struct Event {
created_at: String,
actor: Actor,
repo: RepoView,
action: Action,
}
#[derive(Serialize, TS, Clone)]
#[ts(export, rename = "FeedActor")]
pub struct Actor {
did: String,
handle: String,
}
/// A repository, under the name it has now.
#[derive(Serialize, TS, Clone)]
#[ts(export, rename = "FeedRepo")]
pub struct RepoView {
did: String,
handle: String,
name: String,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "FeedCommit")]
pub struct CommitView {
sha: String,
message: String,
}
#[derive(Serialize, TS)]
#[serde(tag = "$type")]
#[ts(export, rename = "FeedAction")]
pub enum Action {
/// A repository made, or forked from another.
#[serde(rename = "dev.knotless.getFeed#created", rename_all = "camelCase")]
Created {
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
description: Option<String>,
from: Option<(String, String)>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
fork_of: Option<RepoView>,
},
/// A ref made or moved, and the commits that brought.
/// A ref made or moved, and the newest of the commits that brought.
#[serde(rename = "dev.knotless.getFeed#pushed")]
Pushed {
#[serde(rename = "ref")]
refname: String,
new: bool,
commits: Vec<read::Commit>,
created: bool,
count: usize,
commits: Vec<CommitView>,
},
#[serde(rename = "dev.knotless.getFeed#deleted")]
Deleted {
#[serde(rename = "ref")]
refname: String,
},
Opened {
kind: &'static str,
/// A pull or an issue opened.
#[serde(rename = "dev.knotless.getFeed#opened")]
Opened { subject: String, title: String },
/// A comment, or a review with a verdict.
#[serde(rename = "dev.knotless.getFeed#commented")]
Commented {
subject: String,
title: String,
href: String,
},
Said {
kind: &'static str,
title: String,
href: String,
body: String,
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
verdict: Option<String>,
},
/// A pull or an issue moved to another state.
#[serde(rename = "dev.knotless.getFeed#changed")]
Changed {
kind: &'static str,
subject: String,
title: String,
href: String,
state: String,
},
}
const SEARCH: &str = r#"<form action="/" role="search" class="find"><input type="search" name="q" placeholder="find a repository" aria-label="Find a repository"></form>"#;
/// The page: what knotless is, and what happened.
pub(super) async fn home(app: &App, index: Option<&Index>) -> Result<String> {
let mut body = String::from(
r##"<section class="intro"><p><b>knotless</b> keeps git repositories as records in your atproto account. push with plain git, clone from any PDS, and let agents commit over MCP. <a href="/start">getting started →</a></p></section>"##,
);
if let Some(index) = index {
body.push_str(SEARCH);
let events = events(app, index).await?;
for (day, events) in by_day(&events) {
write!(
body,
r#"<h2 class="feed-day">{}</h2><ul class="feed">"#,
weekday(day)
)
.unwrap();
for e in events {
write!(
body,
r#"<li><time datetime="{}">{}</time><div class="grow"><p><a class="who" href="/{}">{}</a> {}</p>{}</div></li>"#,
esc(&e.at),
esc(e.at.get(11..16).unwrap_or_default()),
url_path(&e.who),
esc(&e.who),
sentence(e),
detail(e)
)
.unwrap();
}
body.push_str("</ul>");
}
body.push_str(r#"<p class="feed-note">pushes, pulls and issues show up here as they happen, from any account on any PDS.</p>"#);
}
Ok(body)
#[derive(Deserialize, Default)]
pub struct Params {
limit: Option<usize>,
}
/// How to start: install, log in, push, clone and connect an agent.
pub(super) fn start(host: &str) -> String {
let mut body = String::from(
r#"<section class="start"><h1>getting started</h1><p class="lede">knotless works with any atmosphere account. log in with an app password.</p>"#,
);
let mcp = r#"{ "mcpServers": { "knotless": { "command": "knotless-mcp" } } }"#;
for (say, run) in [
(
"install, from a checkout of knotless",
"cargo install --path crates/knotless\ncargo install --path crates/knotless-mcp",
),
(
"log in with your handle and an app password",
"knotless login you.bsky.social",
),
(
"push any repository, to any account on any PDS",
"git push knotless://you.bsky.social/project main",
),
(
"or clone one without knotless installed",
&format!("git clone http://{host}/you.bsky.social/project"),
),
(
"connect an agent: it acts as the account you logged in as",
"claude mcp add knotless -- knotless-mcp",
),
("other MCP clients take this in their settings", mcp),
] {
write!(body, "<p># {say}</p>{}", command(run)).unwrap();
}
body.push_str("</section>");
body
}
/// `handle/name`, and its page.
fn named(owner: &str, name: &str) -> (String, String) {
(
format!("{owner}/{name}"),
format!("/{}/{}", url_path(owner), url_path(name)),
)
pub async fn get_feed(
State(app): State<Arc<App>>,
Query(p): Query<Params>,
) -> Result<axum::Json<Output>> {
let index = app.index.as_ref().ok_or(Shown(
StatusCode::NOT_FOUND,
"this server keeps no index".into(),
))?;
let limit = p.limit.unwrap_or(LIMIT).clamp(1, 100);
let events = events(&app, index, limit).await?;
Ok(axum::Json(Output { events }))
}
/// The owner and name of the repository `uri` names.
@@ -143,28 +128,19 @@ fn owner_and_name(uri: &str) -> Option<(String, String)> {
Some((did.to_string(), name.to_string()))
}
/// `pull` or `issue`, for the record at `uri`.
fn kind(uri: &str) -> &'static str {
if uri.contains(&format!("/{}/", nsid::PULL)) {
"pull"
} else {
"issue"
}
}
/// What happened, newest first: repositories made, refs moved, and what
/// was opened, said and decided on them.
async fn events(app: &App, index: &Index) -> Result<Vec<Event>> {
async fn events(app: &App, index: &Index, limit: usize) -> Result<Vec<Event>> {
let repos = index
.repos("", REACH)
.repos("", limit)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
let pushes = index
.pushes(REACH)
.pushes(limit)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
let said = index
.recent(REACH)
.recent(limit)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
@@ -188,21 +164,29 @@ async fn events(app: &App, index: &Index) -> Result<Vec<Event>> {
.await
.into_iter()
.collect();
let h = |did: &str| handles[did].clone();
let actor = |did: &str| Actor {
did: did.to_string(),
handle: handles[did].clone(),
};
let repo = |did: &str, name: &str| RepoView {
did: did.to_string(),
handle: handles[did].clone(),
name: name.to_string(),
};
let mut events: Vec<Event> = repos
.iter()
.map(|r| Event {
at: r.created_at.clone(),
who: h(&r.did),
repo: named(&h(&r.did), &r.name),
what: What::Created {
created_at: r.created_at.clone(),
actor: actor(&r.did),
repo: repo(&r.did, &r.name),
action: Action::Created {
description: r.description.clone(),
from: r
fork_of: r
.fork_of
.as_deref()
.and_then(owner_and_name)
.map(|(did, name)| named(&h(&did), &name)),
.map(|(did, name)| repo(&did, &name)),
},
})
.collect();
@@ -222,27 +206,23 @@ async fn events(app: &App, index: &Index) -> Result<Vec<Event>> {
.filter_map(|(at, repo)| Some((at, repo.ok()?)))
.collect();
for p in &pushes {
let repo = mirrors.get(&(p.did.clone(), p.name.clone()));
let refname = p
.refname
.strip_prefix("refs/heads/")
.or_else(|| p.refname.strip_prefix("refs/tags/"))
.unwrap_or(&p.refname)
.to_string();
let what = match &p.to {
None => What::Deleted { refname },
let mirror = mirrors.get(&(p.did.clone(), p.name.clone()));
let action = match &p.to {
None => Action::Deleted {
refname: p.refname.clone(),
},
Some(to) => {
// A ref that moved brought what it did not reach before; a
// new one, what the default branch did not reach just
// before, nor the default branch of a fork's source, which
// a fork's is named for.
let head = repo.map(|r| r.head.as_str()).unwrap_or_default();
let head = mirror.map(|r| r.head.as_str()).unwrap_or_default();
let branch = format!("refs/heads/{head}");
let before = |at: &str| index.tip_before(at, &branch, &p.created_at);
let known: Vec<String> = match &p.from {
Some(from) => vec![from.clone()],
None => [
(head != refname).then_some(p.repo.as_str()),
(branch != p.refname).then_some(p.repo.as_str()),
p.fork_of.as_deref(),
]
.into_iter()
@@ -256,315 +236,113 @@ async fn events(app: &App, index: &Index) -> Result<Vec<Event>> {
.collect(),
};
let known: Vec<&str> = known.iter().map(String::as_str).collect();
let commits = repo
let brought = mirror
.and_then(|r| r.objects().ok())
.and_then(|o| o.brought(to, &known, WALKED).ok())
.unwrap_or_default();
// A fork's copy of its source's refs is the fork itself.
if p.fork_of.is_some() && p.from.is_none() && commits.is_empty() {
if p.fork_of.is_some() && p.from.is_none() && brought.is_empty() {
continue;
}
What::Pushed {
refname,
new: p.from.is_none(),
commits,
Action::Pushed {
refname: p.refname.clone(),
created: p.from.is_none(),
count: brought.len(),
commits: brought
.into_iter()
.take(LISTED)
.map(|c| CommitView {
sha: c.sha,
message: c.message,
})
.collect(),
}
}
};
let who = h(&p.did);
events.push(Event {
at: p.created_at.clone(),
repo: named(&who, &p.name),
who,
what,
created_at: p.created_at.clone(),
actor: actor(&p.did),
repo: repo(&p.did, &p.name),
action,
});
}
// Pulls and issues, as their repositories' owners let them stand, by
// title and page.
let mut titles: HashMap<String, (String, String)> = HashMap::new();
// title.
let mut titles: HashMap<String, String> = HashMap::new();
let on: HashSet<(&str, &str)> = said
.iter()
.map(|(_, did, key)| (did.as_str(), key.as_str()))
.collect();
let mut pages = HashMap::new();
let mut named = HashMap::new();
for (did, key) in on {
let name = index
.name(did, key)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?
.unwrap_or_default();
let page = named(&h(did), &name);
let entries = index
.forge(did, key)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
for e in entries {
let (author, collection, rkey) = forge::parse_uri(&e.uri).unwrap_or_default();
if !matches!(collection, nsid::PULL | nsid::ISSUE) {
continue;
let collection = forge::parse_uri(&e.uri).unwrap_or_default().1;
if matches!(collection, nsid::PULL | nsid::ISSUE) {
let title = e.value["title"].as_str().unwrap_or_default().to_string();
titles.insert(e.uri, title);
}
let href = format!("{}/{}/{author}/{rkey}", page.1, kind(&e.uri));
let title = e.value["title"].as_str().unwrap_or_default().to_string();
titles.insert(e.uri, (title, href));
}
pages.insert((did, key), page);
named.insert((did, key), repo(did, &name));
}
for (e, did, key) in &said {
let page = &pages[&(did.as_str(), key.as_str())];
let subject = e.value["subject"].as_str().unwrap_or_default();
let shown = titles.contains_key(&e.uri) || titles.contains_key(subject);
// A pull or an issue is its own subject. One the repository's owner
// keeps out is not shown, nor what is said about it.
let subject = if titles.contains_key(&e.uri) {
e.uri.as_str()
} else {
e.value["subject"].as_str().unwrap_or_default()
};
let shown = titles.contains_key(subject);
let title = titles.get(subject).cloned().unwrap_or_default();
let text = |field: &str| e.value[field].as_str().unwrap_or_default().to_string();
let (title, href) = titles
.get(&e.uri)
.or_else(|| titles.get(subject))
.cloned()
.unwrap_or_default();
let what = match forge::parse_uri(&e.uri).unwrap_or_default().1 {
nsid::PULL | nsid::ISSUE => What::Opened {
kind: kind(&e.uri),
let subject = subject.to_string();
let action = match forge::parse_uri(&e.uri).unwrap_or_default().1 {
nsid::PULL | nsid::ISSUE => Action::Opened { subject, title },
nsid::COMMENT => Action::Commented {
subject,
title,
href,
},
nsid::COMMENT => What::Said {
kind: kind(subject),
title,
href,
body: text("body"),
text: text("body"),
verdict: e.value["verdict"].as_str().map(str::to_string),
},
_ => What::Changed {
kind: kind(subject),
_ => Action::Changed {
subject,
title,
href,
state: text("state"),
},
};
let event = Event {
at: text("createdAt"),
who: h(&e.author),
repo: page.clone(),
what,
created_at: text("createdAt"),
actor: actor(&e.author),
repo: named[&(did.as_str(), key.as_str())].clone(),
action,
};
events.extend(shown.then_some(event));
}
events.sort_by(|a, b| b.at.cmp(&a.at));
events.truncate(REACH);
events.sort_by(|a, b| b.created_at.cmp(&a.created_at));
events.truncate(limit);
Ok(events)
}
/// Events by the day they happened on, as `2026-10-09`, in order.
fn by_day(events: &[Event]) -> Vec<(&str, Vec<&Event>)> {
let mut days: Vec<(&str, Vec<&Event>)> = Vec::new();
for e in events {
let on = e.at.get(..10).unwrap_or_default();
match days.last_mut() {
Some((day, list)) if *day == on => list.push(e),
_ => days.push((on, vec![e])),
}
}
days
}
/// What the event says `who` did, as HTML.
fn sentence(e: &Event) -> String {
let repo = format!(
r#"<a class="repo" href="{}">{}</a>"#,
e.repo.1,
esc(&e.repo.0)
);
let item = |title: &str, href: &str| format!(r#"<a href="{href}">{}</a>"#, esc(title));
let branch = |name: &str| format!(r#"<span class="ref">{}</span>"#, esc(name));
match &e.what {
What::Created {
from: Some((from, href)),
..
} => format!(
r#"forked <a class="repo" href="{href}">{}</a> to {repo}"#,
esc(from)
),
What::Created { from: None, .. } => format!("created {repo}"),
What::Pushed {
refname, new: true, ..
} => format!("created {} on {repo}", branch(refname)),
What::Pushed {
refname, commits, ..
} => format!(
"pushed {} to {} on {repo}",
count(commits.len()),
branch(refname)
),
What::Deleted { refname } => format!("deleted {} on {repo}", branch(refname)),
What::Opened { kind, title, href } => {
format!("opened {kind} {} on {repo}", item(title, href))
}
What::Said {
kind,
title,
href,
verdict,
..
} => format!(
"{} {kind} {} on {repo}",
said(verdict.as_deref()),
item(title, href)
),
What::Changed {
kind,
title,
href,
state,
} => format!("{} {kind} {} on {repo}", changed(state), item(title, href)),
}
}
/// How many commits a push brought, in words.
fn count(n: usize) -> String {
match n {
0 => "nothing new".into(),
1 => "1 commit".into(),
WALKED => format!("{WALKED}+ commits"),
n => format!("{n} commits"),
}
}
/// What a comment with `verdict` did.
fn said(verdict: Option<&str>) -> &'static str {
match verdict {
Some("approve") => "approved",
Some("changes") => "asked for changes on",
_ => "commented on",
}
}
/// What moving to `state` did.
fn changed(state: &str) -> &str {
match state {
"open" => "reopened",
s => s,
}
}
/// What the event shows under its sentence: a repository's description,
/// the commits a push brought, or the start of what was said.
fn detail(e: &Event) -> String {
match &e.what {
What::Created {
description: Some(d),
..
} => format!(r#"<p class="feed-note">{}</p>"#, esc(d)),
What::Pushed { commits, .. } => {
let lines: String = commits
.iter()
.take(LISTED)
.map(|c| {
format!(
r#"<li><a class="hash" href="{}/commit/{}">{}</a><span class="clip">{}</span></li>"#,
e.repo.1,
c.sha,
&c.sha[..7],
esc(subject(&c.message))
)
})
.collect();
let rest = if commits.len() > LISTED {
format!(
r#"<li class="rest">and {} more</li>"#,
commits.len() - LISTED
)
} else {
String::new()
};
if commits.is_empty() {
String::new()
} else {
format!(r#"<ul class="commits">{lines}{rest}</ul>"#)
}
}
What::Said { body, .. } => {
format!(r#"<p class="feed-note">“{}”</p>"#, esc(&excerpt(body, 120)))
}
_ => String::new(),
}
}
/// `2026-10-09` as `friday, october 9`.
fn weekday(iso: &str) -> String {
const MONTHS: [&str; 12] = [
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
];
// From 1970-01-01, a Thursday.
const DAYS: [&str; 7] = [
"thursday",
"friday",
"saturday",
"sunday",
"monday",
"tuesday",
"wednesday",
];
let num = |at: std::ops::Range<usize>| iso.get(at).and_then(|s| s.parse::<i64>().ok());
let (Some(y), Some(m @ 1..=12), Some(d)) = (num(0..4), num(5..7), num(8..10)) else {
return iso.to_string();
};
// Days since the epoch, as civil_from_days in reverse.
let (y, mp) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + (153 * mp + 2) / 5 + d - 1;
let days = era * 146_097 + doe - 719_468;
format!(
"{}, {} {d}",
DAYS[days.rem_euclid(7) as usize],
MONTHS[(m - 1) as usize]
)
}
/// The first paragraph of Markdown, as plain text, cut short.
fn excerpt(text: &str, max: usize) -> String {
let first = text.split("\n\n").next().unwrap_or_default();
let plain: String = first
.chars()
.map(|c| if c == '\n' { ' ' } else { c })
.filter(|c| !matches!(c, '`' | '*'))
.collect();
match plain.char_indices().nth(max) {
Some((at, _)) => format!("{}…", plain[..at].trim_end()),
None => plain,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_and_words() {
assert_eq!(weekday("2026-10-09T10:00:00Z"), "friday, october 9");
assert_eq!(weekday("1970-01-01"), "thursday, january 1");
assert_eq!(weekday("2024-02-29"), "thursday, february 29");
assert_eq!(weekday("1969-12-31"), "wednesday, december 31");
assert_eq!(weekday("2026-13-01"), "2026-13-01");
assert_eq!(weekday("soon"), "soon");
assert_eq!(count(0), "nothing new");
assert_eq!(count(1), "1 commit");
assert_eq!(count(7), "7 commits");
assert_eq!(count(WALKED), "100+ commits");
assert_eq!(excerpt("one\ntwo `x`\n\nthree", 50), "one two x");
assert_eq!(excerpt("abcdef ghij", 7), "abcdef…");
assert_eq!(kind("at://did:a/dev.knotless.pull/1"), "pull");
assert_eq!(kind("at://did:a/dev.knotless.issue/1"), "issue");
fn owners_and_names() {
assert_eq!(
owner_and_name("at://did:a/dev.knotless.repo/r"),
Some(("did:a".into(), "r".into()))
);
assert_eq!(owner_and_name("nope"), None);
}
}
crates/knotless-serve/src/web/pages.rsnew+88−0
@@ -0,0 +1,88 @@
//! The pages: every request this server does not answer itself, handed to
//! the SvelteKit app that draws them, which reads back under `/_data`.
use axum::Router;
use axum::body::Body;
use axum::extract::Request;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use std::sync::LazyLock;
/// The largest request body handed on: the pages take none to speak of.
const BODY_LIMIT: usize = 1 << 20;
/// Headers about one hop of a connection, which are not handed on.
const HOP: [header::HeaderName; 6] = [
header::CONNECTION,
header::HOST,
header::TRANSFER_ENCODING,
header::CONTENT_LENGTH,
header::UPGRADE,
header::TE,
];
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("a client builds")
});
/// A router that hands every request to the app at `web`.
pub fn pages(web: String) -> Router {
let web: &'static str = web.trim_end_matches('/').to_string().leak();
Router::new().fallback(move |req: Request| page(web, req))
}
async fn page(web: &str, req: Request) -> Response {
match handed(web, req).await {
Ok(res) => res,
Err(e) => (
StatusCode::BAD_GATEWAY,
format!("The pages are not being drawn: {e:#}"),
)
.into_response(),
}
}
/// `req`, as the app at `web` answers it. The app sees the host it was
/// asked for, as `x-forwarded-host`.
async fn handed(web: &str, req: Request) -> anyhow::Result<Response> {
let (parts, body) = req.into_parts();
let at = parts.uri.path_and_query().map_or("/", |p| p.as_str());
let mut headers = kept(&parts.headers);
if let Some(host) = parts.headers.get(header::HOST) {
headers.insert("x-forwarded-host", host.clone());
}
headers
.entry("x-forwarded-proto")
.or_insert(HeaderValue::from_static("http"));
let body = axum::body::to_bytes(body, BODY_LIMIT).await?;
let answer = CLIENT
.request(parts.method, format!("{web}{at}"))
.headers(headers)
.body(body)
.send()
.await?;
let mut res = Response::builder().status(answer.status());
let mut headers = kept(answer.headers());
for (name, value) in [
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
(header::REFERRER_POLICY, "same-origin"),
] {
headers
.entry(name)
.or_insert(HeaderValue::from_static(value));
}
*res.headers_mut().unwrap() = headers;
Ok(res.body(Body::from(answer.bytes().await?))?)
}
/// `headers`, but for those about one hop.
fn kept(headers: &HeaderMap) -> HeaderMap {
let mut out = headers.clone();
for name in HOP {
out.remove(name);
}
out
}
crates/knotless-serve/tests/forge.rs+9−6
@@ -597,7 +597,7 @@ fn moving_to_another_account() {
let old_page = || {
direct
.get(format!(
"{}/{}/moving/issues",
"{}/_data/{}/moving/issues",
av.base, owner.account.handle
))
.send()
@@ -606,7 +606,7 @@ fn moving_to_another_account() {
wait_until(|| old_page().status().as_u16() == 307);
assert_eq!(
old_page().headers()["location"],
format!("/{}/moving/issues", heir.account.did)
format!("/_data/{}/moving/issues", heir.account.did)
);
// Saying a repository came from another proves nothing without its
@@ -755,10 +755,13 @@ fn moderating() {
// An account taken down takes its repositories with it.
let page = || {
reqwest::blocking::get(format!("{}/{}/moderated", av.base, owner.account.handle))
.unwrap()
.status()
.as_u16()
reqwest::blocking::get(format!(
"{}/_data/{}/moderated",
av.base, owner.account.handle
))
.unwrap()
.status()
.as_u16()
};
assert_eq!(page(), 200);
ok(&admin, &["admin", "takedown", &owner.account.handle]);
crates/knotless-serve/tests/serve.rs+7−0
@@ -295,6 +295,8 @@ fn binary_lifecycle() {
"http://127.0.0.1:9",
"--labeler",
"did:web:127.0.0.1%3A9",
"--web",
"http://127.0.0.1:9",
])
.stdout(std::process::Stdio::piped())
.spawn()
@@ -316,6 +318,8 @@ fn binary_lifecycle() {
.1
.contains("did:web:localhost%3A1")
);
// The pages are handed on, here to nothing.
assert_eq!(get(&format!("{base}/someone.test")).0, 502);
std::process::Command::new("kill")
.args(["-TERM", &child.id().to_string()])
.status()
@@ -357,6 +361,9 @@ fn arguments() {
assert_eq!(o.public_url.as_deref(), Some("https://knot.example"));
let o = args(&["--labeler", "did:web:a", "--labeler", "did:web:b"]).unwrap();
assert_eq!(o.labelers, ["did:web:a", "did:web:b"]);
assert_eq!(o.web, None);
let o = args(&["--web", "http://127.0.0.1:2591"]).unwrap();
assert_eq!(o.web.as_deref(), Some("http://127.0.0.1:2591"));
assert!(args(&["--ttl", "x"]).is_err());
assert!(args(&["--ttl"]).is_err());
}
crates/knotless-serve/tests/web.rs+510−252
@@ -1,4 +1,6 @@
//! The web pages, over a reference PDS, with an index following its firehose.
//! What the web pages show, as knotless-serve answers it under `/_data`
//! and `dev.knotless.getFeed`, over a reference PDS with an index following
//! its firehose; and the pages themselves, handed to the app that draws them.
#[path = "../../knotless/tests/common/mod.rs"]
mod common;
@@ -7,6 +9,7 @@ use common::*;
use knotless_serve::index::Index;
use knotless_serve::{App, router};
use reqwest::blocking::{Client, Response};
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
@@ -48,11 +51,20 @@ impl Site {
.unwrap()
}
/// The page at `path`, which must be there.
fn page(&self, path: &str) -> String {
/// The answer at `path`, which must be there.
fn json(&self, path: &str) -> Value {
let res = self.get(path);
assert_eq!(res.status().as_u16(), 200, "{path}");
res.text().unwrap()
res.json().unwrap()
}
/// What the page at `path` shows.
fn data(&self, path: &str) -> Value {
self.json(&format!("/_data{path}"))
}
fn feed(&self) -> Value {
self.json("/xrpc/dev.knotless.getFeed?limit=100")["events"].clone()
}
fn status(&self, path: &str) -> (u16, String) {
@@ -87,6 +99,25 @@ fn write(dir: &std::path::Path, path: &str, bytes: &[u8]) {
std::fs::write(path, bytes).unwrap();
}
/// The entry named `name` among `entries`.
fn named<'a>(entries: &'a Value, name: &str) -> &'a Value {
entries
.as_array()
.unwrap()
.iter()
.find(|e| e["name"] == name)
.unwrap_or_else(|| panic!("no {name} in {entries}"))
}
fn names(entries: &Value) -> Vec<&str> {
entries
.as_array()
.unwrap()
.iter()
.map(|e| e["name"].as_str().unwrap())
.collect()
}
#[test]
fn browsing_a_repository() {
let site = serve(true);
@@ -121,86 +152,94 @@ fn browsing_a_repository() {
let repo = format!("/{h}/browsed");
// The search finds it once the index has seen it.
wait_until(|| site.page("/?q=").contains("<b>browsed</b></a>"));
assert!(site.page("/?q=BROWS").contains("<b>browsed</b></a>"));
assert!(site.page("/?q=zzzz").contains("No repositories found."));
let res = site.get(&repo);
let csp = res.headers()["content-security-policy"]
.to_str()
.unwrap()
.to_string();
let found = |q: &str| site.json(&format!("/_data/search?q={q}"));
wait_until(|| names(&found("")["repos"]).contains(&"browsed"));
let upper = found("BROWS");
assert_eq!(upper["query"], "BROWS");
assert!(names(&upper["repos"]).contains(&"browsed"));
assert_eq!(upper["repos"][0]["owner"], h.as_str());
assert_eq!(found("zzzz")["repos"], serde_json::json!([]));
let res = site.get(&format!("/_data{repo}"));
assert_eq!(res.headers()["cache-control"], "no-cache");
let top: Value = res.json().unwrap();
assert_eq!(top["empty"], false);
assert_eq!(top["rev"], "main");
assert_eq!(top["repo"]["head"], "main");
assert_eq!(top["repo"]["authority"], h.as_str());
assert!(
csp.starts_with("default-src 'none'") && csp.contains("script-src 'sha256-"),
"{csp}"
top["last"]["message"]
.as_str()
.unwrap()
.starts_with("Lay out the tree")
);
assert_eq!(res.headers()["cache-control"], "no-cache");
let home = res.text().unwrap();
// Folders first, then files, each by name.
let listed = names(&top["entries"]);
assert!(
listed.iter().position(|n| *n == "src") < listed.iter().position(|n| *n == "README.md")
);
assert_eq!(named(&top["entries"], "docs")["kind"], "tree");
assert_eq!(named(&top["entries"], "data.bin")["size"], 4);
let readme = top["readme"]["html"].as_str().unwrap();
for want in [
">src</a>",
">docs</a>",
"Lay out the tree</a>",
"git clone knotless://",
"<h1>Browsed</h1>",
"&lt;script&gt;alert(1)&lt;/script&gt;",
&format!(r#"href="{repo}/blob/main/docs/guide.md""#),
r##"href="#""##,
&format!(r#"src="{repo}/raw/main/logo.png""#),
r#"aria-current="page"><svg"#,
] {
assert!(home.contains(want), "{want} not in\n{home}");
assert!(readme.contains(want), "{want} not in\n{readme}");
}
assert!(home.find(">docs</a>").unwrap() < home.find(">README.md</a>").unwrap());
// Compressed for browsers that ask.
// Compressed for those that ask.
let res = Client::builder()
.no_gzip()
.no_brotli()
.build()
.unwrap()
.get(format!("{}{repo}", site.base))
.get(format!("{}/_data{repo}", site.base))
.header("accept-encoding", "br")
.send()
.unwrap();
assert_eq!(res.headers()["content-encoding"], "br");
// A branch with a slash in its name, and a pinned commit, which never changes.
let sub = site.page(&format!("{repo}/tree/feature/x/src"));
assert!(
sub.contains(&format!(r#"href="{repo}/blob/feature/x/src/main.rs""#)),
"{sub}"
let sub = site.data(&format!("{repo}/tree/feature/x/src"));
assert_eq!(
(sub["rev"].as_str(), sub["path"].as_str()),
(Some("feature/x"), Some("src"))
);
let res = site.get(&format!("{repo}/tree/{tip}/docs"));
assert_eq!(names(&sub["entries"]), ["main.rs"]);
let res = site.get(&format!("/_data{repo}/tree/{tip}/docs"));
assert_eq!(
res.headers()["cache-control"],
"public, max-age=31536000, immutable"
);
assert!(
res.text()
.unwrap()
.contains("<pre>plain &lt;words&gt;\n</pre>")
);
let docs: Value = res.json().unwrap();
assert_eq!(docs["readme"]["text"], "plain <words>\n");
assert_eq!(docs["readme"]["html"], Value::Null);
let file = site.page(&format!("{repo}/blob/main/src/main.rs"));
assert!(file.contains(r##"<a id="L4" href="#L4">4</a>"##), "{file}");
assert!(file.contains("data-hl=\"comment:0,8;"), "{file}");
assert!(file.contains("// héllo\nfn main()"));
assert!(
site.page(&format!("{repo}/blob/main/notes.unknown-kind"))
.contains(r#"data-hl="""#)
);
assert!(
site.page(&format!("{repo}/blob/main/logo.png"))
.contains(&format!(r#"<img src="{repo}/raw/main/logo.png""#))
);
let blob = |path: &str| site.data(&format!("{repo}/blob/main/{path}"));
let file = blob("src/main.rs");
assert_eq!(file["shown"], "text");
assert_eq!(file["lines"], 4);
assert!(
site.page(&format!("{repo}/blob/main/data.bin"))
.contains("A binary file, not shown.")
file["ranges"].as_str().unwrap().starts_with("comment:0,8;"),
"{file}"
);
assert!(
site.page(&format!("{repo}/blob/main/big.txt"))
.contains("Too large to show here")
file["text"]
.as_str()
.unwrap()
.starts_with("// héllo\nfn main()")
);
assert_eq!(blob("notes.unknown-kind")["ranges"], "");
assert_eq!(blob("logo.png")["shown"], "image");
assert_eq!(blob("data.bin")["shown"], "binary");
let big = blob("big.txt");
assert_eq!(big["shown"], "large");
assert_eq!(big["text"], Value::Null);
assert_eq!(big["size"], 1100 * 1024);
for (path, kind) in [
("logo.png", "image/png"),
@@ -217,65 +256,76 @@ fn browsing_a_repository() {
);
}
let log = site.page(&format!("{repo}/commits/main"));
assert_eq!(log.matches("<li>").count(), 50);
assert!(log.contains("Commits on "), "{log}");
assert!(log.contains(r#"href="?page=1">Older"#));
let older = site.page(&format!("{repo}/commits/main?page=1"));
assert_eq!(older.matches("<li>").count(), 2);
assert!(!older.contains("Older"));
let log = site.data(&format!("{repo}/commits/main"));
assert_eq!(log["commits"].as_array().unwrap().len(), 50);
assert_eq!(
site.page(&format!("{repo}/commits/main/src"))
.matches("<li>")
.count(),
1
(log["page"].as_u64(), log["more"].as_bool()),
(Some(0), Some(true))
);
let older = site.data(&format!("{repo}/commits/main?page=1"));
assert_eq!(older["commits"].as_array().unwrap().len(), 2);
assert_eq!(older["more"], false);
let src = site.data(&format!("{repo}/commits/main/src"));
assert_eq!(src["commits"].as_array().unwrap().len(), 1);
assert_eq!(src["path"], "src");
assert_eq!(
site.data(&format!("{repo}/commits/main/nowhere"))["commits"],
serde_json::json!([])
);
assert!(
site.page(&format!("{repo}/commits/main/nowhere"))
.contains("No commits here.")
let commit = site.data(&format!("{repo}/commit/{tip}"));
assert_eq!(commit["commit"]["sha"], tip.as_str());
assert_eq!(
commit["commit"]["message"],
"Lay out the tree\n\nWith a body that says why."
);
assert_eq!(commit["commit"]["parents"].as_array().unwrap().len(), 1);
let files = commit["files"].as_array().unwrap();
let main = files.iter().find(|f| f["new"] == "src/main.rs").unwrap();
assert_eq!(
(main["status"].as_str(), main["added"].as_u64()),
(Some("added"), Some(4))
);
assert!(main["ranges"].as_str().unwrap().starts_with("comment:0,8;"));
let last = main["rows"].as_array().unwrap().last().unwrap();
assert_eq!(
(
last["kind"].as_str(),
last["new"].as_u64(),
last["text"].as_str()
),
(Some("a"), Some(4), Some("}"))
);
let commit = site.page(&format!("{repo}/commit/{tip}"));
for want in [
"<h2>Lay out the tree</h2>",
"With a body that says why.",
r#"<span class="path">src/main.rs</span><span class="tag">new</span>"#,
r#"<table class="diff" data-hl="comment:0,8;"#,
r#"<td class="n" data-n="4"></td><td class="c"><code>}</code>"#,
"parent <a class=\"sha\"",
"changed files</b>",
] {
assert!(commit.contains(want), "{want} not in\n{commit}");
}
let first = s.git(&work, &["rev-list", "--max-parents=0", "HEAD"]);
assert!(
!site
.page(&format!("{repo}/commit/{first}"))
.contains("parent <a")
assert_eq!(
site.data(&format!("{repo}/commit/{first}"))["commit"]["parents"],
serde_json::json!([])
);
for missing in [
format!("{repo}/commit/0000000000000000000000000000000000000000"),
format!("{repo}/tree/main/nope"),
format!("{repo}/blob/main/nope"),
format!("/_data{repo}/commit/0000000000000000000000000000000000000000"),
format!("/_data{repo}/tree/main/nope"),
format!("/_data{repo}/blob/main/nope"),
format!("{repo}/raw/main/nope"),
format!("{repo}/commits/nope"),
format!("{repo}/tree/-x"),
format!("{repo}/commit/-x"),
format!("{repo}/wiki/did:plc:x/y"),
format!("/{h}/missing"),
format!("/_data{repo}/commits/nope"),
format!("/_data{repo}/tree/-x"),
format!("/_data{repo}/commit/-x"),
format!("/_data{repo}/wiki/did:plc:x/y"),
format!("/_data/{h}/missing"),
] {
let (status, body) = site.status(&missing);
let res = site.get(&missing);
let status = res.status().as_u16();
assert!(status == 404 || status == 502, "{missing}: {status}");
assert!(body.starts_with("<!doctype html>"), "{missing}");
assert_eq!(res.headers()["cache-control"], "no-store", "{missing}");
let failure: Value = res.json().unwrap();
assert!(failure["message"].is_string(), "{missing}");
}
// A repository with nothing in it yet.
ok(&s, &["create", &s.url("empty")]);
assert!(
site.page(&format!("/{h}/empty"))
.contains("Nothing pushed yet")
);
let empty = site.data(&format!("/{h}/empty"));
assert_eq!(empty["empty"], true);
assert_eq!(empty["entries"], serde_json::json!([]));
}
#[test]
@@ -321,86 +371,119 @@ fn pulls_and_issues() {
let rkey = |uri: &str| uri.rsplit('/').next().unwrap().to_string();
let other_did = &other.account.did;
let thread = format!("{repo}/pull/{other_did}/{}", rkey(&pull));
wait_until(|| site.page(&thread).contains("Merged as"));
let page = site.page(&thread);
for want in [
r#"<h2 class="item-title">Add b</h2>"#,
"<strong>b</strong>",
"Asked for changes",
"Approved",
"<p>Thanks</p>",
"3 comments",
&format!(">{}</a>", &merged[..7]),
&format!(
r#"<span class="ref">main</span> from <span class="ref">{}/tracked:topic</span>"#,
other.account.handle
),
&format!(
r#"<a href="/{0}"><b>{0}</b></a> wants to merge"#,
other.account.handle
),
] {
assert!(page.contains(want), "{want} not in\n{page}");
}
wait_until(|| {
site.page(&format!("{repo}/issues?state=closed"))
.contains(">Quiet</a>")
});
assert!(site.page(&repo).contains("<p>Tracks &lt;things&gt;</p>"));
let open = site.page(&format!("{repo}/issues"));
wait_until(|| site.data(&thread)["merged"].is_string());
let d = site.data(&thread);
assert_eq!(
(d["kind"].as_str(), d["title"].as_str()),
(Some("pull"), Some("Add b"))
);
assert_eq!(d["state"], "merged");
assert_eq!(d["author"], other.account.handle.as_str());
assert!(d["body"].as_str().unwrap().contains("<strong>b</strong>"));
let verdicts: Vec<&Value> = d["comments"]
.as_array()
.unwrap()
.iter()
.map(|c| &c["verdict"])
.collect();
assert_eq!(
verdicts,
[
&Value::from("changes"),
&Value::from("approve"),
&Value::Null
]
);
assert_eq!(d["comments"][2]["html"], "<p>Thanks</p>\n");
assert_eq!(d["comments"][2]["author"], owner.account.handle.as_str());
assert!(
open.contains("Broken &lt;thing&gt;")
&& open.contains("1 Open")
&& open.contains("1 Closed")
&& open.contains(r#"Issues<span class="count">1</span>"#),
"{open}"
merged
.trim()
.starts_with(&d["merged"].as_str().unwrap()[..7]),
"{merged}"
);
assert!(
site.page(&format!("{repo}/pulls"))
.contains("No open pulls.")
assert_eq!(
(d["base"].as_str(), d["from"].as_str(), d["branch"].as_str()),
(
Some("main"),
Some(format!("{}/tracked", other.account.handle).as_str()),
Some("topic")
)
);
let items = |tail: &str| site.data(&format!("{repo}{tail}"));
let titles = |v: &Value| -> Vec<String> {
v["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["title"].as_str().unwrap().to_string())
.collect()
};
wait_until(|| titles(&items("/issues?state=closed")).contains(&"Quiet".to_string()));
assert_eq!(items("")["repo"]["description"], "Tracks <things>");
let open = items("/issues");
assert_eq!(titles(&open), ["Broken <thing>"]);
assert_eq!(
(open["opened"].as_u64(), open["closed"].as_u64()),
(Some(1), Some(1))
);
let closed = site.page(&format!("{repo}/pulls?state=closed"));
assert!(
closed.contains(r#"<span class="st merged" title="merged">"#)
&& closed.contains("</svg>3</span>"),
"{closed}"
assert_eq!(
(open["kind"].as_str(), open["open"].as_bool()),
(Some("issue"), Some(true))
);
let page = site.page(&format!("{repo}/issue/{other_did}/{}", rkey(&issue)));
assert!(
page.contains(r#"<h2 class="item-title">Broken &lt;thing&gt;</h2>"#),
"{page}"
assert_eq!(
(
open["repo"]["issues"].as_u64(),
open["repo"]["pulls"].as_u64()
),
(Some(1), Some(0))
);
assert!(
page.contains("Nothing said yet.") && page.contains("opened this issue on"),
"{page}"
assert_eq!(
open["items"][0]["href"],
format!("{repo}/issue/{other_did}/{}", rkey(&issue))
);
assert_eq!(items("/pulls")["items"], serde_json::json!([]));
let closed = items("/pulls?state=closed");
assert_eq!(closed["open"], false);
assert_eq!(
(
closed["items"][0]["state"].as_str(),
closed["items"][0]["comments"].as_u64()
),
(Some("merged"), Some(3))
);
let page = site.data(&format!("{repo}/issue/{other_did}/{}", rkey(&issue)));
assert_eq!(page["title"], "Broken <thing>");
assert_eq!(
(&page["body"], &page["base"], &page["merged"]),
(&Value::Null, &Value::Null, &Value::Null)
);
assert_eq!(page["comments"], serde_json::json!([]));
assert_eq!(
site.status(&format!("{repo}/issue/{other_did}/nothing")).0,
site.status(&format!("/_data{repo}/issue/{other_did}/nothing"))
.0,
404
);
// The fork says where it came from.
let forked = site.page(&format!("/{}/tracked", other.account.handle));
assert!(
forked.contains(&format!(
"Forked from <a href=\"/{0}/tracked\">{0}/tracked</a>",
owner.account.handle
)),
"{forked}"
);
let source = serde_json::json!({
"shown": format!("{}/tracked", owner.account.handle),
"href": repo,
});
let forked = site.data(&format!("/{}/tracked", other.account.handle));
assert_eq!(forked["repo"]["forkOf"], source);
// Each account has a page: its public repositories, newest first, and
// who it is, as its Bluesky profile says.
let theirs = site.page(&format!("/{}", other.account.handle));
assert!(
theirs.contains(&format!(
r#"Forked from <a href="/{0}/tracked">{0}/tracked</a>"#,
owner.account.handle
)) && theirs.contains(&other.account.did),
"{theirs}"
let theirs = site.data(&format!("/{}", other.account.handle));
assert_eq!(theirs["did"], other_did.as_str());
assert_eq!(theirs["repos"][0]["forkOf"], source);
assert_eq!(
(&theirs["name"], &theirs["bio"]),
(&Value::Null, &Value::Null)
);
let session: serde_json::Value = Client::new()
let session: Value = Client::new()
.post(format!(
"{}/xrpc/com.atproto.server.createSession",
net().pds
@@ -430,89 +513,112 @@ fn pulls_and_issues() {
.unwrap();
assert!(put.status().is_success());
ok(&owner, &["create", &owner.url("later")]);
let mine = site.page(&format!("/{}", owner.account.did));
assert!(mine.find(">later</a>").unwrap() < mine.find(">tracked</a>").unwrap());
for want in [
"<h1>The &lt;Owner&gt;</h1>",
&format!(r#"<p class="who">{}</p>"#, owner.account.handle),
r#"<p class="bio">Keeps things tracked.</p>"#,
&format!(r#"href="/{}/tracked">tracked</a>"#, owner.account.handle),
"Tracks &lt;things&gt;",
r#"<span class="count">2</span>"#,
] {
assert!(mine.contains(want), "{want} not in\n{mine}");
}
let mine = site.data(&format!("/{}", owner.account.did));
assert_eq!(names(&mine["repos"]), ["later", "tracked"]);
assert_eq!(mine["name"], "The <Owner>");
assert_eq!(mine["handle"], owner.account.handle.as_str());
assert_eq!(mine["bio"], "Keeps things tracked.");
assert_eq!(
named(&mine["repos"], "tracked")["description"],
"Tracks <things>"
);
assert_eq!(
named(&mine["repos"], "tracked")["owner"],
owner.account.handle.as_str()
);
let nobody = person(account(), &site);
assert!(
site.page(&format!("/{}", nobody.account.handle))
.contains("No public repositories yet.")
assert_eq!(
site.data(&format!("/{}", nobody.account.handle))["repos"],
serde_json::json!([])
);
assert_eq!(site.status("/nobody.invalid").0, 404);
assert_eq!(site.status("/_data/nobody.invalid").0, 404);
// Renamed, it keeps its pulls and issues, even through the old name.
// A page asked for by the old name redirects to the same page under the
// new one, and the fork names its source as it is called now.
// An answer asked for by the old name redirects to the same one under
// the new, as does a raw file; and the fork names its source as it is
// called now.
ok(&owner, &["rename", &url, "tracked2"]);
let moved = format!("/{}/tracked2", owner.account.handle);
wait_until(|| {
site.page(&format!("{moved}/issues"))
.contains("Broken &lt;thing&gt;")
titles(&site.data(&format!("{moved}/issues"))).contains(&"Broken <thing>".to_string())
});
assert!(ok(&owner, &["list", &url]).contains(&format!("pull\tmerged\t{pull}")));
let direct = Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let old = || {
direct
.get(format!("{}{repo}/pulls?state=closed", site.base))
.send()
.unwrap()
};
let old = |path: &str| direct.get(format!("{}{path}", site.base)).send().unwrap();
let closed = format!("/_data{repo}/pulls?state=closed");
// Once the firehose has told the server of the rename.
wait_until(|| old().status().as_u16() == 307);
wait_until(|| old(&closed).status().as_u16() == 307);
assert_eq!(
old(&closed).headers()["location"],
format!("/_data{moved}/pulls?state=closed")
);
let file = site.data(&moved)["entries"][0]["name"]
.as_str()
.unwrap()
.to_string();
assert_eq!(
old().headers()["location"],
format!("{moved}/pulls?state=closed")
old(&format!("{repo}/raw/main/{file}")).headers()["location"],
format!("{moved}/raw/main/{file}")
);
assert!(site.page(&thread).contains("Merged as"));
assert!(site.data(&thread)["merged"].is_string());
wait_until(|| {
site.page(&format!("/{}/tracked", other.account.handle))
.contains(&format!(
"Forked from <a href=\"{moved}\">{}/tracked2</a>",
owner.account.handle
))
site.data(&format!("/{}/tracked", other.account.handle))["repo"]["forkOf"]["shown"]
== format!("{}/tracked2", owner.account.handle).as_str()
});
// Without an index, there is no knowing.
let bare = serve(false);
let front = bare.page("/");
assert!(front.contains("keeps git repositories as records"));
assert!(front.contains(r#"href="/start">getting started"#));
let start = bare.page("/start");
assert!(start.contains("claude mcp add knotless -- knotless-mcp"));
assert!(start.contains(&format!("git clone {}/you.bsky.social/project", bare.base)));
assert!(!front.contains("Find a repository") && !front.contains(r#"class="feed""#));
let (status, body) = bare.status(&format!("{repo}/pulls"));
assert_eq!(status, 404);
assert!(body.contains("keeps no index"));
for path in [
"/xrpc/dev.knotless.getFeed".to_string(),
"/_data/search?q=x".to_string(),
format!("/_data{moved}/pulls"),
] {
let (status, body) = bare.status(&path);
assert_eq!(status, 404, "{path}");
assert!(body.contains("keeps no index"), "{path}: {body}");
}
}
/// The events about `handle`'s repository `name`, newest first.
fn about<'a>(feed: &'a Value, handle: &str, name: &str) -> Vec<&'a Value> {
feed.as_array()
.unwrap()
.iter()
.filter(|e| e["repo"]["handle"] == handle && e["repo"]["name"] == name)
.collect()
}
/// Whether one of `events` did `kind`, with each field as `fields` has it.
fn did(events: &[&Value], kind: &str, fields: Value) -> bool {
events.iter().any(|e| {
let a = &e["action"];
a["$type"] == format!("dev.knotless.getFeed#{kind}")
&& fields.as_object().unwrap().iter().all(|(k, v)| &a[k] == v)
})
}
#[test]
fn the_feed() {
use serde_json::json;
let site = serve(true);
let owner = person(account(), &site);
let other = person(account(), &site);
let (o, t) = (&owner.account.handle, &other.account.handle);
let repo = format!(r#"<a class="repo" href="/{o}/fed">{o}/fed</a>"#);
let fork_repo = format!(r#"<a class="repo" href="/{t}/fed">{t}/fed</a>"#);
// What the feed shows once it shows `last`: other tests' pushes and
// pulls share it, so each part is looked for while it is recent.
let shows = |last: &str, wants: &[String]| {
wait_until(|| site.page("/").contains(last));
let feed = site.page("/");
for want in wants {
assert!(feed.contains(want), "{want} not in\n{feed}");
// Other tests' pushes and pulls share the feed, so each part is looked
// for while it is recent: once the feed shows `last`, it must show
// each of `wants`.
let shows = |handle: &str, last: (&str, Value), wants: &[(&str, Value)]| {
wait_until(|| did(&about(&site.feed(), handle, "fed"), last.0, last.1.clone()));
let feed = site.feed();
let events = about(&feed, handle, "fed");
for (kind, fields) in wants {
assert!(
did(&events, kind, fields.clone()),
"no {kind} {fields} in {events:#?}"
);
}
feed
};
@@ -524,8 +630,8 @@ fn the_feed() {
);
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
// A push of several commits lists the first few and counts the rest;
// one back to where it was brings nothing.
// A push of several commits lists them, newest first; one back to
// where it was brings nothing.
for i in 0..6 {
owner.commit_file(&work, "f", &format!("{i}\n"));
}
@@ -537,19 +643,33 @@ fn the_feed() {
owner.git(&work, &["tag", "v1"]);
owner.git(&work, &["push", "-q", &url, "v1"]);
owner.git(&work, &["push", "-q", &url, ":side"]);
shows(
&format!(r#"deleted <span class="ref">side</span> on {repo}"#),
let feed = shows(
o,
("deleted", json!({"ref": "refs/heads/side"})),
&[
format!("created {repo}"),
"<p class=\"feed-note\">Fed &lt;to&gt; the feed</p>".into(),
format!(r#"pushed 6 commits to <span class="ref">main</span> on {repo}"#),
r#"<li class="rest">and 1 more</li>"#.into(),
format!(r#"pushed nothing new to <span class="ref">main</span> on {repo}"#),
format!(r#"created <span class="ref">side</span> on {repo}"#),
format!(r#"created <span class="ref">v1</span> on {repo}"#),
r#"<h2 class="feed-day">"#.into(),
("created", json!({"description": "Fed <to> the feed"})),
(
"pushed",
json!({"ref": "refs/heads/main", "created": false, "count": 6}),
),
(
"pushed",
json!({"ref": "refs/heads/main", "created": false, "count": 0, "commits": []}),
),
(
"pushed",
json!({"ref": "refs/heads/side", "created": true, "count": 3}),
),
("pushed", json!({"ref": "refs/tags/v1", "created": true})),
],
);
let six = about(&feed, o, "fed")
.into_iter()
.find(|e| e["action"]["count"] == 6)
.unwrap();
assert_eq!(six["action"]["commits"].as_array().unwrap().len(), 6);
assert_eq!(six["actor"]["handle"], o.as_str());
assert_eq!(six["actor"]["did"], owner.account.did.as_str());
let fork = ok(&other, &["fork", &url]);
let clone = other.path("clone");
@@ -557,14 +677,22 @@ fn the_feed() {
other.commit_file(&clone, "b", "b\n");
other.git(&clone, &["push", "-q", "origin", "main:topic"]);
let feed = shows(
&format!(r#"created <span class="ref">topic</span> on {fork_repo}"#),
&[
format!("forked {repo} to {fork_repo}"),
format!(r#"<a class="who" href="/{t}">{t}</a>"#),
],
t,
(
"pushed",
json!({"ref": "refs/heads/topic", "created": true, "count": 1}),
),
&[(
"created",
json!({"forkOf": {"did": owner.account.did, "handle": o, "name": "fed"}}),
)],
);
// A fork's copy of its source's branches is the fork itself.
assert!(!feed.contains(&format!(r#"<span class="ref">main</span> on {fork_repo}"#)));
assert!(!did(
&about(&feed, t, "fed"),
"pushed",
json!({"ref": "refs/heads/main"})
));
let pull = ok(&other, &["pull", &url, "main", &fork, "topic", "Add b"]);
let issue = ok(&other, &["issue", &url, "Broken <thing>"]);
@@ -573,30 +701,160 @@ fn the_feed() {
ok(&owner, &["comment", &url, &issue, "Seen it"]);
ok(&owner, &["close", &url, &issue]);
ok(&owner, &["reopen", &url, &issue]);
let reopened = format!(r#"reopened issue <a href="/{o}/fed/issue/"#);
wait_until(|| site.page("/").contains(&reopened));
ok(&owner, &["merge", &url, &pull]);
let (pull, issue) = (pull.trim(), issue.trim());
wait_until(|| {
did(
&about(&site.feed(), o, "fed"),
"changed",
json!({"subject": issue, "state": "open"}),
)
});
ok(&owner, &["merge", &url, pull]);
let feed = shows(
&format!(r#"merged pull <a href="/{o}/fed/pull/"#),
o,
("changed", json!({"subject": pull, "state": "merged"})),
&[
format!(r#"opened pull <a href="/{o}/fed/pull/"#),
format!(r#"asked for changes on pull <a href="/{o}/fed/pull/"#),
"<p class=\"feed-note\">“Not yet”</p>".into(),
format!(r#"approved pull <a href="/{o}/fed/pull/"#),
format!(r#"opened issue <a href="/{o}/fed/issue/"#),
format!(r#"commented on issue <a href="/{o}/fed/issue/"#),
format!(r#"closed issue <a href="/{o}/fed/issue/"#),
reopened.clone(),
("opened", json!({"subject": pull, "title": "Add b"})),
(
"commented",
json!({"subject": pull, "verdict": "changes", "text": "Not *yet*"}),
),
(
"commented",
json!({"subject": pull, "verdict": "approve", "title": "Add b"}),
),
(
"opened",
json!({"subject": issue, "title": "Broken <thing>"}),
),
(
"commented",
json!({"subject": issue, "text": "Seen it", "title": "Broken <thing>"}),
),
("changed", json!({"subject": issue, "state": "closed"})),
],
);
// Opened, said and decided about, by title.
assert_eq!(feed.matches(&format!(">Add b</a> on {repo}")).count(), 4);
let titled = |title: &str| {
about(&feed, o, "fed")
.iter()
.filter(|e| e["action"]["title"] == title)
.count()
};
assert_eq!((titled("Add b"), titled("Broken <thing>")), (4, 4));
// A comment without a verdict has none to say.
assert!(
about(&feed, o, "fed")
.iter()
.any(|e| e["action"]["text"] == "Seen it" && e["action"].get("verdict").is_none())
);
// At most forty, unless asked for more or fewer; at least one.
let events = |q: &str| {
site.json(&format!("/xrpc/dev.knotless.getFeed{q}"))["events"]
.as_array()
.unwrap()
.len()
};
assert!(events("") <= 40);
assert_eq!((events("?limit=1"), events("?limit=0")), (1, 1));
let found = site.json("/_data/search?q=FED");
assert_eq!(found["query"], "FED");
assert!(names(&found["repos"]).contains(&"fed"));
}
/// The pages: anything knotless-serve does not answer itself is handed to
/// the app at `--web`, as asked, with the host it was asked of; and a
/// failure to reach it is said.
#[test]
fn pages_drawn_elsewhere() {
use axum::http::HeaderMap;
use axum::response::IntoResponse;
let rt = tokio::runtime::Runtime::new().unwrap();
let listen = || {
let listener = rt
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
.unwrap();
let at = format!("http://{}", listener.local_addr().unwrap());
(listener, at)
};
// An app that says what it was asked.
let (app, at) = listen();
let echo = axum::Router::new().fallback(
|method: axum::http::Method, uri: axum::http::Uri, headers: HeaderMap, body: String| async move {
let header = |name: &str| headers.get(name).map(|v| v.to_str().unwrap().to_string());
let said = serde_json::json!({
"method": method.as_str(),
"at": uri.to_string(),
"host": header("x-forwarded-host"),
"proto": header("x-forwarded-proto"),
"body": body,
});
let policy = if uri.path() == "/own" { "no-referrer" } else { "" };
let mut res = axum::Json(said).into_response();
if !policy.is_empty() {
res.headers_mut()
.insert("referrer-policy", policy.parse().unwrap());
}
res
},
);
rt.spawn(async move { axum::serve(app, echo).await.unwrap() });
let s = Sandbox::new();
let front = |web: String| {
let (listener, base) = listen();
let app: Arc<App> = App::build(ctx(&s), Duration::ZERO, None, None);
let routes = router(app).merge(knotless_serve::web::pages(web));
rt.spawn(async move { axum::serve(listener, routes).await.unwrap() });
base
};
let base = front(format!("{at}/"));
let client = Client::new();
let res = client
.post(format!("{base}/alice.test/tally?x=1"))
.header("host", "pages.example")
.header("x-forwarded-proto", "https")
.body("hi")
.send()
.unwrap();
assert_eq!(res.status().as_u16(), 200);
assert_eq!(res.headers()["x-content-type-options"], "nosniff");
assert_eq!(res.headers()["referrer-policy"], "same-origin");
let said: Value = res.json().unwrap();
assert_eq!(
feed.matches(&format!(">Broken &lt;thing&gt;</a> on {repo}"))
.count(),
4
said,
serde_json::json!({
"method": "POST",
"at": "/alice.test/tally?x=1",
"host": "pages.example",
"proto": "https",
"body": "hi",
})
);
// What the app says of itself stands.
let own = client.get(format!("{base}/own")).send().unwrap();
assert_eq!(own.headers()["referrer-policy"], "no-referrer");
assert_eq!(own.json::<Value>().unwrap()["proto"], "http");
// What knotless-serve answers itself is not handed on.
let ours = client
.get(format!("{base}/xrpc/dev.knotless.getFeed"))
.send()
.unwrap();
assert!(ours.text().unwrap().contains("keeps no index"));
// Too much to hand on, or no app to hand it to.
let big = client
.post(format!("{base}/x"))
.body(vec![b'x'; 2 << 20])
.send()
.unwrap();
assert_eq!(big.status().as_u16(), 502);
let (gone, nowhere) = listen();
drop(gone);
let res = client.get(front(nowhere)).send().unwrap();
assert_eq!(res.status().as_u16(), 502);
assert!(
res.text()
.unwrap()
.starts_with("The pages are not being drawn")
);
let found = site.page("/?q=FED");
assert!(found.contains("repositories matching “FED”"), "{found}");
assert!(found.contains("<b>fed</b></a>"), "{found}");
}
harness/changed.mjs+4−3
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const stamp = join(root, 'target/tested.stamp')
const since = existsSync(stamp) ? statSync(stamp).mtimeMs : 0
const skip = new Set(['target', 'node_modules', '.git', 'baseline'])
const skip = new Set(['target', 'node_modules', '.git', 'baseline', 'build', '.svelte-kit'])
const walk = (dir, out = []) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -29,7 +29,7 @@ const walk = (dir, out = []) => {
}
return out
}
const changed = ['crates', 'harness', 'Cargo.toml', 'Cargo.lock']
const changed = ['crates', 'harness', 'web', 'Cargo.toml', 'Cargo.lock']
.map((p) => join(root, p))
.filter(existsSync)
.flatMap((p) => (statSync(p).isDirectory() ? walk(p) : statSync(p).mtimeMs > since ? [relative(root, p)] : []))
@@ -47,6 +47,7 @@ const runs = (file) => {
if ((x = m(/^crates\/knotless\/tests\/(\w+)\.rs$/))) return { runs: [['-p', 'knotless', '--test', x[1]]] }
if (m(/^crates\/knotless-mcp\//)) return { runs: [['-p', 'knotless-mcp']] }
if ((x = m(/^crates\/knotless-serve\/tests\/(\w+)\.rs$/))) return { runs: [['-p', 'knotless-serve', '--test', x[1]]] }
if (m(/^crates\/knotless-serve\/src\/web\//)) return { runs: serve('web'), pages: true }
if ((x = m(/^crates\/knotless-serve\/src\/(\w+)\.rs$/))) {
const pages = { runs: serve('web'), pages: true }
return (
@@ -66,7 +67,7 @@ const runs = (file) => {
)
}
if (m(/^crates\/knotless-serve\//)) return { runs: [['-p', 'knotless-serve']], pages: true }
if (m(/^harness\/shots\//)) return { runs: [], pages: true }
if (m(/^harness\/shots\//) || m(/^web\//)) return { runs: [], pages: true }
return { runs: [] }
}
harness/shots/baseline/darwin/commit-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-desktop-dark.sha256+1−1
@@ -1 +1 @@
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2
\ No newline at end of file
a29279b1bfa9b39e9ffdd6c62ba9a7c271ce824f4a8fdf46c68ef95894ca15df
\ No newline at end of file
harness/shots/baseline/darwin/commit-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-desktop-light.sha256+1−1
@@ -1 +1 @@
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2
\ No newline at end of file
a29279b1bfa9b39e9ffdd6c62ba9a7c271ce824f4a8fdf46c68ef95894ca15df
\ No newline at end of file
harness/shots/baseline/darwin/commit-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-phone-dark.sha256+1−1
@@ -1 +1 @@
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2
\ No newline at end of file
a29279b1bfa9b39e9ffdd6c62ba9a7c271ce824f4a8fdf46c68ef95894ca15df
\ No newline at end of file
harness/shots/baseline/darwin/commit-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-phone-light.sha256+1−1
@@ -1 +1 @@
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2
\ No newline at end of file
a29279b1bfa9b39e9ffdd6c62ba9a7c271ce824f4a8fdf46c68ef95894ca15df
\ No newline at end of file
harness/shots/baseline/darwin/commits-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-desktop-dark.sha256+1−1
@@ -1 +1 @@
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376
\ No newline at end of file
240ab63c6a35b7b938864e83b6f5a1ebadff90540694c54ebfdf639f1a22c1d0
\ No newline at end of file
harness/shots/baseline/darwin/commits-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-desktop-light.sha256+1−1
@@ -1 +1 @@
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376
\ No newline at end of file
240ab63c6a35b7b938864e83b6f5a1ebadff90540694c54ebfdf639f1a22c1d0
\ No newline at end of file
harness/shots/baseline/darwin/commits-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-phone-dark.sha256+1−1
@@ -1 +1 @@
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376
\ No newline at end of file
240ab63c6a35b7b938864e83b6f5a1ebadff90540694c54ebfdf639f1a22c1d0
\ No newline at end of file
harness/shots/baseline/darwin/commits-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-phone-light.sha256+1−1
@@ -1 +1 @@
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376
\ No newline at end of file
240ab63c6a35b7b938864e83b6f5a1ebadff90540694c54ebfdf639f1a22c1d0
\ No newline at end of file
harness/shots/baseline/darwin/directory-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-desktop-dark.sha256+1−1
@@ -1 +1 @@
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377
\ No newline at end of file
92428364781d630fb0219d58ccd80cdc99b1fbd6ccb82c18dd1cd7264f1226ba
\ No newline at end of file
harness/shots/baseline/darwin/directory-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-desktop-light.sha256+1−1
@@ -1 +1 @@
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377
\ No newline at end of file
92428364781d630fb0219d58ccd80cdc99b1fbd6ccb82c18dd1cd7264f1226ba
\ No newline at end of file
harness/shots/baseline/darwin/directory-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-phone-dark.sha256+1−1
@@ -1 +1 @@
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377
\ No newline at end of file
92428364781d630fb0219d58ccd80cdc99b1fbd6ccb82c18dd1cd7264f1226ba
\ No newline at end of file
harness/shots/baseline/darwin/directory-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-phone-light.sha256+1−1
@@ -1 +1 @@
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377
\ No newline at end of file
92428364781d630fb0219d58ccd80cdc99b1fbd6ccb82c18dd1cd7264f1226ba
\ No newline at end of file
harness/shots/baseline/darwin/empty-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-desktop-dark.sha256+1−1
@@ -1 +1 @@
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b
\ No newline at end of file
368664cceba5ce268deacb3b0eedb9ba8d58774668623c37c2e25c36f47ba1f8
\ No newline at end of file
harness/shots/baseline/darwin/empty-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-desktop-light.sha256+1−1
@@ -1 +1 @@
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b
\ No newline at end of file
368664cceba5ce268deacb3b0eedb9ba8d58774668623c37c2e25c36f47ba1f8
\ No newline at end of file
harness/shots/baseline/darwin/empty-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-phone-dark.sha256+1−1
@@ -1 +1 @@
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b
\ No newline at end of file
368664cceba5ce268deacb3b0eedb9ba8d58774668623c37c2e25c36f47ba1f8
\ No newline at end of file
harness/shots/baseline/darwin/empty-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-phone-light.sha256+1−1
@@ -1 +1 @@
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b
\ No newline at end of file
368664cceba5ce268deacb3b0eedb9ba8d58774668623c37c2e25c36f47ba1f8
\ No newline at end of file
harness/shots/baseline/darwin/file-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-desktop-dark.sha256+1−1
@@ -1 +1 @@
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b
\ No newline at end of file
1664f016097bb240f30cb958578da5487fc737dd30a8ab4c08057cd87f3800d9
\ No newline at end of file
harness/shots/baseline/darwin/file-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-desktop-light.sha256+1−1
@@ -1 +1 @@
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b
\ No newline at end of file
1664f016097bb240f30cb958578da5487fc737dd30a8ab4c08057cd87f3800d9
\ No newline at end of file
harness/shots/baseline/darwin/file-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-phone-dark.sha256+1−1
@@ -1 +1 @@
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b
\ No newline at end of file
1664f016097bb240f30cb958578da5487fc737dd30a8ab4c08057cd87f3800d9
\ No newline at end of file
harness/shots/baseline/darwin/file-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-phone-light.sha256+1−1
@@ -1 +1 @@
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b
\ No newline at end of file
1664f016097bb240f30cb958578da5487fc737dd30a8ab4c08057cd87f3800d9
\ No newline at end of file
harness/shots/baseline/darwin/fork-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-desktop-dark.sha256+1−1
@@ -1 +1 @@
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033
\ No newline at end of file
33642e31dc4ebad7b3cc0cd4dc8cf8fa622b0d3547443bcdb88e60f3e7a20a69
\ No newline at end of file
harness/shots/baseline/darwin/fork-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-desktop-light.sha256+1−1
@@ -1 +1 @@
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033
\ No newline at end of file
33642e31dc4ebad7b3cc0cd4dc8cf8fa622b0d3547443bcdb88e60f3e7a20a69
\ No newline at end of file
harness/shots/baseline/darwin/fork-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-phone-dark.sha256+1−1
@@ -1 +1 @@
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033
\ No newline at end of file
33642e31dc4ebad7b3cc0cd4dc8cf8fa622b0d3547443bcdb88e60f3e7a20a69
\ No newline at end of file
harness/shots/baseline/darwin/fork-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-phone-light.sha256+1−1
@@ -1 +1 @@
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033
\ No newline at end of file
33642e31dc4ebad7b3cc0cd4dc8cf8fa622b0d3547443bcdb88e60f3e7a20a69
\ No newline at end of file
harness/shots/baseline/darwin/home-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-desktop-dark.sha256+1−1
@@ -1 +1 @@
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4
\ No newline at end of file
1a1b674e493f97d544322dae8b50b9c9e0963f2268eabb072fd04c9ae296bcc4
\ No newline at end of file
harness/shots/baseline/darwin/home-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-desktop-light.sha256+1−1
@@ -1 +1 @@
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4
\ No newline at end of file
1a1b674e493f97d544322dae8b50b9c9e0963f2268eabb072fd04c9ae296bcc4
\ No newline at end of file
harness/shots/baseline/darwin/home-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-phone-dark.sha256+1−1
@@ -1 +1 @@
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4
\ No newline at end of file
1a1b674e493f97d544322dae8b50b9c9e0963f2268eabb072fd04c9ae296bcc4
\ No newline at end of file
harness/shots/baseline/darwin/home-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-phone-light.sha256+1−1
@@ -1 +1 @@
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4
\ No newline at end of file
1a1b674e493f97d544322dae8b50b9c9e0963f2268eabb072fd04c9ae296bcc4
\ No newline at end of file
harness/shots/baseline/darwin/image-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-desktop-dark.sha256+1−1
@@ -1 +1 @@
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0
\ No newline at end of file
0d965ab50021f96fa9e36b6c298a0eafefc306bb19a246e8e3c5f79e16985492
\ No newline at end of file
harness/shots/baseline/darwin/image-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-desktop-light.sha256+1−1
@@ -1 +1 @@
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0
\ No newline at end of file
0d965ab50021f96fa9e36b6c298a0eafefc306bb19a246e8e3c5f79e16985492
\ No newline at end of file
harness/shots/baseline/darwin/image-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-phone-dark.sha256+1−1
@@ -1 +1 @@
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0
\ No newline at end of file
0d965ab50021f96fa9e36b6c298a0eafefc306bb19a246e8e3c5f79e16985492
\ No newline at end of file
harness/shots/baseline/darwin/image-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-phone-light.sha256+1−1
@@ -1 +1 @@
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0
\ No newline at end of file
0d965ab50021f96fa9e36b6c298a0eafefc306bb19a246e8e3c5f79e16985492
\ No newline at end of file
harness/shots/baseline/darwin/issue-closed-desktop-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-closed-desktop-dark.sha256new+1−0
@@ -0,0 +1 @@
ea9e773e5867af592c96fef4b334c5380603f480d41b75e03bef3b07cb1c37c8
\ No newline at end of file
harness/shots/baseline/darwin/issue-closed-desktop-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-closed-desktop-light.sha256new+1−0
@@ -0,0 +1 @@
ea9e773e5867af592c96fef4b334c5380603f480d41b75e03bef3b07cb1c37c8
\ No newline at end of file
harness/shots/baseline/darwin/issue-closed-phone-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-closed-phone-dark.sha256new+1−0
@@ -0,0 +1 @@
ea9e773e5867af592c96fef4b334c5380603f480d41b75e03bef3b07cb1c37c8
\ No newline at end of file
harness/shots/baseline/darwin/issue-closed-phone-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-closed-phone-light.sha256new+1−0
@@ -0,0 +1 @@
ea9e773e5867af592c96fef4b334c5380603f480d41b75e03bef3b07cb1c37c8
\ No newline at end of file
harness/shots/baseline/darwin/issue-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-desktop-dark.sha256+1−1
@@ -1 +1 @@
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa
\ No newline at end of file
8a77258c9e6c52fd8277ab52c304c59f8e52f32d3770209704320c2c602862db
\ No newline at end of file
harness/shots/baseline/darwin/issue-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-desktop-light.sha256+1−1
@@ -1 +1 @@
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa
\ No newline at end of file
8a77258c9e6c52fd8277ab52c304c59f8e52f32d3770209704320c2c602862db
\ No newline at end of file
harness/shots/baseline/darwin/issue-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-phone-dark.sha256+1−1
@@ -1 +1 @@
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa
\ No newline at end of file
8a77258c9e6c52fd8277ab52c304c59f8e52f32d3770209704320c2c602862db
\ No newline at end of file
harness/shots/baseline/darwin/issue-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-phone-light.sha256+1−1
@@ -1 +1 @@
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa
\ No newline at end of file
8a77258c9e6c52fd8277ab52c304c59f8e52f32d3770209704320c2c602862db
\ No newline at end of file
harness/shots/baseline/darwin/issues-closed-desktop-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-closed-desktop-dark.sha256new+1−0
@@ -0,0 +1 @@
3a1e0f31a3a324407b43311809612a353138f60e4cc353bf13be081195298b08
\ No newline at end of file
harness/shots/baseline/darwin/issues-closed-desktop-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-closed-desktop-light.sha256new+1−0
@@ -0,0 +1 @@
3a1e0f31a3a324407b43311809612a353138f60e4cc353bf13be081195298b08
\ No newline at end of file
harness/shots/baseline/darwin/issues-closed-phone-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-closed-phone-dark.sha256new+1−0
@@ -0,0 +1 @@
3a1e0f31a3a324407b43311809612a353138f60e4cc353bf13be081195298b08
\ No newline at end of file
harness/shots/baseline/darwin/issues-closed-phone-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-closed-phone-light.sha256new+1−0
@@ -0,0 +1 @@
3a1e0f31a3a324407b43311809612a353138f60e4cc353bf13be081195298b08
\ No newline at end of file
harness/shots/baseline/darwin/issues-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-desktop-dark.sha256+1−1
@@ -1 +1 @@
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f
\ No newline at end of file
d235778020c3eb58cc89d8635c3b58fe6ca2c6e65d496fd161a3bd0052147845
\ No newline at end of file
harness/shots/baseline/darwin/issues-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-desktop-light.sha256+1−1
@@ -1 +1 @@
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f
\ No newline at end of file
d235778020c3eb58cc89d8635c3b58fe6ca2c6e65d496fd161a3bd0052147845
\ No newline at end of file
harness/shots/baseline/darwin/issues-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-phone-dark.sha256+1−1
@@ -1 +1 @@
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f
\ No newline at end of file
d235778020c3eb58cc89d8635c3b58fe6ca2c6e65d496fd161a3bd0052147845
\ No newline at end of file
harness/shots/baseline/darwin/issues-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-phone-light.sha256+1−1
@@ -1 +1 @@
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f
\ No newline at end of file
d235778020c3eb58cc89d8635c3b58fe6ca2c6e65d496fd161a3bd0052147845
\ No newline at end of file
harness/shots/baseline/darwin/markdown-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-desktop-dark.sha256+1−1
@@ -1 +1 @@
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875
\ No newline at end of file
9a79e75f2e32aec99b311af602267fbdff9383a9311163537aa2730aa421d802
\ No newline at end of file
harness/shots/baseline/darwin/markdown-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-desktop-light.sha256+1−1
@@ -1 +1 @@
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875
\ No newline at end of file
9a79e75f2e32aec99b311af602267fbdff9383a9311163537aa2730aa421d802
\ No newline at end of file
harness/shots/baseline/darwin/markdown-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-phone-dark.sha256+1−1
@@ -1 +1 @@
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875
\ No newline at end of file
9a79e75f2e32aec99b311af602267fbdff9383a9311163537aa2730aa421d802
\ No newline at end of file
harness/shots/baseline/darwin/markdown-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-phone-light.sha256+1−1
@@ -1 +1 @@
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875
\ No newline at end of file
9a79e75f2e32aec99b311af602267fbdff9383a9311163537aa2730aa421d802
\ No newline at end of file
harness/shots/baseline/darwin/missing-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-desktop-dark.sha256+1−1
@@ -1 +1 @@
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b
\ No newline at end of file
f7f00f19830982f8c17649bca019c8c0224a4c5f42f15c3fc0c6303948aae4e7
\ No newline at end of file
harness/shots/baseline/darwin/missing-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-desktop-light.sha256+1−1
@@ -1 +1 @@
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b
\ No newline at end of file
f7f00f19830982f8c17649bca019c8c0224a4c5f42f15c3fc0c6303948aae4e7
\ No newline at end of file
harness/shots/baseline/darwin/missing-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-phone-dark.sha256+1−1
@@ -1 +1 @@
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b
\ No newline at end of file
f7f00f19830982f8c17649bca019c8c0224a4c5f42f15c3fc0c6303948aae4e7
\ No newline at end of file
harness/shots/baseline/darwin/missing-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-phone-light.sha256+1−1
@@ -1 +1 @@
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b
\ No newline at end of file
f7f00f19830982f8c17649bca019c8c0224a4c5f42f15c3fc0c6303948aae4e7
\ No newline at end of file
harness/shots/baseline/darwin/profile-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-desktop-dark.sha256+1−1
@@ -1 +1 @@
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99
\ No newline at end of file
9772c564083a24460d91e1ba2e6338e09aa7e6f01177af322fbc1d996ba3d605
\ No newline at end of file
harness/shots/baseline/darwin/profile-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-desktop-light.sha256+1−1
@@ -1 +1 @@
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99
\ No newline at end of file
9772c564083a24460d91e1ba2e6338e09aa7e6f01177af322fbc1d996ba3d605
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-desktop-dark.sha256+1−1
@@ -1 +1 @@
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25
\ No newline at end of file
d11c905f298b3142121c78a117500b208d0cc6b64a33169bf62141c7ee82248c
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-desktop-light.sha256+1−1
@@ -1 +1 @@
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25
\ No newline at end of file
d11c905f298b3142121c78a117500b208d0cc6b64a33169bf62141c7ee82248c
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-phone-dark.sha256+1−1
@@ -1 +1 @@
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25
\ No newline at end of file
d11c905f298b3142121c78a117500b208d0cc6b64a33169bf62141c7ee82248c
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-phone-light.sha256+1−1
@@ -1 +1 @@
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25
\ No newline at end of file
d11c905f298b3142121c78a117500b208d0cc6b64a33169bf62141c7ee82248c
\ No newline at end of file
harness/shots/baseline/darwin/profile-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-phone-dark.sha256+1−1
@@ -1 +1 @@
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99
\ No newline at end of file
9772c564083a24460d91e1ba2e6338e09aa7e6f01177af322fbc1d996ba3d605
\ No newline at end of file
harness/shots/baseline/darwin/profile-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-phone-light.sha256+1−1
@@ -1 +1 @@
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99
\ No newline at end of file
9772c564083a24460d91e1ba2e6338e09aa7e6f01177af322fbc1d996ba3d605
\ No newline at end of file
harness/shots/baseline/darwin/pull-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-desktop-dark.sha256+1−1
@@ -1 +1 @@
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6
\ No newline at end of file
96c02c81f3b0c0e5b889111a9b0e730fd8d677226837f40884a6ba0f737fce8e
\ No newline at end of file
harness/shots/baseline/darwin/pull-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-desktop-light.sha256+1−1
@@ -1 +1 @@
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6
\ No newline at end of file
96c02c81f3b0c0e5b889111a9b0e730fd8d677226837f40884a6ba0f737fce8e
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-desktop-dark.sha256+1−1
@@ -1 +1 @@
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141
\ No newline at end of file
529f35b9a38a1d111e1bfd9c83c003577d8a10447a4232a0235d40c88c171631
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-desktop-light.sha256+1−1
@@ -1 +1 @@
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141
\ No newline at end of file
529f35b9a38a1d111e1bfd9c83c003577d8a10447a4232a0235d40c88c171631
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-phone-dark.sha256+1−1
@@ -1 +1 @@
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141
\ No newline at end of file
529f35b9a38a1d111e1bfd9c83c003577d8a10447a4232a0235d40c88c171631
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-phone-light.sha256+1−1
@@ -1 +1 @@
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141
\ No newline at end of file
529f35b9a38a1d111e1bfd9c83c003577d8a10447a4232a0235d40c88c171631
\ No newline at end of file
harness/shots/baseline/darwin/pull-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-phone-dark.sha256+1−1
@@ -1 +1 @@
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6
\ No newline at end of file
96c02c81f3b0c0e5b889111a9b0e730fd8d677226837f40884a6ba0f737fce8e
\ No newline at end of file
harness/shots/baseline/darwin/pull-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-phone-light.sha256+1−1
@@ -1 +1 @@
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6
\ No newline at end of file
96c02c81f3b0c0e5b889111a9b0e730fd8d677226837f40884a6ba0f737fce8e
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-desktop-dark.sha256+1−1
@@ -1 +1 @@
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833
\ No newline at end of file
dbd29833296863391f1f52a5edb0f461472579762c984245858388b27cd3b6ed
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-desktop-light.sha256+1−1
@@ -1 +1 @@
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833
\ No newline at end of file
dbd29833296863391f1f52a5edb0f461472579762c984245858388b27cd3b6ed
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-phone-dark.sha256+1−1
@@ -1 +1 @@
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833
\ No newline at end of file
dbd29833296863391f1f52a5edb0f461472579762c984245858388b27cd3b6ed
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-phone-light.sha256+1−1
@@ -1 +1 @@
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833
\ No newline at end of file
dbd29833296863391f1f52a5edb0f461472579762c984245858388b27cd3b6ed
\ No newline at end of file
harness/shots/baseline/darwin/pulls-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-desktop-dark.sha256+1−1
@@ -1 +1 @@
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16
\ No newline at end of file
063fef24a62a4037e5225d9af1b2432063d64d74abd0537ccbe45cd6213df15a
\ No newline at end of file
harness/shots/baseline/darwin/pulls-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-desktop-light.sha256+1−1
@@ -1 +1 @@
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16
\ No newline at end of file
063fef24a62a4037e5225d9af1b2432063d64d74abd0537ccbe45cd6213df15a
\ No newline at end of file
harness/shots/baseline/darwin/pulls-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-phone-dark.sha256+1−1
@@ -1 +1 @@
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16
\ No newline at end of file
063fef24a62a4037e5225d9af1b2432063d64d74abd0537ccbe45cd6213df15a
\ No newline at end of file
harness/shots/baseline/darwin/pulls-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-phone-light.sha256+1−1
@@ -1 +1 @@
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16
\ No newline at end of file
063fef24a62a4037e5225d9af1b2432063d64d74abd0537ccbe45cd6213df15a
\ No newline at end of file
harness/shots/baseline/darwin/repository-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-desktop-dark.sha256+1−1
@@ -1 +1 @@
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2
\ No newline at end of file
908af4b5f25fc81ad33347a694c7758d7a0ae60737fa4353078b93bebc3410ff
\ No newline at end of file
harness/shots/baseline/darwin/repository-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-desktop-light.sha256+1−1
@@ -1 +1 @@
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2
\ No newline at end of file
908af4b5f25fc81ad33347a694c7758d7a0ae60737fa4353078b93bebc3410ff
\ No newline at end of file
harness/shots/baseline/darwin/repository-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-phone-dark.sha256+1−1
@@ -1 +1 @@
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2
\ No newline at end of file
908af4b5f25fc81ad33347a694c7758d7a0ae60737fa4353078b93bebc3410ff
\ No newline at end of file
harness/shots/baseline/darwin/repository-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-phone-light.sha256+1−1
@@ -1 +1 @@
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2
\ No newline at end of file
908af4b5f25fc81ad33347a694c7758d7a0ae60737fa4353078b93bebc3410ff
\ No newline at end of file
harness/shots/baseline/darwin/search-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-desktop-dark.sha256+1−1
@@ -1 +1 @@
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8
\ No newline at end of file
0d0b1d078d8627e0914b4cb466e61e65b6bcc451fe57d864c58c68df508bbff8
\ No newline at end of file
harness/shots/baseline/darwin/search-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-desktop-light.sha256+1−1
@@ -1 +1 @@
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8
\ No newline at end of file
0d0b1d078d8627e0914b4cb466e61e65b6bcc451fe57d864c58c68df508bbff8
\ No newline at end of file
harness/shots/baseline/darwin/search-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-phone-dark.sha256+1−1
@@ -1 +1 @@
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8
\ No newline at end of file
0d0b1d078d8627e0914b4cb466e61e65b6bcc451fe57d864c58c68df508bbff8
\ No newline at end of file
harness/shots/baseline/darwin/search-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-phone-light.sha256+1−1
@@ -1 +1 @@
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8
\ No newline at end of file
0d0b1d078d8627e0914b4cb466e61e65b6bcc451fe57d864c58c68df508bbff8
\ No newline at end of file
harness/shots/baseline/darwin/start-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-desktop-dark.sha256+1−1
@@ -1 +1 @@
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df
\ No newline at end of file
ad253478f1763b7939986ce3621cf36999c1a1073400ebe45ec266c9bee72b50
\ No newline at end of file
harness/shots/baseline/darwin/start-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-desktop-light.sha256+1−1
@@ -1 +1 @@
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df
\ No newline at end of file
ad253478f1763b7939986ce3621cf36999c1a1073400ebe45ec266c9bee72b50
\ No newline at end of file
harness/shots/baseline/darwin/start-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-phone-dark.sha256+1−1
@@ -1 +1 @@
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df
\ No newline at end of file
ad253478f1763b7939986ce3621cf36999c1a1073400ebe45ec266c9bee72b50
\ No newline at end of file
harness/shots/baseline/darwin/start-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-phone-light.sha256+1−1
@@ -1 +1 @@
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df
\ No newline at end of file
ad253478f1763b7939986ce3621cf36999c1a1073400ebe45ec266c9bee72b50
\ No newline at end of file
harness/shots/fixtures.jsonnew+222−0
@@ -0,0 +1,222 @@
{
"views": {
"home": "/",
"search": "/?q=t",
"profile": "/alice.test",
"profile-fork": "/bob.test",
"repository": "/alice.test/tally",
"directory": "/alice.test/tally/tree/main/src",
"file": "/alice.test/tally/blob/main/src/lib.rs",
"markdown": "/alice.test/tally/blob/main/README.md",
"image": "/alice.test/tally/blob/main/assets/logo.svg",
"commits": "/alice.test/tally/commits/main",
"commit": "/alice.test/tally/commit/97727c2c19d5d8b3849b2ed9ad47130cee9798f5",
"pulls": "/alice.test/tally/pulls",
"pulls-closed": "/alice.test/tally/pulls?state=closed",
"pull": "/alice.test/tally/pull/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr4ing2u4",
"pull-open": "/alice.test/tally/pull/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr6n5c7dh",
"issues": "/alice.test/tally/issues",
"issues-closed": "/alice.test/tally/issues?state=closed",
"issue": "/alice.test/tally/issue/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr6ojnudl",
"issue-closed": "/alice.test/tally/issue/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr6rk55ds",
"fork": "/bob.test/tally",
"empty": "/alice.test/scratch",
"missing": "/alice.test/tally/blob/main/nowhere",
"start": "/start"
},
"answers": {
"/_data/alice.test": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"Alice Moreau\",\"bio\":\"Small tools for text. Writes tally.\",\"repos\":[{\"owner\":\"alice.test\",\"name\":\"scratch\",\"description\":null,\"forkOf\":null,\"createdAt\":\"2026-10-09T13:42:00.000Z\"},{\"owner\":\"alice.test\",\"name\":\"notes\",\"description\":\"Scratch notes\",\"forkOf\":null,\"createdAt\":\"2026-10-09T11:51:00.000Z\"},{\"owner\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"createdAt\":\"2026-10-09T10:00:00.000Z\"}]}"
},
"/_data/alice.test/scratch": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"scratch\",\"description\":null,\"forkOf\":null,\"head\":\"main\",\"pulls\":0,\"issues\":0},\"rev\":\"main\",\"path\":\"\",\"empty\":true,\"last\":null,\"entries\":[],\"readme\":null}"
},
"/_data/alice.test/tally": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"rev\":\"main\",\"path\":\"\",\"empty\":false,\"last\":{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"parents\":[\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\"],\"author\":\"Bob Tanaka\",\"date\":\"2026-10-09T09:31:00+00:00\",\"message\":\"Count no lines in empty input\"},\"entries\":[{\"name\":\"assets\",\"kind\":\"tree\",\"size\":null},{\"name\":\"docs\",\"kind\":\"tree\",\"size\":null},{\"name\":\"src\",\"kind\":\"tree\",\"size\":null},{\"name\":\".gitignore\",\"kind\":\"blob\",\"size\":8},{\"name\":\"Cargo.toml\",\"kind\":\"blob\",\"size\":122},{\"name\":\"README.md\",\"kind\":\"blob\",\"size\":388}],\"readme\":{\"name\":\"README.md\",\"html\":\"<h1>tally</h1>\\n<p>Counts lines, words and bytes, like <code>wc</code>, with output that lines up.</p>\\n<p><img src=\\\"/alice.test/tally/raw/main/assets/logo.svg\\\" alt=\\\"tally\\\" /></p>\\n<h2>Usage</h2>\\n<pre><code class=\\\"language-sh\\\">cargo install --path .\\ntally notes.txt\\necho \\\"one two\\\" | tally\\n</code></pre>\\n<h2>Output</h2>\\n<table><thead><tr><th>Column</th><th>Meaning</th></tr></thead><tbody>\\n<tr><td>1</td><td>lines</td></tr>\\n<tr><td>2</td><td>words, split on whitespace</td></tr>\\n<tr><td>3</td><td>bytes</td></tr>\\n</tbody></table>\\n<p>See <a href=\\\"/alice.test/tally/blob/main/docs/design.md\\\">the design notes</a> for why it reads the whole file.</p>\\n<h2>License</h2>\\n<p>MIT</p>\\n\",\"text\":null}}"
},
"/_data/alice.test/tally/blob/main/README.md": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"rev\":\"main\",\"path\":\"README.md\",\"shown\":\"text\",\"size\":388,\"lines\":27,\"text\":\"# tally\\n\\nCounts lines, words and bytes, like `wc`, with output that lines up.\\n\\n![tally](assets/logo.svg)\\n\\n## Usage\\n\\n```sh\\ncargo install --path .\\ntally notes.txt\\necho \\\"one two\\\" | tally\\n```\\n\\n## Output\\n\\n| Column | Meaning |\\n| --- | --- |\\n| 1 | lines |\\n| 2 | words, split on whitespace |\\n| 3 | bytes |\\n\\nSee [the design notes](docs/design.md) for why it reads the whole file.\\n\\n## License\\n\\nMIT\\n\",\"ranges\":\"\"}"
},
"/_data/alice.test/tally/blob/main/assets/logo.svg": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"rev\":\"main\",\"path\":\"assets/logo.svg\",\"shown\":\"image\",\"size\":282,\"lines\":1,\"text\":null,\"ranges\":null}"
},
"/_data/alice.test/tally/blob/main/nowhere": {
"status": 404,
"headers": {
"content-type": "application/json",
"cache-control": "no-store"
},
"body": "{\"message\":\"no nowhere at main\"}"
},
"/_data/alice.test/tally/blob/main/src/lib.rs": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"rev\":\"main\",\"path\":\"src/lib.rs\",\"shown\":\"text\",\"size\":783,\"lines\":37,\"text\":\"//! Counting, kept apart from reading so it can be tested.\\n\\nmod parse;\\n\\n/// What a text holds.\\n#[derive(Debug, Default, PartialEq)]\\npub struct Counts {\\n pub lines: usize,\\n pub words: usize,\\n pub bytes: usize,\\n}\\n\\n/// Counts `text` in one pass.\\npub fn count(text: &str) -> Counts {\\n let mut counts = Counts { bytes: text.len(), ..Counts::default() };\\n for line in parse::lines(text) {\\n counts.lines += 1;\\n counts.words += parse::words(line);\\n }\\n counts\\n}\\n\\n#[cfg(test)]\\nmod tests {\\n use super::*;\\n\\n #[test]\\n fn counts() {\\n let c = count(\\\"one two\\\\nthree\\\\n\\\");\\n assert_eq!(c, Counts { lines: 2, words: 3, bytes: 14 });\\n }\\n\\n #[test]\\n fn a_last_line_without_a_newline() {\\n assert_eq!(count(\\\"a\\\\nb\\\").lines, 2);\\n }\\n}\\n\",\"ranges\":\"comment:0,59,13,23,126,31;string:585,18,153,6;keyword:60,3,69,3,1,6,14,3,19,3,19,3,49,3,1,2,35,3,1,3,65,3,6,2,126,3,13,3,1,5,22,2,20,3,120,2;number:422,1,221,1,9,1,9,2,105,1;function:259,5,67,3,14,7,29,5,67,5,91,6,21,5,30,10,73,29,13,10;type:104,5,2,7,2,9,14,6,18,5,17,5,17,5,56,3,5,6,24,6,24,6,282,6;attr:95,9,5,2,7,2,9,2,358,12,36,7,138,7\"}"
},
"/_data/alice.test/tally/commit/97727c2c19d5d8b3849b2ed9ad47130cee9798f5": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "public, max-age=31536000, immutable"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"commit\":{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"parents\":[\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},\"files\":[{\"old\":\"docs/design.md\",\"new\":\"docs/DESIGN.md\",\"status\":\"renamed\",\"added\":0,\"removed\":0,\"note\":\"Moved without changes.\",\"ranges\":null,\"rows\":[]},{\"old\":\"src/lib.rs\",\"new\":\"src/lib.rs\",\"status\":\"modified\",\"added\":9,\"removed\":2,\"note\":null,\"ranges\":\"comment:0,58,14,22,58,30;string:529,18,153,6;keyword:60,3,69,3,1,6,41,3,38,3,1,3,65,3,6,2,20,3,6,2,177,3,120,2;number:384,1,203,1,9,1,9,2,105,1;function:262,3,14,7,27,5,28,5,65,16,3,5,35,5,43,5,30,10,73,29,13,10;type:104,5,2,7,2,9,14,6,54,3,5,6,24,6,24,6,295,6;attr:95,9,5,2,7,2,9,2,495,7;xins:339,17,112,17;xdel:308,11,92,30\",\"rows\":[{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -1,5 +1,7 @@\"},{\"kind\":\"\",\"line\":true,\"old\":1,\"new\":1,\"text\":\"//! Counting, kept apart from reading so it can be tested.\"},{\"kind\":\"\",\"line\":true,\"old\":2,\"new\":2,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":3,\"text\":\"mod parse;\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":4,\"text\":\"\"},{\"kind\":\"\",\"line\":true,\"old\":3,\"new\":5,\"text\":\"/// What a text holds.\"},{\"kind\":\"\",\"line\":true,\"old\":4,\"new\":6,\"text\":\"#[derive(Debug, Default, PartialEq)]\"},{\"kind\":\"\",\"line\":true,\"old\":5,\"new\":7,\"text\":\"pub struct Counts {\"},{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -11,9 +13,9 @@ pub struct Counts {\"},{\"kind\":\"\",\"line\":true,\"old\":11,\"new\":13,\"text\":\"/// Counts `text` in one pass.\"},{\"kind\":\"\",\"line\":true,\"old\":12,\"new\":14,\"text\":\"pub fn count(text: &str) -> Counts {\"},{\"kind\":\"\",\"line\":true,\"old\":13,\"new\":15,\"text\":\" let mut counts = Counts { bytes: text.len(), ..Counts::default() };\"},{\"kind\":\"d\",\"line\":true,\"old\":14,\"new\":null,\"text\":\" for line in text.lines() {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":16,\"text\":\" for line in parse::lines(text) {\"},{\"kind\":\"\",\"line\":true,\"old\":15,\"new\":17,\"text\":\" counts.lines += 1;\"},{\"kind\":\"d\",\"line\":true,\"old\":16,\"new\":null,\"text\":\" counts.words += line.split_whitespace().count();\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":18,\"text\":\" counts.words += parse::words(line);\"},{\"kind\":\"\",\"line\":true,\"old\":17,\"new\":19,\"text\":\" }\"},{\"kind\":\"\",\"line\":true,\"old\":18,\"new\":20,\"text\":\" counts\"},{\"kind\":\"\",\"line\":true,\"old\":19,\"new\":21,\"text\":\"}\"},{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -27,4 +29,9 @@ mod tests {\"},{\"kind\":\"\",\"line\":true,\"old\":27,\"new\":29,\"text\":\" let c = count(\\\"one two\\\\nthree\\\\n\\\");\"},{\"kind\":\"\",\"line\":true,\"old\":28,\"new\":30,\"text\":\" assert_eq!(c, Counts { lines: 2, words: 3, bytes: 14 });\"},{\"kind\":\"\",\"line\":true,\"old\":29,\"new\":31,\"text\":\" }\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":32,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":33,\"text\":\" #[test]\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":34,\"text\":\" fn a_last_line_without_a_newline() {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":35,\"text\":\" assert_eq!(count(\\\"a\\\\nb\\\").lines, 2);\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":36,\"text\":\" }\"},{\"kind\":\"\",\"line\":true,\"old\":30,\"new\":37,\"text\":\"}\"}]},{\"old\":\"src/parse.rs\",\"new\":\"src/parse.rs\",\"status\":\"added\",\"added\":11,\"removed\":0,\"note\":null,\"ranges\":\"comment:0,48,2,57,76,68;keyword:108,3,1,2,138,3,1,2,22,4;function:115,5,33,16,3,5,82,5,54,5;type:128,3,5,5,131,3,10,8,1,4,4,3\",\"rows\":[{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -0,0 +1,11 @@\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":1,\"text\":\"//! Splitting text into the things tally counts.\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":2,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":3,\"text\":\"/// The words of `line`: runs of anything but whitespace.\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":4,\"text\":\"pub fn words(line: &str) -> usize {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":5,\"text\":\" line.split_whitespace().count()\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":6,\"text\":\"}\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":7,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":8,\"text\":\"/// The lines of `text`. A last line without a newline still counts.\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":9,\"text\":\"pub fn lines(text: &str) -> impl Iterator<Item = &str> {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":10,\"text\":\" text.lines()\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":11,\"text\":\"}\"}]}]}"
},
"/_data/alice.test/tally/commits/main": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"rev\":\"main\",\"path\":\"\",\"page\":0,\"commits\":[{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"parents\":[\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\"],\"author\":\"Bob Tanaka\",\"date\":\"2026-10-09T09:31:00+00:00\",\"message\":\"Count no lines in empty input\"},{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"parents\":[\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},{\"sha\":\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\",\"parents\":[\"d1f55d39d12c4d6ba6ab25b1e9c76d15fb2cbaca\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-06T10:02:00+00:00\",\"message\":\"Document usage and output\"},{\"sha\":\"d1f55d39d12c4d6ba6ab25b1e9c76d15fb2cbaca\",\"parents\":[\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-05T17:21:00+00:00\",\"message\":\"Add a logo\"},{\"sha\":\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\",\"parents\":[\"a7e467c6f9acd7b1be0603afdec0b8cfdcf3cf49\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-03T08:05:00+00:00\",\"message\":\"Read from stdin when no file is given\\n\\nSo tally works at the end of a pipe, as wc does. The name shown\\nfor stdin is \\\"-\\\".\"},{\"sha\":\"a7e467c6f9acd7b1be0603afdec0b8cfdcf3cf49\",\"parents\":[\"cac02c464843db4d55fecfa69d327ef7b12702c0\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-02T14:40:00+00:00\",\"message\":\"Count words, lines and bytes\"},{\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\",\"parents\":[],\"author\":\"Alice Moreau\",\"date\":\"2026-10-01T09:12:00+00:00\",\"message\":\"Start tally\"}],\"more\":false}"
},
"/_data/alice.test/tally/issue/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr6ojnudl": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"kind\":\"issue\",\"title\":\"Fails on files that are not UTF-8\",\"state\":\"open\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T23:34:00.000Z\",\"base\":null,\"from\":null,\"branch\":null,\"body\":\"<p>Running <code>tally image.png</code> panics:</p>\\n<pre><code>thread 'main' panicked at src/main.rs:8:56:\\nstream did not contain valid UTF-8\\n</code></pre>\\n<p>wc counts bytes for any file.</p>\\n\",\"comments\":[{\"author\":\"alice.test\",\"createdAt\":\"2026-10-10T00:11:00.000Z\",\"html\":\"<p>Confirmed. Reading bytes and counting words lossily would fix it.</p>\\n\",\"verdict\":null}],\"merged\":null}"
},
"/_data/alice.test/tally/issue/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr6rk55ds": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"kind\":\"issue\",\"title\":\"Typo in the README\",\"state\":\"closed\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-10T00:48:00.000Z\",\"base\":null,\"from\":null,\"branch\":null,\"body\":null,\"comments\":[],\"merged\":null}"
},
"/_data/alice.test/tally/issues": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"kind\":\"issue\",\"open\":true,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Fails on files that are not UTF-8\",\"state\":\"open\",\"href\":\"/alice.test/tally/issue/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr6ojnudl\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T23:34:00.000Z\",\"comments\":1}]}"
},
"/_data/alice.test/tally/issues?state=closed": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"kind\":\"issue\",\"open\":false,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Typo in the README\",\"state\":\"closed\",\"href\":\"/alice.test/tally/issue/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr6rk55ds\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-10T00:48:00.000Z\",\"comments\":0}]}"
},
"/_data/alice.test/tally/pull/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr4ing2u4": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"kind\":\"pull\",\"title\":\"Count no lines in empty input\",\"state\":\"merged\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T18:38:00.000Z\",\"base\":\"main\",\"from\":\"bob.test/tally\",\"branch\":\"empty-input\",\"body\":\"<p><code>tally</code> printed one line for an empty file. <code>lines</code> now yields nothing when the text is empty, and a test covers it.</p>\\n\",\"comments\":[{\"author\":\"alice.test\",\"createdAt\":\"2026-10-09T19:15:00.000Z\",\"html\":\"<p>Could <code>lines</code> check for emptiness once, rather than in the filter?</p>\\n\",\"verdict\":\"changes\"},{\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T19:52:00.000Z\",\"html\":\"<p>It only runs per line, so the cost is one comparison each. Happy to change it if you prefer.</p>\\n\",\"verdict\":null},{\"author\":\"alice.test\",\"createdAt\":\"2026-10-09T20:29:00.000Z\",\"html\":\"<p>Fair enough. Thanks!</p>\\n\",\"verdict\":\"approve\"}],\"merged\":\"686a36205b82620af07dd99997c570f04f461df9\"}"
},
"/_data/alice.test/tally/pull/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr6n5c7dh": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"kind\":\"pull\",\"title\":\"Print counts as JSON with --json\",\"state\":\"open\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T22:57:00.000Z\",\"base\":\"main\",\"from\":\"bob.test/tally\",\"branch\":\"json\",\"body\":\"<p>For scripts. The object has <code>lines</code>, <code>words</code>, <code>bytes</code> and <code>name</code>.</p>\\n\",\"comments\":[],\"merged\":null}"
},
"/_data/alice.test/tally/pulls": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"kind\":\"pull\",\"open\":true,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Print counts as JSON with --json\",\"state\":\"open\",\"href\":\"/alice.test/tally/pull/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr6n5c7dh\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T22:57:00.000Z\",\"comments\":0}]}"
},
"/_data/alice.test/tally/pulls?state=closed": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"kind\":\"pull\",\"open\":false,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Count no lines in empty input\",\"state\":\"merged\",\"href\":\"/alice.test/tally/pull/did:plc:oeqgnid4r7ec4ra6khplp3og/3myfnr4ing2u4\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T18:38:00.000Z\",\"comments\":3}]}"
},
"/_data/alice.test/tally/tree/main/src": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1},\"rev\":\"main\",\"path\":\"src\",\"empty\":false,\"last\":{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"parents\":[\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\"],\"author\":\"Bob Tanaka\",\"date\":\"2026-10-09T09:31:00+00:00\",\"message\":\"Count no lines in empty input\"},\"entries\":[{\"name\":\"lib.rs\",\"kind\":\"blob\",\"size\":783},{\"name\":\"main.rs\",\"kind\":\"blob\",\"size\":484},{\"name\":\"parse.rs\",\"kind\":\"blob\",\"size\":518}],\"readme\":null}"
},
"/_data/bob.test": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\",\"name\":null,\"bio\":null,\"repos\":[{\"owner\":\"bob.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"shown\":\"alice.test/tally\",\"href\":\"/alice.test/tally\"},\"createdAt\":\"2026-10-09T14:19:00.000Z\"}]}"
},
"/_data/bob.test/tally": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"bob.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"shown\":\"alice.test/tally\",\"href\":\"/alice.test/tally\"},\"head\":\"main\",\"pulls\":0,\"issues\":0},\"rev\":\"main\",\"path\":\"\",\"empty\":false,\"last\":{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"parents\":[\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},\"entries\":[{\"name\":\"assets\",\"kind\":\"tree\",\"size\":null},{\"name\":\"docs\",\"kind\":\"tree\",\"size\":null},{\"name\":\"src\",\"kind\":\"tree\",\"size\":null},{\"name\":\".gitignore\",\"kind\":\"blob\",\"size\":8},{\"name\":\"Cargo.toml\",\"kind\":\"blob\",\"size\":122},{\"name\":\"README.md\",\"kind\":\"blob\",\"size\":388}],\"readme\":{\"name\":\"README.md\",\"html\":\"<h1>tally</h1>\\n<p>Counts lines, words and bytes, like <code>wc</code>, with output that lines up.</p>\\n<p><img src=\\\"/bob.test/tally/raw/main/assets/logo.svg\\\" alt=\\\"tally\\\" /></p>\\n<h2>Usage</h2>\\n<pre><code class=\\\"language-sh\\\">cargo install --path .\\ntally notes.txt\\necho \\\"one two\\\" | tally\\n</code></pre>\\n<h2>Output</h2>\\n<table><thead><tr><th>Column</th><th>Meaning</th></tr></thead><tbody>\\n<tr><td>1</td><td>lines</td></tr>\\n<tr><td>2</td><td>words, split on whitespace</td></tr>\\n<tr><td>3</td><td>bytes</td></tr>\\n</tbody></table>\\n<p>See <a href=\\\"/bob.test/tally/blob/main/docs/design.md\\\">the design notes</a> for why it reads the whole file.</p>\\n<h2>License</h2>\\n<p>MIT</p>\\n\",\"text\":null}}"
},
"/_data/search?q=t": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"query\":\"t\",\"repos\":[{\"owner\":\"bob.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"createdAt\":\"2026-10-09T14:19:00.000Z\"},{\"owner\":\"alice.test\",\"name\":\"scratch\",\"description\":null,\"forkOf\":null,\"createdAt\":\"2026-10-09T13:42:00.000Z\"},{\"owner\":\"alice.test\",\"name\":\"notes\",\"description\":\"Scratch notes\",\"forkOf\":null,\"createdAt\":\"2026-10-09T11:51:00.000Z\"},{\"owner\":\"alice.test\",\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"createdAt\":\"2026-10-09T10:00:00.000Z\"}]}"
},
"/alice.test/tally/raw/main/assets/logo.svg": {
"status": 200,
"headers": {
"content-type": "image/svg+xml",
"cache-control": "no-cache",
"content-security-policy": "sandbox; default-src 'none'; style-src 'unsafe-inline'"
},
"body": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 120 120\" width=\"120\" height=\"120\"><rect width=\"120\" height=\"120\" rx=\"28\" fill=\"#1c1d20\"/><g stroke=\"#fff\" stroke-width=\"8\" stroke-linecap=\"round\"><path d=\"M36 34v52M52 34v52M68 34v52M84 34v52\"/><path d=\"M28 72l64-24\"/></g></svg>\n"
},
"/bob.test/tally/raw/main/assets/logo.svg": {
"status": 200,
"headers": {
"content-type": "image/svg+xml",
"cache-control": "no-cache",
"content-security-policy": "sandbox; default-src 'none'; style-src 'unsafe-inline'"
},
"body": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 120 120\" width=\"120\" height=\"120\"><rect width=\"120\" height=\"120\" rx=\"28\" fill=\"#1c1d20\"/><g stroke=\"#fff\" stroke-width=\"8\" stroke-linecap=\"round\"><path d=\"M36 34v52M52 34v52M68 34v52M84 34v52\"/><path d=\"M28 72l64-24\"/></g></svg>\n"
},
"/xrpc/dev.knotless.getFeed": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"events\":[{\"createdAt\":\"2026-10-10T01:25:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#changed\",\"subject\":\"at://did:plc:oeqgnid4r7ec4ra6khplp3og/dev.knotless.issue/3myfnr6rk55ds\",\"title\":\"Typo in the README\",\"state\":\"closed\"}},{\"createdAt\":\"2026-10-10T00:48:00.000Z\",\"actor\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:oeqgnid4r7ec4ra6khplp3og/dev.knotless.issue/3myfnr6rk55ds\",\"title\":\"Typo in the README\"}},{\"createdAt\":\"2026-10-10T00:11:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:oeqgnid4r7ec4ra6khplp3og/dev.knotless.issue/3myfnr6ojnudl\",\"title\":\"Fails on files that are not UTF-8\",\"text\":\"Confirmed. Reading bytes and counting words lossily would fix it.\"}},{\"createdAt\":\"2026-10-09T23:34:00.000Z\",\"actor\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:oeqgnid4r7ec4ra6khplp3og/dev.knotless.issue/3myfnr6ojnudl\",\"title\":\"Fails on files that are not UTF-8\"}},{\"createdAt\":\"2026-10-09T22:57:00.000Z\",\"actor\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:oeqgnid4r7ec4ra6khplp3og/dev.knotless.pull/3myfnr6n5c7dh\",\"title\":\"Print counts as JSON with --json\"}},{\"createdAt\":\"2026-10-09T22:20:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#changed\",\"subject\":\"at://did:plc:oeqgnid4r7ec4ra6khplp3og/dev.knotless.pull/3myfnr4ing2u4\",\"title\":\"Count no lines in empty input\",\"state\":\"merged\"}},{\"createdAt\":\"2026-10-09T21:06:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/main\",\"created\":false,\"count\":1,\"commits\":[{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"message\":\"Count no lines in empty input\"}]}},{\"createdAt\":\"2026-10-09T20:29:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:oeqgnid4r7ec4ra6khplp3og/dev.knotless.pull/3myfnr4ing2u4\",\"title\":\"Count no lines in empty input\",\"text\":\"Fair enough. Thanks!\",\"verdict\":\"approve\"}},{\"createdAt\":\"2026-10-09T19:52:00.000Z\",\"actor\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:oeqgnid4r7ec4ra6khplp3og/dev.knotless.pull/3myfnr4ing2u4\",\"title\":\"Count no lines in empty input\",\"text\":\"It only runs per line, so the cost is one comparison each. Happy to change it if you prefer.\"}},{\"createdAt\":\"2026-10-09T19:15:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:oeqgnid4r7ec4ra6khplp3og/dev.knotless.pull/3myfnr4ing2u4\",\"title\":\"Count no lines in empty input\",\"text\":\"Could `lines` check for emptiness once, rather than in the filter?\",\"verdict\":\"changes\"}},{\"createdAt\":\"2026-10-09T18:38:00.000Z\",\"actor\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:oeqgnid4r7ec4ra6khplp3og/dev.knotless.pull/3myfnr4ing2u4\",\"title\":\"Count no lines in empty input\"}},{\"createdAt\":\"2026-10-09T17:24:00.000Z\",\"actor\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/json\",\"created\":true,\"count\":1,\"commits\":[{\"sha\":\"2063dabcda7417d5831f3b6bfd8ffb18da82fb8e\",\"message\":\"Print counts as JSON with --json\"}]}},{\"createdAt\":\"2026-10-09T16:47:00.000Z\",\"actor\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/empty-input\",\"created\":true,\"count\":1,\"commits\":[{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"message\":\"Count no lines in empty input\"}]}},{\"createdAt\":\"2026-10-09T14:19:00.000Z\",\"actor\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:oeqgnid4r7ec4ra6khplp3og\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"}}},{\"createdAt\":\"2026-10-09T13:42:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"scratch\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\"}},{\"createdAt\":\"2026-10-09T12:28:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"notes\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/main\",\"created\":true,\"count\":1,\"commits\":[{\"sha\":\"e78543abf0f799b7eb5713ccdf8d88b84d0f8ec0\",\"message\":\"Begin\"}]}},{\"createdAt\":\"2026-10-09T11:51:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"notes\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\",\"description\":\"Scratch notes\"}},{\"createdAt\":\"2026-10-09T10:37:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/main\",\"created\":true,\"count\":6,\"commits\":[{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"message\":\"Split parsing into its own module\"},{\"sha\":\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\",\"message\":\"Document usage and output\"},{\"sha\":\"d1f55d39d12c4d6ba6ab25b1e9c76d15fb2cbaca\",\"message\":\"Add a logo\"},{\"sha\":\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\",\"message\":\"Read from stdin when no file is given\\n\\nSo tally works at the end of a pipe, as wc does. The name shown\\nfor stdin is \\\"-\\\".\"},{\"sha\":\"a7e467c6f9acd7b1be0603afdec0b8cfdcf3cf49\",\"message\":\"Count words, lines and bytes\"},{\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\",\"message\":\"Start tally\"}]}},{\"createdAt\":\"2026-10-09T10:00:00.000Z\",\"actor\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:lmqtbfaid35mm2jozhegkgg7\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\",\"description\":\"Counts lines, words and bytes, with output that lines up\"}}]}"
}
}
}
harness/shots/fixtures.mjsnew+108−0
@@ -0,0 +1,108 @@
// The screenshots without a PDS: what knotless-serve answered for each page,
// recorded once in fixtures.json, and played back by a stand-in for it in
// front of the pages. The pages are drawn as they are by the real app, from
// web/build; only their data is fixed.
//
// node harness/shots/fixtures.mjs record them again, from the world
// `just shots-up` keeps up, or a new
// one: `just shots-record`
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { createServer, request } from 'node:http'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { base, create, pages, port, root, saved, serve, until } from './world.mjs'
export const fixtures = join(root, 'harness/shots/fixtures.json')
/// What knotless-serve answers itself, rather than handing to the pages.
const answered = (path) => /^\/(_data|xrpc)\//.test(path) || /^\/[^/]+\/[^/]+\/raw\//.test(path)
/// The headers of an answer worth keeping.
const KEPT = ['content-type', 'cache-control', 'location', 'content-security-policy']
/// knotless-serve's place on `port`, in front of the pages: answering from
/// `recorded`, or, given `from`, asking knotless-serve there and recording
/// what it says. Returns the recording, and how to stop.
export async function front({ recorded = {}, from } = {}) {
const drawn = pages(port + 1, base)
const answer = async (path) => {
if (!from) return recorded[path]
const res = await fetch(from + path, { redirect: 'manual' })
const body = Buffer.from(await res.arrayBuffer())
const text = body.toString('utf8')
const binary = !Buffer.from(text, 'utf8').equals(body)
const headers = Object.fromEntries(KEPT.filter((h) => res.headers.has(h)).map((h) => [h, res.headers.get(h)]))
return (recorded[path] = { status: res.status, headers, ...(binary ? { base64: body.toString('base64') } : { body: text }) })
}
const server = createServer(async (req, res) => {
if (answered(req.url)) {
const a = await answer(req.url).catch(() => null)
if (!a) {
res.writeHead(404, { 'content-type': 'application/json' })
res.end(JSON.stringify({ message: `nothing recorded for ${req.url}` }))
return
}
res.writeHead(a.status, a.headers)
res.end(a.base64 === undefined ? a.body : Buffer.from(a.base64, 'base64'))
return
}
const headers = { ...req.headers, 'x-forwarded-host': req.headers.host, 'x-forwarded-proto': 'http' }
const on = request({ host: '127.0.0.1', port: port + 1, path: req.url, method: req.method, headers }, (up) => {
res.writeHead(up.statusCode, up.headers)
up.pipe(res)
})
on.on('error', (e) => {
res.writeHead(502)
res.end(`The pages are not being drawn: ${e.message}`)
})
req.pipe(on)
})
await new Promise((r) => server.listen(port, '127.0.0.1', r))
const kill = () => {
server.close()
drawn.kill()
}
try {
await until('the pages', async () => (await fetch(`${base}/start`)).ok)
} catch (e) {
kill()
throw e
}
return { recorded, kill }
}
/// The recording, if there is one.
export const recording = () => (existsSync(fixtures) ? JSON.parse(readFileSync(fixtures, 'utf8')) : null)
/// Records every page's answers from `world`, and writes them with the pages.
async function record(world) {
const at = port + 2
const server = await serve(world, { at, alone: true })
const stand = await front({ from: `http://127.0.0.1:${at}` })
try {
for (const path of Object.values(world.views)) {
const html = await (await fetch(base + path)).text()
// What the browser asks for itself: images, from the raw files.
for (const [, src] of html.matchAll(/src="(\/[^"]+\/raw\/[^"]+)"/g)) await fetch(base + src)
}
} finally {
stand.kill()
server.kill()
}
const sorted = Object.fromEntries(Object.entries(stand.recorded).sort(([a], [b]) => (a < b ? -1 : 1)))
writeFileSync(fixtures, JSON.stringify({ views: world.views, answers: sorted }, null, 1) + '\n')
return Object.keys(sorted).length
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const kept = existsSync(saved) ? JSON.parse(readFileSync(saved, 'utf8')) : null
const up = kept && (await fetch(`${kept.net.pds}/xrpc/com.atproto.server.describeServer`).then((r) => r.ok, () => false))
const world = up ? kept : await create()
// A new world's server is stopped first: the recording starts its own.
if (!up) world.server.kill()
const n = await record(world)
if (!up) world.stop()
console.log(`recorded ${n} answers for ${Object.keys(world.views).length} pages in ${fixtures}`)
process.exit(0)
}
harness/shots/setup.mjs+12−5
@@ -1,6 +1,8 @@
// Before the screenshots: a world to shoot, the one `just shots-up` keeps
// up if it is there, else a new one for this run, and knotless-serve from
// this build in front of it. The pages are written to
// Before the screenshots: what the pages show, from fixtures.json, played
// back in front of the pages from web/build. With SHOTS_LIVE=1, or with no
// recording, a world instead: the one `just shots-up` keeps up if it is
// there, else a new one for this run, and knotless-serve from 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
@@ -10,6 +12,7 @@ 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 { front, recording } from './fixtures.mjs'
import config from './playwright.config.mjs'
const here = join(root, 'harness/shots')
@@ -23,9 +26,13 @@ const kept = async () => {
}
export default async function setup() {
let world = await kept()
const recorded = process.env.SHOTS_LIVE ? null : recording()
let world = recorded ? { views: recorded.views } : await kept()
let teardown
if (world) {
if (recorded) {
const stand = await front({ recorded: recorded.answers })
teardown = async () => stand.kill()
} else if (world) {
const server = await serve(world)
teardown = async () => server.kill()
// The index is on disk already; this waits only for the mirrors.
harness/shots/views.spec.mjs+3−1
@@ -22,7 +22,7 @@ const unchanged = new Set(read('unchanged.json', []))
const names = [
'home', 'search', 'start', 'profile', 'profile-fork', 'repository', 'directory', 'file', 'markdown', 'image', 'commits', 'commit',
'pulls', 'pulls-closed', 'pull', 'pull-open', 'issues', 'issue', 'fork', 'empty', 'missing',
'pulls', 'pulls-closed', 'pull', 'pull-open', 'issues', 'issues-closed', 'issue', 'issue-closed', 'fork', 'empty', 'missing',
]
for (const name of names) {
@@ -32,6 +32,8 @@ for (const name of names) {
const path = read('views.json', {})[name]
const sum = digest(await (await request.get(path)).text())
await page.goto(path, { waitUntil: 'load' })
// The pages paint their highlighting once they are running.
await page.locator('html[data-ready]').waitFor({ state: 'attached' })
// A DID is new each run, as the PLC directory makes one per account:
// it is covered, so the rest can match to the pixel.
await expect(page).toHaveScreenshot(`${name}.png`, { fullPage: true, mask: [page.locator('.did')], maskColor: '#80808a' })
harness/shots/world.mjs+39−6
@@ -68,15 +68,46 @@ const environment = (net, dir, who, when) => {
}
}
/// knotless-serve, from this build, over the world's index and mirrors.
export const serve = async (world) => {
const child = spawn(
/// The pages, from web/build, answering on `at` and reading from `data`.
export const pages = (at, data) =>
spawn('node', [join(root, 'web/build')], {
env: {
...process.env,
HOST: '127.0.0.1',
PORT: String(at),
KNOTLESS_SERVE: data,
HOST_HEADER: 'x-forwarded-host',
PROTOCOL_HEADER: 'x-forwarded-proto',
},
stdio: 'inherit',
})
/// knotless-serve from this build, over the world's index and mirrors, on
/// `at`: in front of the pages, unless `alone`.
export const serve = async (world, { at = port, alone = false } = {}) => {
const drawn = alone ? null : pages(port + 1, base)
const server = spawn(
join(bin, 'knotless-serve'),
['--listen', `127.0.0.1:${port}`, '--index', join(world.dir, 'index.db'), '--firehose', world.net.pds.replace('http', 'ws')],
[
'--listen', `127.0.0.1:${at}`,
'--index', join(world.dir, 'index.db'),
'--firehose', world.net.pds.replace('http', 'ws'),
...(alone ? [] : ['--web', `http://127.0.0.1:${port + 1}`]),
],
{ env: environment(world.net, world.dir, 'server'), stdio: 'inherit' },
)
await until('knotless-serve', async () => (await fetch(base)).ok)
return child
const kill = () => {
server.kill()
drawn?.kill()
}
const up = alone ? `http://127.0.0.1:${at}/healthz` : base
try {
await until('knotless-serve and the pages', async () => (await fetch(up)).ok)
} catch (e) {
kill()
throw e
}
return { kill }
}
/// A new world: its PDS, a server, and the seeded data. Returns them with
@@ -285,7 +316,9 @@ export async function create() {
pull: item('pull', pull),
'pull-open': item('pull', json),
issues: '/alice.test/tally/issues',
'issues-closed': '/alice.test/tally/issues?state=closed',
issue: item('issue', utf8),
'issue-closed': item('issue', typo),
fork: '/bob.test/tally',
empty: '/alice.test/scratch',
missing: '/alice.test/tally/blob/main/nowhere',
lexicons/dev/knotless/getFeed.jsonnew+124−0
@@ -0,0 +1,124 @@
{
"lexicon": 1,
"id": "dev.knotless.getFeed",
"description": "What happened on the public repositories an appview has indexed, newest first.",
"defs": {
"main": {
"type": "query",
"description": "What happened on the public repositories an appview has indexed, newest first: repositories created and forked, refs pushed and deleted, and pulls and issues opened, discussed and moved between states. No authentication; responses are not personalized.",
"parameters": {
"type": "params",
"properties": {
"limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 40 }
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["events"],
"properties": {
"events": { "type": "array", "items": { "type": "ref", "ref": "#eventView" } }
}
}
}
},
"eventView": {
"type": "object",
"description": "Something an account did to a repository.",
"required": ["createdAt", "actor", "repo", "action"],
"properties": {
"createdAt": { "type": "string", "format": "datetime", "description": "When the record saying so was made." },
"actor": { "type": "ref", "ref": "#actorView" },
"repo": { "type": "ref", "ref": "#repoView" },
"action": {
"type": "union",
"refs": ["#created", "#pushed", "#deleted", "#opened", "#commented", "#changed"]
}
}
},
"actorView": {
"type": "object",
"required": ["did", "handle"],
"properties": {
"did": { "type": "string", "format": "did" },
"handle": { "type": "string", "description": "The handle the account claims, when it resolves back to it; else the DID." }
}
},
"repoView": {
"type": "object",
"description": "A repository, under the name it has now.",
"required": ["did", "handle", "name"],
"properties": {
"did": { "type": "string", "format": "did", "description": "The repository's owner." },
"handle": { "type": "string", "description": "The owner's handle, as in #actorView." },
"name": { "type": "string", "maxLength": 100 }
}
},
"created": {
"type": "object",
"description": "A repository created, or forked from another.",
"properties": {
"description": { "type": "string", "maxGraphemes": 300, "maxLength": 3000 },
"forkOf": { "type": "ref", "ref": "#repoView" }
}
},
"pushed": {
"type": "object",
"description": "A ref created or moved. A new ref brings what the repository's default branch did not reach just before, nor its fork source's; a fork's copies of its source's refs are not events.",
"required": ["ref", "created", "count", "commits"],
"properties": {
"ref": { "type": "string", "description": "The full ref name, such as refs/heads/main." },
"created": { "type": "boolean", "description": "Whether the ref was new." },
"count": { "type": "integer", "minimum": 0, "maximum": 100, "description": "Commits the push brought; 100 means at least that many." },
"commits": { "type": "array", "maxLength": 10, "items": { "type": "ref", "ref": "#commitView" }, "description": "The newest of them, newest first." }
}
},
"deleted": {
"type": "object",
"description": "A ref deleted.",
"required": ["ref"],
"properties": {
"ref": { "type": "string" }
}
},
"opened": {
"type": "object",
"description": "A pull or an issue opened.",
"required": ["subject", "title"],
"properties": {
"subject": { "type": "string", "format": "at-uri", "description": "The dev.knotless.pull or dev.knotless.issue record." },
"title": { "type": "string" }
}
},
"commented": {
"type": "object",
"description": "A comment or review on a pull or an issue.",
"required": ["subject", "title", "text"],
"properties": {
"subject": { "type": "string", "format": "at-uri" },
"title": { "type": "string", "description": "The subject's title." },
"text": { "type": "string", "description": "The comment, as Markdown." },
"verdict": { "type": "string", "knownValues": ["approve", "changes"] }
}
},
"changed": {
"type": "object",
"description": "A pull or an issue moved to another state.",
"required": ["subject", "title", "state"],
"properties": {
"subject": { "type": "string", "format": "at-uri" },
"title": { "type": "string", "description": "The subject's title." },
"state": { "type": "string", "knownValues": ["open", "closed", "merged"] }
}
},
"commitView": {
"type": "object",
"required": ["sha", "message"],
"properties": {
"sha": { "type": "string" },
"message": { "type": "string" }
}
}
}
}
web/.gitignorenew+23−0
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
web/.npmrcnew+1−0
@@ -0,0 +1 @@
engine-strict=true
web/package-lock.jsonnew+1361−0
@@ -0,0 +1,1361 @@
{
"name": "knotless-web",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "knotless-web",
"devDependencies": {
"@primer/octicons": "^19.38.0",
"@sveltejs/adapter-node": "^6.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/node": "^24.19.2",
"svelte": "^5.57.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"vite": "^8.3.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@oxc-project/types": {
"version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@primer/octicons": {
"version": "19.38.0",
"resolved": "https://registry.npmjs.org/@primer/octicons/-/octicons-19.38.0.tgz",
"integrity": "sha512-YUU2R66aDm72CdjlTyD663sCAjILrHWK2czw+BdTUkzZfcB7bXfO6GNXm22sepLOWKPkLHezVjIHch/KNd8rMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
"integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
}
},
"node_modules/@sveltejs/adapter-node": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-6.0.0.tgz",
"integrity": "sha512-s7muM4JmJ75OZ8o0G4VRhrEKdi8twuEm3t2o/qQsdlm12hKaX7S/CuUBuw3n983wB/IFBSpzG2ugX0xdGzbKwg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@sveltejs/kit": "^3.0.0-next.0"
}
},
"node_modules/@sveltejs/kit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-3.1.0.tgz",
"integrity": "sha512-JTWlq2HQlkqx6vIoxhC0R6sc4WVqbQoRyEmr5YlqKmiYbzY1U3awtSWTmjMLlS056a49AmkQR6rnNANXIE8B0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1",
"@standard-schema/spec": "^1.1.0",
"cookie": "^2.0.1",
"devalue": "^5.9.4",
"esm-env": "^1.2.2",
"magic-string": "^1.2.3",
"mrmime": "^2.0.1",
"sirv": "^3.0.2"
},
"bin": {
"svelte-kit": "svelte-kit.js"
},
"engines": {
"node": ">=22.17"
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"svelte": "^5.57.1",
"typescript": "^6.0.0",
"vite": "^8.0.12"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@sveltejs/load-config": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz",
"integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz",
"integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"deepmerge": "^4.3.1",
"magic-string": "^1.0.0",
"obug": "^2.1.0",
"vitefu": "^1.1.2"
},
"engines": {
"node": "^20.19 || ^22.12 || >=24"
},
"peerDependencies": {
"svelte": "^5.46.4",
"vite": "^8.0.0-beta.7 || ^8.0.0"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/acorn": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/devalue": {
"version": "5.9.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
"integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
"dev": true,
"license": "MIT"
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz",
"integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
},
"peerDependencies": {
"@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/types": {
"optional": true
}
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/magic-string": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rolldown": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.155.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.15",
"@rolldown/binding-android-arm64": "1.2.15",
"@rolldown/binding-darwin-arm64": "1.2.15",
"@rolldown/binding-darwin-x64": "1.2.15",
"@rolldown/binding-freebsd-x64": "1.2.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
"@rolldown/binding-linux-arm64-musl": "1.2.15",
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
"@rolldown/binding-linux-x64-gnu": "1.2.15",
"@rolldown/binding-linux-x64-musl": "1.2.15",
"@rolldown/binding-openharmony-arm64": "1.2.15",
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
"@rolldown/binding-win32-x64-msvc": "1.2.15"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/source-map-js": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/svelte": {
"version": "5.57.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
"integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"@jridgewell/sourcemap-codec": "^1.6.0",
"@sveltejs/acorn-typescript": "^1.0.13",
"@types/estree": "^1.0.9",
"acorn": "^8.18.0",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.9.2",
"esm-env": "^1.2.1",
"esrap": "^2.3.6",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
"zimmerframe": "^1.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/svelte-check": {
"version": "4.7.7",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.7.tgz",
"integrity": "sha512-Ge2IMdeTpbJuzNFGRiiMg0RWgbScysKQu7xwcyFJ22+DU9F5nPX51+Q3gHmGqRxYQMx2knreVWt5oCtfe0vf2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"@sveltejs/load-config": "^0.2.3",
"chokidar": "^4.0.1",
"fdir": "^6.2.0",
"picocolors": "^1.0.0",
"sade": "^1.7.4"
},
"bin": {
"svelte-check": "bin/svelte-check"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.0.0 || ^6.0.0"
}
},
"node_modules/svelte/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
"integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
"dev": true,
"license": "MIT",
"workspaces": [
"tests/deps/*",
"tests/projects/*",
"tests/projects/workspace/packages/*"
],
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/zimmerframe": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
"integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==",
"dev": true,
"license": "MIT"
}
}
}
web/package.jsonnew+26−0
@@ -0,0 +1,26 @@
{
"name": "knotless-web",
"private": true,
"type": "module",
"description": "The web pages knotless-serve shows, drawn from its JSON under /_data",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings"
},
"devDependencies": {
"@primer/octicons": "^19.38.0",
"@sveltejs/adapter-node": "^6.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/node": "^24.19.2",
"svelte": "^5.57.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"vite": "^8.3.0"
},
"imports": {
"#lib/*": "./src/lib/*"
}
}
web/src/app.cssnew+209−0
@@ -0,0 +1,209 @@
:root{color-scheme:light;--bg:#fff;--fg:#171717;--mute:#636363;--faint:#8f8f8f;--line:#e8e8e8;--soft:#f4f4f4;--panel:#fafafa;--accent:#2f5fb3;--accent-soft:#eaeff7;--open:#1a7f37;--merged:#7048b5;--closed-pull:#cf222e;--closed-issue:#636363;--add:#1a7f37;--rem:#cf222e;--ins:#e6ffec;--ins-n:#ccffd8;--ins-x:#abf2bc;--del:#ffebe9;--del-n:#ffd7d5;--del-x:#ffb8b0;--hunk:#ddf4ff;--target:#fff8c5;--dir:#54aeff;--comment:#59636e;--string:#0a3069;--keyword:#cf222e;--number:#0550ae;--function:#6639ba;--type:#953800;--tag:#116329;--attr:#0550ae;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;--r:4px}
@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#111;--fg:#ededed;--mute:#a1a1a1;--faint:#707070;--line:#262626;--soft:#1a1a1a;--panel:#161616;--accent:#7aa7f0;--accent-soft:#222935;--open:#3fb950;--merged:#b39af0;--closed-pull:#f85149;--closed-issue:#9198a1;--add:#3fb950;--rem:#f85149;--ins:rgba(46,160,67,.15);--ins-n:rgba(63,185,80,.3);--ins-x:rgba(46,160,67,.4);--del:rgba(248,81,73,.1);--del-n:rgba(248,81,73,.3);--del-x:rgba(248,81,73,.4);--hunk:rgba(56,139,253,.1);--target:#3b3313;--dir:#9198a1;--comment:#9198a1;--string:#a5d6ff;--keyword:#ff7b72;--number:#79c0ff;--function:#d2a8ff;--type:#ffa657;--tag:#7ee787;--attr:#79c0ff}}
@view-transition{navigation:auto}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 var(--sans);-webkit-font-smoothing:antialiased;min-height:100vh;display:flex;flex-direction:column}
a{color:inherit;text-decoration:none}
a:hover{color:var(--accent)}
.wrap{width:100%;max-width:76rem;margin:0 auto;padding:0 1.5rem}
.top{border-bottom:1px solid var(--line);view-transition-name:top}
.top .wrap{display:flex;align-items:center;gap:1.5rem;height:3.5rem}
.brand{display:flex;align-items:center;gap:.55rem;font-weight:650;font-size:15px;letter-spacing:-.01em}
.top nav{margin-left:auto;display:flex;gap:1.25rem;color:var(--mute)}
main.wrap{flex:1;padding-top:1.75rem;padding-bottom:4rem}
.foot{color:var(--faint);font-size:12.5px;padding-top:1.25rem;padding-bottom:2rem;border-top:1px solid var(--line)}
h1,h2,h3{letter-spacing:-.015em;margin:0}
h1{font-size:20px;font-weight:600}
h2{font-size:16px;font-weight:600}
h3{font-size:14px;font-weight:600}
p{margin:0}
code,pre,.mono{font-family:var(--mono);font-size:12.5px}
.mute{color:var(--mute)}
.end{margin-left:auto}
svg.i{width:16px;height:16px;flex:none;fill:currentColor;vertical-align:-3px}
.av{display:inline-grid;place-items:center;flex:none;width:20px;height:20px;border-radius:50%;font:600 10px var(--sans);color:#fff;background:hsl(var(--h) 45% 52%);text-transform:uppercase}
.av.lg{width:32px;height:32px;font-size:13px}
.av.xl{width:88px;height:88px;font-size:34px;font-weight:600}
.profile{display:flex;align-items:center;gap:1.5rem;margin:.75rem 0 2.25rem;min-width:0}
.profile>div{min-width:0}
.profile h1{font-size:24px;letter-spacing:-.02em;overflow-wrap:anywhere}
.profile .who{color:var(--mute);margin-top:.15rem;overflow-wrap:anywhere}
.profile .did{font:12px var(--mono);color:var(--faint);margin-top:.35rem;overflow-wrap:anywhere}
.profile .bio{margin-top:.6rem;max-width:40rem;white-space:pre-line}
.btn{display:inline-flex;align-items:center;gap:.4rem;height:28px;padding:0 .7rem;border:1px solid var(--line);border-radius:7px;background:var(--bg);font:500 12.5px var(--sans);color:var(--fg);cursor:pointer;white-space:nowrap}
.btn:hover{background:var(--soft);color:var(--fg)}
.box{border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--bg)}
.box+.box{margin-top:1rem}
.boxhead{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;padding:.6rem 1rem;background:var(--panel);border-bottom:1px solid var(--line);min-height:2.75rem}
.boxhead:last-child{border-bottom:0}
.list{list-style:none;margin:0;padding:0}
.list li{display:flex;gap:.75rem;align-items:center;padding:.6rem 1rem;border-top:1px solid var(--line)}
.list li:first-child{border-top:0}
.list li:hover{background:var(--panel)}
.list li>.mute{display:inline-flex;align-items:center;gap:.3rem}
.list li>svg.i{color:var(--faint);margin-top:2px}
.grow{flex:1;min-width:0}
.clip{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.title{font-weight:600}
.meta{color:var(--mute);font-size:12.5px;margin-top:.1rem;display:flex;flex-wrap:wrap;align-items:center;gap:.35rem}
.sha{font:12px var(--mono);color:var(--mute);padding:.15rem .45rem;border:1px solid var(--line);border-radius:6px}
.sha:hover{color:var(--accent)}
.empty{padding:2.5rem 1rem;text-align:center;color:var(--mute)}
.cmd{display:flex;align-items:flex-start;gap:.5rem;border:1px solid var(--line);border-radius:8px;background:var(--bg);padding:.5rem .5rem .5rem .75rem}
.cmd+.cmd{margin-top:.5rem}
.cmd pre{flex:1;min-width:0;margin:0;padding:.2rem 0;white-space:pre-wrap;overflow-wrap:anywhere}
.cmd .btn{height:24px;padding:0 .5rem;font-size:11.5px}
.section{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;margin:0 0 .9rem}
.repohead{display:flex;flex-wrap:wrap;align-items:flex-start;gap:1rem 2rem}
.repohead h1{font-weight:500;display:flex;flex-wrap:wrap;align-items:center;gap:.35rem}
.repohead h1 b{font-weight:650}
.repohead .about{flex:1;min-width:16rem}
.repohead .about p{color:var(--mute);margin-top:.35rem}
.clone{display:flex;align-items:center;gap:.5rem;border:1px solid var(--line);border-radius:8px;padding:.3rem .3rem .3rem .75rem;max-width:100%;background:var(--panel)}
.clone code{overflow-x:auto;white-space:nowrap;color:var(--mute)}
.tabs{display:flex;gap:.25rem;border-bottom:1px solid var(--line);margin:1.25rem 0 1.25rem;view-transition-name:tabs;overflow-x:auto;scrollbar-width:none}
.tabs a{display:flex;align-items:center;gap:.45rem;padding:.55rem .75rem;color:var(--mute);border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap}
.tabs a:hover{color:var(--fg)}
.tabs a[aria-current]{color:var(--fg);border-color:var(--accent);font-weight:550}
.count{font-size:11.5px;padding:0 .4rem;border-radius:9px;background:var(--soft);color:var(--mute);line-height:18px}
.toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:.6rem .9rem;margin:0 0 .9rem}
.branch{display:inline-flex;align-items:center;gap:.35rem;height:28px;padding:0 .65rem;border:1px solid var(--line);border-radius:7px;background:var(--panel);font:500 12.5px var(--sans);max-width:14rem}
.branch span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.crumbs{font-size:15px;min-width:0;overflow-wrap:anywhere}
.crumbs a{color:var(--accent)}.crumbs b{font-weight:600}
.crumbs .sep{color:var(--faint);margin:0 .3rem}
.files li{padding:.45rem 1rem}
.files svg.i{color:var(--faint)}
.files .dir svg.i{color:var(--accent)}
.files .size{color:var(--faint);font-size:12px}
.code{display:grid;grid-template-columns:auto minmax(0,1fr)}
.code pre{margin:0;padding:.75rem 0;line-height:20px;tab-size:4;font-size:12.5px}
.code .ln{text-align:right;color:var(--faint);user-select:none;padding:.75rem .75rem .75rem 1rem;min-width:3.5rem}
.code .ln a{color:inherit}
.code .ln a:target{color:var(--fg)}
.code .src{overflow-x:auto;padding:.75rem 1rem}
.prose{padding:1.25rem 2rem 1.75rem;overflow-wrap:anywhere;font-size:15px;line-height:1.65}
.prose>:first-child{margin-top:0}.prose>:last-child{margin-bottom:0}
.prose h1,.prose h2{padding-bottom:.35rem;border-bottom:1px solid var(--line);margin:1.5rem 0 .75rem}
.prose h1{font-size:24px}.prose h2{font-size:19px}.prose h3{font-size:16px;margin:1.25rem 0 .5rem}
.prose p,.prose ul,.prose ol,.prose table,.prose pre,.prose blockquote{margin:0 0 1rem}
.prose a{color:var(--accent)}.prose a:hover{text-decoration:underline}
.prose img{max-width:100%}
.prose pre{background:var(--soft);border-radius:8px;padding:.85rem 1rem;overflow:auto;line-height:1.5}
.prose :not(pre)>code{background:var(--soft);padding:.12rem .35rem;border-radius:5px;font-size:.85em}
.prose table{border-collapse:collapse;display:block;overflow-x:auto}.prose td,.prose th{border:1px solid var(--line);padding:.35rem .7rem}
.prose blockquote{color:var(--mute);border-left:3px solid var(--line);padding-left:1rem;margin-left:0}
.day{display:flex;align-items:center;gap:.5rem;color:var(--mute);font-weight:500;font-size:13px;margin:1.5rem 0 .6rem}
.day:first-of-type{margin-top:0}
.more{display:flex;justify-content:center;margin-top:1.25rem}
.commit-msg{padding:1rem}
.commit-msg pre{white-space:pre-wrap;color:var(--mute);margin:.6rem 0 0;font-size:12.5px}
.summary{margin:1.5rem 0 .9rem;color:var(--mute)}
.summary b{color:var(--fg)}
.add{color:var(--add)}.rem{color:var(--rem)}
.bars{display:inline-flex;gap:1px;vertical-align:-1px}.bars i{width:7px;height:7px;border-radius:1px;background:var(--line)}.bars i.a{background:var(--add)}.bars i.d{background:var(--rem)}
.file{border:1px solid var(--line);border-radius:var(--r);margin:0 0 1rem;overflow:hidden}
.file>summary{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem .75rem;padding:.55rem 1rem;background:var(--panel);cursor:pointer;list-style:none;position:sticky;top:0}
.file>summary::-webkit-details-marker{display:none}
.file>summary::before{content:"";width:6px;height:6px;border:solid var(--mute);border-width:0 1.5px 1.5px 0;transform:rotate(-45deg);transition:transform .15s;margin-right:.1rem}
.file[open]>summary::before{transform:rotate(45deg)}
.file[open]>summary{border-bottom:1px solid var(--line)}
.file .path{font:500 12.5px var(--mono);overflow-wrap:anywhere;min-width:0}
.file .stat{font:12px var(--mono);display:flex;align-items:center;gap:.4rem;margin-left:auto}
.tag{font:500 11px var(--sans);padding:.05rem .45rem;border-radius:9px;border:1px solid var(--line);color:var(--mute)}
.note{padding:1rem;color:var(--mute);text-align:center}
.scroll{overflow-x:auto}
.diff{border-collapse:collapse;width:100%;font:12px/20px var(--mono);tab-size:4}
.diff td{padding:0;vertical-align:top}
.diff .n{width:1%;min-width:3rem;padding:0 .6rem;text-align:right;color:var(--faint);user-select:none}
.diff .n::before{content:attr(data-n)}
.diff .c{padding:0 1rem 0 1.4rem;white-space:pre;position:relative}
.diff .c::before{position:absolute;left:.5rem;user-select:none}
.diff code{font:inherit}
.diff .a .c{background:var(--ins)}.diff .a .n{background:var(--ins-n)}.diff .a .c::before{content:"+"}
.diff .d .c{background:var(--del)}.diff .d .n{background:var(--del-n)}.diff .d .c::before{content:"-"}
.diff .h td{background:var(--hunk);color:var(--mute);padding-top:.2rem;padding-bottom:.2rem}
.diff .h .c{white-space:pre-wrap}
.diff .x .c{color:var(--mute);font-style:italic}
.filter{display:flex;gap:1rem}
.filter a{display:flex;align-items:center;gap:.35rem;color:var(--mute)}
.filter a[aria-current]{color:var(--fg);font-weight:600}
.st.open{color:var(--open)}.st.merged{color:var(--merged)}.st.closed{color:var(--closed-issue)}.st.closed.pull{color:var(--closed-pull)}
.pill{display:inline-flex;align-items:center;gap:.35rem;height:26px;padding:0 .7rem;border-radius:13px;color:#fff;font-weight:550;font-size:13px}
.pill.open{background:var(--open)}.pill.merged{background:var(--merged)}.pill.closed{background:var(--closed-issue)}.pill.closed.pull{background:var(--closed-pull)}
@media (prefers-color-scheme:dark){.pill{color:#0d0e10}}
.item-title{font-size:24px;font-weight:500;letter-spacing:-.02em;overflow-wrap:anywhere}
.item-meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;margin:.6rem 0 1.5rem;padding-bottom:1.25rem;border-bottom:1px solid var(--line);color:var(--mute)}
.item-meta b{color:var(--fg);font-weight:600}
.item-meta .end{display:inline-flex;align-items:center;gap:.35rem}
.ref{font:12px var(--mono);padding:.1rem .45rem;border-radius:6px;background:var(--accent-soft);color:var(--accent)}
.timeline{display:flex;flex-direction:column;gap:1rem;max-width:56rem}
.comment .boxhead{gap:.5rem}
.comment .prose{padding:1rem 1.25rem;font-size:14px}
.verdict{display:inline-flex;align-items:center;gap:.3rem;font-weight:550;font-size:12.5px}
.verdict.approve{color:var(--open)}.verdict.changes{color:var(--closed-pull)}
.event{display:flex;align-items:center;gap:.6rem;color:var(--mute);padding:0 1rem}
.event .dot{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:50%;background:var(--merged);color:#fff}
.error{padding:4rem 0;text-align:center}
.error h1{font-size:44px;letter-spacing:-.04em}
.error p{color:var(--mute);margin-top:.5rem}
@media (max-width:640px){
.av.xl{width:64px;height:64px;font-size:26px}
.profile{gap:1rem;align-items:flex-start}
.wrap{padding:0 1rem}
main.wrap{padding-top:1.25rem}
.top nav{gap:1rem}
.tabs a{padding:.55rem .5rem;gap:.35rem}
.clone{width:100%}
.prose{padding:1rem 1.1rem 1.25rem}
.list li{align-items:flex-start}
.list .sha,.list .hide-sm{display:none}
.file .stat{margin-left:0}
.code .ln{min-width:2.75rem;padding-left:.5rem}
.diff .n{min-width:2.25rem;padding:0 .35rem}
.item-title{font-size:20px}
}
.wrap{max-width:58rem}
.top{border-bottom:0}
.top .wrap{height:3.25rem}
.brand{font:600 14px var(--mono)}
.top nav{font:13px var(--mono)}
.foot{font-family:var(--mono);font-size:11.5px;border-top:1px dashed var(--line)}
.repohead h1,.profile h1{font-size:20px;font-weight:600;letter-spacing:-.01em}
.tabs{gap:1.25rem;font-family:var(--sans);font-size:14px}
.tabs svg{display:none}
.tabs a{padding:.45rem 0;text-transform:lowercase}
.boxhead{background:transparent}
.list li:hover{background:var(--soft)}
.files .dir svg.i{color:var(--dir)}
.intro{max-width:44rem;padding:1.25rem 0 1rem;font-size:15px;line-height:1.6;color:var(--mute)}
.intro b{color:var(--fg);font-weight:600}
.intro a{color:var(--accent);text-decoration:underline;text-underline-offset:3px}
.find input{font:13px var(--mono);color:inherit;background:transparent;border:0;border-bottom:1px solid var(--line);padding:.4rem 0;width:min(22rem,100%)}
.find input:focus{outline:0;border-color:var(--accent)}
.feed-day{font-size:13px;font-weight:600;color:var(--mute);margin:2rem 0 .5rem;padding-bottom:.35rem;border-bottom:1px solid var(--line);letter-spacing:0}
.feed{list-style:none;margin:0;padding:0}
.feed>li{display:flex;gap:1rem;padding:.45rem 0;align-items:baseline}
.feed time{font:12px var(--mono);color:var(--faint);flex:none;width:2.75rem}
.feed p{overflow-wrap:anywhere}
.feed .who,.feed .repo{font-weight:600}
.feed a:not(.who):not(.repo):not(.hash){text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}
.feed .ref{background:transparent;color:var(--mute);padding:0}
.feed-note{color:var(--mute);font-size:13px;margin-top:.1rem}
.commits{list-style:none;margin:.25rem 0 .1rem;padding:0 0 0 .9rem;border-left:1px solid var(--line)}
.commits li{display:flex;align-items:baseline;gap:.7rem;font-size:13px;color:var(--mute);min-width:0}
.commits .hash{font:12px var(--mono);color:var(--accent);flex:none}
.commits .rest{display:block;font:12px var(--mono)}
.start{max-width:40rem;margin-top:1rem}
.start h1{font-size:18px;font-weight:600;margin:0 0 .4rem}
.start .lede{color:var(--mute);margin:0 0 1.5rem}
.start>p:not(.lede){font:12.5px var(--mono);color:var(--faint);margin:.9rem 0 .35rem}
.start .cmd{background:var(--panel)}
.results h1{font-size:18px;font-weight:600;margin:1rem 0}
.results .find{margin-bottom:1.25rem}
@media (max-width:640px){.feed>li{flex-direction:column;gap:.1rem}.feed time{width:auto}}
::highlight(comment){color:var(--comment)}::highlight(string){color:var(--string)}::highlight(keyword){color:var(--keyword)}::highlight(number){color:var(--number)}::highlight(function){color:var(--function)}::highlight(type){color:var(--type)}::highlight(tag){color:var(--tag)}::highlight(attr){color:var(--attr)}
::highlight(xins){background-color:var(--ins-x)}::highlight(xdel){background-color:var(--del-x)}::highlight(target){background-color:var(--target)}
web/src/app.d.tsnew+5−0
@@ -0,0 +1,5 @@
declare global {
namespace App {}
}
export {};
web/src/app.htmlnew+12−0
@@ -0,0 +1,12 @@
<!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" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
web/src/env.tsnew+8−0
@@ -0,0 +1,8 @@
import { defineEnvVars } from '@sveltejs/kit/env';
export const variables = defineEnvVars({
KNOTLESS_SERVE: {
description: 'knotless-serve, which answers `/_data` and `/xrpc` for the pages.',
schema: (value) => (value || 'http://127.0.0.1:2590').replace(/\/$/, '')
}
});
web/src/hooks.server.tsnew+18−0
@@ -0,0 +1,18 @@
import type { Handle, HandleFetch } from '@sveltejs/kit/hooks';
import { KNOTLESS_SERVE } from '$app/env/private';
export const handle: Handle = ({ event, resolve }) =>
resolve(event, { filterSerializedResponseHeaders: (name) => name === 'cache-control' });
/**
* Asked of this site, `/_data` and `/xrpc` are asked of knotless-serve
* directly, and with the global fetch: knotless-serve can have the same
* origin as the site it fronts, which Kit's own would answer itself.
*/
export const handleFetch: HandleFetch = ({ event, request, fetch }) => {
const url = new URL(request.url);
if (url.origin === event.url.origin && /^\/(_data|xrpc)\//.test(url.pathname)) {
return globalThis.fetch(new Request(`${KNOTLESS_SERVE}${url.pathname}${url.search}`, request));
}
return fetch(request);
};
web/src/lib/api/Blob.tsnew+12−0
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RepoHead } from "./RepoHead";
/**
* One file at a revision: an image, a binary, too large to show, or text
* with the ranges its highlighting paints.
*/
export type Blob = { repo: RepoHead, rev: string, path: string,
/**
* `image`, `binary`, `large` or `text`.
*/
shown: string, size: number, lines: number, text: string | null, ranges: string | null, };
web/src/lib/api/Change.tsnew+9−0
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Commit } from "./Commit";
import type { FileDiff } from "./FileDiff";
import type { RepoHead } from "./RepoHead";
/**
* A commit and what it changed.
*/
export type Change = { repo: RepoHead, commit: Commit, files: Array<FileDiff>, };
web/src/lib/api/Commit.tsnew+6−0
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A commit, as the pages show one.
*/
export type Commit = { sha: string, parents: Array<string>, author: string, date: string, message: string, };
web/src/lib/api/Discussion.tsnew+24−0
@@ -0,0 +1,24 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RepoHead } from "./RepoHead";
import type { Said } from "./Said";
/**
* A pull or an issue, and everything said on it.
*/
export type Discussion = { repo: RepoHead,
/**
* `pull` or `issue`.
*/
kind: string, title: string, state: string, author: string, createdAt: string,
/**
* A pull's target branch, and the repository and branch it brings.
*/
base: string | null, from: string | null, branch: string | null,
/**
* What it says, as its author opened it.
*/
body: string | null, comments: Array<Said>,
/**
* The merge, for a merged pull.
*/
merged: string | null, };
web/src/lib/api/Entry.tsnew+10−0
@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* One entry of a directory.
*/
export type Entry = { name: string,
/**
* `tree` or `blob`.
*/
kind: string, size: number | null, };
web/src/lib/api/Failure.tsnew+3−0
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Failure = { message: string, };
web/src/lib/api/Feed.tsnew+4−0
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FeedEvent } from "./FeedEvent";
export type Feed = { events: Array<FeedEvent>, };
web/src/lib/api/FeedAction.tsnew+5−0
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FeedCommit } from "./FeedCommit";
import type { FeedRepo } from "./FeedRepo";
export type FeedAction = { "$type": "dev.knotless.getFeed#created", description?: string, forkOf?: FeedRepo, } | { "$type": "dev.knotless.getFeed#pushed", ref: string, created: boolean, count: number, commits: Array<FeedCommit>, } | { "$type": "dev.knotless.getFeed#deleted", ref: string, } | { "$type": "dev.knotless.getFeed#opened", subject: string, title: string, } | { "$type": "dev.knotless.getFeed#commented", subject: string, title: string, text: string, verdict?: string, } | { "$type": "dev.knotless.getFeed#changed", subject: string, title: string, state: string, };
web/src/lib/api/FeedActor.tsnew+3−0
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FeedActor = { did: string, handle: string, };
web/src/lib/api/FeedCommit.tsnew+3−0
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FeedCommit = { sha: string, message: string, };
web/src/lib/api/FeedEvent.tsnew+9−0
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FeedAction } from "./FeedAction";
import type { FeedActor } from "./FeedActor";
import type { FeedRepo } from "./FeedRepo";
/**
* Something an account did to a repository.
*/
export type FeedEvent = { createdAt: string, actor: FeedActor, repo: FeedRepo, action: FeedAction, };
web/src/lib/api/FeedRepo.tsnew+6−0
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A repository, under the name it has now.
*/
export type FeedRepo = { did: string, handle: string, name: string, };
web/src/lib/api/FileDiff.tsnew+11−0
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Row } from "./Row";
/**
* One file of a diff, or why its rows are not shown.
*/
export type FileDiff = { old: string, new: string,
/**
* `added`, `deleted`, `renamed` or `modified`.
*/
status: string, added: number, removed: number, note: string | null, ranges: string | null, rows: Array<Row>, };
web/src/lib/api/History.tsnew+12−0
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Commit } from "./Commit";
import type { RepoHead } from "./RepoHead";
/**
* History, a page at a time, newest first.
*/
export type History = { repo: RepoHead, rev: string, path: string, page: number, commits: Array<Commit>,
/**
* Whether there may be older commits than these.
*/
more: boolean, };
web/src/lib/api/Items.tsnew+16−0
@@ -0,0 +1,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RepoHead } from "./RepoHead";
import type { Summary } from "./Summary";
/**
* A repository's pulls or issues, open or closed.
*/
export type Items = { repo: RepoHead,
/**
* `pull` or `issue`.
*/
kind: string,
/**
* Whether these are the open ones, rather than the closed.
*/
open: boolean, opened: number, closed: number, items: Array<Summary>, };
web/src/lib/api/Link.tsnew+6−0
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Something shown by name, and its page.
*/
export type Link = { shown: string, href: string, };
web/src/lib/api/Listed.tsnew+7−0
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Link } from "./Link";
/**
* A repository among others: whose, its name, and what it is.
*/
export type Listed = { owner: string, name: string, description: string | null, forkOf: Link | null, createdAt: string, };
web/src/lib/api/Listing.tsnew+11−0
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Commit } from "./Commit";
import type { Entry } from "./Entry";
import type { Readme } from "./Readme";
import type { RepoHead } from "./RepoHead";
/**
* A directory at a revision: its latest commit, its entries, folders
* first, and its README. Nothing at all, for a repository never pushed.
*/
export type Listing = { repo: RepoHead, rev: string, path: string, empty: boolean, last: Commit | null, entries: Array<Entry>, readme: Readme | null, };
web/src/lib/api/Profile.tsnew+8−0
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Listed } from "./Listed";
/**
* An account: who it is, as its Bluesky profile says if it has one, and
* its public repositories, read from its PDS, newest first.
*/
export type Profile = { did: string, handle: string, name: string | null, bio: string | null, repos: Array<Listed>, };
web/src/lib/api/Readme.tsnew+6−0
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A README, as Markdown made into HTML or as text.
*/
export type Readme = { name: string, html: string | null, text: string | null, };
web/src/lib/api/RepoHead.tsnew+15−0
@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Link } from "./Link";
/**
* What every page about a repository shows above itself.
*/
export type RepoHead = { authority: string, name: string, description: string | null, forkOf: Link | null,
/**
* The default branch.
*/
head: string,
/**
* Open pulls and issues, as the index knows them.
*/
pulls: number, issues: number, };
web/src/lib/api/Row.tsnew+11−0
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* One row of a file's diff: a hunk header, a note, or a line with its old
* and new numbers.
*/
export type Row = {
/**
* `h`, `x`, `a`, `d`, or empty for context.
*/
kind: string, line: boolean, old: number | null, new: number | null, text: string, };
web/src/lib/api/Said.tsnew+10−0
@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Something said on a pull or an issue, made into HTML.
*/
export type Said = { author: string, createdAt: string, html: string,
/**
* `approve` or `changes`, for a review.
*/
verdict: string | null, };
web/src/lib/api/Search.tsnew+4−0
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Listed } from "./Listed";
export type Search = { query: string, repos: Array<Listed>, };
web/src/lib/api/Summary.tsnew+6−0
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A pull or an issue in a list of them.
*/
export type Summary = { title: string, state: string, href: string, author: string, createdAt: string, comments: number, };
web/src/lib/atoms/Avatar.sveltenew+7−0
@@ -0,0 +1,7 @@
<script lang="ts">
import { hue, initial } from '#lib/format.ts';
/** A round mark with a name's first letter, in a color its name picks. */
let { name, size }: { name: string; size?: 'lg' | 'xl' } = $props();
</script>
<span class={size ? `av ${size}` : 'av'} style="--h:{hue(name)}" aria-hidden="true">{initial(name)}</span>
web/src/lib/atoms/Bars.sveltenew+10−0
@@ -0,0 +1,10 @@
<script lang="ts">
/** Five squares, filled in proportion to what a file gained and lost. */
let { added, removed }: { added: number; removed: number } = $props();
const total = $derived(added + removed);
const share = (n: number) => (total ? Math.floor((n * 5 + Math.floor(total / 2)) / total) : 0);
const a = $derived(share(added));
const d = $derived(Math.min(share(removed), 5 - a));
</script>
<span class="bars" aria-hidden="true">{#each [0, 1, 2, 3, 4] as i (i)}<i class={i < a ? 'a' : i < a + d ? 'd' : undefined}></i>{/each}</span>
web/src/lib/atoms/CopyButton.sveltenew+12−0
@@ -0,0 +1,12 @@
<script lang="ts">
let { text }: { text: string } = $props();
let copied = $state(false);
async function copy() {
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 1500);
}
</script>
<button class="btn" type="button" onclick={copy}>{copied ? 'Copied' : 'Copy'}</button>
web/src/lib/atoms/Count.sveltenew+5−0
@@ -0,0 +1,5 @@
<script lang="ts">
let { n }: { n: number } = $props();
</script>
<span class="count">{n}</span>
web/src/lib/atoms/Icon.sveltenew+6−0
@@ -0,0 +1,6 @@
<script lang="ts">
import { icons, type IconName } from '#lib/icons.ts';
let { name }: { name: IconName } = $props();
</script>
<svg class="i" viewBox="0 0 16 16" aria-hidden="true">{@html icons[name]}</svg>
web/src/lib/atoms/Ref.sveltenew+5−0
@@ -0,0 +1,5 @@
<script lang="ts">
let { name }: { name: string } = $props();
</script>
<span class="ref">{name}</span>
web/src/lib/atoms/Sha.sveltenew+6−0
@@ -0,0 +1,6 @@
<script lang="ts">
/** A commit's short hash, linked to it when `href` is given. */
let { sha, href }: { sha: string; href?: string } = $props();
</script>
{#if href}<a class="sha" {href}>{sha.slice(0, 7)}</a>{:else}<span class="sha">{sha.slice(0, 7)}</span>{/if}
web/src/lib/data.tsnew+36−0
@@ -0,0 +1,36 @@
// What a page shows, read from knotless-serve.
import { error, redirect } from '@sveltejs/kit';
import type { Failure } from '#lib/api/Failure.ts';
type Load = {
fetch: typeof fetch;
setHeaders: (headers: Record<string, string>) => void;
url: URL;
};
/**
* The answer at `path`, for this page: `/_data` and the page's own path
* if it names none. The page is cached as the answer is; a repository
* renamed sends it on to the same page under the new name; and a failure
* is the page's, but for a 404 when the page can do `missing` it.
*/
export async function data<T>(
{ fetch, setHeaders, url }: Load,
path?: string,
missing = false
): Promise<T> {
const res = await fetch(path ?? `/_data${url.pathname}${url.search}`);
if (res.redirected) {
const to = new URL(res.url);
redirect(307, `${to.pathname.replace(/^\/_data/, '')}${to.search}`);
}
if (res.status === 404 && missing) return null as T;
if (!res.ok) {
const failure: Failure | null = await res.json().catch(() => null);
error(res.status, failure?.message ?? res.statusText);
}
const cache = res.headers.get('cache-control');
if (cache) setHeaders({ 'cache-control': cache });
return res.json();
}
web/src/lib/format.tsnew+103−0
@@ -0,0 +1,103 @@
// How the pages put things into words.
const encoder = new TextEncoder();
/** A path for a URL: each segment percent-encoded, the slashes kept. */
export function urlPath(path: string): string {
let out = '';
for (const b of encoder.encode(path)) {
const c = String.fromCharCode(b);
out += /[A-Za-z0-9\-._~/:@]/.test(c) ? c : `%${b.toString(16).toUpperCase().padStart(2, '0')}`;
}
return out;
}
/** The page of `owner`'s repository `name`, and what follows. */
export const repoHref = (owner: string, name: string, tail = '') =>
`/${urlPath(owner)}/${urlPath(name)}${tail}`;
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** `2026-10-20T…` as `Oct 20, 2026`; anything else as it is. */
export function date(iso: string): string {
const [y, m, d] = [iso.slice(0, 4), iso.slice(5, 7), iso.slice(8, 10)];
const month = MONTHS[Number(m) - 1];
const day = Number(d);
if (y.length < 4 || !month || !/^\d\d$/.test(m) || !/^\d+$/.test(d)) return iso;
return `${month} ${day}, ${y}`;
}
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
const LONG_MONTHS = [
'january', 'february', 'march', 'april', 'may', 'june',
'july', 'august', 'september', 'october', 'november', 'december'
];
/** `2026-10-09` as `friday, october 9`. */
export function weekday(iso: string): string {
const d = new Date(`${iso.slice(0, 10)}T00:00:00Z`);
if (isNaN(+d)) return iso;
return `${WEEKDAYS[d.getUTCDay()]}, ${LONG_MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
}
/** A commit message's first line. */
export const subject = (message: string) => message.split('\n')[0];
export function size(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
export const capitalized = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** A branch or tag as it is called, without the `refs/…/` before it. */
export const shortRef = (ref: string) => ref.replace(/^refs\/(heads|tags)\//, '');
/** A hue from 0 to 359 that `name` picks, the same each time. */
export function hue(name: string): number {
let h = 7;
for (const b of encoder.encode(name)) h = (Math.imul(h, 31) + b) >>> 0;
return h % 360;
}
/** The first letter or digit of `name`. */
export const initial = (name: string) => /[\p{L}\p{N}]/u.exec(name)?.[0] ?? '?';
/** The first paragraph of Markdown, as plain text, cut short. */
export function excerpt(text: string, max: number): string {
const plain = text.split('\n\n')[0].replaceAll('\n', ' ').replace(/[`*]/g, '');
const chars = [...plain];
return chars.length > max ? `${chars.slice(0, max).join('').trimEnd()}…` : plain;
}
/** The page of the pull or issue at `uri`, on the repository at `repo`. */
export function itemHref(repo: string, uri: string): string {
const [, , did, , rkey] = uri.split('/');
return `${repo}/${kindOf(uri)}/${did}/${rkey}`;
}
/** Commits a push is walked for: a push that brought this many brought at least as many. */
const WALKED = 100;
/** How many commits a push brought, in words. */
export function commits(n: number): string {
if (n === 0) return 'nothing new';
if (n === 1) return '1 commit';
return n >= WALKED ? `${WALKED}+ commits` : `${n} commits`;
}
/** What a comment with `verdict` did. */
export function said(verdict?: string | null): string {
if (verdict === 'approve') return 'approved';
if (verdict === 'changes') return 'asked for changes on';
return 'commented on';
}
/** What moving to `state` did. */
export const changed = (state: string) => (state === 'open' ? 'reopened' : state);
/** `pull` or `issue`, for the record at `uri`. */
export const kindOf = (uri: string) => (uri.split('/')[3] === 'dev.knotless.pull' ? 'pull' : 'issue');
web/src/lib/highlight.tsnew+89−0
@@ -0,0 +1,89 @@
// Highlighting, painted with the CSS Custom Highlight API rather than
// spans, so the text stays one node per line and the page stays small.
/** The text in `el`, as one node, or nothing for an empty line. */
function textOf(el: Element): Text | null {
el.normalize();
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
return walker.nextNode() as Text | null;
}
/** The highlight named `name`, made if there is none yet. */
function named(name: string): Highlight {
let h = CSS.highlights.get(name);
if (!h) CSS.highlights.set(name, (h = new Highlight()));
return h;
}
/**
* Paints the ranges `ranges` encodes over the text of the `code`
* elements in `el`, taken as if joined by newlines. `ranges` is
* `name:start,length,start,length…;…`, each start counted from the
* end of the range before it.
*/
function paint(el: Element, ranges: string): [Highlight, Range][] {
const nodes: [number, Text][] = [];
let at = 0;
for (const code of el.querySelectorAll('code')) {
const text = textOf(code);
if (text) nodes.push([at, text]);
at += (text ? text.length : 0) + 1;
}
const painted: [Highlight, Range][] = [];
for (const group of ranges.split(';')) {
if (!group) continue;
const [name, list] = group.split(':');
const h = named(name);
const x = list.split(',');
let o = 0;
let j = 0;
for (let i = 0; i < x.length; i += 2) {
o += +x[i];
const end = o + +x[i + 1];
while (j < nodes.length && nodes[j][0] + nodes[j][1].length <= o) j++;
for (let k = j; k < nodes.length && nodes[k][0] < end; k++) {
const [a, t] = nodes[k];
const r = new Range();
r.setStart(t, Math.max(o, a) - a);
r.setEnd(t, Math.min(end, a + t.length) - a);
h.add(r);
painted.push([h, r]);
}
o = end;
}
}
return painted;
}
/** An action painting `ranges` over the code in its element, again as what it shows changes. */
export function highlight(el: Element, shown: { ranges: string | null; text: unknown }) {
if (!globalThis.CSS?.highlights) return {};
let painted = paint(el, shown.ranges ?? '');
const clear = () => painted.forEach(([h, r]) => h.delete(r));
return {
update(next: typeof shown) {
clear();
painted = paint(el, next.ranges ?? '');
},
destroy: clear
};
}
/** Marks line `line` of the text in `el`, or nothing. */
export function target(el: Element | undefined, line: number | null) {
if (!globalThis.CSS?.highlights) return;
CSS.highlights.delete('target');
const text = el && line ? textOf(el) : null;
if (!text || !line) return;
const data = text.data;
let a = 0;
for (let i = 1; i < line; i++) {
a = data.indexOf('\n', a) + 1;
if (!a) return;
}
const e = data.indexOf('\n', a);
const r = new Range();
r.setStart(text, a);
r.setEnd(text, e < 0 ? data.length : e + 1);
CSS.highlights.set('target', new Highlight(r));
}
web/src/lib/icons.tsnew+29−0
@@ -0,0 +1,29 @@
// Icons: GitHub's Octicons, at 16, as the package draws them.
import code from '@primer/octicons/build/svg/code-16.svg?raw';
import commit from '@primer/octicons/build/svg/git-commit-16.svg?raw';
import pull from '@primer/octicons/build/svg/git-pull-request-16.svg?raw';
import pullClosed from '@primer/octicons/build/svg/git-pull-request-closed-16.svg?raw';
import issue from '@primer/octicons/build/svg/issue-opened-16.svg?raw';
import done from '@primer/octicons/build/svg/issue-closed-16.svg?raw';
import merge from '@primer/octicons/build/svg/git-merge-16.svg?raw';
import dir from '@primer/octicons/build/svg/file-directory-fill-16.svg?raw';
import file from '@primer/octicons/build/svg/file-16.svg?raw';
import branch from '@primer/octicons/build/svg/git-branch-16.svg?raw';
import chat from '@primer/octicons/build/svg/comment-16.svg?raw';
import fork from '@primer/octicons/build/svg/repo-forked-16.svg?raw';
/** What is drawn inside an icon's `<svg>`. */
const inside = (svg: string) => svg.replace(/^<svg[^>]*>|<\/svg>\s*$/g, '');
export const icons = Object.fromEntries(
Object.entries({ code, commit, pull, pullClosed, issue, done, merge, dir, file, branch, chat, fork }).map(([k, v]) => [k, inside(v)])
) as Record<'code' | 'commit' | 'pull' | 'pullClosed' | 'issue' | 'done' | 'merge' | 'dir' | 'file' | 'branch' | 'chat' | 'fork', string>;
export type IconName = keyof typeof icons;
/** The icon for a pull or an issue in `state`. */
export function stateIcon(kind: string, state: string): IconName {
if (kind === 'pull') return state === 'merged' ? 'merge' : state === 'closed' ? 'pullClosed' : 'pull';
return state === 'open' ? 'issue' : 'done';
}
web/src/lib/molecules/Command.sveltenew+7−0
@@ -0,0 +1,7 @@
<script lang="ts">
import CopyButton from '#lib/atoms/CopyButton.svelte';
/** A command, shown with a button that copies it. */
let { text }: { text: string } = $props();
</script>
<div class="cmd"><pre>{text}</pre><CopyButton {text} /></div>
web/src/lib/molecules/CommitList.sveltenew+9−0
@@ -0,0 +1,9 @@
<script lang="ts">
import type { FeedCommit } from '#lib/api/FeedCommit.ts';
import { subject } from '#lib/format.ts';
/** The commits a push brought, the first few listed and the rest counted. */
let { commits, count, repo }: { commits: FeedCommit[]; count: number; repo: string } = $props();
const LISTED = 5;
</script>
{#if commits.length}<ul class="commits">{#each commits.slice(0, LISTED) as c (c.sha)}<li><a class="hash" href="{repo}/commit/{c.sha}">{c.sha.slice(0, 7)}</a><span class="clip">{subject(c.message)}</span></li>{/each}{#if count > LISTED}<li class="rest">and {count - LISTED} more</li>{/if}</ul>{/if}
web/src/lib/molecules/Crumbs.sveltenew+9−0
@@ -0,0 +1,9 @@
<script lang="ts">
import { urlPath } from '#lib/format.ts';
/** Links up `path`, each segment to its tree, from the repository's root at `root`; the last is the page itself. */
let { name, root, path }: { name: string; root: string; path: string } = $props();
const segments = $derived(path.split('/').filter(Boolean));
const at = (i: number) => segments.slice(0, i + 1).join('/');
</script>
<nav class="crumbs">{#if segments.length}<a href={root}>{name}</a>{:else}<b>{name}</b>{/if}{#each segments as segment, i (i)}<span class="sep">/</span>{#if i + 1 === segments.length}<b>{segment}</b>{:else}<a href="{root}/{urlPath(at(i))}">{segment}</a>{/if}{/each}</nav>
web/src/lib/molecules/FeedItem.sveltenew+49−0
@@ -0,0 +1,49 @@
<script lang="ts">
import type { FeedEvent } from '#lib/api/FeedEvent.ts';
import { changed, commits, excerpt, itemHref, kindOf, repoHref, said, shortRef, urlPath } from '#lib/format.ts';
import Ref from '#lib/atoms/Ref.svelte';
import RepoLink from './RepoLink.svelte';
import CommitList from './CommitList.svelte';
/** Something an account did to a repository: a sentence, and what it brought or said. */
let { event: e }: { event: FeedEvent } = $props();
const a = $derived(e.action);
const repo = $derived(repoHref(e.repo.handle, e.repo.name));
/**
* The sentence after who did it: words, then what it was done to, then
* words again before the repository. Each run of words is one text node,
* so it is shaped as one.
*/
type Thing = { ref: string } | { item: string; title: string } | { fork: { handle: string; name: string } } | null;
const sentence = $derived.by((): [string, Thing, string] => {
switch (a.$type) {
case 'dev.knotless.getFeed#created':
return a.forkOf ? [' forked ', { fork: a.forkOf }, ' to '] : [' created ', null, ''];
case 'dev.knotless.getFeed#pushed':
return [a.created ? ' created ' : ` pushed ${commits(a.count)} to `, { ref: shortRef(a.ref) }, ' on '];
case 'dev.knotless.getFeed#deleted':
return [' deleted ', { ref: shortRef(a.ref) }, ' on '];
case 'dev.knotless.getFeed#opened':
return [` opened ${kindOf(a.subject)} `, { item: a.subject, title: a.title }, ' on '];
case 'dev.knotless.getFeed#commented':
return [` ${said(a.verdict)} ${kindOf(a.subject)} `, { item: a.subject, title: a.title }, ' on '];
default:
return [` ${changed(a.state)} ${kindOf(a.subject)} `, { item: a.subject, title: a.title }, ' on '];
}
});
const [lead, thing, mid] = $derived(sentence);
</script>
<li>
<time datetime={e.createdAt}>{e.createdAt.slice(11, 16)}</time>
<div class="grow">
<p><a class="who" href="/{urlPath(e.actor.handle)}">{e.actor.handle}</a>{lead}{#if thing && 'ref' in thing}<Ref name={thing.ref} />{:else if thing && 'item' in thing}<a href={itemHref(repo, thing.item)}>{thing.title}</a>{:else if thing}<RepoLink owner={thing.fork.handle} name={thing.fork.name} />{/if}{mid}<RepoLink owner={e.repo.handle} name={e.repo.name} /></p>
{#if a.$type === 'dev.knotless.getFeed#created' && a.description}
<p class="feed-note">{a.description}</p>
{:else if a.$type === 'dev.knotless.getFeed#pushed'}
<CommitList commits={a.commits} count={a.count} {repo} />
{:else if a.$type === 'dev.knotless.getFeed#commented'}
<p class="feed-note">“{excerpt(a.text, 120)}”</p>
{/if}
</div>
</li>
web/src/lib/molecules/Person.sveltenew+7−0
@@ -0,0 +1,7 @@
<script lang="ts">
import { urlPath } from '#lib/format.ts';
/** A handle, linked to its profile. */
let { handle }: { handle: string } = $props();
</script>
<a href="/{urlPath(handle)}"><b>{handle}</b></a>
web/src/lib/molecules/RepoLink.sveltenew+7−0
@@ -0,0 +1,7 @@
<script lang="ts">
import { repoHref } from '#lib/format.ts';
/** A repository as `handle/name`, linked to its page. */
let { owner, name }: { owner: string; name: string } = $props();
</script>
<a class="repo" href={repoHref(owner, name)}>{owner}/{name}</a>
web/src/lib/molecules/SearchForm.sveltenew+34−0
@@ -0,0 +1,34 @@
<script lang="ts" module>
/** Whether the search was being typed into as the page changed. */
let typing = false;
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
/** A search for repositories, which looks as it is typed; without script, on enter. */
let { query = '' }: { query?: string } = $props();
// What was typed, kept as typed: the answer's query is trimmed.
// svelte-ignore state_referenced_locally
let value = $state(query);
let input: HTMLInputElement;
let timer: ReturnType<typeof setTimeout>;
function look() {
clearTimeout(timer);
timer = setTimeout(() => {
typing = true;
goto(`/?q=${encodeURIComponent(value)}`, { replace: true, reset: false });
}, 150);
}
// Typing on the front page moves to the results, where the typing goes on.
onMount(() => {
if (!typing) return;
typing = false;
input.focus();
input.setSelectionRange(value.length, value.length);
});
</script>
<form action="/" role="search" class="find"><input bind:this={input} type="search" name="q" bind:value oninput={look} placeholder="find a repository" aria-label="Find a repository" /></form>
web/src/lib/molecules/TabLink.sveltenew+9−0
@@ -0,0 +1,9 @@
<script lang="ts">
import type { IconName } from '#lib/icons.ts';
import Icon from '#lib/atoms/Icon.svelte';
import Count from '#lib/atoms/Count.svelte';
let { href, icon, label, count = 0, current }: { href: string; icon: IconName; label: string; count?: number; current: boolean } =
$props();
</script>
<a {href} aria-current={current ? 'page' : undefined}><Icon name={icon} />{label}{#if count > 0}<Count n={count} />{/if}</a>
web/src/lib/molecules/Verdict.sveltenew+6−0
@@ -0,0 +1,6 @@
<script lang="ts">
import Icon from '#lib/atoms/Icon.svelte';
let { verdict }: { verdict: string | null } = $props();
</script>
{#if verdict === 'approve'}<span class="verdict approve"><Icon name="done" />Approved</span>{:else if verdict === 'changes'}<span class="verdict changes"><Icon name="issue" />Asked for changes</span>{/if}
web/src/lib/organisms/CodeView.sveltenew+15−0
@@ -0,0 +1,15 @@
<script lang="ts">
import { page } from '$app/state';
import { highlight, target } from '#lib/highlight.ts';
/** A text file, with a gutter of line numbers to link to. */
let { text, lines, ranges }: { text: string; lines: number; ranges: string | null } = $props();
let code: HTMLElement | undefined = $state();
$effect(() => {
void text;
const m = /^#L(\d+)$/.exec(page.url.hash);
target(code, m ? +m[1] : null);
return () => target(undefined, null);
});
</script>
<div class="code"><pre class="ln" aria-hidden="true">{#each { length: lines }, i (i)}<a id="L{i + 1}" href="#L{i + 1}">{i + 1}</a>{'\n'}{/each}</pre><pre class="src" use:highlight={{ ranges, text }}><code bind:this={code}>{text}</code></pre></div>
web/src/lib/organisms/Comment.sveltenew+19−0
@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { date } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
import Person from '#lib/molecules/Person.svelte';
/** Something said on a pull or an issue: who said it, when, and what. */
let { author, said, createdAt, html, end }: { author: string; said: string; createdAt: string; html: string; end?: Snippet } =
$props();
</script>
<div class="box comment">
<div class="boxhead">
<Avatar name={author} />
<Person handle={author} />
<span class="mute">{said} on {date(createdAt)}</span>
{#if end}<span class="end">{@render end()}</span>{/if}
</div>
<div class="prose">{@html html}</div>
</div>
web/src/lib/organisms/DiffFile.sveltenew+30−0
@@ -0,0 +1,30 @@
<script lang="ts">
import type { FileDiff } from '#lib/api/FileDiff.ts';
import { highlight } from '#lib/highlight.ts';
import Bars from '#lib/atoms/Bars.svelte';
/** One file of a diff: its header, then a row per line with the line's old and new numbers. */
let { file: f }: { file: FileDiff } = $props();
const path = $derived(f.status === 'renamed' ? `${f.old} → ${f.new}` : f.status === 'deleted' ? f.old : f.new);
const tag = $derived({ added: 'new', deleted: 'deleted', renamed: 'renamed' }[f.status]);
</script>
<details class="file" open>
<summary><span class="path">{path}</span>{#if tag}<span class="tag">{tag}</span>{/if}<span class="stat"><span class="add">+{f.added}</span><span class="rem">−{f.removed}</span><Bars added={f.added} removed={f.removed} /></span></summary>
{#if f.note}
<div class="note">{f.note}</div>
{:else}
<div class="scroll">
<table class="diff" use:highlight={{ ranges: f.ranges, text: f.rows }}>
<tbody>
{#each f.rows as row, i (i)}
{#if row.line}
<tr class={row.kind}><td class="n" data-n={row.old ?? ''}></td><td class="n" data-n={row.new ?? ''}></td><td class="c"><code>{row.text}</code></td></tr>
{:else}
<tr class={row.kind}><td class="n" colspan="2"></td><td class="c">{row.text}</td></tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</details>
web/src/lib/organisms/Feed.sveltenew+26−0
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FeedEvent } from '#lib/api/FeedEvent.ts';
import { weekday } from '#lib/format.ts';
import FeedItem from '#lib/molecules/FeedItem.svelte';
/** What happened, a day at a time. */
let { events }: { events: FeedEvent[] } = $props();
const days = $derived.by(() => {
const out: [string, FeedEvent[]][] = [];
for (const e of events) {
const on = e.createdAt.slice(0, 10);
const last = out.at(-1);
if (last && last[0] === on) last[1].push(e);
else out.push([on, [e]]);
}
return out;
});
</script>
{#each days as [day, list] (day)}
<h2 class="feed-day">{weekday(day)}</h2>
<ul class="feed">
{#each list as e, i (i)}<FeedItem event={e} />{/each}
</ul>
{:else}
<p class="feed-note">pushes, pulls and issues show up here as they happen, from any account on any PDS.</p>
{/each}
web/src/lib/organisms/FileList.sveltenew+38−0
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Commit } from '#lib/api/Commit.ts';
import type { Entry } from '#lib/api/Entry.ts';
import { date, repoHref, size, subject, urlPath } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
import Icon from '#lib/atoms/Icon.svelte';
import Sha from '#lib/atoms/Sha.svelte';
/** A directory: its latest commit, then its entries. */
let { authority, name, rev, path, last, entries }: {
authority: string; name: string; rev: string; path: string; last: Commit | null; entries: Entry[];
} = $props();
const href = (tail: string) => repoHref(authority, name, tail);
const prefix = $derived(path ? `${path}/` : '');
const history = $derived(href(`/commits/${urlPath(rev)}/${urlPath(path)}`).replace(/\/$/, ''));
</script>
<div class="box">
{#if last}
<div class="boxhead">
<Avatar name={last.author} />
<b>{last.author}</b>
<a class="grow clip mute" href={href(`/commit/${last.sha}`)}>{subject(last.message)}</a>
<Sha sha={last.sha} href={href(`/commit/${last.sha}`)} />
<span class="mute hide-sm">{date(last.date)}</span>
<a class="btn" href={history}><Icon name="commit" />History</a>
</div>
{/if}
<ul class="list files">
{#each entries as e (e.name)}
{@const tree = e.kind === 'tree'}
<li class={tree ? 'dir' : ''}>
<Icon name={tree ? 'dir' : 'file'} />
<a class="grow clip" href={href(`/${tree ? 'tree' : 'blob'}/${urlPath(rev)}/${urlPath(prefix + e.name)}`)}>{e.name}</a>
<span class="size">{e.size === null ? '' : size(e.size)}</span>
</li>
{/each}
</ul>
</div>
web/src/lib/organisms/ItemList.sveltenew+33−0
@@ -0,0 +1,33 @@
<script lang="ts">
import type { Items } from '#lib/api/Items.ts';
import { stateIcon } from '#lib/icons.ts';
import { date } from '#lib/format.ts';
import Icon from '#lib/atoms/Icon.svelte';
import Person from '#lib/molecules/Person.svelte';
/** A repository's pulls or issues, open or closed, with a switch between the two. */
let { items: list }: { items: Items } = $props();
const label = $derived(list.kind === 'pull' ? 'pulls' : 'issues');
</script>
<div class="box">
<div class="boxhead">
<nav class="filter">
<a href="?state=open" aria-current={list.open ? 'page' : undefined}><Icon name={stateIcon(list.kind, 'open')} />{list.opened} Open</a>
<a href="?state=closed" aria-current={list.open ? undefined : 'page'}><Icon name="done" />{list.closed} Closed</a>
</nav>
</div>
<ul class="list">
{#each list.items as i (i.href)}
<li>
<span class="st {i.state} {list.kind}" title={i.state}><Icon name={stateIcon(list.kind, i.state)} /></span>
<div class="grow">
<a class="title" href={i.href}>{i.title}</a>
<div class="meta">opened on {date(i.createdAt)} by <Person handle={i.author} /></div>
</div>
{#if i.comments > 0}<span class="mute"><Icon name="chat" />{i.comments}</span>{/if}
</li>
{:else}
<li class="empty">No {list.open ? 'open' : 'closed'} {label}.</li>
{/each}
</ul>
</div>
web/src/lib/organisms/ProfileHeader.sveltenew+16−0
@@ -0,0 +1,16 @@
<script lang="ts">
import type { Profile } from '#lib/api/Profile.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
/** Who an account is. */
let { profile: p }: { profile: Profile } = $props();
</script>
<section class="profile">
<Avatar name={p.handle} size="xl" />
<div>
<h1>{p.name ?? p.handle}</h1>
{#if p.name}<p class="who">{p.handle}</p>{/if}
<p class="did">{p.did}</p>
{#if p.bio}<p class="bio">{p.bio}</p>{/if}
</div>
</section>
web/src/lib/organisms/Readme.sveltenew+14−0
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Readme } from '#lib/api/Readme.ts';
import Icon from '#lib/atoms/Icon.svelte';
let { readme }: { readme: Readme } = $props();
</script>
<article class="box">
<div class="boxhead"><Icon name="file" /><b>{readme.name}</b></div>
{#if readme.html !== null}
<div class="prose">{@html readme.html}</div>
{:else}
<div class="prose"><pre>{readme.text}</pre></div>
{/if}
</article>
web/src/lib/organisms/RepoHeader.sveltenew+32−0
@@ -0,0 +1,32 @@
<script lang="ts" module>
export type Tab = 'code' | 'commits' | 'pulls' | 'issues';
</script>
<script lang="ts">
import type { RepoHead } from '#lib/api/RepoHead.ts';
import { repoHref, urlPath } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
import CopyButton from '#lib/atoms/CopyButton.svelte';
import Icon from '#lib/atoms/Icon.svelte';
import TabLink from '#lib/molecules/TabLink.svelte';
/** What every page about a repository shows above itself: whose it is, how to clone it, and its tabs. */
let { repo, tab }: { repo: RepoHead; tab: Tab } = $props();
const href = (tail = '') => repoHref(repo.authority, repo.name, tail);
const clone = $derived(`git clone knotless://${repo.authority}/${repo.name}`);
</script>
<div class="repohead">
<div class="about">
<h1><Avatar name={repo.authority} /><a href="/{urlPath(repo.authority)}">{repo.authority}</a><span class="mute">/</span><a href={href()}><b>{repo.name}</b></a></h1>
{#if repo.description}<p>{repo.description}</p>{/if}
{#if repo.forkOf}<p><Icon name="fork" /> Forked from <a href={repo.forkOf.href}>{repo.forkOf.shown}</a></p>{/if}
</div>
<div class="clone"><code>{clone}</code><CopyButton text={clone} /></div>
</div>
<nav class="tabs">
<TabLink href={href()} icon="code" label="Code" current={tab === 'code'} />
<TabLink href={href(`/commits/${urlPath(repo.head)}`)} icon="commit" label="Commits" current={tab === 'commits'} />
<TabLink href={href('/pulls')} icon="pull" label="Pulls" count={repo.pulls} current={tab === 'pulls'} />
<TabLink href={href('/issues')} icon="issue" label="Issues" count={repo.issues} current={tab === 'issues'} />
</nav>
web/src/lib/organisms/RepoList.sveltenew+40−0
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Listed } from '#lib/api/Listed.ts';
import { date, repoHref } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
import Icon from '#lib/atoms/Icon.svelte';
import type { Snippet } from 'svelte';
/**
* Repositories, each with what it is and when it was made: by name
* alone on an owner's page, else with whose it is.
*/
let { repos, owned = false, empty }: { repos: Listed[]; owned?: boolean; empty: Snippet } = $props();
</script>
<div class="box">
<ul class="list">
{#each repos as r (r.owner + '/' + r.name)}
{#if owned}
<li>
<div class="grow">
<a class="title" href={repoHref(r.owner, r.name)}>{r.name}</a>
<div class="meta">{r.description ?? 'No description'}</div>
{#if r.forkOf}<div class="meta"><Icon name="fork" />Forked from <a href={r.forkOf.href}>{r.forkOf.shown}</a></div>{/if}
</div>
<span class="mute hide-sm">{date(r.createdAt)}</span>
</li>
{:else}
<li>
<Avatar name={r.owner} size="lg" />
<div class="grow">
<a class="clip" href={repoHref(r.owner, r.name)}><span class="mute">{r.owner}/</span><b>{r.name}</b></a>
<div class="meta clip">{r.description ?? 'No description'}</div>
</div>
<span class="mute hide-sm">{date(r.createdAt)}</span>
</li>
{/if}
{:else}
<li class="empty">{@render empty()}</li>
{/each}
</ul>
</div>
web/src/lib/organisms/SiteFooter.sveltenew+1−0
@@ -0,0 +1 @@
<footer class="wrap foot">knotless · git on atproto</footer>
web/src/lib/organisms/SiteHeader.sveltenew+6−0
@@ -0,0 +1,6 @@
<header class="top">
<div class="wrap">
<a class="brand" href="/">~ knotless</a>
<nav><a href="/">feed</a><a href="/?q=">repos</a><a href="/start">getting started</a></nav>
</div>
</header>
web/src/lib/organisms/Toolbar.sveltenew+13−0
@@ -0,0 +1,13 @@
<script lang="ts">
import { repoHref, urlPath } from '#lib/format.ts';
import Icon from '#lib/atoms/Icon.svelte';
import Crumbs from '#lib/molecules/Crumbs.svelte';
/** The revision shown, and the way up the path. */
let { authority, name, rev, path }: { authority: string; name: string; rev: string; path: string } = $props();
const pinned = $derived(/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(rev));
</script>
<div class="toolbar">
<span class="branch" title={rev}><Icon name="branch" /><span>{pinned ? rev.slice(0, 7) : rev}</span></span>
<Crumbs {name} root={repoHref(authority, name, `/tree/${urlPath(rev)}`)} {path} />
</div>
web/src/lib/templates/Listing.sveltenew+16−0
@@ -0,0 +1,16 @@
<script lang="ts">
import type { Listing } from '#lib/api/Listing.ts';
import FileList from '#lib/organisms/FileList.svelte';
import Readme from '#lib/organisms/Readme.svelte';
import Toolbar from '#lib/organisms/Toolbar.svelte';
import RepoPage from './RepoPage.svelte';
/** A directory at a revision, and its README. */
let { listing: l }: { listing: Listing } = $props();
const r = $derived(l.repo);
</script>
<RepoPage repo={r} tab="code" title={l.path ? `${l.path} · ${r.name}` : r.name}>
<Toolbar authority={r.authority} name={r.name} rev={l.rev} path={l.path} />
<FileList authority={r.authority} name={r.name} rev={l.rev} path={l.path} last={l.last} entries={l.entries} />
{#if l.readme}<Readme readme={l.readme} />{/if}
</RepoPage>
web/src/lib/templates/RepoPage.sveltenew+12−0
@@ -0,0 +1,12 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { RepoHead } from '#lib/api/RepoHead.ts';
import RepoHeader, { type Tab } from '#lib/organisms/RepoHeader.svelte';
/** A page about a repository: its header and tabs, then the page. */
let { repo, tab, title, children }: { repo: RepoHead; tab: Tab; title: string; children: Snippet } = $props();
</script>
<svelte:head><title>{title}</title></svelte:head>
<RepoHeader {repo} {tab} />
{@render children()}
web/src/params.tsnew+6−0
@@ -0,0 +1,6 @@
import { defineParams } from '@sveltejs/kit/params';
export const params = defineParams({
/** The kinds of thing discussed on a repository, each with its own page. */
item: (kind) => (kind === 'pull' || kind === 'issue' ? kind : undefined)
});
web/src/routes/+error.sveltenew+17−0
@@ -0,0 +1,17 @@
<script lang="ts">
import { page } from '$app/state';
const REASONS: Record<number, string> = {
400: 'Bad Request',
403: 'Forbidden',
404: 'Not Found',
410: 'Gone',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable'
};
const reason = $derived(REASONS[page.status] ?? 'Error');
</script>
<svelte:head><title>{reason}</title></svelte:head>
<div class="error"><h1>{page.status}</h1><p><b>{reason}</b></p><p>{page.error?.message}</p></div>
web/src/routes/+layout.sveltenew+14−0
@@ -0,0 +1,14 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import SiteHeader from '#lib/organisms/SiteHeader.svelte';
import SiteFooter from '#lib/organisms/SiteFooter.svelte';
let { children } = $props();
// Marks the page once it is running, with its highlighting painted:
// what the screenshots wait for.
onMount(() => document.documentElement.setAttribute('data-ready', ''));
</script>
<SiteHeader />
<main class="wrap">{@render children()}</main>
<SiteFooter />
web/src/routes/+page.sveltenew+23−0
@@ -0,0 +1,23 @@
<script lang="ts">
import Feed from '#lib/organisms/Feed.svelte';
import RepoList from '#lib/organisms/RepoList.svelte';
import SearchForm from '#lib/molecules/SearchForm.svelte';
let { data } = $props();
</script>
<svelte:head><title>knotless</title></svelte:head>
{#if data.search}
{@const q = data.search.query}
<section class="results">
<h1>{q ? `repositories matching “${q}”` : 'repositories'}</h1>
<SearchForm query={q} />
<RepoList repos={data.search.repos}>{#snippet empty()}No repositories found.{/snippet}</RepoList>
</section>
{:else}
<section class="intro"><p><b>knotless</b> keeps git repositories as records in your atproto account. push with plain git, clone from any PDS, and let agents commit over MCP. <a href="/start">getting started →</a></p></section>
{#if data.feed}
<SearchForm />
<Feed events={data.feed.events} />
{/if}
{/if}
web/src/routes/+page.tsnew+15−0
@@ -0,0 +1,15 @@
import type { PageLoad } from './$types';
import type { Feed } from '#lib/api/Feed.ts';
import type { Search } from '#lib/api/Search.ts';
import { data } from '#lib/data.ts';
/** Asked for `q`, the repositories that match it; else what happened. Neither, without an index. */
export const load: PageLoad = async (event) => {
const q = event.url.searchParams.get('q');
if (q !== null) {
const search = await data<Search | null>(event, `/_data/search?q=${encodeURIComponent(q)}`, true);
if (search) return { search, feed: null };
}
const feed = await data<Feed | null>(event, '/xrpc/dev.knotless.getFeed', true);
return { search: null, feed };
};
web/src/routes/[authority]/+page.sveltenew+15−0
@@ -0,0 +1,15 @@
<script lang="ts">
import Command from '#lib/molecules/Command.svelte';
import ProfileHeader from '#lib/organisms/ProfileHeader.svelte';
import RepoList from '#lib/organisms/RepoList.svelte';
let { data } = $props();
const p = $derived(data.profile);
</script>
<svelte:head><title>{p.name ?? p.handle}</title></svelte:head>
<ProfileHeader profile={p} />
<div class="section"><h2>Repositories</h2><span class="count">{p.repos.length}</span></div>
<RepoList repos={p.repos} owned>
{#snippet empty()}<p>No public repositories yet.</p><div style="max-width:32rem;margin:1rem auto 0;text-align:left"><Command text="git push knotless://{p.handle}/project main" /></div>{/snippet}
</RepoList>
web/src/routes/[authority]/+page.tsnew+5−0
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
import type { Profile } from '#lib/api/Profile.ts';
import { data } from '#lib/data.ts';
export const load: PageLoad = async (event) => ({ profile: await data<Profile>(event) });
web/src/routes/[authority]/[repo]/+page.sveltenew+15−0
@@ -0,0 +1,15 @@
<script lang="ts">
import Command from '#lib/molecules/Command.svelte';
import Listing from '#lib/templates/Listing.svelte';
import RepoPage from '#lib/templates/RepoPage.svelte';
let { data } = $props();
const l = $derived(data.listing);
</script>
{#if l.empty}
<RepoPage repo={l.repo} tab="code" title={l.repo.name}>
<div class="box"><div class="empty"><h2>Nothing pushed yet</h2><p style="margin:.5rem 0 1rem">Push a branch to start this repository.</p><div style="max-width:32rem;margin:0 auto;text-align:left"><Command text="git push knotless://{l.repo.authority}/{l.repo.name} main" /></div></div></div>
</RepoPage>
{:else}
<Listing listing={l} />
{/if}
web/src/routes/[authority]/[repo]/+page.tsnew+5−0
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
import type { Listing } from '#lib/api/Listing.ts';
import { data } from '#lib/data.ts';
export const load: PageLoad = async (event) => ({ listing: await data<Listing>(event) });
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/+page.sveltenew+40−0
@@ -0,0 +1,40 @@
<script lang="ts">
import { stateIcon } from '#lib/icons.ts';
import { capitalized, date, repoHref } from '#lib/format.ts';
import Icon from '#lib/atoms/Icon.svelte';
import Ref from '#lib/atoms/Ref.svelte';
import Sha from '#lib/atoms/Sha.svelte';
import Person from '#lib/molecules/Person.svelte';
import Verdict from '#lib/molecules/Verdict.svelte';
import Comment from '#lib/organisms/Comment.svelte';
import RepoPage from '#lib/templates/RepoPage.svelte';
let { data } = $props();
const d = $derived(data.discussion);
const r = $derived(d.repo);
const n = $derived(d.comments.length);
</script>
<RepoPage repo={r} tab={d.kind === 'pull' ? 'pulls' : 'issues'} title="{d.title} · {r.name}">
<h2 class="item-title">{d.title}</h2>
<div class="item-meta">
<span class="pill {d.state} {d.kind}"><Icon name={stateIcon(d.kind, d.state)} />{capitalized(d.state)}</span>
{#if d.base !== null && d.branch !== null}
<span><Person handle={d.author} /> wants to merge into <Ref name={d.base} /> from <Ref name="{d.from ?? ''}:{d.branch}" /></span>
{:else}
<span><Person handle={d.author} /> opened this issue on {date(d.createdAt)}</span>
{/if}
<span class="end"><Icon name="chat" />{n} comment{n === 1 ? '' : 's'}</span>
</div>
<div class="timeline">
{#if d.body !== null}<Comment author={d.author} said="opened" createdAt={d.createdAt} html={d.body} />{/if}
{#each d.comments as c, i (i)}
<Comment author={c.author} said="commented" createdAt={c.createdAt} html={c.html}>
{#snippet end()}<Verdict verdict={c.verdict} />{/snippet}
</Comment>
{/each}
{#if d.merged}
<div class="event"><span class="dot"><Icon name="merge" /></span><span>Merged as <Sha sha={d.merged} href={repoHref(r.authority, r.name, `/commit/${d.merged}`)} /></span></div>
{/if}
{#if n === 0 && d.body === null}<div class="box"><div class="empty">Nothing said yet.</div></div>{/if}
</div>
</RepoPage>
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/+page.tsnew+5−0
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
import type { Discussion } from '#lib/api/Discussion.ts';
import { data } from '#lib/data.ts';
export const load: PageLoad = async (event) => ({ discussion: await data<Discussion>(event) });
web/src/routes/[authority]/[repo]/blob/[...rest]/+page.sveltenew+33−0
@@ -0,0 +1,33 @@
<script lang="ts">
import { repoHref, size, urlPath } from '#lib/format.ts';
import Icon from '#lib/atoms/Icon.svelte';
import CodeView from '#lib/organisms/CodeView.svelte';
import Toolbar from '#lib/organisms/Toolbar.svelte';
import RepoPage from '#lib/templates/RepoPage.svelte';
let { data } = $props();
const b = $derived(data.blob);
const r = $derived(b.repo);
const at = (view: string) => repoHref(r.authority, r.name, `/${view}/${urlPath(b.rev)}/${urlPath(b.path)}`);
const shape = $derived(b.shown === 'image' || b.shown === 'binary' ? size(b.size) : `${b.lines} lines · ${size(b.size)}`);
</script>
<RepoPage repo={r} tab="code" title="{b.path} · {r.name}">
<Toolbar authority={r.authority} name={r.name} rev={b.rev} path={b.path} />
<div class="box">
<div class="boxhead">
<span class="mute">{shape}</span>
<span class="end"></span>
<a class="btn" href={at('raw')} data-sveltekit-reload>Raw</a>
<a class="btn" href={at('commits')}><Icon name="commit" />History</a>
</div>
{#if b.shown === 'image'}
<div class="prose" style="text-align:center"><img src={at('raw')} alt={b.path} /></div>
{:else if b.shown === 'binary'}
<div class="note">A binary file, not shown.</div>
{:else if b.shown === 'large'}
<div class="note">Too large to show here: view it raw.</div>
{:else}
<CodeView text={b.text ?? ''} lines={b.lines} ranges={b.ranges} />
{/if}
</div>
</RepoPage>
web/src/routes/[authority]/[repo]/blob/[...rest]/+page.tsnew+5−0
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
import type { Blob } from '#lib/api/Blob.ts';
import { data } from '#lib/data.ts';
export const load: PageLoad = async (event) => ({ blob: await data<Blob>(event) });
web/src/routes/[authority]/[repo]/commit/[rev]/+page.sveltenew+33−0
@@ -0,0 +1,33 @@
<script lang="ts">
import { date, plural, repoHref, subject } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
import Sha from '#lib/atoms/Sha.svelte';
import DiffFile from '#lib/organisms/DiffFile.svelte';
import RepoPage from '#lib/templates/RepoPage.svelte';
let { data } = $props();
const ch = $derived(data.change);
const r = $derived(ch.repo);
const c = $derived(ch.commit);
const cut = $derived(c.message.indexOf('\n'));
const title = $derived(cut < 0 ? c.message : c.message.slice(0, cut));
const rest = $derived(cut < 0 ? '' : c.message.slice(cut + 1).trim());
const added = $derived(ch.files.reduce((n, f) => n + f.added, 0));
const removed = $derived(ch.files.reduce((n, f) => n + f.removed, 0));
</script>
<RepoPage repo={r} tab="commits" title="{subject(c.message)} · {r.name}">
<div class="box">
<div class="commit-msg">
<h2>{title}</h2>
{#if rest}<pre>{rest}</pre>{/if}
</div>
<div class="boxhead" style="border-top:1px solid var(--line);border-bottom:0">
<Avatar name={c.author} />
<b>{c.author}</b>
<span class="mute">committed on {date(c.date)}</span>
<span class="end mute">{#each c.parents as p, i (p)}{i ? ' parent ' : 'parent '}<Sha sha={p} href={repoHref(r.authority, r.name, `/commit/${p}`)} />{/each}{c.parents.length ? ' commit ' : 'commit '}<Sha sha={c.sha} /></span>
</div>
</div>
<div class="summary">Showing <b>{plural(ch.files.length, 'changed file', 'changed files')}</b> with <b class="add">{plural(added, 'addition', 'additions')}</b> and <b class="rem">{plural(removed, 'deletion', 'deletions')}</b></div>
{#each ch.files as f, i (i)}<DiffFile file={f} />{/each}
</RepoPage>
web/src/routes/[authority]/[repo]/commit/[rev]/+page.tsnew+5−0
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
import type { Change } from '#lib/api/Change.ts';
import { data } from '#lib/data.ts';
export const load: PageLoad = async (event) => ({ change: await data<Change>(event) });
web/src/routes/[authority]/[repo]/commits/[...rest]/+page.sveltenew+47−0
@@ -0,0 +1,47 @@
<script lang="ts">
import type { Commit } from '#lib/api/Commit.ts';
import { date, repoHref, subject } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
import Icon from '#lib/atoms/Icon.svelte';
import Sha from '#lib/atoms/Sha.svelte';
import Toolbar from '#lib/organisms/Toolbar.svelte';
import RepoPage from '#lib/templates/RepoPage.svelte';
let { data } = $props();
const h = $derived(data.history);
const r = $derived(h.repo);
/** The commits by the day they were made on, in order. */
const days = $derived.by(() => {
const out: [string, Commit[]][] = [];
for (const c of h.commits) {
const on = date(c.date);
const last = out.at(-1);
if (last && last[0] === on) last[1].push(c);
else out.push([on, [c]]);
}
return out;
});
const href = (c: Commit) => repoHref(r.authority, r.name, `/commit/${c.sha}`);
</script>
<RepoPage repo={r} tab="commits" title="Commits · {r.name}">
<Toolbar authority={r.authority} name={r.name} rev={h.rev} path={h.path} />
{#each days as [day, commits] (day)}
<h3 class="day"><Icon name="commit" />Commits on {day}</h3>
<div class="box">
<ul class="list">
{#each commits as c (c.sha)}
<li>
<div class="grow">
<a class="title clip" href={href(c)}>{subject(c.message)}</a>
<div class="meta"><Avatar name={c.author} /><b>{c.author}</b> committed</div>
</div>
<Sha sha={c.sha} href={href(c)} />
</li>
{/each}
</ul>
</div>
{:else}
<div class="box"><div class="empty">No commits here.</div></div>
{/each}
{#if h.more}<div class="more"><a class="btn" href="?page={h.page + 1}">Older commits</a></div>{/if}
</RepoPage>
web/src/routes/[authority]/[repo]/commits/[...rest]/+page.tsnew+5−0
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
import type { History } from '#lib/api/History.ts';
import { data } from '#lib/data.ts';
export const load: PageLoad = async (event) => ({ history: await data<History>(event) });
web/src/routes/[authority]/[repo]/issues/+page.sveltenew+9−0
@@ -0,0 +1,9 @@
<script lang="ts">
import ItemList from '#lib/organisms/ItemList.svelte';
import RepoPage from '#lib/templates/RepoPage.svelte';
let { data } = $props();
</script>
<RepoPage repo={data.items.repo} tab="issues" title="Issues · {data.items.repo.name}">
<ItemList items={data.items} />
</RepoPage>
web/src/routes/[authority]/[repo]/issues/+page.tsnew+5−0
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
import type { Items } from '#lib/api/Items.ts';
import { data } from '#lib/data.ts';
export const load: PageLoad = async (event) => ({ items: await data<Items>(event) });
web/src/routes/[authority]/[repo]/pulls/+page.sveltenew+9−0
@@ -0,0 +1,9 @@
<script lang="ts">
import ItemList from '#lib/organisms/ItemList.svelte';
import RepoPage from '#lib/templates/RepoPage.svelte';
let { data } = $props();
</script>
<RepoPage repo={data.items.repo} tab="pulls" title="Pulls · {data.items.repo.name}">
<ItemList items={data.items} />
</RepoPage>
web/src/routes/[authority]/[repo]/pulls/+page.tsnew+5−0
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
import type { Items } from '#lib/api/Items.ts';
import { data } from '#lib/data.ts';
export const load: PageLoad = async (event) => ({ items: await data<Items>(event) });
web/src/routes/[authority]/[repo]/tree/[...rest]/+page.sveltenew+6−0
@@ -0,0 +1,6 @@
<script lang="ts">
import Listing from '#lib/templates/Listing.svelte';
let { data } = $props();
</script>
<Listing listing={data.listing} />
web/src/routes/[authority]/[repo]/tree/[...rest]/+page.tsnew+5−0
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
import type { Listing } from '#lib/api/Listing.ts';
import { data } from '#lib/data.ts';
export const load: PageLoad = async (event) => ({ listing: await data<Listing>(event) });
web/src/routes/start/+page.sveltenew+23−0
@@ -0,0 +1,23 @@
<script lang="ts">
import { page } from '$app/state';
import Command from '#lib/molecules/Command.svelte';
const steps = $derived([
['install, from a checkout of knotless', 'cargo install --path crates/knotless\ncargo install --path crates/knotless-mcp'],
['log in with your handle and an app password', 'knotless login you.bsky.social'],
['push any repository, to any account on any PDS', 'git push knotless://you.bsky.social/project main'],
['or clone one without knotless installed', `git clone http://${page.url.host}/you.bsky.social/project`],
['connect an agent: it acts as the account you logged in as', 'claude mcp add knotless -- knotless-mcp'],
['other MCP clients take this in their settings', '{ "mcpServers": { "knotless": { "command": "knotless-mcp" } } }']
]);
</script>
<svelte:head><title>getting started · knotless</title></svelte:head>
<section class="start">
<h1>getting started</h1>
<p class="lede">knotless works with any atmosphere account. log in with an app password.</p>
{#each steps as [say, run] (say)}
<p># {say}</p>
<Command text={run} />
{/each}
</section>
web/tsconfig.jsonnew+7−0
@@ -0,0 +1,7 @@
{
"extends": "$app/tsconfig",
"compilerOptions": {
"strict": true
},
"include": ["src", "vite.config.ts"]
}
web/vite.config.tsnew+34−0
@@ -0,0 +1,34 @@
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
/** knotless-serve, which `vite dev` hands what it answers itself. */
const serve = process.env.KNOTLESS_SERVE ?? 'http://127.0.0.1:2590';
export default defineConfig({
server: {
proxy: { '^/(_data|xrpc)/': serve, '^/[^/]+/[^/]+/raw/': serve }
},
plugins: [
sveltekit({
compilerOptions: { runes: true },
adapter: adapter(),
// One stylesheet, small enough to come with each page.
inlineStyleThreshold: Infinity,
csp: {
mode: 'hash',
directives: {
'default-src': ['none'],
'img-src': ['self', 'https:', 'data:'],
// Avatars take their hue from a style attribute.
'style-src': ['unsafe-inline'],
'script-src': ['self'],
'connect-src': ['self'],
'base-uri': ['none'],
'form-action': ['self'],
'frame-ancestors': ['none']
}
}
})
]
});