git-ents.gitmain
⌘K
foforge
commit 551bb1b
feat: format Markdown in the web viewer and show file comments inline

The README and .md blobs now render as formatted documents through pulldown-cmark, matching the AsciiDoc treatment; the blob gutter’s line numbers are self-linking anchors; and a file’s anchored comments appear under both blob views, projected onto HEAD, linking their line range to the gutter and flagged when outdated.

feat: render Markdown READMEs and blobs as formatted documents feat: make blob gutter line numbers self-linking #L<n> anchors feat: list a file’s anchored comments under the blob and files views build: add pulldown-cmark to git-ents-server for Markdown rendering Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -1299,6 +1299,8 @@ "facet", "form_urlencoded", "getrandom 0.4.3", + "git-anchor", + "git-comment", "git-ents", "git-store", "gix-actor", @@ -1307,6 +1309,7 @@ "gix-object", "maud", "portable-pty", + "pulldown-cmark", "rstest", "tempfile", "tokio", @@ -2967,6 +2970,24 @@ "parking_lot", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.0", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quote" version = "1.0.45" @@ -3725,6 +3746,12 @@ "arrayvec", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bom" version = "2.0.3"
Cargo.toml @@ -57,6 +57,7 @@ gix-date = "0.15" iddqd = "0.4" maud = { version = "0.27", features = ["axum"] } +pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } rstest = "0.26" tempfile = "3" thiserror = "2"
crates/git-ents-server/Cargo.toml @@ -18,12 +18,15 @@ facet = { workspace = true } form_urlencoded = { workspace = true } getrandom = { workspace = true } +git-anchor = { workspace = true } +git-comment = { workspace = true } git-store = { workspace = true } gix-actor = { workspace = true } gix-date = { workspace = true } gix-hash = { workspace = true } gix-object = { workspace = true } maud = { workspace = true } +pulldown-cmark = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } uuid = { workspace = true }
crates/git-ents-server/src/main.rs @@ -3,6 +3,7 @@ mod asciidoc; mod checks; mod http; +mod markdown; mod verify; mod web;
crates/git-ents-server/src/web/pages.rs @@ -10,6 +10,7 @@ use arborium::{Config, Highlighter, HtmlFormat}; use askama::Template; use axum::response::{IntoResponse, Response}; +use git_anchor::{LineRange, Projection}; use gix_date::Time; use gix_hash::{ObjectId, Prefix}; use gix_object::bstr::ByteSlice; @@ -156,14 +157,14 @@ ) } -/// The rendered README for the overview: the first AsciiDoc file in the root -/// tree whose stem is `README`, converted to HTML, paired with its filename. -/// `None` when there is no such file or it fails to render. +/// The rendered README for the overview: the first AsciiDoc or Markdown file +/// in the root tree whose stem is `README`, converted to HTML, paired with its +/// filename. `None` when there is no such file or it fails to render. async fn readme(repo: &Path, tree: &[Entry]) -> Option<(String, String)> { let entry = tree.iter().find(|e| { let name = e.filename.to_str_lossy(); !e.mode.is_tree() - && crate::asciidoc::is_asciidoc(&name) + && (crate::asciidoc::is_asciidoc(&name) || crate::markdown::is_markdown(&name)) && name .rsplit_once('.') .is_some_and(|(stem, _)| stem.eq_ignore_ascii_case("readme")) @@ -171,10 +172,20 @@ let name = entry.filename.to_str_lossy(); let spec = format!("HEAD:{name}"); let bytes = git_output_bytes(repo, &["cat-file", "-p", &spec]).await?; - let html = crate::asciidoc::to_html(&String::from_utf8_lossy(&bytes))?; + let html = doc_html(&name, &String::from_utf8_lossy(&bytes))?; Some((name.into_owned(), html)) } +/// The formatted-document HTML for `name`, when it is a prose format the forge +/// renders (AsciiDoc via acdc, Markdown via pulldown-cmark), or `None` when it +/// is not one or fails to render. +fn doc_html(name: &str, text: &str) -> Option<String> { + if crate::asciidoc::is_asciidoc(name) { + return crate::asciidoc::to_html(text); + } + crate::markdown::is_markdown(name).then(|| crate::markdown::to_html(text)) +} + /// The clone URL for `rel`, using the request host when known. fn clone_url(host: Option<&str>, rel: &str) -> String { match host { @@ -306,7 +317,11 @@ .await; let right = match &selected_file { - Some(path) => blob_pane(repo, path).await, + Some(path) => { + let pane = blob_pane(repo, path).await; + let comments = file_comments(repo, path).await; + html! { (pane) (comments_card(&comments)) } + } None => html! { div.files-empty { (icon_file()) @@ -414,7 +429,12 @@ /// A git date rendered as a relative "time ago" label, measured against the /// current time. fn ago(time: &Time) -> String { - let secs = Time::now_utc().seconds.saturating_sub(time.seconds).max(0); + ago_seconds(time.seconds) +} + +/// [`ago`] for a bare epoch-seconds timestamp. +fn ago_seconds(then: i64) -> String { + let secs = Time::now_utc().seconds.saturating_sub(then).max(0); let mins = secs.checked_div(60).unwrap_or(0); let hours = mins.checked_div(60).unwrap_or(0); let days = hours.checked_div(24).unwrap_or(0); @@ -509,14 +529,12 @@ html! { div.blob { div.binary { "Binary file (" (human_size(bytes.len())) ") not shown." } } } } else { let text = String::from_utf8_lossy(&bytes); - match crate::asciidoc::is_asciidoc(name) - .then(|| crate::asciidoc::to_html(&text)) - .flatten() - { + match doc_html(name, &text) { Some(html) => html! { div.card { article.adoc-body { (PreEscaped(html)) } } }, None => blob_body(name, &text), } }; + let comments = file_comments(repo, &path).await; repo_shell( meta, Tab::Files, @@ -524,24 +542,25 @@ html! { (crumbs(rel, &path, true)) (body) + (comments_card(&comments)) }, ) .into_response() } -/// Render text file `source` with a line-number gutter, highlighting via -/// `arborium` when the filename maps to a known grammar. +/// Render text file `source` with a line-number gutter — each number a +/// self-linking `#L<n>` anchor — highlighting via `arborium` when the filename +/// maps to a known grammar. fn blob_body(name: &str, source: &str) -> Markup { let lines = source.lines().count().max(1); - let mut gutter = String::new(); - for n in 1..=lines { - gutter.push_str(&n.to_string()); - gutter.push('\n'); - } let highlighted = highlight(name, source); html! { div.blob { - pre.blob-nums { (gutter) } + pre.blob-nums { + @for n in 1..=lines { + a id={ "L" (n) } href={ "#L" (n) } { (n) } + } + } pre.blob-code { @match highlighted { Some(html) => code.code { (PreEscaped(html)) }, @@ -573,6 +592,89 @@ bytes.iter().take(8000).any(|b| *b == 0) } +/// A comment as a file view shows it: who wrote it and when, where its anchor +/// lands on `HEAD`, and its body. +struct FileComment { + author: String, + seconds: i64, + lines: Option<LineRange>, + outdated: bool, + body: String, +} + +/// The comments whose anchors project onto `path` at `HEAD`, read off the +/// async runtime since git-comment reads the object database synchronously. +/// Comments that fail to project (say, an anchor commit the repository no +/// longer has) are skipped rather than failing the page. +async fn file_comments(repo: &Path, path: &str) -> Vec<FileComment> { + let repo = repo.to_owned(); + let path = path.to_owned(); + tokio::task::spawn_blocking(move || { + let Ok(comments) = git_comment::list(&repo) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (id, comment) in comments { + let Ok(projection) = git_comment::project(&repo, &comment, "HEAD") else { + continue; + }; + let (landed, lines, outdated) = match projection { + Projection::Current => (comment.anchor.path.clone(), comment.anchor.lines, false), + Projection::Relocated { path, lines } => (path, lines, false), + Projection::Outdated { path } => (path, None, true), + Projection::FileDeleted => continue, + }; + if landed != path { + continue; + } + let provenance = git_comment::provenance(&repo, &id).ok().flatten(); + out.push(FileComment { + author: provenance + .as_ref() + .map_or_else(|| "?".to_owned(), |p| p.created.name.clone()), + seconds: provenance + .map_or(0, |p| i64::try_from(p.created.seconds).unwrap_or(i64::MAX)), + lines, + outdated, + body: comment.body, + }); + } + out + }) + .await + .unwrap_or_default() +} + +/// The Comments card under a file view, or nothing when the file has none. +/// A line-anchored comment links its range to the gutter's `#L<n>` anchors; +/// an outdated one is flagged instead, since its lines no longer exist. +fn comments_card(comments: &[FileComment]) -> Markup { + if comments.is_empty() { + return html! {}; + } + html! { + div.card.file-comments { + div.card-header { "Comments (" (comments.len()) ")" } + @for comment in comments { + div.comment-row { + div.comment-meta { + span.author { (comment.author) } + @if comment.seconds > 0 { span { (ago_seconds(comment.seconds)) } } + @if let Some(range) = comment.lines { + a.chip href={ "#L" (range.start) } { + @if range.start == range.end { "line " (range.start) } + @else { "lines " (range.start) "\u{2013}" (range.end) } + } + } + @if comment.outdated { span.chip { "outdated" } } + } + p.comment-body { (comment.body) } + } + } + } + } +} + /// A single commit: its metadata and a colorized unified diff. pub(super) async fn commit_page(repo: &Path, meta: &RepoMeta, sha: &str) -> Response { if sha.is_empty() || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
crates/git-ents-server/src/web/style.css @@ -221,10 +221,21 @@ .blob { display: grid; grid-template-columns: auto minmax(0, 1fr); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: hidden; margin-bottom: 1.5rem; } .blob pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; margin: 0; padding: 1rem 0; } .blob pre.blob-nums { text-align: right; color: var(--color-text-muted); background: var(--color-code-bg); border-right: 1px solid var(--color-border); padding-left: 1ch; padding-right: 1ch; user-select: none; -webkit-user-select: none; } +.blob-nums a { display: block; color: inherit; text-decoration: none; } +.blob-nums a:hover { color: var(--color-accent); } +.blob-nums a:target { color: var(--color-accent); font-weight: 700; } .blob-code { overflow-x: auto; min-width: 0; } .blob-code code { display: block; font-family: inherit; padding: 0 1.25rem; white-space: pre; color: var(--color-text); } .binary { padding: 2.5rem; text-align: center; font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); } +.file-comments { margin-bottom: 1.5rem; } +.comment-row { padding: .8rem 1.1rem; border-bottom: 1px solid var(--color-border); } +.comment-row:last-child { border-bottom: 0; } +.comment-meta { display: flex; align-items: center; gap: .6rem; font-size: .8rem; color: var(--color-text-muted); margin-bottom: .35rem; } +.comment-meta .author { font-weight: 600; color: var(--color-text); } +.comment-meta a.chip { cursor: pointer; } +.comment-body { margin: 0; white-space: pre-wrap; font-size: .92rem; } + .code .keyword, .code .macro, .code .tag { color: var(--s-keyword); } .code .function, .code .constructor { color: var(--s-func); } .code .type { color: var(--s-type); }
crates/git-ents-server/src/markdown.rs @@ -1,0 +1,30 @@ +//! Markdown rendering via [`pulldown_cmark`]. +//! +//! Markdown gets the same treatment AsciiDoc does: a repository's `README.md` +//! renders as the editorial centerpiece of the overview, and `.md` blobs as +//! formatted documents rather than highlighted source. Output is an embedded +//! fragment (no document frame) styled by the page's own stylesheet. + +use pulldown_cmark::{Options, Parser, html}; + +/// File extensions that name a Markdown document. +const EXTENSIONS: [&str; 4] = ["md", "markdown", "mdown", "mkd"]; + +/// Whether `name` looks like a Markdown file by its extension. +pub(crate) fn is_markdown(name: &str) -> bool { + name.rsplit_once('.') + .is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) +} + +/// Render Markdown `source` to an embedded HTML fragment, with the tables, +/// footnotes, strikethrough, and task-list extensions people expect from +/// forge-flavored Markdown. +pub(crate) fn to_html(source: &str) -> String { + let options = Options::ENABLE_TABLES + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS; + let mut out = String::new(); + html::push_html(&mut out, Parser::new_ext(source, options)); + out +}