git-ents.gitmain
⌘K
foforge
commit e6ddaa7
roots: render frontmatter and header attributes as a properties table

Markdown files opening with YAML (---) or TOML (+) frontmatter have the block stripped from the rendered body and shown above the document as a key-value table (render::properties_table, styled on the existing entity-view dl look). The splitter is a minimal, conservative line-based parse of top-level scalar keys — nested structures render as their raw text — rather than a new YAML/TOML dependency for a display-only table. AsciiDoc surfaces its header’s :name: value attribute entries through the same component, read by a header line scan since acdc’s parsed attribute map folds explicit entries in with its defaults; the reconstructed doctitle handling is untouched.

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

crates/cli/ents-web/src/asciidoc.rs @@ -39,6 +39,15 @@ /// which hit the same gap: without this, a README's own `= Title` line /// would silently vanish from the rendered page. /// +/// The header's own attribute entries (`:name: value`) surface above the +/// document as a key-value properties table +/// ([`crate::render::properties_table`], the same component +/// [`crate::markdown`] renders frontmatter through), read by +/// [`header_attribute_entries`]'s own line scan of the source header -- +/// deliberately not `acdc`'s parsed `doc.attributes`, which folds the +/// explicitly-written entries in with its ~80 defaults and exposes no +/// explicit-only view to read back. +/// /// No sanitization is applied beyond what `acdc`'s HTML converter itself /// guarantees -- the pre-redo version did the same, emitting the /// converter's output unescaped via `maud::PreEscaped`. @@ -51,6 +60,7 @@ .map_err(|err| Error::Asciidoc(err.to_string()))?; let doc = parsed.document(); + let attributes = header_attribute_entries(source); let heading = doc .header .as_ref() @@ -74,12 +84,48 @@ let body = processor .convert_to_string(doc, &options) .map_err(|err| Error::Asciidoc(err.to_string()))?; - Ok(match heading { - Some(heading) => html! { (heading) (PreEscaped(body)) }, - None => PreEscaped(body), + Ok(html! { + (crate::render::properties_table(&attributes)) + @if let Some(heading) = heading { (heading) } + (PreEscaped(body)) }) } +/// The attribute entries (`:name: value`, or a bare/unset `:name:` / +/// `:!name:`) written in `source`'s own document header -- the lines from +/// the top of the document to its first blank line, where AsciiDoc allows +/// attribute entries at all. A minimal line scan, for the reason +/// [`to_html`]'s own doc gives: `acdc`'s parsed attribute map cannot say +/// which entries the document actually wrote. Non-attribute header lines +/// (the title, an author line, a `//` comment) are skipped; a bare +/// `:name:` renders with an empty value and an unsetting `:name!:` (or +/// `:!name:`) keeps its `!`, both verbatim -- this is a display of what +/// the header says, not an evaluation of it. +pub(crate) fn header_attribute_entries(source: &str) -> Vec<(String, String)> { + let mut entries = Vec::new(); + for line in source.lines() { + let trimmed = line.trim_end(); + if trimmed.trim().is_empty() { + break; + } + let Some(rest) = trimmed.strip_prefix(':') else { + continue; + }; + let Some((name, value)) = rest.split_once(':') else { + continue; + }; + let bare = name.trim_matches('!'); + let is_name = !bare.is_empty() + && bare + .chars() + .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-')); + if is_name { + entries.push((name.to_owned(), value.trim().to_owned())); + } + } + entries +} + #[cfg(test)] mod tests { #![allow(clippy::expect_used, reason = "unit test")] @@ -117,4 +163,44 @@ assert!(rendered.contains(r#"class="doc-subtitle""#)); assert!(rendered.contains("Subtitle")); } + + #[test] + fn to_html_surfaces_header_attributes_without_regressing_the_doctitle() { + let rendered = to_html("= Title\n:toc: left\n:experimental:\n\nBody.\n") + .expect("valid asciidoc") + .into_string(); + assert!( + rendered.contains("doc-props"), + "the properties table renders" + ); + assert!(rendered.contains("toc")); + assert!(rendered.contains("left")); + assert!( + rendered.contains("<h1>Title</h1>"), + "the reconstructed doctitle stays: {rendered}" + ); + } + + #[test] + fn header_attribute_entries_reads_only_the_header_block() { + let entries = header_attribute_entries( + "= Title\nAn Author <author@ents.test>\n:toc: left\n:experimental:\n:sectnums!:\n\n:not-header: too late\n", + ); + assert_eq!( + entries, + vec![ + ("toc".to_owned(), "left".to_owned()), + ("experimental".to_owned(), String::new()), + ("sectnums!".to_owned(), String::new()), + ] + ); + } + + #[rstest] + #[case::no_header_at_all("Just a paragraph.\n")] + #[case::title_only("= Title\n\nBody.\n")] + #[case::prose_colon_line("= Title\nnote: this is an author line, not an attribute\n\nBody.\n")] + fn header_attribute_entries_finds_none_where_none_are_written(#[case] source: &str) { + assert!(header_attribute_entries(source).is_empty()); + } }
crates/cli/ents-web/src/markdown.rs @@ -5,8 +5,18 @@ //! rather than a plain-text listing. Output is an embedded fragment (no //! document frame) styled by [`crate::assets::OVERRIDES`]'s own //! `.doc-body` rules. +//! +//! A document opening with YAML (`---`) or TOML (`+++`) frontmatter has +//! it stripped from the rendered body ([`split_frontmatter`]) and +//! rendered above the document as a key-value properties table instead +//! ([`crate::render::properties_table`]) -- the same table +//! [`crate::asciidoc`] renders a header's attribute entries through. The +//! parse is deliberately a minimal, line-based one over top-level scalar +//! keys (see [`split_frontmatter`]'s own doc): no YAML/TOML dependency +//! carries its weight for a display-only table that renders anything +//! nested as raw text anyway. -use maud::{Markup, PreEscaped}; +use maud::{Markup, PreEscaped, html as maud_html}; use pulldown_cmark::{Options, Parser, html}; /// File extensions that name a Markdown document. @@ -21,7 +31,9 @@ /// Render Markdown `source` to an embedded HTML fragment, with the tables, /// footnotes, strikethrough, and task-list extensions people expect from -/// forge-flavored Markdown. +/// forge-flavored Markdown. Leading YAML/TOML frontmatter is stripped from +/// the body and rendered above it as a properties table (this module's own +/// doc; [`split_frontmatter`]). /// /// No additional sanitization is applied beyond what `pulldown_cmark` /// itself guarantees (well-formed HTML output from the parsed Markdown @@ -31,13 +43,129 @@ /// unescaped, straight into the page. #[must_use] pub(crate) fn to_html(source: &str) -> Markup { + let (frontmatter, body) = split_frontmatter(source); 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)); - PreEscaped(out) + html::push_html(&mut out, Parser::new_ext(body, options)); + maud_html! { + (crate::render::properties_table(&frontmatter)) + (PreEscaped(out)) + } +} + +/// Split leading frontmatter off `source`: `(entries, body)`, where +/// `entries` is empty when `source` carries no frontmatter at all and +/// `body` is the document with the frontmatter block (fences included) +/// stripped. +/// +/// Frontmatter is recognized only in the one shape static-site tooling +/// actually writes: the document's very first line is exactly a `---` +/// (YAML) or `+++` (TOML) fence, closed by a later line that is exactly +/// the same fence. A `---` further down the document is a thematic break, +/// never frontmatter, and an unclosed fence is not frontmatter either -- +/// both render as ordinary Markdown, untouched. +/// +/// The parse between the fences is deliberately minimal and line-based +/// (this module's own doc): an unindented `key: value` (YAML) or +/// `key = value` (TOML) line becomes one entry, with one level of +/// matching surrounding quotes stripped from the value. Anything deeper +/// -- an indented nested block, a list continuation, a TOML `[table]` +/// header and everything after it -- is not parsed: it is appended +/// verbatim, raw text, to the entry it follows +/// ([`crate::render::properties_table`] renders it as-is). A nested block +/// with no preceding entry at all opens one keyed by its own raw first +/// line, so no frontmatter line is ever silently dropped. +pub(crate) fn split_frontmatter(source: &str) -> (Vec<(String, String)>, &str) { + let (fence, separator) = if source.starts_with("---\n") || source.starts_with("---\r\n") { + ("---", ':') + } else if source.starts_with("+++\n") || source.starts_with("+++\r\n") { + ("+++", '=') + } else { + return (Vec::new(), source); + }; + + // Walk physical lines by byte offset so the body can be returned as a + // slice of `source` rather than a rebuilt copy. + let mut offset: usize = 0; + let mut lines = Vec::new(); + let mut close = None; + for line in source.split_inclusive('\n') { + let text = line.trim_end_matches(['\n', '\r']); + if offset > 0 && text == fence { + close = Some(offset.saturating_add(line.len())); + break; + } + if offset > 0 { + lines.push(text); + } + offset = offset.saturating_add(line.len()); + } + let Some(body_start) = close else { + return (Vec::new(), source); + }; + + let mut entries: Vec<(String, String)> = Vec::new(); + let mut raw_only = false; + for line in lines { + let top_level = !raw_only + && !line.starts_with([' ', '\t']) + && line + .split_once(separator) + .is_some_and(|(key, _)| is_bare_key(key)); + if top_level && let Some((key, value)) = line.split_once(separator) { + entries.push((key.trim().to_owned(), unquote(value.trim()).to_owned())); + continue; + } + if line.trim().is_empty() { + continue; + } + if separator == '=' && line.trim_start().starts_with('[') { + // A TOML `[table]` header: nothing after it is top-level, so + // the rest of the block stays raw under this one entry. + raw_only = true; + entries.push((line.trim().to_owned(), String::new())); + continue; + } + match entries.last_mut() { + Some((_, value)) => { + if !value.is_empty() { + value.push('\n'); + } + value.push_str(line); + } + None => entries.push((line.trim().to_owned(), String::new())), + } + } + (entries, source.get(body_start..).unwrap_or("")) +} + +/// Whether `key` looks like a bare frontmatter key: non-empty, no +/// whitespace or quoting inside it -- what keeps [`split_frontmatter`] +/// from misreading prose containing a `:` (or a quoted value containing +/// `=`) as an entry. +fn is_bare_key(key: &str) -> bool { + let key = key.trim_end(); + !key.is_empty() + && key + .chars() + .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.')) +} + +/// Strip one level of matching surrounding single or double quotes from a +/// scalar frontmatter value. +fn unquote(value: &str) -> &str { + let stripped = value + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + }); + stripped.unwrap_or(value) } #[cfg(test)] @@ -62,4 +190,98 @@ assert!(rendered.contains("<h1>Title</h1>")); assert!(rendered.contains("<table>")); } + + #[test] + fn to_html_strips_frontmatter_from_the_body_and_renders_it_as_properties() { + let rendered = to_html("---\ntitle: Design Notes\n---\n# Title\n\nBody.\n").into_string(); + assert!( + rendered.contains("doc-props"), + "the properties table renders" + ); + assert!(rendered.contains("Design Notes")); + assert!(rendered.contains("<h1>Title</h1>")); + assert!( + !rendered.contains("<hr"), + "the fences are stripped, not rendered as thematic breaks: {rendered}" + ); + } + + #[test] + fn split_frontmatter_reads_yaml_scalars_and_strips_the_block() { + let (entries, body) = split_frontmatter("---\ntitle: \"Hello\"\ndraft: true\n---\n# Doc\n"); + assert_eq!( + entries, + vec![ + ("title".to_owned(), "Hello".to_owned()), + ("draft".to_owned(), "true".to_owned()), + ] + ); + assert_eq!(body, "# Doc\n"); + } + + #[test] + fn split_frontmatter_reads_toml_scalars_behind_plus_fences() { + let (entries, body) = split_frontmatter("+++\ntitle = 'Hi'\nweight = 3\n+++\nBody.\n"); + assert_eq!( + entries, + vec![ + ("title".to_owned(), "Hi".to_owned()), + ("weight".to_owned(), "3".to_owned()), + ] + ); + assert_eq!(body, "Body.\n"); + } + + #[test] + fn split_frontmatter_keeps_a_nested_yaml_block_as_raw_text_under_its_key() { + let (entries, body) = split_frontmatter("---\ntags:\n - a\n - b\nname: x\n---\nBody.\n"); + assert_eq!( + entries, + vec![ + ("tags".to_owned(), " - a\n - b".to_owned()), + ("name".to_owned(), "x".to_owned()), + ] + ); + assert_eq!(body, "Body.\n"); + } + + #[test] + fn split_frontmatter_keeps_a_toml_table_and_everything_after_it_raw() { + let (entries, _body) = + split_frontmatter("+++\ntitle = 'Hi'\n[params]\nx = 1\n+++\nBody.\n"); + assert_eq!( + entries, + vec![ + ("title".to_owned(), "Hi".to_owned()), + ("[params]".to_owned(), "x = 1".to_owned()), + ] + ); + } + + #[rstest] + #[case::no_fence_at_all("# Just a doc\n")] + #[case::fence_not_first("\n---\nkey: value\n---\n")] + #[case::unclosed_fence("---\nkey: value\n# Doc\n")] + #[case::thematic_break_later("# Doc\n\n---\n\nMore.\n")] + fn split_frontmatter_leaves_a_document_without_frontmatter_untouched(#[case] source: &str) { + let (entries, body) = split_frontmatter(source); + assert!(entries.is_empty()); + assert_eq!(body, source); + } + + #[test] + fn split_frontmatter_keeps_a_prose_colon_line_raw_rather_than_splitting_it() { + let (entries, _body) = + split_frontmatter("---\nnote this: is prose\nreal-key: yes\n---\nBody.\n"); + assert_eq!( + entries, + vec![ + // Not a bare key ("note this" holds a space), so the line + // stays raw -- and with no entry before it, it opens one + // keyed by its own text rather than being dropped. + ("note this: is prose".to_owned(), String::new()), + ("real-key".to_owned(), "yes".to_owned()), + ] + ); + } }
crates/cli/ents-web/src/render.rs @@ -221,6 +221,42 @@ } } +/// A key-value properties table for a rendered document's own metadata -- +/// Markdown frontmatter ([`crate::markdown`]) and an AsciiDoc header's +/// attribute entries ([`crate::asciidoc`]) both render through this one +/// component, above the document body, styled by `ents.css`'s +/// `.doc-props` rules on top of the same `.entity-view` definition-list +/// look every generic entity view already has. Values are plain text +/// (maud-escaped as any interpolation is); a nested structure the caller +/// chose not to parse arrives here as its raw text and renders verbatim +/// (`.doc-props dd` preserves its line breaks). Renders nothing at all +/// when `entries` is empty, so a document with no metadata carries no +/// empty table. +/// +/// # Examples +/// +/// ``` +/// let entries = vec![("title".to_owned(), "Design Notes".to_owned())]; +/// let rendered = ents_web::render::properties_table(&entries).into_string(); +/// assert!(rendered.contains("doc-props")); +/// assert!(rendered.contains("Design Notes")); +/// assert!(ents_web::render::properties_table(&[]).into_string().is_empty()); +/// ``` +#[must_use] +pub fn properties_table(entries: &[(String, String)]) -> Markup { + if entries.is_empty() { + return html! {}; + } + html! { + dl.entity-view.doc-props { + @for (key, value) in entries { + dt { (key) } + dd { (value) } + } + } + } +} + /// A list of plain strings with no reflected entity behind them (inbox /// entries, toolchain names) -- deliberately not the [`fields`] mechanism, /// since there is no struct to reflect over, only a bare list of ids.
crates/cli/ents-web/src/assets/ents.css @@ -408,6 +408,14 @@ .diff .meta { color: var(--color-text-muted); } .diff .file { color: var(--color-text); font-weight: 600; background: var(--color-code-bg); padding-top: .3rem; padding-bottom: .3rem; } +/* A rendered document's own metadata -- Markdown frontmatter and AsciiDoc + * header attributes (`crate::render::properties_table`) -- as a bordered + * definition list above the document body, reusing `.entity-view`'s own + * dt/dd rhythm. `pre-wrap` on the value cell keeps an unparsed nested + * structure's raw text readable line by line. */ +.doc-props { border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-code-bg); padding: .35rem 0; margin: 0 0 1.5rem; } +.doc-props dd { white-space: pre-wrap; } + /* Rendered Markdown/AsciiDoc documents (`crate::markdown`, `crate::asciidoc`). */ .doc-body { padding: 40px 48px 52px; max-width: 44rem; overflow-wrap: break-word; } .doc-body > :first-child { margin-top: 0; }