lmjtfy.git / apps / lmjtfy / src / view / code.rs
code.rsannotatedcode.rssource943 lines · 42.9 KB · raw
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 &sections {
540                div .sec {
541                    div .prose.md { (PreEscaped(tree::markdown(&section.prose, dir, &frame.base()).html)) }
542                    div .source { pre { @for line in &section.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("&lt;b&gt;"));
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 = &quot;https://x/jevcrates.git&quot;, rev = &quot;4651441abc&quot; }"), "{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 = &quot;https://x/jevcrates.git&quot; }"));
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 &lt; 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&amp;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}