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 ")?;
+ 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(" ");
+ alert_body.push_str(after_marker);
+ alert_body.push_str("
", + 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 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#"".len(); + + let Some(rel_end) = html[inner_start..].find("") else { + break; + }; + + let inner_end = inner_start + rel_end; + let block_end = inner_end + "
"#)); + 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%;