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