diff --git a/src/app.rs b/src/app.rs index 68b3358..56355c2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,6 +73,87 @@ fn is_markdown_file(path: &Path) -> bool { .unwrap_or(false) } +/// Transforms a single GitHub's Alert (aka callout or admonition) from raw text to CSS classes for proper +/// styling in the frontend. This only handles one at a time, and returns `None` when none is found. +fn transform_github_alert_blockquote(inner: &str) -> Option { + let first_paragraph_start = inner.find("

")?; + if !inner[..first_paragraph_start].trim().is_empty() { + return None; + } + + let paragraph_content_start = first_paragraph_start + "

".len(); + let paragraph_end_relative = inner[paragraph_content_start..].find("

")?; + let paragraph_content_end = paragraph_content_start + paragraph_end_relative; + let first_paragraph_content = &inner[paragraph_content_start..paragraph_content_end]; + + let trimmed = first_paragraph_content.trim_start(); + let marker_start = trimmed.find("[!")?; + if marker_start != 0 { + return None; + } + + let marker_end = trimmed.find(']')?; + let marker = &trimmed[2..marker_end]; + let title = match marker { + "NOTE" => Some("Note"), + "TIP" => Some("Tip"), + "IMPORTANT" => Some("Important"), + "WARNING" => Some("Warning"), + "CAUTION" => Some("Caution"), + _ => None, + }?; + + let after_marker = trimmed[(marker_end + 1)..].trim_start(); + let mut alert_body = String::new(); + + if !after_marker.is_empty() { + alert_body.push_str("

"); + alert_body.push_str(after_marker); + alert_body.push_str("

"); + } + + let remaining = &inner[(paragraph_content_end + "

".len())..]; + alert_body.push_str(remaining); + + Some(format!( + "

{}

{}
", + marker.to_ascii_lowercase(), + title, + alert_body + )) +} + +/// Transforms all GitHub Alerts (aka callouts or admonitions) in the HTML to proper styled CSS classes. +/// The string is transformed in-place, i.e. without creating a whole new one; this means it must +/// accept a `&mut String`. +fn transform_github_alerts_html_in_place(html: &mut String) { + let mut replacements = Vec::new(); + let mut search_start = 0; + + while let Some(rel_start) = html[search_start..].find("
") { + let block_start = search_start + rel_start; + let inner_start = block_start + "
".len(); + + let Some(rel_end) = html[inner_start..].find("
") else { + break; + }; + + let inner_end = inner_start + rel_end; + let block_end = inner_end + "
".len(); + let inner = &html[inner_start..inner_end]; + + if let Some(alert_html) = transform_github_alert_blockquote(inner) { + replacements.push((block_start, block_end, alert_html)); + } + + search_start = block_end; + } + + for (start, end, replacement) in replacements.into_iter().rev() { + html.replace_range(start..end, &replacement); + } +} + struct TrackedFile { path: PathBuf, last_modified: SystemTime, @@ -163,9 +244,10 @@ impl MarkdownState { options.compile.allow_dangerous_html = true; options.parse.constructs.frontmatter = true; - let html_body = markdown::to_html_with_options(content, &options) + let mut html_body = markdown::to_html_with_options(content, &options) .unwrap_or_else(|_| "Error parsing markdown".to_string()); + transform_github_alerts_html_in_place(&mut html_body); Ok(html_body) } } @@ -1031,6 +1113,58 @@ fn main() { assert!(body.contains("fn main()")); } + #[tokio::test] + async fn test_github_alert_blocks_are_supported() { + let markdown_content = r#"# Alert Test + +> [!NOTE] +> Note details + +> [!TIP] +> Tip details + +> [!IMPORTANT] +> Important details + +> [!WARNING] +> Warning details + +> [!CAUTION] +> Caution details +"#; + + let (server, _temp_file) = create_test_server(markdown_content).await; + + let response = server.get("/").await; + + assert_eq!(response.status_code(), 200); + let body = response.text(); + + assert!(!body.contains("[!NOTE]")); + assert!(!body.contains("[!TIP]")); + assert!(!body.contains("[!IMPORTANT]")); + assert!(!body.contains("[!WARNING]")); + assert!(!body.contains("[!CAUTION]")); + + assert!(body.contains(r#"
"#)); + assert!(body.contains(r#"
"#)); + assert!(body.contains(r#"
"#)); + assert!(body.contains(r#"
"#)); + assert!(body.contains(r#"
"#)); + + assert!(body.contains(r#"

Note

"#)); + assert!(body.contains(r#"

Tip

"#)); + assert!(body.contains(r#"

Important

"#)); + assert!(body.contains(r#"

Warning

"#)); + assert!(body.contains(r#"

Caution

"#)); + + assert!(body.contains("Note details")); + assert!(body.contains("Tip details")); + assert!(body.contains("Important details")); + assert!(body.contains("Warning details")); + assert!(body.contains("Caution details")); + } + #[tokio::test] async fn test_404_for_unknown_routes() { let (server, _temp_file) = create_test_server("# 404 Test").await; diff --git a/templates/main.html b/templates/main.html index 99b31bb..1f5b8a8 100644 --- a/templates/main.html +++ b/templates/main.html @@ -57,6 +57,16 @@ --blockquote-color: #6a737d; --link-color: #0366d6; --table-header-bg: #f6f8fa; + --alert-note-bg: #ddf4ff; + --alert-note-border: #54aeff; + --alert-tip-bg: #dafbe1; + --alert-tip-border: #34d058; + --alert-important-bg: #fbefff; + --alert-important-border: #bf4bff; + --alert-warning-bg: #fff8c5; + --alert-warning-border: #d29922; + --alert-caution-bg: #ffebe9; + --alert-caution-border: #cf222e; --sidebar-width: 250px; --sidebar-collapsed-width: 48px; --content-max-width: 900px; @@ -73,6 +83,16 @@ --blockquote-color: #8b949e; --link-color: #58a6ff; --table-header-bg: #161b22; + --alert-note-bg: rgba(56, 139, 253, 0.15); + --alert-note-border: #388bfd; + --alert-tip-bg: rgba(63, 185, 80, 0.15); + --alert-tip-border: #3fb950; + --alert-important-bg: rgba(163, 113, 247, 0.18); + --alert-important-border: #a371f7; + --alert-warning-bg: rgba(210, 153, 34, 0.18); + --alert-warning-border: #d29922; + --alert-caution-bg: rgba(248, 81, 73, 0.15); + --alert-caution-border: #f85149; } [data-theme="catppuccin-latte"] { @@ -84,6 +104,16 @@ --blockquote-color: #6c6f85; --link-color: #1e66f5; --table-header-bg: #ccd0da; + --alert-note-border: #1e66f5; + --alert-tip-border: #40a02b; + --alert-important-border: #8839ef; + --alert-warning-border: #df8e1d; + --alert-caution-border: #d20f39; + --alert-note-bg: color-mix(in srgb, #eff1f5 84%, #1e66f5 16%); + --alert-tip-bg: color-mix(in srgb, #eff1f5 84%, #40a02b 16%); + --alert-important-bg: color-mix(in srgb, #eff1f5 84%, #8839ef 16%); + --alert-warning-bg: color-mix(in srgb, #eff1f5 80%, #df8e1d 20%); + --alert-caution-bg: color-mix(in srgb, #eff1f5 84%, #d20f39 16%); } [data-theme="catppuccin-macchiato"] { @@ -95,6 +125,16 @@ --blockquote-color: #a5adcb; --link-color: #8aadf4; --table-header-bg: #363a4f; + --alert-note-border: #8aadf4; + --alert-tip-border: #a6da95; + --alert-important-border: #c6a0f6; + --alert-warning-border: #eed49f; + --alert-caution-border: #ed8796; + --alert-note-bg: color-mix(in srgb, #24273a 82%, #8aadf4 18%); + --alert-tip-bg: color-mix(in srgb, #24273a 82%, #a6da95 18%); + --alert-important-bg: color-mix(in srgb, #24273a 80%, #c6a0f6 20%); + --alert-warning-bg: color-mix(in srgb, #24273a 78%, #eed49f 22%); + --alert-caution-bg: color-mix(in srgb, #24273a 82%, #ed8796 18%); } [data-theme="catppuccin-mocha"] { @@ -106,6 +146,16 @@ --blockquote-color: #a6adc8; --link-color: #89b4fa; --table-header-bg: #313244; + --alert-note-border: #89b4fa; + --alert-tip-border: #a6e3a1; + --alert-important-border: #cba6f7; + --alert-warning-border: #f9e2af; + --alert-caution-border: #f38ba8; + --alert-note-bg: color-mix(in srgb, #1e1e2e 82%, #89b4fa 18%); + --alert-tip-bg: color-mix(in srgb, #1e1e2e 82%, #a6e3a1 18%); + --alert-important-bg: color-mix(in srgb, #1e1e2e 80%, #cba6f7 20%); + --alert-warning-bg: color-mix(in srgb, #1e1e2e 78%, #f9e2af 22%); + --alert-caution-bg: color-mix(in srgb, #1e1e2e 82%, #f38ba8 18%); } /* Common body styles */ @@ -424,6 +474,42 @@ margin-left: 0; color: var(--blockquote-color); } + blockquote.markdown-alert { + border-left-width: 4px; + border-left-style: solid; + border-radius: 6px; + color: var(--text-color); + margin: 16px 0; + padding: 12px 16px; + } + .markdown-alert-title { + font-weight: 600; + line-height: 1.25; + margin: 0 0 8px 0; + } + blockquote.markdown-alert > :last-child { + margin-bottom: 0; + } + blockquote.markdown-alert-note { + background: var(--alert-note-bg); + border-left-color: var(--alert-note-border); + } + blockquote.markdown-alert-tip { + background: var(--alert-tip-bg); + border-left-color: var(--alert-tip-border); + } + blockquote.markdown-alert-important { + background: var(--alert-important-bg); + border-left-color: var(--alert-important-border); + } + blockquote.markdown-alert-warning { + background: var(--alert-warning-bg); + border-left-color: var(--alert-warning-border); + } + blockquote.markdown-alert-caution { + background: var(--alert-caution-bg); + border-left-color: var(--alert-caution-border); + } table { border-collapse: collapse; width: 100%;