Skip to main content

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;