git-ents.gitmain
⌘K
foforge
markdown.rs287 lines · 10.8 KB · rusthistorycomment 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
19use maud::{Markup, PreEscaped, html as maud_html};
20use pulldown_cmark::{Options, Parser, html};
21
22/// File extensions that name a Markdown document.
23const EXTENSIONS: [&str; 4] = ["md", "markdown", "mdown", "mkd"];
24
25/// Whether `name` looks like a Markdown file by its extension.
26#[must_use]
27pub(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]
45pub(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.
81pub(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.
149fn 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.
159fn 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)]
172mod 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}