feat: format Markdown in the web viewer and show file comments inline
commit
551bb1bfeat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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
+}