roots: render frontmatter and header attributes as a properties table
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.
No reviews of this commit yet — record a verdict below.
Start a review
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; }