moss_core/frontmatter.rs
1//! YAML frontmatter parsing with body preservation.
2//!
3//! Uses `serde_yaml` directly (NOT `gray_matter`, whose `Pod` type
4//! doesn't properly deserialize YAML arrays — see ADR-008).
5//!
6//! The body is preserved byte-for-byte via boundary-aware splitting.
7//! `frontmatter_range` records the byte offsets of the `---` delimiters
8//! so callers can do surgical replacement without re-serializing.
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// A parsed markdown document with frontmatter separated from body.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ParsedDocument {
16 /// Parsed frontmatter key-value pairs.
17 pub frontmatter: HashMap<String, serde_yaml::Value>,
18 /// The markdown body (everything after the closing `---`).
19 pub body: String,
20 /// Byte offsets of the frontmatter block: (start_of_opening_delimiter, end_of_closing_delimiter).
21 /// `None` if no frontmatter was found.
22 pub frontmatter_range: Option<(usize, usize)>,
23 /// serde_yaml error message when a delimited `---...---` block failed to
24 /// parse as YAML. `None` when the block parsed cleanly or there was no
25 /// delimited block. When `Some`, `frontmatter` is empty and `body` still
26 /// holds the WHOLE document (so the editor can show/repair the bad block);
27 /// use `render_body()` for the HTML-render view that excludes it.
28 ///
29 /// `#[serde(default)]` is defensive: the type derives Deserialize but is
30 /// transient (no code deserializes INTO it).
31 #[serde(default)]
32 pub frontmatter_error: Option<String>,
33}
34
35impl ParsedDocument {
36 /// Body suitable for RENDERING to HTML (build pipeline): excludes a
37 /// delimited frontmatter block that FAILED to parse, so malformed YAML never
38 /// leaks verbatim into output. Equal to `body` when the frontmatter parsed
39 /// cleanly or there was no block.
40 ///
41 /// The EDITOR must NOT use this — it needs the raw `body` so the author can
42 /// see/repair the bad block and a full-reserialize save preserves it.
43 #[allow(clippy::string_slice)]
44 // `fm_end` is a line-boundary offset (the `frontmatter_range` contract); on
45 // the error path `body` == the CRLF-normalized content that `fm_end` indexes,
46 // so the slice is char-aligned and CRLF-safe.
47 pub fn render_body(&self) -> &str {
48 match (self.frontmatter_error.as_ref(), self.frontmatter_range) {
49 (Some(_), Some((_, fm_end))) => &self.body[fm_end..],
50 _ => &self.body,
51 }
52 }
53}
54
55/// Parse a markdown document, extracting frontmatter and body.
56///
57/// If the content starts with `---\n`, the YAML frontmatter is extracted
58/// and deserialized into a `HashMap`. The body is everything after the
59/// closing `---` delimiter (preserved byte-for-byte).
60///
61/// If no frontmatter is found, returns an empty map with the full content as body.
62pub fn parse(content: &str) -> ParsedDocument {
63 // Normalize CRLF → LF so byte-offset arithmetic can assume single-byte newlines.
64 let owned;
65 let content = if content.contains("\r\n") {
66 owned = content.replace("\r\n", "\n");
67 owned.as_str()
68 } else {
69 content
70 };
71
72 // Must start with `---` followed by newline (or just `---` at end of content).
73 if !content.starts_with("---") {
74 return ParsedDocument {
75 frontmatter: HashMap::new(),
76 body: content.to_string(),
77 frontmatter_range: None,
78 frontmatter_error: None,
79 };
80 }
81
82 // Find end of opening `---` line.
83 let after_opening = match content.find('\n') {
84 Some(pos) => pos + 1,
85 None => {
86 // Content is just "---" with no newline — no valid frontmatter.
87 return ParsedDocument {
88 frontmatter: HashMap::new(),
89 body: content.to_string(),
90 frontmatter_range: None,
91 frontmatter_error: None,
92 };
93 }
94 };
95
96 // Search for closing `---` line in the remainder.
97 // Char-aligned: `after_opening = pos + 1` where `pos = content.find('\n')`,
98 // and '\n' is a single ASCII byte, so the index lands on a char boundary.
99 #[allow(clippy::string_slice)]
100 let rest = &content[after_opening..];
101 let mut offset = 0;
102 for line in rest.lines() {
103 if line.trim() == "---" {
104 // Found closing delimiter.
105 let close_line_start = after_opening + offset;
106 let close_line_end = close_line_start + line.len();
107
108 // Include the newline after the closing `---` if present.
109 let fm_end = if close_line_end < content.len()
110 && content.as_bytes()[close_line_end] == b'\n'
111 {
112 close_line_end + 1
113 } else {
114 close_line_end
115 };
116
117 // The YAML text is between the opening and closing delimiters.
118 // Char-aligned: `after_opening` follows '\n' (ASCII), and
119 // `close_line_start = after_opening + offset` where `offset`
120 // accumulates `line.len() + 1` per line returned by `lines()`
121 // (each line is a complete-char slice and '\n' is one byte).
122 #[allow(clippy::string_slice)]
123 let yaml_text = &content[after_opening..close_line_start];
124
125 // Parse the YAML.
126 let frontmatter: HashMap<String, serde_yaml::Value> =
127 match serde_yaml::from_str(yaml_text) {
128 Ok(map) => map,
129 Err(e) => {
130 // Invalid YAML. Record the block range + surface the
131 // error instead of silently swallowing it (which used to
132 // dump the raw `---...---` block into `body`, leaking it
133 // verbatim into rendered HTML with no warning — the
134 // "Europe - A Prophecy.md" bug). `body` stays the WHOLE
135 // document so the editor can still show/repair the block
136 // and a re-serialize save preserves the file; the build
137 // renders `render_body()` (block-excluded) so nothing
138 // leaks. See ADR-020.
139 return ParsedDocument {
140 frontmatter: HashMap::new(),
141 body: content.to_string(),
142 frontmatter_range: Some((0, fm_end)),
143 frontmatter_error: Some(e.to_string()),
144 };
145 }
146 };
147
148 // Char-aligned: `fm_end` is `close_line_end` (= line-aligned via `lines()`
149 // + ASCII '---') optionally + 1 for an ASCII '\n'.
150 #[allow(clippy::string_slice)]
151 let body = &content[fm_end..];
152
153 return ParsedDocument {
154 frontmatter,
155 body: body.to_string(),
156 frontmatter_range: Some((0, fm_end)),
157 frontmatter_error: None,
158 };
159 }
160 offset += line.len() + 1; // +1 for '\n'
161 }
162
163 // No closing delimiter found — no valid frontmatter.
164 ParsedDocument {
165 frontmatter: HashMap::new(),
166 body: content.to_string(),
167 frontmatter_range: None,
168 frontmatter_error: None,
169 }
170}
171
172/// Serialize frontmatter and body back into a markdown document.
173///
174/// Produces `---\n{yaml}\n---\n{body}`. If frontmatter is empty,
175/// returns just the body.
176///
177/// String values that look like YAML numbers (integers, floats, scientific
178/// notation like `753659e7`) are forced to `serde_yaml::Value::String` before
179/// serialization so that serde_yaml quotes them. This prevents silent data
180/// corruption on the next parse.
181pub fn serialize(
182 frontmatter: &HashMap<String, serde_yaml::Value>,
183 body: &str,
184) -> Result<String, String> {
185 if frontmatter.is_empty() {
186 return Ok(body.to_string());
187 }
188
189 // Ensure string values that look numeric are serialized as quoted strings.
190 // Also strip stray control characters (defense-in-depth mirror of the
191 // frontend `beforeinput` guard, see below) before either transform.
192 let safe_fm: HashMap<String, serde_yaml::Value> = frontmatter
193 .iter()
194 .map(|(k, v)| (k.clone(), ensure_strings_quoted(&strip_control_chars(v))))
195 .collect();
196
197 let yaml =
198 serde_yaml::to_string(&safe_fm).map_err(|e| format!("YAML serialize error: {}", e))?;
199
200 // serde_yaml adds a trailing newline; no need to add another.
201 Ok(format!("---\n{}---\n{}", yaml, body))
202}
203
204/// Recursively strip stray C0/C1 control characters from `serde_yaml::Value`
205/// strings (write-boundary defense-in-depth).
206///
207/// ── Why this exists (root cause) ─────────────────────────────────────────
208/// On macOS, Tauri v2's multiwebview path (moss enables the `unstable`
209/// feature and creates the editor as a child webview via `window.add_child`)
210/// hits an unfixed wry bug: arrow keys forward into AppKit's
211/// `interpretKeyEvents:` -> `insertText:`, which types the arrow key's
212/// legacy control code (Left 0x1C, Right 0x1D, Up 0x1E, Down 0x1F) into a
213/// plain `<input>`/`<textarea>` instead of only moving the caret. See
214/// `tauri-apps/tauri#10194` (open upstream issue).
215///
216/// The frontend guards this at the DOM `beforeinput` boundary (see
217/// `frontend/app/shared/ui/control-char-guard.ts`), but this Rust strip mirrors it
218/// at the write boundary as defense-in-depth — any control char that reaches
219/// this point (e.g. a value set before the guard was installed, or via a
220/// path that bypasses the DOM entirely) is stripped before it is ever
221/// persisted to disk.
222///
223/// Removes C0 controls (0x00-0x1F) EXCEPT TAB (0x09), LF (0x0A), CR (0x0D);
224/// DEL (0x7F); and C1 controls (0x80-0x9F). This numeric-range approach
225/// mirrors the frontend guard's `CONTROL_RANGES` table exactly.
226fn strip_control_chars(value: &serde_yaml::Value) -> serde_yaml::Value {
227 match value {
228 serde_yaml::Value::String(s) => serde_yaml::Value::String(strip_control_chars_str(s)),
229 serde_yaml::Value::Sequence(seq) => {
230 serde_yaml::Value::Sequence(seq.iter().map(strip_control_chars).collect())
231 }
232 serde_yaml::Value::Mapping(map) => {
233 let mut new_map = serde_yaml::Mapping::new();
234 for (k, v) in map {
235 new_map.insert(k.clone(), strip_control_chars(v));
236 }
237 serde_yaml::Value::Mapping(new_map)
238 }
239 // Leave other types as-is
240 other => other.clone(),
241 }
242}
243
244/// True if `c` is a C0/C1 control character that must never survive into
245/// saved frontmatter (excludes TAB/LF/CR, which are legitimate whitespace).
246fn is_stray_control_char(c: char) -> bool {
247 matches!(c as u32,
248 0x00..=0x08 | 0x0b..=0x0c | 0x0e..=0x1f | 0x7f..=0x9f
249 )
250}
251
252/// Remove all stray C0/C1 control characters (excluding TAB/LF/CR) from `s`.
253///
254/// The shared control-char stripper: this is the same string-level primitive
255/// `strip_control_chars` (above) applies recursively to `serde_yaml::Value`
256/// trees. It is `pub` so other crates (e.g. `src-tauri`'s scrape/email write
257/// paths) can apply the identical defense-in-depth strip at their own
258/// hand-rolled or `serde_yaml`-based frontmatter funnels — see
259/// `tauri-apps/tauri#10194`.
260pub fn strip_control_chars_str(s: &str) -> String {
261 s.chars().filter(|c| !is_stray_control_char(*c)).collect()
262}
263
264/// Recursively ensure that `serde_yaml::Value::Number` values that were
265/// originally strings (e.g., UIDs like "753659e7") remain as strings.
266///
267/// This is a defensive measure: if a value is already a `String`, leave it.
268/// If it's a `Number`, convert it to `String` representation so serde_yaml
269/// will quote it. This handles the case where a previous parse already
270/// corrupted a hex-like UID into a float.
271///
272/// For sequences and mappings, recurse.
273fn ensure_strings_quoted(value: &serde_yaml::Value) -> serde_yaml::Value {
274 match value {
275 serde_yaml::Value::Sequence(seq) => {
276 serde_yaml::Value::Sequence(seq.iter().map(ensure_strings_quoted).collect())
277 }
278 serde_yaml::Value::Mapping(map) => {
279 let mut new_map = serde_yaml::Mapping::new();
280 for (k, v) in map {
281 new_map.insert(k.clone(), ensure_strings_quoted(v));
282 }
283 serde_yaml::Value::Mapping(new_map)
284 }
285 // Leave other types as-is
286 other => other.clone(),
287 }
288}
289
290/// Extract a frontmatter value as a string, handling the case where YAML
291/// parsed a hex-like string (e.g., `753659e7`) as a number.
292///
293/// Returns `Some(string)` if the value is a String or a Number that can be
294/// converted to string. Returns `None` for other types.
295pub fn value_as_string(value: &serde_yaml::Value) -> Option<String> {
296 match value {
297 serde_yaml::Value::String(s) => Some(s.clone()),
298 serde_yaml::Value::Number(n) => Some(format!("{}", n)),
299 serde_yaml::Value::Bool(b) => Some(format!("{}", b)),
300 _ => None,
301 }
302}
303
304// ---------------------------------------------------------------------------
305// Tests
306// ---------------------------------------------------------------------------
307
308// ── Structural asset paths in frontmatter ────────────────────────────────
309
310/// Byte spans of frontmatter values that name a project file.
311///
312/// The field set is derived from the schema
313/// ([`crate::schema_fields::asset_field_names`]), never listed here.
314///
315/// # Why this does not use `parse`
316///
317/// [`parse`] CRLF-normalizes before computing `frontmatter_range`, so those
318/// offsets index a COPY and are unsafe for rewriting a CRLF source. This
319/// locates the block on its own raw line table instead. It agrees with
320/// `parse` on what a block is (any content starting with `---`, ending at
321/// the next line that trims to `---`), so it adds no false-positive surface
322/// relative to the parser that already reads these files.
323///
324/// # Why this does not use the inert mask
325///
326/// Frontmatter is YAML, not markdown. A 4-space-indented `cover:` under
327/// `cascade:` after a blank line reads as an indented code block to
328/// [`crate::inert_regions`] and would be silently dropped.
329///
330/// Nothing is deserialized and nothing re-serialized: only the value span is
331/// ever replaced, so YAML comments, key order and quoting style survive.
332pub fn frontmatter_asset_spans(source: &str) -> Vec<crate::resolve::md_extract::AssetPathSpan> {
333 use crate::resolve::md_extract::{AssetPathSpan, PathContainer};
334
335 let mut out = Vec::new();
336 let table = crate::resolve::md_extract::line_table(source);
337 let line_at = |k: usize| -> &str {
338 let (base, content, _) = table[k];
339 #[allow(clippy::string_slice)]
340 // Line boundaries from `line_table`, which splits on ASCII '\n'/'\r'.
341 &source[base..base + content]
342 };
343
344 // The block must open on line 0 and close on a later `---` line.
345 if table.is_empty() || line_at(0).trim() != "---" {
346 return out;
347 }
348 let Some(close) = (1..table.len()).find(|&k| line_at(k).trim() == "---") else {
349 return out;
350 };
351
352 let keys: Vec<&str> = crate::schema_fields::asset_field_names().collect();
353 // Indent of the key that opened a `|`/`>` block scalar, if we are inside
354 // one. Every deeper-indented line below it is content, not a mapping —
355 // this is what stops a `description: |` body containing `cover: x.png`
356 // from being rewritten.
357 let mut block_scalar_indent: Option<usize> = None;
358
359 for k in 1..close {
360 let (base, content_len, term_len) = table[k];
361 let line = line_at(k);
362 let indent = line.len() - line.trim_start().len();
363
364 if let Some(bi) = block_scalar_indent {
365 if line.trim().is_empty() || indent > bi {
366 continue;
367 }
368 block_scalar_indent = None;
369 }
370 if line.trim().is_empty() {
371 continue;
372 }
373
374 let Some(colon) = line.find(':') else { continue };
375 #[allow(clippy::string_slice)]
376 // `find` on ASCII ':' → char boundary; `indent` counts leading ASCII
377 // whitespace.
378 let key = line[indent..colon].trim();
379 #[allow(clippy::string_slice)]
380 let after = &line[colon + 1..];
381
382 // `key: |` / `key: >-` / `key: |2` opens a block scalar.
383 let t = after.trim();
384 if t.starts_with('|') || t.starts_with('>') {
385 #[allow(clippy::string_slice)]
386 // '|' and '>' are ASCII.
387 let tail = t[1..].trim_start_matches(['+', '-']);
388 if tail.chars().all(|c| c.is_ascii_digit()) {
389 block_scalar_indent = Some(indent);
390 continue;
391 }
392 }
393
394 if !keys.contains(&key) {
395 continue;
396 }
397
398 // Value start: first non-space after the colon.
399 let vrel = colon + 1 + (after.len() - after.trim_start().len());
400 if vrel >= content_len {
401 continue;
402 }
403 #[allow(clippy::string_slice)]
404 let raw_tail = &line[vrel..];
405 let first = raw_tail.as_bytes()[0];
406 // Flow collections are not asset paths.
407 if first == b'[' || first == b'{' {
408 continue;
409 }
410
411 let (value_len, quote) = match first {
412 b'"' => (scan_quoted(raw_tail, '"'), Some('"')),
413 b'\'' => (scan_quoted(raw_tail, '\''), Some('\'')),
414 _ => (scan_plain(raw_tail), None),
415 };
416 let Some(value_len) = value_len else { continue };
417 #[allow(clippy::string_slice)]
418 // `scan_quoted` / `scan_plain` return char-boundary lengths.
419 let raw = &raw_tail[..value_len];
420 let inner = match quote {
421 Some('"') => unescape_double(raw),
422 Some('\'') => raw
423 .trim_matches('\'')
424 .replace("''", "'"),
425 _ => raw.to_string(),
426 };
427 if inner.trim().is_empty() {
428 continue;
429 }
430 let (path, attrs) = crate::media::split_pipe(&inner);
431 let path = crate::media::strip_wikilink(path).trim().to_string();
432 if path.is_empty() {
433 continue;
434 }
435
436 out.push(AssetPathSpan {
437 path,
438 attrs: attrs.to_string(),
439 quote,
440 value: base + vrel..base + vrel + value_len,
441 outer: base..base + content_len + term_len,
442 container: PathContainer::FrontmatterField {
443 key: key.to_string(),
444 },
445 });
446 }
447
448 out
449}
450
451/// Byte length of a quoted YAML scalar starting at `s[0]` (the open quote),
452/// INCLUDING both quotes. `None` when the quote never closes on this line.
453fn scan_quoted(s: &str, q: char) -> Option<usize> {
454 let bytes = s.as_bytes();
455 let mut i = 1;
456 while i < bytes.len() {
457 if q == '"' && bytes[i] == b'\\' {
458 i += 2;
459 continue;
460 }
461 if bytes[i] == q as u8 {
462 // YAML single quotes escape by doubling.
463 if q == '\'' && bytes.get(i + 1) == Some(&b'\'') {
464 i += 2;
465 continue;
466 }
467 return Some(i + 1);
468 }
469 i += 1;
470 }
471 None
472}
473
474/// Byte length of a plain (unquoted) YAML scalar: to end of line, minus a
475/// trailing ` #` comment, minus trailing whitespace.
476fn scan_plain(s: &str) -> Option<usize> {
477 let bytes = s.as_bytes();
478 let mut end = bytes.len();
479 for i in 0..bytes.len() {
480 if bytes[i] == b'#' && i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
481 end = i;
482 break;
483 }
484 }
485 while end > 0 && (bytes[end - 1] == b' ' || bytes[end - 1] == b'\t') {
486 end -= 1;
487 }
488 if end == 0 {
489 None
490 } else {
491 Some(end)
492 }
493}
494
495/// Decode a double-quoted YAML scalar's inner text (`\"` and `\\`).
496fn unescape_double(raw: &str) -> String {
497 let inner = raw
498 .strip_prefix('"')
499 .and_then(|r| r.strip_suffix('"'))
500 .unwrap_or(raw);
501 let mut out = String::with_capacity(inner.len());
502 let mut chars = inner.chars();
503 while let Some(c) = chars.next() {
504 if c == '\\' {
505 if let Some(n) = chars.next() {
506 out.push(n);
507 }
508 } else {
509 out.push(c);
510 }
511 }
512 out
513}
514
515#[cfg(test)]
516#[path = "frontmatter_tests.rs"]
517mod tests;