crates/cli/ents-web/src/markdown.rs
markdown.rshistorycomment on this file
| 1 | //! Markdown rendering via [`pulldown_cmark`]. |
| 2 | //! |
| 3 | //! Markdown gets the same treatment AsciiDoc does (`crate::asciidoc`): a |
| 4 | //! `.md` blob in [`crate::pages::files`] renders as a formatted document |
| 5 | //! rather than a plain-text listing. Output is an embedded fragment (no |
| 6 | //! document frame) styled by [`crate::assets::OVERRIDES`]'s own |
| 7 | //! `.doc-body` rules. |
| 8 | //! |
| 9 | //! A document opening with YAML (`---`) or TOML (`+++`) frontmatter has |
| 10 | //! it stripped from the rendered body ([`split_frontmatter`]) and |
| 11 | //! rendered above the document as a key-value properties table instead |
| 12 | //! ([`crate::render::properties_table`]) -- the same table |
| 13 | //! [`crate::asciidoc`] renders a header's attribute entries through. The |
| 14 | //! parse is deliberately a minimal, line-based one over top-level scalar |
| 15 | //! keys (see [`split_frontmatter`]'s own doc): no YAML/TOML dependency |
| 16 | //! carries its weight for a display-only table that renders anything |
| 17 | //! nested as raw text anyway. |
| 18 | |
| 19 | use maud::{Markup, PreEscaped, html as maud_html}; |
| 20 | use pulldown_cmark::{Options, Parser, html}; |
| 21 | |
| 22 | /// File extensions that name a Markdown document. |
| 23 | const EXTENSIONS: [&str; 4] = ["md", "markdown", "mdown", "mkd"]; |
| 24 | |
| 25 | /// Whether `name` looks like a Markdown file by its extension. |
| 26 | #[must_use] |
| 27 | pub(crate) fn is_markdown(name: &str) -> bool { |
| 28 | name.rsplit_once('.') |
| 29 | .is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) |
| 30 | } |
| 31 | |
| 32 | /// Render Markdown `source` to an embedded HTML fragment, with the tables, |
| 33 | /// footnotes, strikethrough, and task-list extensions people expect from |
| 34 | /// forge-flavored Markdown. Leading YAML/TOML frontmatter is stripped from |
| 35 | /// the body and rendered above it as a properties table (this module's own |
| 36 | /// doc; [`split_frontmatter`]). |
| 37 | /// |
| 38 | /// No additional sanitization is applied beyond what `pulldown_cmark` |
| 39 | /// itself guarantees (well-formed HTML output from the parsed Markdown |
| 40 | /// tree, not sanitized against embedded raw HTML in the source) -- |
| 41 | /// `pre-redo:crates/git-ents-server/src/markdown.rs`'s own `to_html` did |
| 42 | /// the same: it emitted `pulldown_cmark::html::push_html`'s output |
| 43 | /// unescaped, straight into the page. |
| 44 | #[must_use] |
| 45 | pub(crate) fn to_html(source: &str) -> Markup { |
| 46 | let (frontmatter, body) = split_frontmatter(source); |
| 47 | let options = Options::ENABLE_TABLES |
| 48 | | Options::ENABLE_FOOTNOTES |
| 49 | | Options::ENABLE_STRIKETHROUGH |
| 50 | | Options::ENABLE_TASKLISTS; |
| 51 | let mut out = String::new(); |
| 52 | html::push_html(&mut out, Parser::new_ext(body, options)); |
| 53 | maud_html! { |
| 54 | (crate::render::properties_table(&frontmatter)) |
| 55 | (PreEscaped(out)) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | /// Split leading frontmatter off `source`: `(entries, body)`, where |
| 60 | /// `entries` is empty when `source` carries no frontmatter at all and |
| 61 | /// `body` is the document with the frontmatter block (fences included) |
| 62 | /// stripped. |
| 63 | /// |
| 64 | /// Frontmatter is recognized only in the one shape static-site tooling |
| 65 | /// actually writes: the document's very first line is exactly a `---` |
| 66 | /// (YAML) or `+++` (TOML) fence, closed by a later line that is exactly |
| 67 | /// the same fence. A `---` further down the document is a thematic break, |
| 68 | /// never frontmatter, and an unclosed fence is not frontmatter either -- |
| 69 | /// both render as ordinary Markdown, untouched. |
| 70 | /// |
| 71 | /// The parse between the fences is deliberately minimal and line-based |
| 72 | /// (this module's own doc): an unindented `key: value` (YAML) or |
| 73 | /// `key = value` (TOML) line becomes one entry, with one level of |
| 74 | /// matching surrounding quotes stripped from the value. Anything deeper |
| 75 | /// -- an indented nested block, a list continuation, a TOML `[table]` |
| 76 | /// header and everything after it -- is not parsed: it is appended |
| 77 | /// verbatim, raw text, to the entry it follows |
| 78 | /// ([`crate::render::properties_table`] renders it as-is). A nested block |
| 79 | /// with no preceding entry at all opens one keyed by its own raw first |
| 80 | /// line, so no frontmatter line is ever silently dropped. |
| 81 | pub(crate) fn split_frontmatter(source: &str) -> (Vec<(String, String)>, &str) { |
| 82 | let (fence, separator) = if source.starts_with("---\n") || source.starts_with("---\r\n") { |
| 83 | ("---", ':') |
| 84 | } else if source.starts_with("+++\n") || source.starts_with("+++\r\n") { |
| 85 | ("+++", '=') |
| 86 | } else { |
| 87 | return (Vec::new(), source); |
| 88 | }; |
| 89 | |
| 90 | // Walk physical lines by byte offset so the body can be returned as a |
| 91 | // slice of `source` rather than a rebuilt copy. |
| 92 | let mut offset: usize = 0; |
| 93 | let mut lines = Vec::new(); |
| 94 | let mut close = None; |
| 95 | for line in source.split_inclusive('\n') { |
| 96 | let text = line.trim_end_matches(['\n', '\r']); |
| 97 | if offset > 0 && text == fence { |
| 98 | close = Some(offset.saturating_add(line.len())); |
| 99 | break; |
| 100 | } |
| 101 | if offset > 0 { |
| 102 | lines.push(text); |
| 103 | } |
| 104 | offset = offset.saturating_add(line.len()); |
| 105 | } |
| 106 | let Some(body_start) = close else { |
| 107 | return (Vec::new(), source); |
| 108 | }; |
| 109 | |
| 110 | let mut entries: Vec<(String, String)> = Vec::new(); |
| 111 | let mut raw_only = false; |
| 112 | for line in lines { |
| 113 | let top_level = !raw_only |
| 114 | && !line.starts_with([' ', '\t']) |
| 115 | && line |
| 116 | .split_once(separator) |
| 117 | .is_some_and(|(key, _)| is_bare_key(key)); |
| 118 | if top_level && let Some((key, value)) = line.split_once(separator) { |
| 119 | entries.push((key.trim().to_owned(), unquote(value.trim()).to_owned())); |
| 120 | continue; |
| 121 | } |
| 122 | if line.trim().is_empty() { |
| 123 | continue; |
| 124 | } |
| 125 | if separator == '=' && line.trim_start().starts_with('[') { |
| 126 | // A TOML `[table]` header: nothing after it is top-level, so |
| 127 | // the rest of the block stays raw under this one entry. |
| 128 | raw_only = true; |
| 129 | entries.push((line.trim().to_owned(), String::new())); |
| 130 | continue; |
| 131 | } |
| 132 | match entries.last_mut() { |
| 133 | Some((_, value)) => { |
| 134 | if !value.is_empty() { |
| 135 | value.push('\n'); |
| 136 | } |
| 137 | value.push_str(line); |
| 138 | } |
| 139 | None => entries.push((line.trim().to_owned(), String::new())), |
| 140 | } |
| 141 | } |
| 142 | (entries, source.get(body_start..).unwrap_or("")) |
| 143 | } |
| 144 | |
| 145 | /// Whether `key` looks like a bare frontmatter key: non-empty, no |
| 146 | /// whitespace or quoting inside it -- what keeps [`split_frontmatter`] |
| 147 | /// from misreading prose containing a `:` (or a quoted value containing |
| 148 | /// `=`) as an entry. |
| 149 | fn is_bare_key(key: &str) -> bool { |
| 150 | let key = key.trim_end(); |
| 151 | !key.is_empty() |
| 152 | && key |
| 153 | .chars() |
| 154 | .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.')) |
| 155 | } |
| 156 | |
| 157 | /// Strip one level of matching surrounding single or double quotes from a |
| 158 | /// scalar frontmatter value. |
| 159 | fn unquote(value: &str) -> &str { |
| 160 | let stripped = value |
| 161 | .strip_prefix('"') |
| 162 | .and_then(|rest| rest.strip_suffix('"')) |
| 163 | .or_else(|| { |
| 164 | value |
| 165 | .strip_prefix('\'') |
| 166 | .and_then(|rest| rest.strip_suffix('\'')) |
| 167 | }); |
| 168 | stripped.unwrap_or(value) |
| 169 | } |
| 170 | |
| 171 | #[cfg(test)] |
| 172 | mod tests { |
| 173 | use rstest::rstest; |
| 174 | |
| 175 | use super::*; |
| 176 | |
| 177 | #[rstest] |
| 178 | #[case::md("readme.md", true)] |
| 179 | #[case::markdown("readme.markdown", true)] |
| 180 | #[case::upper("README.MD", true)] |
| 181 | #[case::adoc("readme.adoc", false)] |
| 182 | #[case::no_ext("readme", false)] |
| 183 | fn is_markdown_matches_by_extension(#[case] name: &str, #[case] expected: bool) { |
| 184 | assert_eq!(is_markdown(name), expected); |
| 185 | } |
| 186 | |
| 187 | #[test] |
| 188 | fn to_html_renders_a_heading_and_a_table() { |
| 189 | let rendered = to_html("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n").into_string(); |
| 190 | assert!(rendered.contains("<h1>Title</h1>")); |
| 191 | assert!(rendered.contains("<table>")); |
| 192 | } |
| 193 | |
| 194 | #[test] |
| 195 | fn to_html_strips_frontmatter_from_the_body_and_renders_it_as_properties() { |
| 196 | let rendered = to_html("---\ntitle: Design Notes\n---\n# Title\n\nBody.\n").into_string(); |
| 197 | assert!( |
| 198 | rendered.contains("doc-props"), |
| 199 | "the properties table renders" |
| 200 | ); |
| 201 | assert!(rendered.contains("Design Notes")); |
| 202 | assert!(rendered.contains("<h1>Title</h1>")); |
| 203 | assert!( |
| 204 | !rendered.contains("<hr"), |
| 205 | "the fences are stripped, not rendered as thematic breaks: {rendered}" |
| 206 | ); |
| 207 | } |
| 208 | |
| 209 | #[test] |
| 210 | fn split_frontmatter_reads_yaml_scalars_and_strips_the_block() { |
| 211 | let (entries, body) = split_frontmatter("---\ntitle: \"Hello\"\ndraft: true\n---\n# Doc\n"); |
| 212 | assert_eq!( |
| 213 | entries, |
| 214 | vec![ |
| 215 | ("title".to_owned(), "Hello".to_owned()), |
| 216 | ("draft".to_owned(), "true".to_owned()), |
| 217 | ] |
| 218 | ); |
| 219 | assert_eq!(body, "# Doc\n"); |
| 220 | } |
| 221 | |
| 222 | #[test] |
| 223 | fn split_frontmatter_reads_toml_scalars_behind_plus_fences() { |
| 224 | let (entries, body) = split_frontmatter("+++\ntitle = 'Hi'\nweight = 3\n+++\nBody.\n"); |
| 225 | assert_eq!( |
| 226 | entries, |
| 227 | vec![ |
| 228 | ("title".to_owned(), "Hi".to_owned()), |
| 229 | ("weight".to_owned(), "3".to_owned()), |
| 230 | ] |
| 231 | ); |
| 232 | assert_eq!(body, "Body.\n"); |
| 233 | } |
| 234 | |
| 235 | #[test] |
| 236 | fn split_frontmatter_keeps_a_nested_yaml_block_as_raw_text_under_its_key() { |
| 237 | let (entries, body) = split_frontmatter("---\ntags:\n - a\n - b\nname: x\n---\nBody.\n"); |
| 238 | assert_eq!( |
| 239 | entries, |
| 240 | vec![ |
| 241 | ("tags".to_owned(), " - a\n - b".to_owned()), |
| 242 | ("name".to_owned(), "x".to_owned()), |
| 243 | ] |
| 244 | ); |
| 245 | assert_eq!(body, "Body.\n"); |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn split_frontmatter_keeps_a_toml_table_and_everything_after_it_raw() { |
| 250 | let (entries, _body) = |
| 251 | split_frontmatter("+++\ntitle = 'Hi'\n[params]\nx = 1\n+++\nBody.\n"); |
| 252 | assert_eq!( |
| 253 | entries, |
| 254 | vec![ |
| 255 | ("title".to_owned(), "Hi".to_owned()), |
| 256 | ("[params]".to_owned(), "x = 1".to_owned()), |
| 257 | ] |
| 258 | ); |
| 259 | } |
| 260 | |
| 261 | #[rstest] |
| 262 | #[case::no_fence_at_all("# Just a doc\n")] |
| 263 | #[case::fence_not_first("\n---\nkey: value\n---\n")] |
| 264 | #[case::unclosed_fence("---\nkey: value\n# Doc\n")] |
| 265 | #[case::thematic_break_later("# Doc\n\n---\n\nMore.\n")] |
| 266 | fn split_frontmatter_leaves_a_document_without_frontmatter_untouched(#[case] source: &str) { |
| 267 | let (entries, body) = split_frontmatter(source); |
| 268 | assert!(entries.is_empty()); |
| 269 | assert_eq!(body, source); |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn split_frontmatter_keeps_a_prose_colon_line_raw_rather_than_splitting_it() { |
| 274 | let (entries, _body) = |
| 275 | split_frontmatter("---\nnote this: is prose\nreal-key: yes\n---\nBody.\n"); |
| 276 | assert_eq!( |
| 277 | entries, |
| 278 | vec![ |
| 279 | // Not a bare key ("note this" holds a space), so the line |
| 280 | // stays raw -- and with no entry before it, it opens one |
| 281 | // keyed by its own text rather than being dropped. |
| 282 | ("note this: is prose".to_owned(), String::new()), |
| 283 | ("real-key".to_owned(), "yes".to_owned()), |
| 284 | ] |
| 285 | ); |
| 286 | } |
| 287 | } |