1//! The code pages: `/<repo>.git`, a repository's front page, and 2//! `/<repo>.git/<path>`, a directory or a file in it, for each repository 3//! the site serves. Pure: what GitHub said in, markup out. 4 5use maud::{DOCTYPE, Markup, PreEscaped, html}; 6 7use super::{JS, clone_line, head, nav}; 8use crate::host::Host; 9 10/// What a path came to, to show. 11pub enum Shown<'a> { 12 /// A folder, and its documents. 13 Dir { docs: Docs }, 14 File { size: u64, body: &'a tree::Body, view: View }, 15 Missing, 16 Unreachable, 17} 18 19/// Which tab of the main pane is open: a folder's README (for people) or 20/// CLAUDE.md (for agents, on top of the README); a markdown file rendered 21/// or as its source. 22#[derive(Clone, Copy, Debug, PartialEq, Eq)] 23pub enum View { 24 People, 25 Agents, 26 Source, 27} 28 29impl View { 30 /// From `?view=`: `agents`, `source`, or anything else for people. 31 pub fn asked(view: Option<&str>) -> View { 32 match view { 33 Some("agents") => View::Agents, 34 Some("source") => View::Source, 35 _ => View::People, 36 } 37 } 38} 39 40/// A document, rendered, with where it is served. 41pub struct Doc { 42 pub path: String, 43 pub rendered: tree::Rendered, 44 /// A CLAUDE.md's `@README.md`, as the served path it imports. 45 pub imports: Option<String>, 46 /// Where a README says its chapter sits: the previous and the next. 47 pub guide: tree::Guide, 48} 49 50impl Doc { 51 /// `text` from the file at `path` (served), rendered for its kind: a 52 /// CLAUDE.md shows its import as a link rather than as a line. 53 /// `base` is the repository's address, `/lmjtfy.git`, that its relative 54 /// links are made under. 55 pub fn new(path: &str, text: &str, base: &str) -> Doc { 56 let dir = tree::parent(path); 57 let name = path.rsplit('/').next().unwrap_or(path); 58 let (imports, text) = 59 if name.eq_ignore_ascii_case("CLAUDE.md") { tree::agents(text) } else { (None, text) }; 60 let imports = imports.map(|imported| if dir.is_empty() { imported.to_owned() } else { format!("{dir}/{imported}") }); 61 Doc { path: path.to_owned(), rendered: tree::markdown(text, dir, base), imports, guide: tree::guide(text, dir, base) } 62 } 63} 64 65/// A folder's README and CLAUDE.md, and which is shown. 66pub struct Docs { 67 pub people: Option<Doc>, 68 pub agents: Option<Doc>, 69 pub view: View, 70} 71 72impl Docs { 73 fn shown(&self) -> Option<&Doc> { 74 match self.view { 75 View::Agents => self.agents.as_ref().or(self.people.as_ref()), 76 View::People | View::Source => self.people.as_ref().or(self.agents.as_ref()), 77 } 78 } 79} 80 81/// A repository the site serves, as the code pages list it. 82#[derive(Clone, Copy, Debug, PartialEq, Eq)] 83pub struct Project { 84 /// `lmjtfy.git`: its address under the site, and its name here. 85 pub served: &'static str, 86 /// The branch the pages read. 87 pub branch: &'static str, 88} 89 90/// What GitHub says a repository is: its description, homepage and topics. 91/// GitHub is the only place these are written; nothing here invents one. 92#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)] 93pub struct Meta { 94 /// `None` when GitHub has none, or only blanks. 95 #[serde(default)] 96 pub description: Option<String>, 97 /// Only ever an `http` or `https` address (`Meta::parse`). 98 #[serde(default)] 99 pub homepage: Option<String>, 100 #[serde(default, deserialize_with = "topics")] 101 pub topics: Vec<String>, 102} 103 104/// `topics` may be absent or `null`; either is none. 105fn topics<'de, D: serde::Deserializer<'de>>(from: D) -> Result<Vec<String>, D::Error> { 106 <Option<Vec<String>> as serde::Deserialize>::deserialize(from).map(Option::unwrap_or_default) 107} 108 109impl Meta { 110 /// From the body of `GET /repos/{owner}/{name}`; `None` if it is not 111 /// that. A description of only blanks is none, and so is a homepage that 112 /// is not an http(s) address, so it is never made a link. 113 pub fn parse(body: &str) -> Option<Meta> { 114 let mut meta: Meta = serde_json::from_str(body).ok()?; 115 meta.description = meta.description.map(|text| text.trim().to_owned()).filter(|text| !text.is_empty()); 116 meta.homepage = meta.homepage.map(|url| url.trim().to_owned()).filter(|url| Meta::is_web(url)); 117 meta.topics.retain(|topic| !topic.trim().is_empty()); 118 Some(meta) 119 } 120 121 fn is_web(url: &str) -> bool { 122 let rest = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://")); 123 rest.is_some_and(|rest| !rest.is_empty() && !rest.starts_with('/') && !rest.chars().any(|c| c.is_whitespace() || c.is_control())) 124 } 125 126 /// The description, or nothing. 127 pub fn about(&self) -> &str { 128 self.description.as_deref().unwrap_or_default() 129 } 130 131 /// Topic chips, the homepage's link, as the repository's card shows them. 132 pub fn details(&self) -> Markup { 133 html! { 134 @if !self.topics.is_empty() { 135 p .topics { 136 @for topic in &self.topics { span .topic { (topic) } " " } 137 } 138 } 139 @if let Some(url) = &self.homepage { 140 p .verdict { a href=(url) rel="noopener" { (url) } } 141 } 142 } 143 } 144} 145 146impl Project { 147 /// `/lmjtfy.git`, which every link on its pages starts with. 148 pub fn base(&self) -> String { 149 format!("/{}", self.served) 150 } 151 152 /// Whether it is jevcrates, which other projects depend on rather than 153 /// clone, and whose pages say how. 154 fn is_jevcrates(&self) -> bool { 155 self.served == "jevcrates.git" 156 } 157} 158 159/// jevcrates' crates, and when to depend on which: the "Use it" panel. 160const CRATES: [(&str, &str); 4] = [ 161 ("jev-protocol", "the questions, the request bytes, the checks on an answer; no I/O, builds for wasm"), 162 ("jev-client", "the retry policy, behind your own transport"), 163 ("jev-http", "a native program: HTTP/2 on tokio, with a spend ledger"), 164 ("jev-worker", "a Cloudflare Worker, on its fetch"), 165]; 166 167/// What every code page has around its main pane: which repository, where 168/// in it, the whole tree for the explorer, the latest commit if GitHub could 169/// be read, and the other repositories. 170pub struct Frame<'a> { 171 pub origin: &'a str, 172 /// Which address the page is served at: the code host's pages do not 173 /// take part in what the site does live (`Host::is_live`). 174 pub host: Host, 175 pub project: Project, 176 /// The served path, `""` for the repository's root. 177 pub path: &'a str, 178 pub tree: &'a tree::Tree, 179 pub latest: Option<&'a card::Commit>, 180 /// How to clone it. 181 pub clone: String, 182 /// Every repository the site serves, this one among them. 183 pub projects: Vec<(Project, Option<Meta>)>, 184 /// What GitHub says this repository is, if it could be read. 185 pub meta: Option<Meta>, 186} 187 188impl Frame<'_> { 189 fn base(&self) -> String { 190 self.project.base() 191 } 192} 193 194/// `/<repo>.git` and `/<repo>.git/<path>`: the editor. A sidebar (the 195/// explorer, the open document's outline, how to use or clone it, the latest 196/// commit, the other repositories) and a main pane with breadcrumbs, tabs and 197/// the document or file. 198pub fn page(frame: &Frame<'_>, shown: Shown<'_>) -> Markup { 199 let root = frame.path.is_empty(); 200 let served = frame.project.served; 201 let title = match root { 202 true if served == "lmjtfy.git" => "LMJTFY · the code".to_owned(), 203 true => format!("{served} · the code"), 204 false => format!("{served}/{}", frame.path), 205 }; 206 let about = frame.meta.as_ref().map(Meta::about).unwrap_or_default(); 207 let description = match &shown { 208 _ if root => joined(&frame.clone, about), 209 Shown::Dir { .. } => joined(about, &frame.clone), 210 Shown::File { size, body: tree::Body::Text(text), .. } => format!("{} lines · {}", text.lines().count(), bytes(*size)), 211 Shown::File { size, .. } => bytes(*size), 212 Shown::Missing => "Nothing is there.".to_owned(), 213 Shown::Unreachable => "GitHub could not be read.".to_owned(), 214 }; 215 let sha = frame.latest.map(card::Commit::short).unwrap_or(frame.project.branch); 216 // The open document: a folder's README or CLAUDE.md, or a markdown file 217 // rendered. Its headings are the outline; a diagram in it needs mermaid. 218 let file_doc = match &shown { 219 Shown::File { body: tree::Body::Text(text), view, .. } if markdown(frame.path) && *view != View::Source => { 220 Some(Doc::new(frame.path, text, &frame.base())) 221 } 222 _ => None, 223 }; 224 let doc: Option<&Doc> = match &shown { 225 Shown::Dir { docs } => docs.shown(), 226 _ => file_doc.as_ref(), 227 }; 228 let mermaid = doc.is_some_and(|doc| doc.rendered.mermaid); 229 let main = html! { 230 div .crumbs-bar { (crumbs(&frame.base(), served, frame.path)) } 231 @match &shown { 232 Shown::Dir { docs } => (folder(frame, docs)), 233 Shown::File { size, body, view } => (file(frame, *size, body, *view, file_doc.as_ref())), 234 Shown::Missing => (notice("Nothing is here. It may have moved: the explorer has what there is.")), 235 Shown::Unreachable => (notice("GitHub could not be read just now. Try again in a minute, or clone it.")), 236 } 237 }; 238 html! { 239 (DOCTYPE) 240 html lang="en" { 241 (head(preview(frame.origin, served, &title, &description, sha))) 242 body .editor { 243 div .editor-top { (nav(frame.host)) } 244 div .ide { 245 aside .side aria-label="Explorer" { (sidebar(frame, doc)) } 246 main .pane { (main) } 247 } 248 script { (PreEscaped(JS)) } 249 @if mermaid { script type="module" { (PreEscaped(MERMAID)) } } 250 } 251 } 252 } 253} 254 255/// The sidebar's panels, each one that folds like an editor's. 256fn sidebar(frame: &Frame<'_>, doc: Option<&Doc>) -> Markup { 257 let outline: Vec<&tree::Heading> = 258 doc.map(|doc| doc.rendered.headings.iter().filter(|heading| (2..=3).contains(&heading.level)).collect()).unwrap_or_default(); 259 html! { 260 details .panel open { 261 summary { "Explorer" } 262 div .explorer { 263 a .row .on[frame.path.is_empty()] href=(frame.base()) style="--depth: 0" { (pixel(&tree::icons::open(tree::icons::REPOSITORY))) (frame.project.served) } 264 @if frame.tree.entries.is_empty() { 265 p .verdict { "The tree could not be read from GitHub just now." } 266 } @else { 267 (branch(frame, "", 1)) 268 } 269 } 270 } 271 @if !outline.is_empty() { 272 details .panel open { 273 summary { "Outline" } 274 ol .outline { 275 @for heading in outline { 276 li .sub[heading.level == 3] { a href={ "#" (heading.id) } { (heading.text) } } 277 } 278 } 279 } 280 } 281 @if frame.project.is_jevcrates() { 282 (uses(frame)) 283 } 284 details .panel open { 285 summary { "Clone" } 286 div .panel-body { 287 (clone_line(&frame.clone, html! {})) 288 p .verdict { "No GitHub account needed. It can be cloned and pulled, and nothing else." } 289 } 290 } 291 @if let Some(latest) = frame.latest { 292 details .panel open { 293 summary { "Latest on " (frame.project.branch) } 294 div .panel-body.latest { 295 p { code { (latest.short()) } " " (latest.subject) } 296 p .verdict { (latest.line()) } 297 } 298 } 299 } 300 details .panel open { 301 summary { "Repositories" } 302 ul .projects { 303 @for (project, meta) in &frame.projects { 304 li .on[*project == frame.project] { 305 // The one you are in is the open one. 306 @let icon = if *project == frame.project { tree::icons::open(tree::icons::REPOSITORY) } else { tree::icons::REPOSITORY.to_owned() }; 307 a href=(project.base()) { (pixel(&icon)) (project.served) } 308 @if let Some(meta) = meta.as_ref().filter(|meta| !meta.about().is_empty()) { 309 span .verdict { (meta.about()) } 310 } 311 } 312 } 313 } 314 } 315 } 316} 317 318/// jevcrates as a dependency: the lines for a project's `Cargo.toml`, pinned 319/// to the latest commit, through this site, so no GitHub account is needed. 320fn uses(frame: &Frame<'_>) -> Markup { 321 let git = format!("{}{}", frame.origin, frame.base()); 322 let rev = frame.latest.map(|latest| latest.sha.as_str()); 323 html! { 324 details .panel open { 325 summary { "Use it" } 326 div .panel-body { 327 p { "In your project's " code { "Cargo.toml" } ", the crates you need:" } 328 pre .cargo #cargo { 329 "[dependencies]\n" 330 @for (name, _) in CRATES { 331 (name) " = { git = \"" (git) "\"" 332 @if let Some(rev) = rev { ", rev = \"" (rev) "\"" } 333 " }\n" 334 } 335 } 336 button .ghost type="button" data-copy="cargo" { "Copy" } 337 ul .crates { 338 @for (name, what) in CRATES { 339 li { a href={ (frame.base()) "/" (name) "/" } { code { (name) } } " " (what) } 340 } 341 } 342 p .verdict { "Keep the ones you use. Cargo fetches them from here; the rev pins the commit." } 343 } 344 } 345 } 346} 347 348/// One folder's entries in the explorer, nested. A folder on the way to the 349/// open path is open; the open path is marked. 350fn branch(frame: &Frame<'_>, folder: &str, depth: usize) -> Markup { 351 let open = |path: &str| frame.path == path || frame.path.starts_with(&format!("{path}/")); 352 html! { 353 @for entry in frame.tree.children(folder) { 354 @match entry.kind { 355 tree::Kind::Dir | tree::Kind::Submodule => { 356 details .folder open[open(&entry.path)] { 357 summary style={ "--depth: " (depth) } { 358 a .row .on[frame.path == entry.path] href={ (frame.base()) "/" (entry.path) "/" } { 359 (folder_icon(&entry.name)) (entry.name) 360 } 361 } 362 (branch(frame, &entry.path, depth + 1)) 363 } 364 } 365 tree::Kind::File | tree::Kind::Symlink => { 366 a .row .file .on[frame.path == entry.path] href={ (frame.base()) "/" (entry.path) } style={ "--depth: " (depth) } { 367 (pixel(tree::icons::file(&entry.name))) (entry.name) 368 } 369 } 370 } 371 } 372 } 373} 374 375/// The chapters before and after this folder's, as `(label, address)`: what 376/// its README's guide line says, and for a README with no guide line, the 377/// folder with a README before or after this one in a depth-first walk of 378/// the tree. A guide line that names no Next is the end of its guide. 379fn steps(frame: &Frame<'_>, docs: &Docs) -> (Option<(String, String)>, Option<(String, String)>) { 380 let said = docs.people.as_ref().map(|doc| doc.guide.clone()).unwrap_or_default(); 381 let walk = chapters(frame); 382 let at = walk.iter().position(|path| path == frame.path); 383 let near = |offset: isize| -> Option<(String, String)> { 384 let path = walk.get(at?.checked_add_signed(offset)?)?; 385 let label = if path.is_empty() { frame.project.served.to_owned() } else { format!("{}/", name(path)) }; 386 let href = if path.is_empty() { frame.base() } else { format!("{}/{path}/", frame.base()) }; 387 Some((label, href)) 388 }; 389 if said.line { (said.previous, said.next) } else { (near(-1), near(1)) } 390} 391 392/// Every folder with a README, the root first, in a depth-first walk in the 393/// explorer's order. 394fn chapters(frame: &Frame<'_>) -> Vec<String> { 395 fn walk(tree: &tree::Tree, folder: &str, out: &mut Vec<String>) { 396 let children = tree.children(folder); 397 if children.iter().any(|entry| entry.kind == tree::Kind::File && entry.name.eq_ignore_ascii_case("README.md")) { 398 out.push(folder.to_owned()); 399 } 400 for child in children.iter().filter(|entry| matches!(entry.kind, tree::Kind::Dir | tree::Kind::Submodule)) { 401 walk(tree, &child.path, out); 402 } 403 } 404 let mut out = Vec::new(); 405 walk(frame.tree, "", &mut out); 406 out 407} 408 409/// A folder in the main pane: its README and CLAUDE.md as tabs, or, with 410/// neither, what is in it. The chapter before is a tab on the left, the one 411/// after on the right. 412fn folder(frame: &Frame<'_>, docs: &Docs) -> Markup { 413 let base = frame.base(); 414 let here = if frame.path.is_empty() { base.clone() } else { format!("{base}/{}/", frame.path) }; 415 let shown = docs.shown(); 416 let is = |doc: &Option<Doc>| doc.as_ref().map(|doc| doc.path.as_str()) == shown.map(|doc| doc.path.as_str()); 417 let (previous, next) = steps(frame, docs); 418 html! { 419 div .tabs-bar role="tablist" { 420 @if let Some((label, href)) = &previous { 421 a .tab .step .previous href=(href) rel="prev" title={ "Previous: " (label) } { span .arrow aria-hidden="true" { "◀" } span .step-label { (label) } } 422 } 423 @if let Some(people) = &docs.people { 424 a .tab .on[is(&docs.people)] href=(here) role="tab" { (pixel(tree::icons::file(name(&people.path)))) (name(&people.path)) span .tab-note { "for people" } } 425 } 426 @if let Some(agents) = &docs.agents { 427 a .tab .on[is(&docs.agents)] href={ (here) "?view=agents" } role="tab" { (pixel(tree::icons::file(name(&agents.path)))) (name(&agents.path)) span .tab-note { "for agents" } } 428 } 429 @if let Some((label, href)) = &next { 430 a .tab .step .next href=(href) rel="next" title={ "Next: " (label) } { span .step-label { (label) } span .arrow aria-hidden="true" { "▶" } } 431 } 432 } 433 @match shown { 434 Some(doc) => (document(frame, doc)), 435 // Nothing to show and no tree to list: GitHub was not read. 436 None if frame.tree.entries.is_empty() => { 437 (notice("GitHub could not be read just now, so there is nothing to show. Try again in a minute, or clone it.")) 438 } 439 None => { 440 div .doc { ul .contents-list { 441 @for entry in frame.tree.children(frame.path) { 442 li { a href={ (base) "/" (entry.path) @if entry.kind != tree::Kind::File { "/" } } { (entry.name) } } 443 } 444 } } 445 } 446 } 447 } 448} 449 450/// A document: what it imports, and itself. 451fn document(frame: &Frame<'_>, doc: &Doc) -> Markup { 452 html! { 453 article .doc.md { 454 @if let Some(imports) = &doc.imports { 455 p .imports { 456 "For agents, on top of " a href={ (frame.base()) "/" (imports) } { (name(imports)) } 457 ", which they read first." 458 } 459 } 460 (PreEscaped(&doc.rendered.html)) 461 } 462 } 463} 464 465/// A file in the main pane: a markdown file rendered or as source, anything 466/// else with numbered lines. 467fn file(frame: &Frame<'_>, size: u64, body: &tree::Body, view: View, rendered: Option<&Doc>) -> Markup { 468 let here = format!("{}/{}", frame.base(), frame.path); 469 html! { 470 div .tabs-bar role="tablist" { 471 @if markdown(frame.path) { 472 a .tab .on[view != View::Source] href=(here) role="tab" { (pixel(tree::icons::file(name(frame.path)))) (name(frame.path)) span .tab-note { "preview" } } 473 a .tab .on[view == View::Source] href={ (here) "?view=source" } role="tab" { (pixel(tree::icons::file(name(frame.path)))) (name(frame.path)) span .tab-note { "source" } } 474 } @else if tree::annotate::language(frame.path).is_some() { 475 a .tab .on[view != View::Source] href=(here) role="tab" { (pixel(tree::icons::file(name(frame.path)))) (name(frame.path)) span .tab-note { "annotated" } } 476 a .tab .on[view == View::Source] href={ (here) "?view=source" } role="tab" { (pixel(tree::icons::file(name(frame.path)))) (name(frame.path)) span .tab-note { "source" } } 477 } @else { 478 span .tab .on { (pixel(tree::icons::file(name(frame.path)))) (name(frame.path)) } 479 } 480 span .tab-meta { 481 @if let tree::Body::Text(text) = body { (text.lines().count()) " lines · " } 482 (bytes(size)) " · " a href={ (here) "?raw" } { "raw" } 483 } 484 } 485 @match (body, rendered) { 486 (_, Some(doc)) => (document(frame, doc)), 487 (tree::Body::Text(text), None) => (source(frame, text, view)), 488 // A picture is shown; anything else that is not text is offered 489 // as it is. 490 (tree::Body::Binary(_), None) if tree::media_type(frame.path, body).starts_with("image/") => { 491 div .doc.md { img src={ (here) "?raw" } alt=(name(frame.path)); } 492 } 493 (tree::Body::Binary(_), None) => { 494 div .doc { p .verdict { "Not text. " a href={ (here) "?raw" } { "Open it as it is." } } } 495 } 496 (tree::Body::TooLarge, None) => { 497 div .doc { p .verdict { "Too large to show here. Clone it to read it." } } 498 } 499 } 500 } 501} 502 503/// One of the pixel icons (`/icons/<name>.png`, third-party/jerrys-pixel-icons). 504fn pixel(icon: &str) -> Markup { 505 html! { img .icon.px src={ "/icons/" (icon) ".png" } alt="" width="18" height="18" loading="lazy"; } 506} 507 508/// A folder's two icons, shut and open; the style sheet shows the one that 509/// matches its `details`. 510fn folder_icon(name: &str) -> Markup { 511 let shut = tree::icons::folder(name); 512 html! { 513 span .shut { (pixel(shut)) } 514 span .opened { (pixel(&tree::icons::open(shut))) } 515 } 516} 517 518/// A source file. In a language known here it is read as Docco reads one 519/// (the owner, 2026-10-03: "extract the docstring put them on the left, and 520/// put the code they map to on the right, with syntax highlighting"): each 521/// run of comments rendered beside the code under it. `?view=source` is the 522/// file top to bottom, coloured; a file with no comments of its own lines, 523/// or in no known language, is only that. 524fn source(frame: &Frame<'_>, text: &str, view: View) -> Markup { 525 let Some(language) = tree::annotate::language(frame.path) else { 526 return html! { div .doc.source { pre { 527 @for (index, line) in text.lines().enumerate() { (numbered(index + 1, html! { (line) })) } 528 } } }; 529 }; 530 let sections = tree::annotate::sections(text, language); 531 if view == View::Source || sections.iter().all(|section| section.prose.is_empty()) { 532 return html! { div .doc.source { pre { 533 @for line in tree::annotate::lines(text, language) { (coloured(&line)) } 534 } } }; 535 } 536 let dir = tree::parent(frame.path); 537 html! { 538 div .doc.docco { 539 @for section in §ions { 540 div .sec { 541 div .prose.md { (PreEscaped(tree::markdown(§ion.prose, dir, &frame.base()).html)) } 542 div .source { pre { @for line in §ion.code { (coloured(line)) } } } 543 } 544 } 545 } 546 } 547} 548 549/// A line of a file with its number, which is also its address (`#L12`). 550fn numbered(number: usize, line: Markup) -> Markup { 551 html! { 552 span .line id={ "L" (number) } { 553 a .number href={ "#L" (number) } { (number) } 554 (line) "\n" 555 } 556 } 557} 558 559fn coloured(line: &tree::annotate::Line<'_>) -> Markup { 560 numbered( 561 line.number, 562 html! { 563 @for (kind, piece) in &line.pieces { 564 @match kind.class() { 565 Some(class) => span class=(class) { (piece) }, 566 None => (piece), 567 } 568 } 569 }, 570 ) 571} 572 573fn notice(why: &str) -> Markup { 574 html! { div .doc { p .verdict { (why) } } } 575} 576 577fn name(path: &str) -> &str { 578 path.rsplit('/').next().unwrap_or(path) 579} 580 581/// The diagram drawer, pinned, from jsdelivr, on pages that have a diagram. 582/// Themed to the page: ink, paper and pink, square, in the mono face. 583const MERMAID: &str = r##" 584import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@12.1.0/dist/mermaid.esm.min.mjs"; 585mermaid.initialize({ 586 startOnLoad: false, 587 theme: "base", 588 fontFamily: "JetBrains Mono, ui-monospace, monospace", 589 themeVariables: { 590 primaryColor: "#fefefe", primaryTextColor: "#1e1e1e", primaryBorderColor: "#1e1e1e", 591 lineColor: "#1e1e1e", secondaryColor: "#f386a1", tertiaryColor: "#dedede", 592 background: "#dedede", mainBkg: "#fefefe", nodeBorder: "#1e1e1e", clusterBkg: "#dedede", 593 clusterBorder: "#1e1e1e", edgeLabelBackground: "#dedede", fontSize: "14px", 594 }, 595 flowchart: { curve: "linear" }, 596 // The page's look, inside each drawing: boxes are the window title bars 597 // (ink, paper text, square), labels in the pixel face, alternatives and 598 // notes in the pink, everything else in ink on paper. It is inside the 599 // SVG, so it outranks mermaid's own rules and comes along when a drawing 600 // is enlarged. 601 themeCSS: ` 602 rect, polygon { rx: 0; ry: 0; } 603 .actor, .node rect, .node polygon, .node circle, .entityBox, .er.entityBox { 604 fill: #1e1e1e !important; stroke: #1e1e1e !important; } 605 text.actor, text.actor > tspan, .nodeLabel, .node .label, .entityLabel, .er.entityLabel { 606 fill: #fefefe !important; color: #fefefe !important; 607 font-family: VT323, "JetBrains Mono", monospace !important; font-size: 18px !important; } 608 .actor-line { stroke: #1e1e1e !important; stroke-dasharray: 2 3; } 609 .messageLine0, .messageLine1, .flowchart-link, .relationshipLine { stroke: #1e1e1e !important; stroke-width: 1.5px !important; } 610 .messageText, .edgeLabel, .labelText, .loopText, .loopText > tspan { 611 fill: #1e1e1e !important; color: #1e1e1e !important; font-family: "JetBrains Mono", monospace !important; } 612 .edgeLabel, .edgeLabel rect, .labelBkg { background: #fefefe !important; fill: #fefefe !important; } 613 .labelBox, .note { fill: #f386a1 !important; stroke: #1e1e1e !important; } 614 .loopLine { stroke: #1e1e1e !important; stroke-dasharray: 3 3; } 615 #arrowhead path, .arrowheadPath, marker path { fill: #1e1e1e !important; stroke: #1e1e1e !important; } 616 .attributeBoxOdd { fill: #fefefe !important; } .attributeBoxEven { fill: #dedede !important; } 617 .attributeBoxOdd, .attributeBoxEven { stroke: #1e1e1e !important; } 618 .er.attributeBoxOdd + text, text.er { font-family: "JetBrains Mono", monospace !important; } 619 .cluster rect { fill: #dedede !important; stroke: #1e1e1e !important; stroke-dasharray: 1 3; } 620 /* Entities (mermaid 12 draws them as paths with HTML labels): rows of 621 ink text on paper and grey, the name in the pixel face. */ 622 .row-rect-odd path, .row-rect-odd { fill: #fefefe !important; } 623 .row-rect-even path, .row-rect-even { fill: #dedede !important; } 624 .outer-path path, .divider path, .divider { stroke: #1e1e1e !important; } 625 .label.name .nodeLabel, .label.name .nodeLabel p { color: #1e1e1e !important; fill: #1e1e1e !important; 626 font-family: VT323, monospace !important; font-size: 20px !important; } 627 .label.attribute-name .nodeLabel, .label.attribute-type .nodeLabel, .label.attribute-keys .nodeLabel, 628 .label.attribute-comment .nodeLabel, .label.attribute-name .nodeLabel p, .label.attribute-type .nodeLabel p, 629 .label.attribute-keys .nodeLabel p, .label.attribute-comment .nodeLabel p { 630 color: #1e1e1e !important; fill: #1e1e1e !important; font-family: "JetBrains Mono", monospace !important; font-size: 13px !important; } 631 .label.attribute-comment .nodeLabel { opacity: 0.75; } 632 `, 633}); 634await mermaid.run({ querySelector: "pre.mermaid" }); 635"##; 636 637pub(super) fn preview(origin: &str, served: &str, title: &str, description: &str, sha: &str) -> Markup { 638 // The commit in the picture's address, so a chat fetches it again when 639 // there is a new one. 640 let picture = format!("{origin}/card.png?code={sha}&repo={served}"); 641 html! { 642 title { (title) } 643 meta name="description" content=(description); 644 meta property="og:type" content="website"; 645 meta property="og:site_name" content="LMJTFY · Let Me Jev That For You"; 646 meta property="og:title" content=(title); 647 meta property="og:description" content=(description); 648 meta property="og:image" content=(picture); 649 meta property="og:image:type" content="image/png"; 650 meta property="og:image:width" content=(card::WIDTH); 651 meta property="og:image:height" content=(card::HEIGHT); 652 meta name="twitter:card" content="summary_large_image"; 653 meta name="theme-color" content="#f386a1"; 654 } 655} 656 657/// `lmjtfy.git / packages / rules`, each part a link to its folder. 658fn crumbs(base: &str, repo: &str, path: &str) -> Markup { 659 let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); 660 html! { 661 span .crumbs { 662 a href=(base) { (repo) } 663 @for (index, part) in parts.iter().enumerate() { 664 " / " 665 @if index + 1 == parts.len() { 666 span { (part) } 667 } @else { 668 a href={ (base) "/" (parts[..=index].join("/")) "/" } { (part) } 669 } 670 } 671 } 672 } 673} 674 675fn markdown(path: &str) -> bool { 676 path.to_ascii_lowercase().ends_with(".md") 677} 678 679/// `335 B`, `16.5 KB`. 680/// Two parts of a description, with `·` between if both are there. 681fn joined(first: &str, second: &str) -> String { 682 [first, second].into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join(" · ") 683} 684 685fn bytes(size: u64) -> String { 686 if size < 1024 { format!("{size} B") } else { format!("{:.1} KB", size as f64 / 1024.0) } 687} 688 689/// The repository's folders are the guide these pages show (the owner, 690/// 2026-10-02): every folder a chapter, and every link in a chapter going 691/// somewhere. These tests read the working tree, so a folder added without 692/// its pages, or a link to a file that moved, fails the build. 693#[cfg(test)] 694mod tests { 695 use super::*; 696 697 const LMJTFY: Project = Project { served: "lmjtfy.git", branch: "main" }; 698 const JEVCRATES: Project = Project { served: "jevcrates.git", branch: "main" }; 699 700 #[test] 701 fn meta_reads_a_full_answer() { 702 let body = r#"{"id":1,"description":"A talking cat.","homepage":"https://whiskers.lmjtfy.fun","topics":["rust","kids"],"private":true}"#; 703 let meta = Meta::parse(body).unwrap(); 704 assert_eq!(meta.about(), "A talking cat."); 705 assert_eq!(meta.homepage.as_deref(), Some("https://whiskers.lmjtfy.fun")); 706 assert_eq!(meta.topics, ["rust", "kids"]); 707 let html = meta.details().into_string(); 708 assert!(html.contains(r#"<span class="topic">rust</span>"#), "{html}"); 709 assert!(html.contains(r#"href="https://whiskers.lmjtfy.fun""#), "{html}"); 710 } 711 712 #[test] 713 fn meta_with_an_empty_or_missing_description_has_none() { 714 assert_eq!(Meta::parse(r#"{"description":" ","topics":[]}"#).unwrap().description, None); 715 assert_eq!(Meta::parse(r#"{"description":null}"#).unwrap().description, None); 716 assert_eq!(Meta::parse(r#"{"description":""}"#).unwrap().about(), ""); 717 } 718 719 #[test] 720 fn meta_with_a_null_or_empty_homepage_has_none() { 721 assert_eq!(Meta::parse(r#"{"homepage":null}"#).unwrap().homepage, None); 722 assert_eq!(Meta::parse(r#"{"homepage":""}"#).unwrap().homepage, None); 723 } 724 725 #[test] 726 fn meta_with_no_topics_has_none() { 727 assert!(Meta::parse(r#"{"description":"x"}"#).unwrap().topics.is_empty()); 728 assert!(Meta::parse(r#"{"topics":null}"#).unwrap().topics.is_empty()); 729 assert!(!Meta::parse(r#"{"topics":[]}"#).unwrap().details().into_string().contains("topic")); 730 } 731 732 #[test] 733 fn meta_never_links_a_homepage_that_is_not_http() { 734 for url in ["javascript:alert(1)", "ftp://x.test", "//x.test", "https://", "https:///x", "data:text/html,x", "https://a b"] { 735 let body = format!(r#"{{"homepage":{}}}"#, serde_json::to_string(url).unwrap()); 736 let meta = Meta::parse(&body).unwrap(); 737 assert_eq!(meta.homepage, None, "{url}"); 738 assert!(!meta.details().into_string().contains("href"), "{url}"); 739 } 740 assert_eq!(Meta::parse(r#"{"homepage":"http://x.test/a"}"#).unwrap().homepage.as_deref(), Some("http://x.test/a")); 741 } 742 743 #[test] 744 fn meta_text_is_escaped() { 745 let meta = Meta::parse(r#"{"topics":["<b>"],"description":"<i>"}"#).unwrap(); 746 assert!(meta.details().into_string().contains("<b>")); 747 } 748 749 #[test] 750 fn meta_is_none_for_what_is_not_a_repository() { 751 assert_eq!(Meta::parse("not json"), None); 752 } 753 754 fn shown(project: Project, latest: Option<&card::Commit>) -> String { 755 let tree = tree::Tree::default(); 756 let frame = Frame { 757 origin: "https://x", 758 host: Host::Other, 759 project, 760 path: "", 761 tree: &tree, 762 latest, 763 clone: format!("git clone https://x/{}", project.served), 764 projects: vec![(LMJTFY, None), (JEVCRATES, None)], 765 meta: None, 766 }; 767 page(&frame, Shown::Dir { docs: Docs { people: None, agents: None, view: View::People } }).into_string() 768 } 769 770 #[test] 771 fn jevcrates_says_how_to_depend_on_it_at_the_latest_commit() { 772 let latest = card::Commit { sha: "4651441abc".into(), subject: "s".into(), date: "2026-10-02".into(), count: None }; 773 let html = shown(JEVCRATES, Some(&latest)); 774 // maud escapes the quotes; the copy button copies the text, unescaped. 775 assert!(html.contains("jev-client = { git = "https://x/jevcrates.git", rev = "4651441abc" }"), "{html}"); 776 assert!(html.contains(r#"href="/jevcrates.git/jev-http/""#), "{html}"); 777 assert!(!shown(LMJTFY, Some(&latest)).contains("Use it")); 778 // With no commit read, the lines still work, unpinned. 779 assert!(shown(JEVCRATES, None).contains("jev-protocol = { git = "https://x/jevcrates.git" }")); 780 } 781 782 #[test] 783 fn a_repository_github_would_not_show_says_so() { 784 assert!(shown(LMJTFY, None).contains("GitHub could not be read just now, so there is nothing to show.")); 785 } 786 787 #[test] 788 fn a_source_file_is_its_comments_beside_its_code() { 789 let tree = tree::parse_tree(r#"{"tree":[],"truncated":false}"#).expect("a tree"); 790 let frame = |path| Frame { origin: "https://x", host: Host::Other, project: LMJTFY, path, tree: &tree, latest: None, clone: String::new(), projects: vec![], meta: None }; 791 let text = "//! The *door*.\n\nuse std::fmt;\n\n/// Adds `<b>`.\nfn add() -> Option<u8> { None } // why\n"; 792 let html = source(&frame("src/lib.rs"), text, View::People).into_string(); 793 // Two runs of comments, each rendered as markdown beside its code. 794 assert_eq!(html.matches(r#"<div class="sec">"#).count(), 2, "{html}"); 795 assert!(html.contains("The <em>door</em>."), "{html}"); 796 // The code keeps its line numbers as addresses, and is coloured. 797 assert!(html.contains(r##"<span class="line" id="L6"><a class="number" href="#L6">6</a>"##), "{html}"); 798 assert!(html.contains(r#"<span class="k">fn</span>"#) && html.contains(r#"<span class="t">Option</span>"#), "{html}"); 799 assert!(html.contains(r#"<span class="c">// why</span>"#), "{html}"); 800 // What a comment or the code says cannot become markup. 801 assert!(!html.contains("<b>"), "{html}"); 802 // The same file top to bottom has every line and no prose column. 803 let plain = source(&frame("src/lib.rs"), text, View::Source).into_string(); 804 assert!(!plain.contains("sec") && plain.contains(r#"id="L1""#) && plain.contains(r#"<span class="c">//! The *door*.</span>"#), "{plain}"); 805 // A file in no known language is its lines, as it was. 806 let other = source(&frame("notes.txt"), "a < b\n", View::People).into_string(); 807 assert!(other.contains("a < b") && !other.contains("sec"), "{other}"); 808 } 809 810 #[test] 811 fn a_chapter_steps_by_its_guide_line_or_else_depth_first() { 812 let listed = r#"[{"path":"README.md","type":"blob","sha":"1"},{"path":"a","type":"tree","sha":"2"},{"path":"a/README.md","type":"blob","sha":"3"}, 813 {"path":"a/b","type":"tree","sha":"4"},{"path":"a/b/README.md","type":"blob","sha":"5"},{"path":"c","type":"tree","sha":"6"},{"path":"c/README.md","type":"blob","sha":"7"}]"#; 814 let tree = tree::parse_tree(&format!(r#"{{"tree":{listed},"truncated":false}}"#)).expect("a tree"); 815 let frame = |path| Frame { origin: "https://x", host: Host::Other, project: LMJTFY, path, tree: &tree, latest: None, clone: String::new(), projects: vec![], meta: None }; 816 let docs = |text: &str, dir: &str| Docs { people: Some(Doc::new(&format!("{dir}/README.md"), text, "/lmjtfy.git")), agents: None, view: View::People }; 817 // No guide line: the walk. a/b comes after a, and c after a/b. 818 let (previous, next) = steps(&frame("a/b"), &docs("# b", "a/b")); 819 assert_eq!(previous, Some(("a/".into(), "/lmjtfy.git/a/".into()))); 820 assert_eq!(next, Some(("c/".into(), "/lmjtfy.git/c/".into()))); 821 // A guide line is followed as it is: here, no Previous. 822 let (previous, next) = steps(&frame("a/b"), &docs("# b\n\nNext: [Chapter 9, elsewhere](../../c/) →", "a/b")); 823 assert_eq!(next, Some(("Chapter 9, elsewhere".into(), "/lmjtfy.git/c/".into()))); 824 assert_eq!(previous, None); 825 // The last chapter of a guide has no Next, and none is made up. 826 assert_eq!(steps(&frame("a/b"), &docs("# b\n\n← Previous: [a](../) · The end.", "a/b")).1, None); 827 let html = folder(&frame("a/b"), &docs("# b", "a/b")).into_string(); 828 assert!(html.contains(r#"rel="prev""#) && html.contains(r#"rel="next""#), "{html}"); 829 // The root has nothing before it. 830 assert_eq!(steps(&frame(""), &docs("# root", "")).0, None); 831 } 832 833 #[test] 834 fn every_page_lists_the_repositories_and_links_under_its_own() { 835 let html = shown(JEVCRATES, None); 836 assert!(html.contains(r#"<li class="on"><a href="/jevcrates.git">"#), "{html}"); 837 assert!(html.contains(r#"<a href="/lmjtfy.git">"#), "{html}"); 838 assert!(html.contains("card.png?code=main&repo=jevcrates.git"), "{html}"); 839 assert!(html.contains("<title>jevcrates.git · the code</title>"), "{html}"); 840 } 841} 842 843#[cfg(test)] 844mod guide { 845 use std::path::{Path, PathBuf}; 846 847 /// Not chapters: tools' state, build output, and jevcrates, which is 848 /// another repository with its own guide. 849 const NOT_CHAPTERS: [&str; 9] = 850 [".git", ".claude", ".dev", ".direnv", ".wrangler", "target", "build", "node_modules", "third-party/jevcrates"]; 851 852 fn root() -> PathBuf { 853 Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().expect("the repository root") 854 } 855 856 fn folders() -> Vec<PathBuf> { 857 let root = root(); 858 let mut found = vec![root.clone()]; 859 let mut index = 0; 860 while index < found.len() { 861 let entries = std::fs::read_dir(&found[index]).expect("a readable folder"); 862 for entry in entries.flatten() { 863 let path = entry.path(); 864 let relative = path.strip_prefix(&root).unwrap_or(&path).to_string_lossy().into_owned(); 865 if path.is_dir() && !NOT_CHAPTERS.iter().any(|skip| relative == *skip || relative.ends_with(&format!("/{skip}"))) { 866 found.push(path); 867 } 868 } 869 index += 1; 870 } 871 found 872 } 873 874 #[test] 875 fn every_folder_is_a_chapter_with_a_page_for_people_and_one_for_agents() { 876 for folder in folders() { 877 let readme = std::fs::read_to_string(folder.join("README.md")); 878 let readme = readme.unwrap_or_else(|_| panic!("{} has no README.md", folder.display())); 879 assert!(readme.starts_with("# "), "{}: a README starts with its title", folder.display()); 880 let claude = std::fs::read_to_string(folder.join("CLAUDE.md")); 881 let claude = claude.unwrap_or_else(|_| panic!("{} has no CLAUDE.md", folder.display())); 882 assert!(claude.starts_with("@README.md"), "{}: a CLAUDE.md imports its README first", folder.display()); 883 assert!(readme.contains("Next:"), "{}: a chapter ends by leading on", folder.display()); 884 } 885 } 886 887 /// `/apps/lmjtfy/` as the guide line's link resolves under base `""`, 888 /// back to the folder it names. 889 fn folder_of(address: &str) -> String { 890 address.trim_matches('/').to_owned() 891 } 892 893 /// The viewer's arrows follow the chapters' own guide lines, so those 894 /// lines must be a depth-first walk: every chapter once, each folder 895 /// before all of its subfolders, and a folder's subtree finished before 896 /// its next sibling. The chain runs on into jevcrates' guide when the 897 /// submodule is checked out. 898 #[test] 899 fn the_chapters_lead_on_depth_first_through_every_folder() { 900 let root = root(); 901 let mut chain = vec![String::new()]; 902 loop { 903 let at = chain.last().expect("a chapter"); 904 let Ok(text) = std::fs::read_to_string(root.join(at).join("README.md")) else { break }; 905 let guide = tree::guide(&text, at, ""); 906 if let (Some((_, previous)), Some(before)) = (&guide.previous, chain.len().checked_sub(2).map(|i| &chain[i])) { 907 assert_eq!(&folder_of(previous), before, "{at}: Previous is not the chapter that led here"); 908 } 909 let Some((_, next)) = guide.next else { break }; 910 let next = folder_of(&next); 911 assert!(!chain.contains(&next), "{at}: Next leads back to {next}"); 912 chain.push(next); 913 } 914 let under = |folder: &str, path: &str| folder.is_empty() || path.starts_with(&format!("{folder}/")); 915 for (index, folder) in chain.iter().enumerate() { 916 let after = &chain[index + 1..]; 917 let inside = after.iter().take_while(|path| under(folder, path)).count(); 918 assert!( 919 !after[inside..].iter().any(|path| under(folder, path)), 920 "{folder}: its subfolders are not all read before the guide moves on: {chain:?}" 921 ); 922 } 923 for folder in folders() { 924 let relative = folder.strip_prefix(&root).expect("under the root").to_string_lossy().into_owned(); 925 assert!(chain.contains(&relative), "{relative} is not on the guide's way: {chain:?}"); 926 } 927 } 928 929 #[test] 930 fn every_relative_link_in_a_chapter_goes_somewhere() { 931 for folder in folders() { 932 let readme = std::fs::read_to_string(folder.join("README.md")).unwrap_or_default(); 933 for target in readme.split("](").skip(1).filter_map(|rest| rest.split(')').next()) { 934 let external = target.contains("://") || target.starts_with('#') || target.starts_with("mailto:"); 935 if external || target.is_empty() { 936 continue; 937 } 938 let path = target.split(['#', '?']).next().unwrap_or(target); 939 assert!(folder.join(path).exists(), "{}: the link `{target}` goes nowhere", folder.join("README.md").display()); 940 } 941 } 942 } 943}