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
136 changes: 135 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let first_paragraph_start = inner.find("<p>")?;
if !inner[..first_paragraph_start].trim().is_empty() {
return None;
}

let paragraph_content_start = first_paragraph_start + "<p>".len();
let paragraph_end_relative = inner[paragraph_content_start..].find("</p>")?;
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("<p>");
alert_body.push_str(after_marker);
alert_body.push_str("</p>");
}

let remaining = &inner[(paragraph_content_end + "</p>".len())..];
alert_body.push_str(remaining);

Some(format!(
"<blockquote class=\"markdown-alert markdown-alert-{}\"><p class=\"markdown-alert-title\">{}</p>{}</blockquote>",
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("<blockquote>") {
let block_start = search_start + rel_start;
let inner_start = block_start + "<blockquote>".len();

let Some(rel_end) = html[inner_start..].find("</blockquote>") else {
break;
};

Comment on lines +137 to +140
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match closing blockquote with nesting-aware parsing

This loop identifies a blockquote’s end via the first </blockquote> after inner_start, which is incorrect when the blockquote contains a nested blockquote. In that case, rel_end points to the inner close tag, so the replacement range is truncated; for an alert like > [!NOTE] containing a nested quote, the transform can emit malformed HTML (leaving an unmatched outer </blockquote>) and also skip transforming nested alerts. A nesting-aware matcher (or HTML parser) is needed here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This actually seems to align with GitHub's own parser:
image
(from Gist)

image

let inner_end = inner_start + rel_end;
let block_end = inner_end + "</blockquote>".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,
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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#"<blockquote class="markdown-alert markdown-alert-note">"#));
assert!(body.contains(r#"<blockquote class="markdown-alert markdown-alert-tip">"#));
assert!(body.contains(r#"<blockquote class="markdown-alert markdown-alert-important">"#));
assert!(body.contains(r#"<blockquote class="markdown-alert markdown-alert-warning">"#));
assert!(body.contains(r#"<blockquote class="markdown-alert markdown-alert-caution">"#));

assert!(body.contains(r#"<p class="markdown-alert-title">Note</p>"#));
assert!(body.contains(r#"<p class="markdown-alert-title">Tip</p>"#));
assert!(body.contains(r#"<p class="markdown-alert-title">Important</p>"#));
assert!(body.contains(r#"<p class="markdown-alert-title">Warning</p>"#));
assert!(body.contains(r#"<p class="markdown-alert-title">Caution</p>"#));

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;
Expand Down
86 changes: 86 additions & 0 deletions templates/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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"] {
Expand All @@ -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"] {
Expand All @@ -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"] {
Expand All @@ -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 */
Expand Down Expand Up @@ -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%;
Expand Down
Loading