Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/src/notebooks/editor/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ use string_offset::CharOffset;
use warp_core::features::FeatureFlag;
use warp_core::semantic_selection::SemanticSelection;
use warp_editor::{
content::{buffer::ShouldAutoscroll, selection_model::BufferSelectionModel},
content::{
buffer::ShouldAutoscroll, mermaid_diagram::strip_mermaid_frontmatter,
selection_model::BufferSelectionModel,
},
model::BufferUpdateWrapper,
render::model::{BlockItem, StyleUpdateAction},
};
Expand Down Expand Up @@ -154,7 +157,8 @@ fn mermaid_image_html(svg: &[u8]) -> String {
}

fn render_mermaid_clipboard_html(source: &str) -> Option<String> {
let svg = mermaid_to_svg::render_mermaid_to_svg(source, Some(&MermaidTheme::light()))
let source = strip_mermaid_frontmatter(source);
let svg = mermaid_to_svg::render_mermaid_to_svg(source.as_ref(), Some(&MermaidTheme::light()))
.ok()?
.into_bytes();
Some(mermaid_image_html(&svg))
Expand Down
51 changes: 50 additions & 1 deletion crates/editor/src/content/mermaid_diagram.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::{
borrow::Cow,
hash::{DefaultHasher, Hash, Hasher},
sync::Arc,
};
Expand All @@ -24,8 +25,56 @@ struct MermaidDiagramAsset;

impl AsyncAssetType for MermaidDiagramAsset {}

/// Strip a leading Mermaid YAML frontmatter block (delimited by `---` lines
/// on their own) from `source`, leaving the diagram body untouched when no
/// frontmatter is present.
///
/// Mermaid 11 supports a `---\nconfig:\n ...\n---` block at the top of a
/// diagram for per-diagram configuration. The pinned `mermaid_to_svg`
/// renderer's diagram-type detection treats the first non-empty,
/// non-`%%`-prefixed line as the diagram token; with frontmatter that token
/// becomes `---` instead of the actual diagram type (e.g. `xychart-beta`),
/// and the renderer fails to dispatch to the right parser. Warp passes a
/// hardcoded [`MermaidTheme::light`] anyway and does not honor any of the
/// frontmatter config keys, so stripping is lossless. See
/// warpdotdev/warp#10676.
pub fn strip_mermaid_frontmatter(source: &str) -> Cow<'_, str> {
fn next_line_end(s: &str, start: usize) -> usize {
s[start..]
.find('\n')
.map(|p| start + p + 1)
.unwrap_or(s.len())
}

let mut cursor = 0;
while cursor < source.len() {
let end = next_line_end(source, cursor);
let line = source[cursor..end].trim();
if line.is_empty() {
cursor = end;
continue;
}
if line != "---" {
return Cow::Borrowed(source);
}
let mut scan = end;
while scan < source.len() {
let scan_end = next_line_end(source, scan);
if source[scan..scan_end].trim() == "---" {
return Cow::Owned(source[scan_end..].to_string());
}
scan = scan_end;
}
// Unterminated frontmatter — leave the source so the renderer's own
// error surfaces rather than silently dropping content.
return Cow::Borrowed(source);
}

Cow::Borrowed(source)
}

pub fn mermaid_asset_source(source: &str) -> AssetSource {
let source = source.to_string();
let source = strip_mermaid_frontmatter(source).into_owned();
let mut hasher = DefaultHasher::new();
source.hash(&mut hasher);
let id = format!("light:{:x}", hasher.finish());
Expand Down
132 changes: 132 additions & 0 deletions crates/editor/src/content/mermaid_diagram_tests.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::borrow::Cow;

use super::*;
use crate::render::{layout::TextLayout, model::test_utils::TEST_STYLES};
use warpui::{
Expand Down Expand Up @@ -37,6 +39,136 @@ fn loading_mermaid_layout_uses_default_height() {
})
}

#[test]
fn strip_frontmatter_removes_leading_config_block() {
// The exact sample from issue #10676.
let source = "---\nconfig:\n theme: default\n---\nxychart-beta\n title \"x\"\n";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, "xychart-beta\n title \"x\"\n");
assert!(matches!(stripped, Cow::Owned(_)));
}

#[test]
fn strip_frontmatter_preserves_source_without_frontmatter() {
let source = "graph TD\nA --> B\n";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, source);
assert!(
matches!(stripped, Cow::Borrowed(_)),
"no-op stripping should return Borrowed to avoid allocation",
);
}

#[test]
fn strip_frontmatter_skips_leading_blank_lines_before_open_delimiter() {
let source = "\n\n \n---\nconfig:\n theme: dark\n---\npie\n \"a\" : 1\n";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, "pie\n \"a\" : 1\n");
}

#[test]
fn strip_frontmatter_handles_crlf_line_endings() {
let source = "---\r\nconfig:\r\n theme: default\r\n---\r\nflowchart TD\r\nA --> B\r\n";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, "flowchart TD\r\nA --> B\r\n");
}

#[test]
fn strip_frontmatter_leaves_text_starting_with_three_dashes_then_content() {
// `--- something` (with content after the dashes) is not a frontmatter
// delimiter — the trimmed line is `--- something`, not `---`.
let source = "--- some weird title\nflowchart TD\nA --> B\n";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, source);
assert!(matches!(stripped, Cow::Borrowed(_)));
}

#[test]
fn strip_frontmatter_leaves_unterminated_block_for_renderer_to_surface() {
// No closing `---`: leave intact so mermaid_to_svg can surface its own
// error instead of silently dropping the body.
let source = "---\nconfig:\n theme: default\nflowchart TD\nA --> B\n";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, source);
assert!(matches!(stripped, Cow::Borrowed(_)));
}

#[test]
fn strip_frontmatter_handles_empty_input() {
assert_eq!(strip_mermaid_frontmatter(""), "");
}

#[test]
fn strip_frontmatter_handles_only_frontmatter_no_body() {
let source = "---\nconfig: {}\n---\n";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, "");
}

#[test]
fn strip_frontmatter_handles_frontmatter_without_trailing_newline() {
// Closing `---` on the final line (no newline after).
let source = "---\nfoo: bar\n---";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, "");
}

#[test]
fn strip_frontmatter_handles_open_delimiter_only_with_newline() {
// `---\n` and nothing else: treated as unterminated, leave intact.
let source = "---\n";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, source);
assert!(matches!(stripped, Cow::Borrowed(_)));
}

#[test]
fn strip_frontmatter_handles_open_delimiter_only_without_newline() {
// Single line `---` with no newline at all: must not panic / loop forever;
// returned as-is for the renderer to surface.
let source = "---";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, source);
assert!(matches!(stripped, Cow::Borrowed(_)));
}

#[test]
fn strip_frontmatter_treats_indented_dashes_as_delimiter() {
// Documents the intentional choice: a leading-whitespace `---` line is
// trimmed before comparison and treated as a frontmatter delimiter. This
// mirrors the renderer's own `first_diagram_type_token`, which trims each
// line before matching the diagram token — being stricter here would
// diverge from the renderer and leave a class of broken sources broken.
let source = "\t---\n config: x\n ---\nflowchart TD\nA --> B\n";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, "flowchart TD\nA --> B\n");
}

#[test]
fn strip_frontmatter_does_not_treat_inner_dashes_line_as_delimiter() {
// The `---` between `title` and `body` here is NOT a frontmatter open
// because the first content line is `flowchart TD`, not `---`.
let source = "flowchart TD\n---\nA --> B\n";
let stripped = strip_mermaid_frontmatter(source);
assert_eq!(stripped, source);
}

#[test]
fn mermaid_asset_source_hashes_post_strip_for_cache_key_stability() {
// The asset cache key must be derived from the post-strip source so that
// the same logical diagram with and without frontmatter doesn't churn
// the cache (and so the bug-fix actually changes what gets rendered).
let with_frontmatter = "---\nconfig:\n theme: default\n---\nflowchart TD\nA --> B\n";
let without_frontmatter = "flowchart TD\nA --> B\n";
let with = mermaid_asset_source(with_frontmatter);
let without = mermaid_asset_source(without_frontmatter);
let (with_id, without_id) = match (&with, &without) {
(AssetSource::Async { id: a, .. }, AssetSource::Async { id: b, .. }) => (a, b),
_ => panic!("expected Async asset sources"),
};
assert_eq!(with_id, without_id);
}

#[test]
fn failed_mermaid_layout_uses_compact_height() {
App::test((), |app| async move {
Expand Down