forks/sorcery

git clone https://git.gae.moe/forks/sorcery

Grace Yodertypst: gate behind feature flag37af77a

typst_support
64.6 KiB1745 linesraw
1use std::collections::BTreeMap;
2use std::fmt::Write as _;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7
8use anyhow::{Context, Result};
9// Safe for text and double-quoted attribute contexts.
10use html_escape::encode_double_quoted_attribute as escape;
11use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, PercentEncode, percent_encode};
12
13use crate::catalog::Repository;
14use crate::highlight::{Cache as GrammarCache, Highlighter};
15
16/// Blobs larger than this get a raw download instead of a rendered page.
17const MAX_RENDER_BYTES: u64 = 1 << 20;
18
19/// Cooperative cancellation for background builds; set when a
20/// human-triggered build wants the machine instead.
21pub(crate) type BuildCancel = Arc<AtomicBool>;
22
23pub(crate) fn bail_if_cancelled(cancel: &Option<BuildCancel>) -> Result<()> {
24    if cancel
25        .as_ref()
26        .is_some_and(|flag| flag.load(Ordering::Relaxed))
27    {
28        anyhow::bail!("site build cancelled");
29    }
30    Ok(())
31}
32
33const PATH: &AsciiSet = &NON_ALPHANUMERIC
34    .remove(b'-')
35    .remove(b'.')
36    .remove(b'_')
37    .remove(b'~')
38    .remove(b'/');
39
40/// Percent-encodes a slash-separated path for use in a URI.
41pub(crate) fn encode_path(path: &str) -> PercentEncode<'_> {
42    percent_encode(path.as_bytes(), PATH)
43}
44
45/// `tip` names the page's ref and the commit it points at; the client reads
46/// them from `<main data-ref data-tip>` to resolve hash routes relative to
47/// the page.
48fn page(
49    instance_name: &str,
50    title: &str,
51    description: &str,
52    tip: Option<(&str, gix::ObjectId)>,
53    body: &str,
54) -> String {
55    format!(
56        "<!doctype html>\n\
57         <html lang=\"en\">\n\
58         <head>\n\
59         <meta charset=\"utf-8\">\n\
60         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
61         <title>{title}</title>\n\
62         <meta name=\"description\" content=\"{description}\">\n\
63         <meta property=\"og:title\" content=\"{title}\">\n\
64         <meta property=\"og:description\" content=\"{description}\">\n\
65         <meta property=\"og:type\" content=\"website\">\n\
66         <meta property=\"og:site_name\" content=\"{instance_name}\">\n\
67         <link rel=\"stylesheet\" href=\"/css/style.css?v={format_version}\">\n\
68         <script type=\"module\" src=\"/js/main.js?v={format_version}\"></script>\n\
69         </head>\n\
70         <body>\n<main{tip}>\n{noscript}{body}</main>\n</body>\n\
71         </html>\n",
72        instance_name = escape(instance_name),
73        title = escape(title),
74        description = escape(description),
75        format_version = crate::generate::OUTPUT_FORMAT_VERSION,
76        tip = tip
77            .map(|(name, oid)| format!(" data-ref=\"{}\" data-tip=\"{oid}\"", escape(name)))
78            .unwrap_or_default(),
79        // only repo pages lose anything to a missing client
80        noscript = if tip.is_some() { NOSCRIPT } else { "" },
81    )
82}
83
84const NOSCRIPT: &str = r#"<noscript>
85  <div class="callout">
86    <p>
87      hey! this is a static git repo viewer. commit diffs, file history,
88      older commits and other revisions won't load without javascript.
89    </p>
90    <p>
91      you are still able to view source files on branch tips,
92      but the experience is degraded.
93    </p>
94  </div>
95</noscript>
96"#;
97
98pub(crate) fn catalog_index(instance_name: &str, repositories: &[Repository]) -> String {
99    let mut body = String::from("<h1>repositories</h1>\n");
100    if repositories.is_empty() {
101        body.push_str("<p class=\"meta\">no repositories found</p>\n");
102    }
103
104    for repos in repositories.chunk_by(|a, b| a.user == b.user) {
105        let user = &repos[0].user;
106        let _ = writeln!(
107            body,
108            "<h2><a href=\"/{}/\">{}</a></h2>",
109            encode_path(user),
110            escape(user),
111        );
112        catalog_list(&mut body, repos);
113    }
114
115    let title = format!("repositories - {instance_name}");
116    let description = format!("Git repositories hosted on {instance_name}.");
117    page(instance_name, &title, &description, None, &body)
118}
119
120/// `repositories` must all belong to `user`.
121pub(crate) fn user_index(instance_name: &str, user: &str, repositories: &[Repository]) -> String {
122    let mut body = format!("<h1>{}</h1>\n", escape(user));
123    catalog_list(&mut body, repositories);
124    let title = format!("{user} - {instance_name}");
125    let description = format!("Git repositories by {user} on {instance_name}.");
126    page(instance_name, &title, &description, None, &body)
127}
128
129fn catalog_list(body: &mut String, repositories: &[Repository]) {
130    body.push_str("<ul class=\"catalog\">\n");
131    for repo in repositories {
132        let _ = writeln!(
133            body,
134            "<li><a href=\"/{user}/{repo}/\"><span class=\"repo-name\">{repo_name}</span> <span class=\"msg\">{description}</span>{updated}</a></li>",
135            user = encode_path(&repo.user),
136            repo = encode_path(&repo.name),
137            repo_name = escape(&repo.name),
138            description = escape(repo.description.as_deref().unwrap_or_default()),
139            updated = repo
140                .updated
141                .map(|t| {
142                    format!(
143                        " <time datetime=\"{}\">{}</time>",
144                        t.format_or_unix(gix::date::time::format::ISO8601_STRICT),
145                        t.format_or_unix(gix::date::time::format::SHORT),
146                    )
147                })
148                .unwrap_or_default(),
149        );
150    }
151    body.push_str("</ul>\n");
152}
153
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub enum RefKind {
156    Branch,
157    Tag,
158}
159
160#[derive(Clone, Copy, PartialEq, Eq)]
161pub enum StaticMode {
162    Highlighted,
163    /// Blob pages carry escaped plain text; the client highlights them.
164    Plain,
165}
166
167#[derive(Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
168pub(crate) struct BlobVersion {
169    pub oid: String,
170    pub mode: u16,
171    pub touched: String,
172}
173
174#[derive(Clone, Copy)]
175pub(crate) struct BlobReuse<'a> {
176    pub versions: &'a BTreeMap<String, BlobVersion>,
177    pub previous: Option<(&'a Path, &'a BTreeMap<String, BlobVersion>)>,
178}
179
180pub struct Site<'a> {
181    pub repo: &'a gix::Repository,
182    pub instance_name: String,
183    pub name: String,
184    pub base_url: String,
185    pub description: Option<String>,
186    pub clone_url: Option<String>,
187}
188
189impl Site<'_> {
190    /// Display names like `user/repo` shorten to the repo part in crumbs.
191    fn crumb_name(&self) -> &str {
192        self.name.rsplit('/').next().unwrap_or(&self.name)
193    }
194
195    fn ref_href(&self, tip: &RefTip) -> String {
196        match tip.static_mode {
197            None => format!("{}#{}", self.base_url, tip.commit_id),
198            Some(_) => {
199                format!("{}ref/{}/", self.base_url, encode_path(&tip.name))
200            }
201        }
202    }
203
204    /// Name, description and clone command; the same on every page of the repo.
205    fn repo_header(&self) -> String {
206        let mut html = format!("<header class=\"repo\"><div>\n<h1>{}</h1>\n", escape(&self.name));
207        if let Some(desc) = &self.description {
208            let _ = writeln!(html, "<p class=\"desc\">{}</p>", escape(desc));
209        }
210        html.push_str("</div>\n");
211        if let Some(url) = &self.clone_url {
212            let _ = writeln!(html, "<pre class=\"clone\">git clone {}</pre>", escape(url));
213        }
214        html.push_str("</header>\n");
215        html
216    }
217}
218
219/// Author, summary, short sha and date of one commit, as a status line.
220fn commit_panel(repo: &gix::Repository, oid: gix::ObjectId) -> String {
221    let Ok(commit) = repo.find_object(oid).map(|o| o.try_into_commit()) else {
222        return String::new();
223    };
224    let Ok(commit) = commit else {
225        return String::new();
226    };
227    let summary = commit.message().map(|m| m.summary().to_string()).unwrap_or_default();
228    let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default();
229    format!(
230        "<p class=\"snapshot-commit\">\
231         <span class=\"commit-author\">{author}</span>\
232         <span class=\"commit-message\" data-commit=\"{oid}\">{summary}</span>\
233         <span class=\"snapshot-sha sha\" data-commit=\"{oid}\">{short}</span>\
234         {time}</p>\n",
235        author = escape(&author),
236        summary = escape(&summary),
237        short = commit.id().shorten_or_id(),
238        time = time_of(&commit),
239    )
240}
241
242pub struct RefTip {
243    pub kind: RefKind,
244    pub name: String,
245    pub commit_id: gix::ObjectId,
246    pub static_mode: Option<StaticMode>,
247}
248
249/// All local branches and tags, peeled to commits. Refs that don't peel to a
250/// commit (e.g. tags of blobs) are skipped.
251pub fn list_refs(repo: &gix::Repository) -> Result<Vec<RefTip>> {
252    let platform = repo.references()?;
253    let mut tips: Vec<RefTip> = Vec::new();
254    for (kind, iter) in [
255        (RefKind::Branch, platform.local_branches()?),
256        (RefKind::Tag, platform.tags()?),
257    ] {
258        for r in iter {
259            let mut r = r.map_err(|e| anyhow::anyhow!("failed to iterate refs: {e}"))?;
260            let name = r.name().shorten().to_string();
261            // A branch and tag may share a short name. The flat /ref/<name>
262            // namespace gives the branch precedence.
263            if kind == RefKind::Tag
264                && tips.iter().any(|t| t.kind == RefKind::Branch && t.name == name)
265            {
266                continue;
267            }
268            if let Ok(commit) = r.peel_to_commit() {
269                tips.push(RefTip {
270                    kind,
271                    name,
272                    commit_id: commit.id,
273                    static_mode: None,
274                });
275            }
276        }
277    }
278    Ok(tips)
279}
280
281/// The branch HEAD points at, if it exists among `tips` (falling back to the
282/// first branch, matching forge behaviour for detached/unborn HEADs).
283pub fn head_tip<'a>(repo: &gix::Repository, tips: &'a [RefTip]) -> Option<&'a RefTip> {
284    let head = repo.head_name().ok().flatten().map(|n| n.shorten().to_string());
285    tips.iter()
286        .find(|t| t.kind == RefKind::Branch && Some(&t.name) == head.as_ref())
287        .or_else(|| tips.iter().find(|t| t.kind == RefKind::Branch))
288}
289
290pub fn write_file(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
291    if let Some(parent) = path.parent() {
292        fs::create_dir_all(parent)?;
293    }
294    fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
295}
296
297/// Renders the full static view of one ref (tree pages, blob pages, raw files
298/// for binaries) into `out`, which is typically a staging dir later swapped
299/// into place at `<site>/ref/<name>/`.
300pub fn render_ref(
301    site: &Site,
302    tips: &[RefTip],
303    tip: &RefTip,
304    commit: &gix::Commit,
305    out: &Path,
306    reuse: Option<BlobReuse<'_>>,
307    highlights: Arc<GrammarCache>,
308    cancel: Option<BuildCancel>,
309) -> Result<()> {
310    let mut r = RefRenderer::new(site, tips, tip, commit, out, highlights.clone(), cancel.clone());
311    let tree = commit.tree()?;
312    let mut blobs = Vec::new();
313    r.walk(&tree, &mut Vec::new(), &mut blobs)?;
314
315    // Blob pages dominate build time and are independent of each other, so
316    // fan out; the repository is only ever read, so each worker gets its own
317    // thread-local handle and reusable highlighter. Page costs vary wildly
318    // (and cluster by directory), so workers pull from a shared counter
319    // rather than taking fixed shares.
320    let ctx = BlobContext {
321        instance_name: site.instance_name.clone(),
322        name: site.name.clone(),
323        base_url: site.base_url.clone(),
324        urlbase: r.urlbase.clone(),
325        repo_header: site.repo_header(),
326        label: tip.name.clone(),
327        tip_id: commit.id,
328        highlight: tip.static_mode != Some(StaticMode::Plain),
329        out: out.to_owned(),
330        reuse,
331        highlights,
332        cancel,
333    };
334    let repo = site.repo.clone().into_sync();
335    let next = AtomicUsize::new(0);
336    rayon::broadcast(|_| {
337        let repo = repo.to_thread_local();
338        let mut highlighter = Highlighter::new(ctx.highlights.clone());
339        while let Some(job) = blobs.get(next.fetch_add(1, Ordering::Relaxed)) {
340            if let Err(error) = bail_if_cancelled(&ctx.cancel)
341                .and_then(|()| blob_page(&ctx, &repo, &mut highlighter, job))
342            {
343                next.store(blobs.len(), Ordering::Relaxed);
344                return Err(error);
345            }
346        }
347        Ok(())
348    })
349    .into_iter()
350    .collect()
351}
352
353/// Everything a blob page needs besides the per-worker repository handle and
354/// highlighter.
355struct BlobContext<'a> {
356    instance_name: String,
357    name: String,
358    base_url: String,
359    urlbase: String,
360    repo_header: String,
361    label: String,
362    tip_id: gix::ObjectId,
363    highlight: bool,
364    out: PathBuf,
365    reuse: Option<BlobReuse<'a>>,
366    highlights: Arc<GrammarCache>,
367    cancel: Option<BuildCancel>,
368}
369
370struct BlobJob {
371    path: Vec<String>,
372    oid: gix::ObjectId,
373    is_link: bool,
374    /// Switcher and crumbs, prebuilt during the walk: they need ref-wide
375    /// state that the workers don't carry.
376    chrome: String,
377}
378
379fn blob_page(
380    ctx: &BlobContext,
381    repo: &gix::Repository,
382    hl: &mut Highlighter,
383    job: &BlobJob,
384) -> Result<()> {
385    let joined = job.path.join("/");
386    let rel = format!("blob/{joined}");
387    // Embedded images may change without the document blob changing.
388    if !is_markdown(&joined)
389        && !is_typst(&joined)
390        && let Some(reuse) = ctx.reuse
391        && let Some((previous, previous_versions)) = reuse.previous
392        && reuse_blob(
393            previous,
394            previous_versions,
395            reuse.versions,
396            &joined,
397            &ctx.out,
398        )
399    {
400        return Ok(());
401    }
402    let data = &repo.find_object(job.oid)?.data.to_vec();
403
404    let size = data.len() as u64;
405    let image = !job.is_link && image_mime(&joined).is_some();
406    let raw_url = format!("{}raw/{}/{}", ctx.base_url, job.oid, encode_path(&joined));
407    let looks_binary = data[..data.len().min(8192)].contains(&0);
408    let text = (!job.is_link && !image && !looks_binary && size <= MAX_RENDER_BYTES)
409        .then(|| String::from_utf8_lossy(data));
410    let lines = text.as_ref().map_or(0, |text| text.lines().count());
411    let stats = if job.is_link {
412        format!("<span>symlink \u{2192} {}</span>", escape(&String::from_utf8_lossy(data)))
413    } else if text.is_some() {
414        format!(
415            "<span>{}</span><span>{lines} line{}</span>",
416            human_size(size),
417            if lines == 1 { "" } else { "s" },
418        )
419    } else {
420        format!(
421            "<span>{}</span><span>{}</span>",
422            if image {
423                "image"
424            } else if looks_binary {
425                "binary file"
426            } else {
427                "large file"
428            },
429            human_size(size),
430        )
431    };
432    let touched = ctx
433        .reuse
434        .and_then(|reuse| reuse.versions.get(&joined))
435        .and_then(|version| gix::ObjectId::from_hex(version.touched.as_bytes()).ok())
436        .unwrap_or(ctx.tip_id);
437    let mut body = format!(
438        "{repo_header}{panel}<header class=\"topbar\">{chrome}<span class=\"view-stats\">{stats}</span>\
439         <span class=\"actions\"><a class=\"raw\" href=\"{raw_url}\">raw</a></span></header>\n",
440        repo_header = ctx.repo_header,
441        panel = commit_panel(repo, touched),
442        chrome = job.chrome,
443    );
444
445    if let Some(text) = text {
446        let name = job.path.last().expect("blob path is never empty");
447        // Plain mode defers highlighting to the client, which detects the
448        // language from the path carried in data-hl.
449        let (src_attrs, code) = if ctx.highlight {
450            let lang = crate::grammar_manifest::detect(name);
451            let code = lang
452                .and_then(|lang| hl.highlight_lines(lang, &text).ok())
453                .unwrap_or_else(|| text.lines().map(|line| escape(line).to_string()).collect());
454            let class = lang.map(|l| format!(" language-{l}")).unwrap_or_default();
455            (format!("class=\"code src{class}\""), code)
456        } else {
457            (
458                format!("class=\"code src\" data-hl=\"{}\"", escape(&joined)),
459                text.lines().map(|line| escape(line).to_string()).collect(),
460            )
461        };
462        let mut source = format!("<pre {src_attrs}>");
463        let line_count = code.len();
464        for (i, line) in code.into_iter().enumerate() {
465            let number = i + 1;
466            let _ = write!(
467                source,
468                "<span class=\"code-line\"><a class=\"ln\" id=\"L{number}\" href=\"#L{number}\">{number}</a><span class=\"code-text\">{line}</span>"
469            );
470            if number < line_count {
471                source.push_str("<span class=\"code-break\">\n</span>");
472            }
473            source.push_str("</span>");
474        }
475        source.push_str("</pre>");
476        if is_markdown(name) {
477            let urls = RelativeUrls {
478                repo,
479                base_url: &ctx.base_url,
480                urlbase: &ctx.urlbase,
481                commit: ctx.tip_id,
482                directory: &job.path[..job.path.len() - 1],
483            };
484            let rendered = markdown(hl, &text, |url, image| urls.resolve(url, image));
485            let _ = writeln!(
486                body,
487                "<div class=\"markdown-view\">\
488                 <input type=\"radio\" name=\"markdown-view\" id=\"markdown-rendered\" checked>\
489                 <label for=\"markdown-rendered\">rendered</label>\
490                 <input type=\"radio\" name=\"markdown-view\" id=\"markdown-code\">\
491                 <label for=\"markdown-code\">code</label>\
492                 <section class=\"readme rendered\">{rendered}</section>\
493                 <div class=\"code-panel\">{source}</div>\
494                 </div>",
495            );
496        } else if is_typst(name) {
497            let urls = RelativeUrls {
498                repo,
499                base_url: &ctx.base_url,
500                urlbase: &ctx.urlbase,
501                commit: ctx.tip_id,
502                directory: &job.path[..job.path.len() - 1],
503            };
504            let rendered = typst(hl, &text, |url, image| urls.resolve(url, image));
505            let _ = writeln!(
506                body,
507                "<div class=\"markdown-view\">\
508                 <input type=\"radio\" name=\"markdown-view\" id=\"markdown-rendered\" checked>\
509                 <label for=\"markdown-rendered\">rendered</label>\
510                 <input type=\"radio\" name=\"markdown-view\" id=\"markdown-code\">\
511                 <label for=\"markdown-code\">code</label>\
512                 <section class=\"readme rendered\">{rendered}</section>\
513                 <div class=\"code-panel\">{source}</div>\
514                 </div>",
515            );
516        } else {
517            let _ = writeln!(body, "{source}");
518        }
519    } else if image {
520        let _ = writeln!(
521            body,
522            "<div class=\"image-preview\"><img src=\"{}\" alt=\"{}\"></div>",
523            escape(&raw_url),
524            escape(&joined)
525        );
526    } else if !job.is_link {
527        let _ = writeln!(
528            body,
529            "<div class=\"file-download\"><p>This file cannot be previewed.</p><a href=\"{}\" download=\"{}\">download raw file</a></div>",
530            escape(&raw_url),
531            escape(job.path.last().expect("blob path is never empty"))
532        );
533    }
534
535    let title = format!("{} - {} @ {}", joined, ctx.name, ctx.label);
536    let description = format!("{joined} in {} at {}.", ctx.name, ctx.label);
537    write_file(
538        &ctx.out.join(&rel),
539        page(&ctx.instance_name, &title, &description, Some((&ctx.label, ctx.tip_id)), &body),
540    )
541}
542
543fn reuse_blob(
544    previous: &Path,
545    previous_versions: &BTreeMap<String, BlobVersion>,
546    versions: &BTreeMap<String, BlobVersion>,
547    path: &str,
548    out: &Path,
549) -> bool {
550    let Some(version) = versions.get(path) else {
551        return false;
552    };
553    if previous_versions.get(path) != Some(version) {
554        return false;
555    }
556    let previous = previous.join("blob").join(path);
557    let out = out.join("blob").join(path);
558    if !previous.is_file()
559        || out.parent().is_none_or(|parent| fs::create_dir_all(parent).is_err())
560    {
561        return false;
562    }
563    if fs::hard_link(&previous, &out).is_ok() || fs::copy(&previous, &out).is_ok() {
564        return true;
565    }
566    let _ = fs::remove_file(out);
567    false
568}
569
570/// The landing page: repo header plus the HEAD branch's root tree view, with
571/// entry links pointing into the branch's own pages under `ref/`.
572pub fn render_site_index(
573    site: &Site,
574    tips: &[RefTip],
575    out: &Path,
576    highlights: Arc<GrammarCache>,
577    cancel: Option<BuildCancel>,
578) -> Result<()> {
579    let mut body = format!("<a class=\"back\" href=\"/\">← back</a>\n{}", site.repo_header());
580
581    let head = head_tip(site.repo, tips);
582    match head {
583        None => body.push_str("<p class=\"meta\">no branches yet</p>\n"),
584        Some(tip) => {
585            let commit = site.repo.find_object(tip.commit_id)?.try_into_commit()?;
586            let tree = commit.tree()?;
587            let mut r = RefRenderer::new(site, tips, tip, &commit, out, highlights, cancel);
588            let entries = collect_entries(&tree)?;
589            body.push_str(&commit_panel(site.repo, tip.commit_id));
590            body.push_str(&r.tree_topbar(&[], &entries));
591            if tip.static_mode == Some(StaticMode::Highlighted) {
592                body.push_str(&render_languages(
593                    &crate::languages::analyze(site.repo, &tree)?,
594                    tip.commit_id,
595                ));
596            }
597            body.push_str(&r.overview(&entries));
598            body.push_str(&r.readme_section(&[], &entries));
599        }
600    }
601
602    let description = site
603        .description
604        .clone()
605        .unwrap_or_else(|| format!("{} repository on {}.", site.name, site.instance_name));
606    write_file(
607        &out.join("index.html"),
608        page(&site.instance_name, &site.name, &description, head.map(|tip| (tip.name.as_str(), tip.commit_id)), &body),
609    )
610}
611
612/// `data-tip` and `data-language` let the client turn each label into a
613/// link to that language's files at the tip.
614fn render_languages(stats: &[crate::languages::Stat], tip: gix::ObjectId) -> String {
615    let total = stats.iter().map(|stat| stat.bytes).sum::<u64>();
616    if total == 0 {
617        return String::new();
618    }
619
620    let mut html = format!(
621        "<details class=\"languages\" data-tip=\"{tip}\">\n<summary><span class=\"language-label\">languages</span>",
622    );
623    for stat in stats {
624        let percentage = stat.bytes as f64 * 100.0 / total as f64;
625        let _ = write!(
626            html,
627            "<span title=\"{} {:.1}%\" style=\"background:{};width:{percentage:.6}%\"></span>",
628            escape(stat.name),
629            percentage,
630            stat.color,
631        );
632    }
633    html.push_str("</summary>\n<ul>\n");
634    for stat in stats {
635        let percentage = stat.bytes as f64 * 100.0 / total as f64;
636        let _ = writeln!(
637            html,
638            "<li data-language=\"{}\"><i style=\"background:{}\"></i><span>{} <small>{:.1}%</small></span></li>",
639            stat.id,
640            stat.color,
641            escape(stat.name),
642            percentage,
643        );
644    }
645    html.push_str("</ul>\n</details>\n");
646    html
647}
648
649/// `/refs`: branch and tag tables with tip summaries and dates.
650pub fn render_refs_page(site: &Site, tips: &[RefTip], out: &Path) -> Result<()> {
651    let head = head_tip(site.repo, tips);
652    let mut body = format!(
653        "{}<header class=\"topbar\"><nav class=\"crumbs\"><a href=\"{}\">{}</a> / <span class=\"cur\">refs</span></nav></header>\n",
654        site.repo_header(),
655        site.base_url,
656        escape(site.crumb_name()),
657    );
658
659    for (kind, heading) in [(RefKind::Branch, "branches"), (RefKind::Tag, "tags")] {
660        // HEAD branch first, then alphabetical; tags newest first
661        let mut dated: Vec<_> = tips
662            .iter()
663            .filter(|t| t.kind == kind)
664            .map(|t| {
665                let commit = site
666                    .repo
667                    .find_object(t.commit_id)
668                    .map_err(anyhow::Error::from)
669                    .and_then(|o| Ok(o.try_into_commit()?));
670                let (summary, time, secs) = match &commit {
671                    Ok(c) => (
672                        c.message().map(|m| m.summary().to_string()).unwrap_or_default(),
673                        time_of(c),
674                        author_time(c).map(|t| t.seconds).unwrap_or(0),
675                    ),
676                    Err(_) => (String::new(), String::new(), 0),
677                };
678                (t, summary, time, secs)
679            })
680            .collect();
681        if dated.is_empty() {
682            continue;
683        }
684        match kind {
685            RefKind::Branch => dated.sort_by(|a, b| {
686                let is_head = |t: &RefTip| head.is_some_and(|head| head.name == t.name);
687                is_head(b.0).cmp(&is_head(a.0)).then_with(|| a.0.name.cmp(&b.0.name))
688            }),
689            RefKind::Tag => dated.sort_by_key(|d| std::cmp::Reverse(d.3)),
690        }
691
692        let _ = write!(body, "<h2>{heading}</h2>\n<table class=\"list\">\n");
693        for (tip, summary, time, _) in dated {
694            let _ = writeln!(
695                body,
696                "<tr><td><a href=\"{href}\">{name}</a></td><td class=\"msg\">{summary}</td><td class=\"date\">{time}</td></tr>",
697                href = site.ref_href(tip),
698                name = escape(&tip.name),
699                summary = escape(&summary),
700                time = time,
701            );
702        }
703        body.push_str("</table>\n");
704    }
705
706    let title = format!("refs - {}", site.name);
707    let description = format!("Branches and tags for {}.", site.name);
708    write_file(
709        &out.join("refs"),
710        page(&site.instance_name, &title, &description, head.map(|tip| (tip.name.as_str(), tip.commit_id)), &body),
711    )
712}
713
714struct RefRenderer<'a> {
715    site: &'a Site<'a>,
716    tips: &'a [RefTip],
717    out: &'a Path,
718    label: String,
719    /// Absolute URL prefix of this ref's pages, e.g. `/user/repo/ref/main/`.
720    urlbase: String,
721    tip_id: gix::ObjectId,
722    hl: Highlighter,
723    cancel: Option<BuildCancel>,
724}
725
726enum EntryKind {
727    Dir,
728    File,
729    Link,
730    Submodule,
731}
732
733type Entry = (String, EntryKind, gix::ObjectId);
734
735fn collect_entries(tree: &gix::Tree<'_>) -> Result<Vec<Entry>> {
736    let mut entries = Vec::new();
737    for entry in tree.iter() {
738        let entry = entry?;
739        let mode = entry.mode();
740        let kind = if mode.is_tree() {
741            EntryKind::Dir
742        } else if mode.is_link() {
743            EntryKind::Link
744        } else if mode.is_commit() {
745            EntryKind::Submodule
746        } else {
747            EntryKind::File
748        };
749        entries.push((entry.filename().to_string(), kind, entry.oid().to_owned()));
750    }
751    entries.sort_by(|a, b| {
752        let rank = |k: &EntryKind| !matches!(k, EntryKind::Dir);
753        rank(&a.1).cmp(&rank(&b.1)).then_with(|| a.0.cmp(&b.0))
754    });
755    Ok(entries)
756}
757
758/// `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone
759/// directories reads better as one link than as three clicks.
760fn collapse_lone_dirs(repo: &gix::Repository, name: &str, mut oid: gix::ObjectId) -> String {
761    let mut path = name.to_string();
762    while let Ok(tree) = repo.find_tree(oid) {
763        let mut entries = tree.iter();
764        match (entries.next(), entries.next()) {
765            (Some(Ok(only)), None) if only.mode().is_tree() => {
766                let _ = write!(path, "/{}", only.filename());
767                oid = only.oid().to_owned();
768            }
769            _ => break,
770        }
771    }
772    path
773}
774
775impl<'a> RefRenderer<'a> {
776    fn new(
777        site: &'a Site<'a>,
778        tips: &'a [RefTip],
779        tip: &RefTip,
780        commit: &gix::Commit,
781        out: &'a Path,
782        highlights: Arc<GrammarCache>,
783        cancel: Option<BuildCancel>,
784    ) -> Self {
785        RefRenderer {
786            site,
787            tips,
788            out,
789            label: tip.name.clone(),
790            urlbase: format!("{}ref/{}/", site.base_url, encode_path(&tip.name)),
791            tip_id: commit.id,
792            hl: Highlighter::new(highlights),
793            cancel,
794        }
795    }
796
797    fn walk(
798        &mut self,
799        tree: &gix::Tree<'_>,
800        path: &mut Vec<String>,
801        blobs: &mut Vec<BlobJob>,
802    ) -> Result<()> {
803        bail_if_cancelled(&self.cancel)?;
804        let entries = collect_entries(tree)?;
805        self.tree_page(path, &entries)?;
806
807        for (name, kind, oid) in entries {
808            path.push(name);
809            match kind {
810                EntryKind::Dir => {
811                    let subtree = self.site.repo.find_object(oid)?.try_into_tree()?;
812                    self.walk(&subtree, path, blobs)?;
813                }
814                EntryKind::File | EntryKind::Link => {
815                    blobs.push(BlobJob {
816                        chrome: format!("{}{}", self.switcher(), self.crumbs(path, true)),
817                        path: path.clone(),
818                        oid,
819                        is_link: matches!(kind, EntryKind::Link),
820                    });
821                }
822                EntryKind::Submodule => {}
823            }
824            path.pop();
825        }
826        Ok(())
827    }
828
829    fn tree_page(&mut self, path: &[String], entries: &[Entry]) -> Result<()> {
830        let rel = if path.is_empty() {
831            "index.html".to_string()
832        } else {
833            format!("tree/{}/index.html", path.join("/"))
834        };
835        let mut body = if path.is_empty() {
836            "<a class=\"back\" href=\"/\">← back</a>\n".to_string()
837        } else {
838            String::new()
839        };
840        body.push_str(&self.site.repo_header());
841        body.push_str(&commit_panel(self.site.repo, self.tip_id));
842        body.push_str(&self.tree_topbar(path, entries));
843        if path.is_empty() {
844            body.push_str(&self.overview(entries));
845        } else {
846            body.push_str("<h2 class=\"file-heading\">files</h2>\n");
847            body.push_str(&self.listing(path, entries));
848        }
849        body.push_str(&self.readme_section(path, entries));
850
851        let title = if path.is_empty() {
852            format!("{} @ {}", self.site.name, self.label)
853        } else {
854            format!("{}/ - {} @ {}", path.join("/"), self.site.name, self.label)
855        };
856        let description = if path.is_empty() {
857            format!("Source tree for {} at {}.", self.site.name, self.label)
858        } else {
859            format!(
860                "{} in {} at {}.",
861                path.join("/"),
862                self.site.name,
863                self.label,
864            )
865        };
866        write_file(
867            &self.out.join(&rel),
868            page(&self.site.instance_name, &title, &description, Some((&self.label, self.tip_id)), &body),
869        )
870    }
871
872    /// Root-page layout: file listing beside a recent-commits log.
873    fn overview(&self, entries: &[Entry]) -> String {
874        format!(
875            "<div class=\"overview\"><section class=\"files\"><h2>files</h2>\n{}</section><aside class=\"commits\">{}</aside></div>\n",
876            self.listing(&[], entries),
877            self.log_section(),
878        )
879    }
880
881    /// Recent commits from this ref's tip, jj-style: change id (from the
882    /// `change-id` commit header jj can write), short sha, summary.
883    fn log_section(&self) -> String {
884        const SHOWN: usize = 10;
885        let mut s = String::from("<h2 class=\"log-heading\">recent commits</h2>\n<ol class=\"log\">\n");
886        let mut shown = Vec::with_capacity(SHOWN);
887        let mut parents = Vec::new();
888        let mut truncated = false;
889        let Ok(walk) = self.site.repo.rev_walk([self.tip_id]).all() else {
890            return String::new();
891        };
892        for (n, info) in walk.flatten().enumerate() {
893            if n == SHOWN {
894                truncated = true;
895                break;
896            }
897            let Ok(commit) = self
898                .site
899                .repo
900                .find_object(info.id)
901                .map_err(anyhow::Error::from)
902                .and_then(|o| Ok(o.try_into_commit()?))
903            else {
904                continue;
905            };
906            shown.push(info.id);
907            parents.extend(commit.parent_ids().map(|id| id.detach()));
908            let change_id = commit
909                .decode()
910                .ok()
911                .and_then(|c| c.extra_headers().find("change-id").map(|v| v.to_string()));
912            let cid = change_id
913                .as_deref()
914                .map(|c| {
915                    format!(
916                        "<span class=\"cid\" data-commit=\"{}\">{}</span> ",
917                        info.id,
918                        escape(&c[..c.len().min(8)]),
919                    )
920                })
921                .unwrap_or_default();
922            let summary = commit.message().map(|m| m.summary().to_string()).unwrap_or_default();
923            let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default();
924            let _ = writeln!(
925                s,
926                "<li data-oid=\"{full}\">{cid}<span class=\"sha\" data-commit=\"{full}\">{sha}</span> <span class=\"who\"><span>{author}</span>{time}</span><span class=\"msg\">{summary}</span></li>",
927                full = info.id,
928                sha = commit.id().shorten_or_id(),
929                author = escape(&author),
930                time = time_of(&commit),
931                summary = escape(&summary),
932            );
933        }
934        s.push_str("</ol>\n");
935        if truncated {
936            let mut frontier = Vec::new();
937            for parent in parents {
938                if !shown.contains(&parent) && !frontier.contains(&parent) {
939                    frontier.push(parent);
940                }
941            }
942            let frontier = frontier
943                .iter()
944                .map(ToString::to_string)
945                .collect::<Vec<_>>()
946                .join(" ");
947            let _ = writeln!(
948                s,
949                "<p class=\"meta log-pagination\" data-log-frontier=\"{frontier}\">⋯ older commits not shown</p>",
950            );
951        }
952        s
953    }
954
955    fn listing(&self, path: &[String], entries: &[Entry]) -> String {
956        let mut sub = path.join("/");
957        if !sub.is_empty() {
958            sub.push('/');
959        }
960        let mut body = "<table class=\"list\">\n".to_string();
961        for (name, kind, oid) in entries {
962            match kind {
963                EntryKind::Dir => {
964                    let name = collapse_lone_dirs(self.site.repo, name, *oid);
965                    let _ = writeln!(
966                        body,
967                        "<tr><td><a href=\"{base}tree/{href}/\">{name}/</a></td><td class=\"size\"></td></tr>",
968                        base = self.urlbase,
969                        href = encode_path(&format!("{sub}{name}")),
970                        name = escape(&name),
971                    );
972                }
973                EntryKind::File | EntryKind::Link => {
974                    let size = self.site.repo.find_header(*oid).map(|h| h.size()).unwrap_or(0);
975                    let _ = writeln!(
976                        body,
977                        "<tr><td><a href=\"{base}blob/{href}\">{name}{sigil}</a></td><td class=\"size\">{size}</td></tr>",
978                        base = self.urlbase,
979                        href = encode_path(&format!("{sub}{name}")),
980                        name = escape(name),
981                        sigil = if matches!(kind, EntryKind::Link) { "@" } else { "" },
982                        size = human_size(size),
983                    );
984                }
985                EntryKind::Submodule => {
986                    let _ = writeln!(
987                        body,
988                        "<tr><td>{name} @ {oid:.8}</td><td class=\"size\"></td></tr>",
989                        name = escape(name),
990                    );
991                }
992            }
993        }
994        body.push_str("</table>\n");
995        body
996    }
997
998    fn readme_section(&mut self, path: &[String], entries: &[Entry]) -> String {
999        match find_readme(self.site.repo, entries) {
1000            Some((name, data)) => {
1001                let text = String::from_utf8_lossy(&data);
1002                let readme = if is_markdown(&name) {
1003                    let urls = RelativeUrls {
1004                        repo: self.site.repo,
1005                        base_url: &self.site.base_url,
1006                        urlbase: &self.urlbase,
1007                        commit: self.tip_id,
1008                        directory: path,
1009                    };
1010                    markdown(&mut self.hl, &text, |url, image| urls.resolve(url, image))
1011                } else if is_typst(&name) {
1012                    let urls = RelativeUrls {
1013                        repo: self.site.repo,
1014                        base_url: &self.site.base_url,
1015                        urlbase: &self.urlbase,
1016                        commit: self.tip_id,
1017                        directory: path,
1018                    };
1019                    typst(&mut self.hl, &text, |url, image| urls.resolve(url, image))
1020                } else {
1021                    format!("<pre>{}</pre>\n", escape(&text))
1022                };
1023                format!("<section class=\"readme\">\n{readme}</section>\n")
1024            }
1025            None => String::new(),
1026        }
1027    }
1028
1029    fn tree_topbar(&self, path: &[String], entries: &[Entry]) -> String {
1030        let folders = entries.iter().filter(|(_, kind, _)| matches!(kind, EntryKind::Dir)).count();
1031        let files = entries.len() - folders;
1032        let mut stats = String::from("<span class=\"view-stats\">");
1033        if folders > 0 {
1034            let _ = write!(stats, "<span>{folders} folder{}</span>", if folders == 1 { "" } else { "s" });
1035        }
1036        if files > 0 {
1037            let _ = write!(stats, "<span>{files} file{}</span>", if files == 1 { "" } else { "s" });
1038        }
1039        stats.push_str("</span>");
1040        // the empty actions slot is where the client puts the history link
1041        format!(
1042            "<header class=\"topbar\">{}{}{stats}<span class=\"actions\"></span></header>\n",
1043            self.switcher(),
1044            self.crumbs(path, false),
1045        )
1046    }
1047
1048    /// A no-JS `<details>` dropdown listing all refs. Baked at build time, so
1049    /// pages of refs untouched since a ref was added/deleted list it stale;
1050    /// the always-rebuilt index and refs pages stay fresh.
1051    fn switcher(&self) -> String {
1052        let mut s = format!(
1053            "<details class=\"switcher\"><summary>{}</summary><div class=\"menu\">",
1054            escape(&self.label),
1055        );
1056        for (kind, heading) in [(RefKind::Branch, "branches"), (RefKind::Tag, "tags")] {
1057            let mut group = self
1058                .tips
1059                .iter()
1060                .filter(|t| t.kind == kind && t.static_mode.is_some())
1061                .peekable();
1062            if group.peek().is_none() {
1063                continue;
1064            }
1065            let _ = write!(s, "<strong>{heading}</strong>");
1066            for t in group {
1067                let current = t.name == self.label;
1068                let _ = write!(
1069                    s,
1070                    "<a{class} href=\"{href}\">{name}</a>",
1071                    class = if current { " class=\"current\"" } else { "" },
1072                    href = self.site.ref_href(t),
1073                    name = escape(&t.name),
1074                );
1075            }
1076        }
1077        let _ = write!(
1078            s,
1079            "<a class=\"all\" href=\"{}refs\">all refs →</a></div></details>",
1080            self.site.base_url,
1081        );
1082        s
1083    }
1084
1085    /// Breadcrumb nav: site name / path components, all but the last linked.
1086    fn crumbs(&self, path: &[String], last_is_file: bool) -> String {
1087        let mut nav = format!(
1088            "<nav class=\"crumbs\"><a href=\"{href}\">{site}</a>",
1089            href = self.site.base_url,
1090            site = escape(self.site.crumb_name()),
1091        );
1092        for (i, comp) in path.iter().enumerate() {
1093            let is_last = i + 1 == path.len();
1094            if is_last && last_is_file {
1095                let _ = write!(nav, " / <span class=\"cur\">{}</span>", escape(comp));
1096            } else if is_last {
1097                let _ = write!(nav, " / <span class=\"cur\">{}</span> /", escape(comp));
1098            } else {
1099                let _ = write!(
1100                    nav,
1101                    " / <a href=\"{base}tree/{href}/\">{name}</a>",
1102                    base = self.urlbase,
1103                    href = encode_path(&path[..=i].join("/")),
1104                    name = escape(comp),
1105                );
1106            }
1107        }
1108        nav.push_str("</nav>\n");
1109        nav
1110    }
1111}
1112
1113/// Author time as a `<time>`: absolute in `datetime` and, on hover, in
1114/// `title`; date-only as text, which the client relativizes while recent.
1115/// The in-browser client also renders author time, so dates match site-wide.
1116fn time_of(commit: &gix::Commit<'_>) -> String {
1117    author_time(commit)
1118        .map(|t| {
1119            format!(
1120                "<time datetime=\"{}\" title=\"{}\">{}</time>",
1121                t.format_or_unix(gix::date::time::format::ISO8601_STRICT),
1122                t.format_or_unix(gix::date::time::format::ISO8601),
1123                t.format_or_unix(gix::date::time::format::SHORT),
1124            )
1125        })
1126        .unwrap_or_default()
1127}
1128
1129pub(crate) fn author_time(commit: &gix::Commit<'_>) -> Option<gix::date::Time> {
1130    commit.author().ok()?.time().ok()
1131}
1132
1133fn find_readme(repo: &gix::Repository, entries: &[Entry]) -> Option<(String, Vec<u8>)> {
1134    ["readme.md", "readme", "readme.txt", "readme.typ"].iter().find_map(|want| {
1135        if *want == "readme.typ" && !cfg!(feature = "typst") { return None; }
1136        entries.iter().find_map(|(name, kind, oid)| {
1137            (matches!(kind, EntryKind::File) && name.to_lowercase() == *want)
1138                .then(|| Some((name.clone(), repo.find_object(*oid).ok()?.data.to_vec())))
1139                .flatten()
1140        })
1141    })
1142}
1143
1144fn is_markdown(name: &str) -> bool {
1145    let name = name.to_lowercase();
1146    name.ends_with(".md") || name.ends_with(".markdown")
1147}
1148
1149fn is_typst(name: &str) -> bool {
1150    let name = name.to_lowercase();
1151    cfg!(feature = "typst") && name.ends_with(".typ")
1152}
1153
1154pub(crate) fn image_mime(path: &str) -> Option<&'static str> {
1155    match Path::new(path)
1156        .extension()?
1157        .to_str()?
1158        .to_ascii_lowercase()
1159        .as_str()
1160    {
1161        "png" => Some("image/png"),
1162        "jpg" | "jpeg" => Some("image/jpeg"),
1163        "gif" => Some("image/gif"),
1164        "webp" => Some("image/webp"),
1165        "avif" => Some("image/avif"),
1166        "bmp" => Some("image/bmp"),
1167        "ico" => Some("image/x-icon"),
1168        _ => None,
1169    }
1170}
1171
1172/// Corrects a markdown file's relative URLs :D
1173struct RelativeUrls<'a> {
1174    repo: &'a gix::Repository,
1175    base_url: &'a str,
1176    urlbase: &'a str,
1177    commit: gix::ObjectId,
1178    directory: &'a [String],
1179}
1180
1181impl RelativeUrls<'_> {
1182    fn resolve(&self, url: &str, image: bool) -> Option<String> {
1183        if url.starts_with(['/', '#']) || url.contains(':') {
1184            return None;
1185        }
1186        let end = url.find(['?', '#']).unwrap_or(url.len());
1187        let suffix = &url[end..];
1188        let decoded = percent_encoding::percent_decode_str(&url[..end])
1189            .decode_utf8()
1190            .ok()?;
1191        let mut path = self.directory.to_vec();
1192        for part in decoded.split('/') {
1193            match part {
1194                "" | "." => {}
1195                ".." => {
1196                    path.pop()?;
1197                }
1198                part => path.push(part.to_string()),
1199            }
1200        }
1201        if path.is_empty() {
1202            return (!image).then(|| format!("{}{suffix}", self.urlbase));
1203        }
1204        let joined = path.join("/");
1205        let tree = self.repo.find_commit(self.commit).ok()?.tree().ok()?;
1206        let entry = tree.lookup_entry_by_path(&joined).ok()??;
1207        let mode = entry.mode();
1208        let href = encode_path(&joined);
1209        if image {
1210            mode.is_blob()
1211                .then(|| format!("{}raw/{}/{href}{suffix}", self.base_url, entry.id()))
1212        } else if mode.is_tree() {
1213            Some(format!("{}tree/{href}/{suffix}", self.urlbase))
1214        } else if mode.is_blob_or_symlink() {
1215            Some(format!("{}blob/{href}{suffix}", self.urlbase))
1216        } else {
1217            None
1218        }
1219    }
1220}
1221
1222fn markdown(
1223    hl: &mut Highlighter,
1224    src: &str,
1225    mut resolve: impl FnMut(&str, bool) -> Option<String>,
1226) -> String {
1227    use pulldown_cmark::{html, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
1228    let opts = Options::ENABLE_TABLES
1229        | Options::ENABLE_STRIKETHROUGH
1230        | Options::ENABLE_FOOTNOTES
1231        | Options::ENABLE_TASKLISTS;
1232    // Buffer fenced code blocks so they get the same arborium treatment as
1233    // blob pages; escape raw HTML rather than pulling in a sanitizer.
1234    let mut fence: Option<(Option<&'static str>, String)> = None;
1235    let events = Parser::new_ext(src, opts).filter_map(|ev| match ev {
1236        Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
1237            // only the first word names the language: ```rust,ignore
1238            let lang = info
1239                .split(|c: char| c == ',' || c.is_whitespace())
1240                .next()
1241                .and_then(crate::grammar_manifest::canonical);
1242            fence = Some((lang, String::new()));
1243            None
1244        }
1245        Event::Text(t) if fence.is_some() => {
1246            fence.as_mut().expect("checked in guard").1.push_str(&t);
1247            None
1248        }
1249        Event::End(TagEnd::CodeBlock) if fence.is_some() => {
1250            let (lang, code) = fence.take().expect("checked in guard");
1251            let class = lang.map(|l| format!(" class=\"language-{l}\"")).unwrap_or_default();
1252            let code = lang
1253                .and_then(|lang| hl.highlight(lang, &code).ok())
1254                .unwrap_or_else(|| escape(&code).to_string());
1255            Some(Event::Html(format!("<pre{class}>{code}</pre>\n").into()))
1256        }
1257        Event::Html(s) => Some(Event::Text(s)),
1258        Event::Start(Tag::Link {
1259            link_type,
1260            dest_url,
1261            title,
1262            id,
1263        }) => {
1264            let dest_url = resolve(&dest_url, false).map(Into::into).unwrap_or(dest_url);
1265            Some(Event::Start(Tag::Link {
1266                link_type,
1267                dest_url,
1268                title,
1269                id,
1270            }))
1271        }
1272        Event::Start(Tag::Image {
1273            link_type,
1274            dest_url,
1275            title,
1276            id,
1277        }) => {
1278            let dest_url = resolve(&dest_url, true).map(Into::into).unwrap_or(dest_url);
1279            Some(Event::Start(Tag::Image {
1280                link_type,
1281                dest_url,
1282                title,
1283                id,
1284            }))
1285        }
1286        Event::InlineHtml(s) => Some(Event::Text(s)),
1287        ev => Some(ev),
1288    });
1289    let mut out = String::new();
1290    html::push_html(&mut out, events);
1291    out
1292}
1293
1294#[cfg(not(feature = "typst"))]
1295fn typst(
1296    _hl: &mut Highlighter,
1297    src: &str,
1298    _resolve: impl FnMut(&str, bool) -> Option<String>,
1299) -> String {
1300    format!("<pre>{}</pre>\n", escape(src))
1301}
1302
1303#[cfg(feature = "typst")]
1304fn typst(
1305    hl: &mut Highlighter,
1306    src: &str,
1307    mut resolve: impl FnMut(&str, bool) -> Option<String>,
1308) -> String {
1309    use typst_syntax::{ast::{self, AstNode, Expr}, parse};
1310
1311    fn image(call: ast::FuncCall<'_>) -> Option<(String, String)> {
1312        let Expr::Ident(name) = call.callee() else { return None };
1313        if name.get() != "image" { return None; }
1314        let mut path = None;
1315        let mut alt = String::new();
1316        for arg in call.args().items() {
1317            match arg {
1318                ast::Arg::Pos(Expr::Str(value)) if path.is_none() => path = Some(value.get().to_string()),
1319                ast::Arg::Named(named) if named.name().get() == "alt" => {
1320                    let Expr::Str(value) = named.expr() else { return None };
1321                    alt = value.get().to_string();
1322                }
1323                // Layout options do not affect the HTML preview.
1324                ast::Arg::Named(named) if matches!(named.name().get().as_str(), "width" | "height" | "fit") => {}
1325                _ => return None,
1326            }
1327        }
1328        Some((path?, alt))
1329    }
1330
1331    fn markup(value: ast::Markup<'_>, hl: &mut Highlighter, resolve: &mut impl FnMut(&str, bool) -> Option<String>, paragraphs: bool) -> String {
1332        let mut out = String::new();
1333        let mut paragraph = false;
1334        let mut list = "";
1335        for expr in value.exprs() {
1336            let list_kind = match expr {
1337                Expr::ListItem(_) => "ul",
1338                Expr::EnumItem(_) => "ol",
1339                Expr::TermItem(_) => "dl",
1340                _ => "",
1341            };
1342            if matches!(expr, Expr::Space(_) | Expr::Parbreak(_)) && !list.is_empty() {
1343                continue;
1344            }
1345            if list != list_kind && !list.is_empty() {
1346                let _ = writeln!(out, "</{list}>");
1347                list = "";
1348            }
1349            let block = !list_kind.is_empty() || matches!(expr, Expr::Heading(_) | Expr::Parbreak(_))
1350                || matches!(expr, Expr::Raw(raw) if raw.block());
1351            if block && paragraph {
1352                out.push_str("</p>\n");
1353                paragraph = false;
1354            }
1355            if !list_kind.is_empty() && list.is_empty() {
1356                list = list_kind;
1357                let _ = writeln!(out, "<{list}>");
1358            }
1359            if !block && !paragraph && paragraphs {
1360                if matches!(expr, Expr::Space(_)) { continue; }
1361                out.push_str("<p>");
1362                paragraph = true;
1363            }
1364            match expr {
1365                Expr::Text(text) => out.push_str(&escape(text.get())),
1366                Expr::Space(_) => out.push(' '),
1367                Expr::Parbreak(_) => {},
1368                Expr::Linebreak(_) => out.push_str("<br>\n"),
1369                Expr::Escape(value) => out.push_str(&escape(&value.get().to_string())),
1370                Expr::Shorthand(value) => out.push_str(&escape(&value.get().to_string())),
1371                Expr::Strong(value) => {
1372                    let body = markup_inline(value.body(), hl, resolve);
1373                    let _ = write!(out, "<strong>{body}</strong>");
1374                }
1375                Expr::Emph(value) => {
1376                    let body = markup_inline(value.body(), hl, resolve);
1377                    let _ = write!(out, "<em>{body}</em>");
1378                }
1379                Expr::Heading(value) => {
1380                    let level = value.depth().get().min(6);
1381                    let body = markup_inline(value.body(), hl, resolve);
1382                    let _ = writeln!(out, "<h{level}>{body}</h{level}>");
1383                }
1384                Expr::ListItem(value) => {
1385                    let body = markup(value.body(), hl, resolve, true);
1386                    let _ = writeln!(out, "<li>{body}</li>");
1387                }
1388                Expr::EnumItem(value) => {
1389                    let number = value.number().map(|n| format!(" value=\"{n}\"")).unwrap_or_default();
1390                    let body = markup(value.body(), hl, resolve, true);
1391                    let _ = writeln!(out, "<li{number}>{body}</li>");
1392                }
1393                Expr::TermItem(value) => {
1394                    let term = markup_inline(value.term(), hl, resolve);
1395                    let body = markup(value.description(), hl, resolve, true);
1396                    let _ = writeln!(out, "<dt>{term}</dt><dd>{body}</dd>");
1397                }
1398                Expr::Link(value) => {
1399                    let target = value.get().as_str();
1400                    let href = resolve(target, false).unwrap_or_else(|| target.to_owned());
1401                    let _ = write!(out, "<a href=\"{}\">{}</a>", escape(&href), escape(target));
1402                }
1403                Expr::Raw(raw) => {
1404                    let code = raw.lines().map(|line| line.get().as_str()).collect::<Vec<_>>().join("\n");
1405                    if raw.block() {
1406                        let lang = raw.lang().and_then(|lang| crate::grammar_manifest::canonical(lang.get()));
1407                        let class = lang.map(|lang| format!(" class=\"language-{lang}\"")).unwrap_or_default();
1408                        let code = lang.and_then(|lang| hl.highlight(lang, &code).ok()).unwrap_or_else(|| escape(&code).to_string());
1409                        let _ = writeln!(out, "<pre{class}>{code}</pre>");
1410                    } else {
1411                        let _ = write!(out, "<code>{}</code>", escape(&code));
1412                    }
1413                }
1414                Expr::FuncCall(call) if image(call).is_some() => {
1415                    let (path, alt) = image(call).unwrap();
1416                    let url = resolve(&path, true).unwrap_or(path);
1417                    let _ = write!(out, "<img src=\"{}\" alt=\"{}\">", escape(&url), escape(&alt));
1418                }
1419                Expr::SmartQuote(_) => out.push_str(&escape(&expr.to_untyped().full_text())),
1420                _ => {
1421                    let source = expr.to_untyped().full_text();
1422                    let prefix = if matches!(expr, Expr::Equation(_) | Expr::Label(_) | Expr::Ref(_)) { "" } else { "#" };
1423                    let _ = write!(out, "<code>{prefix}{}</code>", escape(&source));
1424                }
1425            }
1426        }
1427        if paragraph { out.push_str("</p>\n"); }
1428        if !list.is_empty() { let _ = writeln!(out, "</{list}>"); }
1429        out
1430    }
1431
1432    fn markup_inline(value: ast::Markup<'_>, hl: &mut Highlighter, resolve: &mut impl FnMut(&str, bool) -> Option<String>) -> String {
1433        markup(value, hl, resolve, false)
1434    }
1435
1436    let tree = parse(src);
1437    if tree.errors_and_warnings().0.is_empty() {
1438        if let Some(root) = tree.cast::<ast::Markup>() {
1439            return markup(root, hl, &mut resolve, true);
1440        }
1441    }
1442    format!("<pre>{}</pre>\n", escape(src))
1443}
1444
1445fn human_size(bytes: u64) -> String {
1446    const UNITS: &[&str] = &["KiB", "MiB", "GiB", "TiB"];
1447    if bytes < 1024 {
1448        return format!("{bytes} B");
1449    }
1450    let mut size = bytes as f64;
1451    let mut unit = "";
1452    for u in UNITS {
1453        size /= 1024.0;
1454        unit = u;
1455        if size < 1024.0 {
1456            break;
1457        }
1458    }
1459    format!("{size:.1} {unit}")
1460}
1461
1462#[cfg(test)]
1463mod tests {
1464    use std::collections::BTreeMap;
1465    use std::fs;
1466    use std::os::unix::fs::symlink;
1467
1468    use anyhow::Result;
1469
1470    use crate::highlight::Highlighter;
1471    use crate::testutil::{TempDir, commit, git, grammar_cache, init_repo};
1472
1473    use super::{
1474        BlobVersion, EntryKind, RefKind, collapse_lone_dirs, collect_entries, encode_path,
1475        find_readme, head_tip, human_size, list_refs, markdown, reuse_blob, time_of, typst,
1476    };
1477
1478    #[test]
1479    fn encodes_url_paths() {
1480        assert_eq!(encode_path("a b/c?d#\u{e9}").to_string(), "a%20b/c%3Fd%23%C3%A9");
1481    }
1482
1483    #[test]
1484    fn human_sizes_switch_units_at_1024() {
1485        assert_eq!(human_size(0), "0 B");
1486        assert_eq!(human_size(1023), "1023 B");
1487        assert_eq!(human_size(1024), "1.0 KiB");
1488        assert_eq!(human_size(1536), "1.5 KiB");
1489        assert_eq!(human_size(1 << 20), "1.0 MiB");
1490        assert_eq!(human_size(1 << 40), "1.0 TiB");
1491        assert_eq!(human_size(1 << 50), "1024.0 TiB");
1492    }
1493
1494    #[test]
1495    fn commit_time_carries_absolute_timestamp() -> Result<()> {
1496        let root = TempDir::new("time-of");
1497        let repo_path = root.join("repo");
1498        init_repo(&repo_path)?;
1499        fs::write(repo_path.join("file"), "")?;
1500        git(&repo_path, &["add", "-A"])?;
1501        git(&repo_path, &["commit", "-qm", "first", "--date=@978307200 +0000"])?;
1502        let repo = gix::open(&repo_path)?;
1503        let commit = repo.head_commit()?;
1504        assert_eq!(
1505            time_of(&commit),
1506            "<time datetime=\"2001-01-01T00:00:00+00:00\" title=\"2001-01-01 00:00:00 +0000\">2001-01-01</time>",
1507        );
1508        Ok(())
1509    }
1510
1511    #[test]
1512    fn tree_entries_list_directories_first_then_by_name() -> Result<()> {
1513        let root = TempDir::new("entries");
1514        let repo_path = root.join("repo");
1515        init_repo(&repo_path)?;
1516        for dir in ["zeta", "alpha"] {
1517            fs::create_dir(repo_path.join(dir))?;
1518            fs::write(repo_path.join(dir).join("keep"), "")?;
1519        }
1520        fs::write(repo_path.join("beta"), "")?;
1521        fs::write(repo_path.join("aardvark"), "")?;
1522        symlink("beta", repo_path.join("gamma"))?;
1523        commit(&repo_path, "first")?;
1524
1525        let repo = gix::open(&repo_path)?;
1526        let entries = collect_entries(&repo.head_commit()?.tree()?)?;
1527        let names: Vec<_> = entries.iter().map(|(name, ..)| name.as_str()).collect();
1528        assert_eq!(names, ["alpha", "zeta", "aardvark", "beta", "gamma"]);
1529        assert!(matches!(entries[1].1, EntryKind::Dir));
1530        assert!(matches!(entries[2].1, EntryKind::File));
1531        assert!(matches!(entries[4].1, EntryKind::Link));
1532        Ok(())
1533    }
1534
1535    #[test]
1536    fn lone_directory_chains_collapse_into_one_name() -> Result<()> {
1537        let root = TempDir::new("collapse");
1538        let repo_path = root.join("repo");
1539        init_repo(&repo_path)?;
1540        fs::create_dir_all(repo_path.join("a/b/c"))?;
1541        fs::write(repo_path.join("a/b/c/keep"), "")?;
1542        fs::create_dir_all(repo_path.join("x/y"))?;
1543        fs::write(repo_path.join("x/y/keep"), "")?;
1544        fs::write(repo_path.join("x/stop"), "")?;
1545        commit(&repo_path, "first")?;
1546
1547        let repo = gix::open(&repo_path)?;
1548        let entries = collect_entries(&repo.head_commit()?.tree()?)?;
1549        let collapsed: Vec<_> = entries
1550            .iter()
1551            .map(|(name, _, oid)| collapse_lone_dirs(&repo, name, *oid))
1552            .collect();
1553        assert_eq!(collapsed, ["a/b/c", "x"]);
1554        Ok(())
1555    }
1556
1557    #[test]
1558    fn readme_lookup_prefers_markdown_ignores_case_and_skips_directories() -> Result<()> {
1559        let root = TempDir::new("readme");
1560        let repo_path = root.join("repo");
1561        init_repo(&repo_path)?;
1562        fs::write(repo_path.join("readme.txt"), "txt")?;
1563        fs::write(repo_path.join("README"), "bare")?;
1564        fs::write(repo_path.join("ReadMe.MD"), "md")?;
1565        commit(&repo_path, "first")?;
1566        let repo = gix::open(&repo_path)?;
1567        let entries = collect_entries(&repo.head_commit()?.tree()?)?;
1568        let (name, data) = find_readme(&repo, &entries).unwrap();
1569        assert_eq!((name.as_str(), data.as_slice()), ("ReadMe.MD", b"md".as_slice()));
1570
1571        fs::remove_file(repo_path.join("ReadMe.MD"))?;
1572        fs::create_dir(repo_path.join("readme.md"))?;
1573        fs::write(repo_path.join("readme.md/keep"), "")?;
1574        commit(&repo_path, "second")?;
1575        let entries = collect_entries(&repo.head_commit()?.tree()?)?;
1576        let (name, data) = find_readme(&repo, &entries).unwrap();
1577        assert_eq!((name.as_str(), data.as_slice()), ("README", b"bare".as_slice()));
1578        Ok(())
1579    }
1580
1581    #[test]
1582    fn refs_skip_non_commit_tags_and_let_branches_shadow_tags() -> Result<()> {
1583        let root = TempDir::new("refs");
1584        let repo_path = root.join("repo");
1585        init_repo(&repo_path)?;
1586        fs::write(repo_path.join("file"), "")?;
1587        commit(&repo_path, "first")?;
1588        git(&repo_path, &["tag", "main"])?;
1589        git(&repo_path, &["tag", "v1"])?;
1590        let blob = git(&repo_path, &["rev-parse", "HEAD:file"])?;
1591        git(&repo_path, &["tag", "blobtag", &blob])?;
1592
1593        let repo = gix::open(&repo_path)?;
1594        let mut tips: Vec<_> = list_refs(&repo)?
1595            .into_iter()
1596            .map(|tip| (tip.kind, tip.name))
1597            .collect();
1598        tips.sort_by(|a, b| a.1.cmp(&b.1));
1599        assert_eq!(tips, [(RefKind::Branch, "main".into()), (RefKind::Tag, "v1".into())]);
1600        Ok(())
1601    }
1602
1603    #[test]
1604    fn head_tip_follows_head_and_falls_back_to_any_branch() -> Result<()> {
1605        let root = TempDir::new("head");
1606        let repo_path = root.join("repo");
1607        init_repo(&repo_path)?;
1608        fs::write(repo_path.join("file"), "")?;
1609        commit(&repo_path, "first")?;
1610        git(&repo_path, &["branch", "aaa"])?;
1611        let repo = gix::open(&repo_path)?;
1612        let tips = list_refs(&repo)?;
1613        assert_eq!(head_tip(&repo, &tips).unwrap().name, "main");
1614
1615        git(&repo_path, &["checkout", "-q", "--detach"])?;
1616        let repo = gix::open(&repo_path)?;
1617        assert_eq!(head_tip(&repo, &tips).unwrap().kind, RefKind::Branch);
1618
1619        git(&repo_path, &["symbolic-ref", "HEAD", "refs/heads/unborn"])?;
1620        let repo = gix::open(&repo_path)?;
1621        assert_eq!(head_tip(&repo, &tips).unwrap().kind, RefKind::Branch);
1622        Ok(())
1623    }
1624
1625    #[test]
1626    fn markdown_never_passes_through_raw_html() {
1627        let mut hl = Highlighter::new(grammar_cache());
1628        let html = markdown(
1629            &mut hl,
1630            "<script>alert(1)</script>\n\n\
1631             text <b onclick=\"x\">bold</b>\n\n\
1632             ```no-such-lang\n<img src=x onerror=alert(1)>\n```\n",
1633            |_, _| None,
1634        );
1635        assert!(html.contains("&lt;script&gt;"), "{html}");
1636        for forbidden in ["<script", "<b ", "<img"] {
1637            assert!(!html.contains(forbidden), "{forbidden} leaked into {html}");
1638        }
1639    }
1640
1641    #[test]
1642    fn typst_readme_requires_feature() -> Result<()> {
1643        let root = TempDir::new("typst-feature");
1644        let repo_path = root.join("repo");
1645        init_repo(&repo_path)?;
1646        fs::write(repo_path.join("README.TYP"), "= Title")?;
1647        commit(&repo_path, "typst readme")?;
1648        let repo = gix::open(&repo_path)?;
1649        let entries = collect_entries(&repo.head_commit()?.tree()?)?;
1650        assert_eq!(find_readme(&repo, &entries).is_some(), cfg!(feature = "typst"));
1651        assert_eq!(super::is_typst("README.TYP"), cfg!(feature = "typst"));
1652        Ok(())
1653    }
1654
1655    #[test]
1656    #[cfg(not(feature = "typst"))]
1657    fn typst_disabled_falls_back_to_escaped_source() {
1658        let mut hl = Highlighter::new(grammar_cache());
1659        let html = typst(&mut hl, "<script> #image(\"pic.png\")", |_, _| panic!("Typst disabled"));
1660        assert!(html.starts_with("<pre>&lt;script&gt;"), "{html}");
1661        assert!(!html.contains("<img"), "{html}");
1662    }
1663
1664    #[test]
1665    #[cfg(feature = "typst")]
1666    fn typst_renders_markup_and_blocks() {
1667        let mut hl = Highlighter::new(grammar_cache());
1668        let html = typst(&mut hl, "= Heading\n\n*bold* _italic_ https://typst.org \\# ~\n\n```\nfirst\nsecond\n```\n\n- one\n  - nested\n- two\n\n+ numbered\n\n/ Term: Description", |_, _| None);
1669        for expected in ["<h1>Heading</h1>", "<strong>bold</strong>", "<em>italic</em>", "<a href=\"https://typst.org\">https://typst.org</a>", "first\nsecond", "<ul>", "<ol>", "<dl>", "<dt>Term</dt>"] {
1670            assert!(html.contains(expected), "missing {expected}: {html}");
1671        }
1672        for forbidden in ["<p><h", "<p><pre", "<p><ul", "*bold*", "_italic_"] {
1673            assert!(!html.contains(forbidden), "{forbidden}: {html}");
1674        }
1675        assert_eq!(html.matches("<ul>").count(), 2, "{html}");
1676    }
1677
1678    #[test]
1679    #[cfg(feature = "typst")]
1680    fn typst_images_resolve_and_escape() {
1681        let mut hl = Highlighter::new(grammar_cache());
1682        let html = typst(&mut hl, "#image(\"../pic.png\", alt: \"A & B\", width: 50%)", |url, image| {
1683            assert!(image);
1684            assert_eq!(url, "../pic.png");
1685            Some("/raw/revision/pic.png?x=1&y=2".into())
1686        });
1687        assert!(html.contains("<img src=\"/raw/revision/pic.png?x=1&amp;y=2\" alt=\"A &amp; B\">"), "{html}");
1688        let html = typst(&mut hl, "#image(\"https://example.com/pic.png\")", |_, _| None);
1689        assert!(html.contains("src=\"https://example.com/pic.png\""), "{html}");
1690    }
1691
1692    #[test]
1693    #[cfg(feature = "typst")]
1694    fn typst_unsupported_and_invalid_input_falls_back_to_source() {
1695        let mut hl = Highlighter::new(grammar_cache());
1696        let html = typst(&mut hl, "#image(path) $x^2$ #unknown(\"<script>\")", |_, _| panic!("must not evaluate dynamic expressions"));
1697        assert!(html.contains("#image(path)"), "{html}");
1698        assert!(html.contains("$x^2$"), "{html}");
1699        assert!(html.contains("&lt;script&gt;"), "{html}");
1700        assert!(!html.contains("<script>"), "{html}");
1701        let html = typst(&mut hl, "#image(\"unfinished", |_, _| None);
1702        assert!(html.starts_with("<pre>"), "{html}");
1703    }
1704
1705    #[test]
1706    fn reuses_only_matching_blob_pages() -> Result<()> {
1707        let root = TempDir::new("blob-reuse");
1708        let previous = root.join("previous");
1709        let out = root.join("out");
1710        fs::create_dir_all(previous.join("blob/src"))?;
1711        fs::write(previous.join("blob/src/main.rs"), "highlighted")?;
1712        let old = BlobVersion {
1713            oid: "abc123".into(),
1714            mode: 0o100644,
1715            touched: "first".into(),
1716        };
1717        let previous_versions = BTreeMap::from([("src/main.rs".into(), old.clone())]);
1718        let changed = BlobVersion {
1719            touched: "second".into(),
1720            ..old.clone()
1721        };
1722        let changed_versions = BTreeMap::from([("src/main.rs".into(), changed)]);
1723        let versions = BTreeMap::from([("src/main.rs".into(), old)]);
1724
1725        assert!(!reuse_blob(
1726            &previous,
1727            &previous_versions,
1728            &changed_versions,
1729            "src/main.rs",
1730            &out,
1731        ));
1732        assert!(reuse_blob(
1733            &previous,
1734            &previous_versions,
1735            &versions,
1736            "src/main.rs",
1737            &out,
1738        ));
1739        assert_eq!(
1740            fs::read_to_string(out.join("blob/src/main.rs"))?,
1741            "highlighted",
1742        );
1743        Ok(())
1744    }
1745}