chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
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>", | ||
"<script>alert(1)</script>", | ||
&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 <words>\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 <things></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 <thing>") | ||
&& 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 <thing></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 <Owner></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 <things>", | ||
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 <thing>") | ||
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 <to> 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 <thing></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\\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'] | ||
} | ||
} | ||
}) | ||
] | ||
}); | ||