diff --git a/flake.nix b/flake.nix index b023039..514e450 100644 --- a/flake.nix +++ b/flake.nix @@ -42,6 +42,7 @@ toolchain pkgs.treefmt pkgs.shfmt + pkgs.rust-analyzer pkgs.taplo pkgs.pkg-config pkgs.nixfmt diff --git a/src/highlight.rs b/src/highlight.rs index 0309fe8..aa356ec 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -29,27 +29,28 @@ const HIGHLIGHT_NAMES: &[&str] = &[ /// Static HTML attributes for each highlight class. /// Pre-computed to avoid allocations in the render loop. +/// HtmlRenderer wraps with ..., callback returns just the attributes. const HTML_ATTRS: &[&[u8]] = &[ - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", - b"", + b" class=\"hl-attribute\"", + b" class=\"hl-comment\"", + b" class=\"hl-constant\"", + b" class=\"hl-constant-builtin\"", + b" class=\"hl-constructor\"", + b" class=\"hl-function\"", + b" class=\"hl-function-builtin\"", + b" class=\"hl-keyword\"", + b" class=\"hl-number\"", + b" class=\"hl-operator\"", + b" class=\"hl-property\"", + b" class=\"hl-punctuation\"", + b" class=\"hl-punctuation-bracket\"", + b" class=\"hl-punctuation-delimiter\"", + b" class=\"hl-string\"", + b" class=\"hl-type\"", + b" class=\"hl-type-builtin\"", + b" class=\"hl-variable\"", + b" class=\"hl-variable-builtin\"", + b" class=\"hl-variable-parameter\"", ]; /// Supported languages for syntax highlighting. @@ -147,7 +148,7 @@ mod tests { let html = highlight_code(Language::Rust, code); // Should contain span elements with highlight classes - assert!(html.contains(" String { let options = Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES @@ -14,10 +12,190 @@ pub fn markdown_to_html(markdown: &str) -> String { let parser = Parser::new_ext(markdown, options); let mut html_output = String::new(); - html::push_html(&mut html_output, parser); + let mut code_block_lang: Option = None; + let mut code_block_content = String::new(); + + for event in parser { + match event { + Event::Start(Tag::CodeBlock(kind)) => { + // Extract language from code fence + code_block_lang = match kind { + CodeBlockKind::Fenced(lang) => { + let lang_str = lang.as_ref().split_whitespace().next().unwrap_or(""); + if lang_str.is_empty() { + None + } else { + Some(lang_str.to_string()) + } + } + CodeBlockKind::Indented => None, + }; + code_block_content.clear(); + } + Event::Text(text) if code_block_lang.is_some() || !code_block_content.is_empty() => { + // Accumulate code block content + // Note: we're in a code block if we have a lang OR we've started accumulating + if code_block_lang.is_some() { + code_block_content.push_str(&text); + } else { + // Regular text, render normally + html_output.push_str(&html_escape(&text)); + } + } + Event::End(TagEnd::CodeBlock) => { + // Render the code block with highlighting + let lang_str = code_block_lang.as_deref().unwrap_or(""); + html_output.push_str("
", lang_str));
+                    html_output.push_str(&highlight_code(lang, &code_block_content));
+                } else {
+                    // Unsupported language: render as plain escaped text
+                    if !lang_str.is_empty() {
+                        html_output.push_str(&format!(" class=\"language-{}\">", lang_str));
+                    } else {
+                        html_output.push('>');
+                    }
+                    html_output.push_str(&html_escape(&code_block_content));
+                }
+
+                html_output.push_str("
\n"); + code_block_lang = None; + code_block_content.clear(); + } + Event::Text(text) => { + // Regular text outside code blocks + html_output.push_str(&html_escape(&text)); + } + Event::Code(text) => { + // Inline code + html_output.push_str(""); + html_output.push_str(&html_escape(&text)); + html_output.push_str(""); + } + Event::Start(tag) => { + html_output.push_str(&start_tag_to_html(&tag)); + } + Event::End(tag) => { + html_output.push_str(&end_tag_to_html(&tag)); + } + Event::SoftBreak => { + html_output.push('\n'); + } + Event::HardBreak => { + html_output.push_str("
\n"); + } + Event::Rule => { + html_output.push_str("
\n"); + } + Event::Html(html) | Event::InlineHtml(html) => { + html_output.push_str(&html); + } + Event::FootnoteReference(name) => { + html_output.push_str(&format!( + "{}", + name, name + )); + } + Event::TaskListMarker(checked) => { + let checkbox = if checked { + "" + } else { + "" + }; + html_output.push_str(checkbox); + } + Event::InlineMath(_) | Event::DisplayMath(_) => { + // Future: KaTeX integration + } + } + } + html_output } +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn start_tag_to_html(tag: &Tag) -> String { + match tag { + Tag::Paragraph => "

".to_string(), + Tag::Heading { level, .. } => format!("", *level as u8), + Tag::BlockQuote(_) => "

\n".to_string(), + Tag::CodeBlock(_) => String::new(), // Handled separately + Tag::List(Some(start)) => format!("
    \n", start), + Tag::List(None) => "
      \n".to_string(), + Tag::Item => "
    • ".to_string(), + Tag::FootnoteDefinition(name) => { + format!("
      ", name) + } + Tag::Table(_) => "\n".to_string(), + Tag::TableHead => "\n\n".to_string(), + Tag::TableRow => "\n".to_string(), + Tag::TableCell => "
      ".to_string(), + Tag::Emphasis => "".to_string(), + Tag::Strong => "".to_string(), + Tag::Strikethrough => "".to_string(), + Tag::Link { + dest_url, title, .. + } => { + if title.is_empty() { + format!("", dest_url) + } else { + format!("", dest_url, title) + } + } + Tag::Image { + dest_url, title, .. + } => { + format!("\"\"", dest_url, title) + } + Tag::HtmlBlock => String::new(), + Tag::MetadataBlock(_) => String::new(), + Tag::DefinitionListTitle => "
      ".to_string(), + Tag::DefinitionListDefinition => "
      ".to_string(), + Tag::DefinitionList => "
      ".to_string(), + } +} + +fn end_tag_to_html(tag: &TagEnd) -> String { + match tag { + TagEnd::Paragraph => "

      \n".to_string(), + TagEnd::Heading(level) => format!("\n", *level as u8), + TagEnd::BlockQuote(_) => "\n".to_string(), + TagEnd::CodeBlock => String::new(), // Handled separately + TagEnd::List(ordered) => { + if *ordered { + "\n".to_string() + } else { + "\n".to_string() + } + } + TagEnd::Item => "\n".to_string(), + TagEnd::FootnoteDefinition => "\n".to_string(), + TagEnd::Table => "
      \n".to_string(), + TagEnd::TableHead => "\n\n".to_string(), + TagEnd::TableRow => "\n".to_string(), + TagEnd::TableCell => "\n".to_string(), + TagEnd::Emphasis => "".to_string(), + TagEnd::Strong => "".to_string(), + TagEnd::Strikethrough => "".to_string(), + TagEnd::Link => "".to_string(), + TagEnd::Image => String::new(), + TagEnd::HtmlBlock => String::new(), + TagEnd::MetadataBlock(_) => String::new(), + TagEnd::DefinitionListTitle => "\n".to_string(), + TagEnd::DefinitionListDefinition => "\n".to_string(), + TagEnd::DefinitionList => "\n".to_string(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -29,4 +207,35 @@ mod tests { assert!(html.contains("

      Hello

      ")); assert!(html.contains("test")); } + + #[test] + fn test_code_block_highlighting() { + let md = "```rust\nfn main() {}\n```"; + let html = markdown_to_html(md); + + // Should contain highlighted code + assert!(html.contains("
      cargo run"));
      +    }
       }
      diff --git a/static/style.css b/static/style.css
      index f20208e..9d621a5 100644
      --- a/static/style.css
      +++ b/static/style.css
      @@ -40,7 +40,9 @@
       }
       
       /* Reset */
      -*, *::before, *::after {
      +*,
      +*::before,
      +*::after {
         box-sizing: border-box;
         margin: 0;
         padding: 0;
      @@ -62,7 +64,9 @@ body {
       }
       
       /* Layout */
      -nav, main, footer {
      +nav,
      +main,
      +footer {
         width: 100%;
         max-width: var(--max-width);
         margin: 0 auto;
      @@ -89,16 +93,28 @@ footer {
       }
       
       /* Typography */
      -h1, h2, h3 {
      +h1,
      +h2,
      +h3 {
         line-height: 1.3;
         margin-block: var(--space-lg) var(--space-md);
       }
       
      -h1 { font-size: 2rem; }
      -h2 { font-size: 1.5rem; }
      -h3 { font-size: 1.25rem; }
      +h1 {
      +  font-size: 2rem;
      +}
       
      -h1:first-child, h2:first-child, h3:first-child {
      +h2 {
      +  font-size: 1.5rem;
      +}
      +
      +h3 {
      +  font-size: 1.25rem;
      +}
      +
      +h1:first-child,
      +h2:first-child,
      +h3:first-child {
         margin-top: 0;
       }
       
      @@ -117,7 +133,8 @@ a:hover {
       }
       
       /* Code */
      -code, pre {
      +code,
      +pre {
         font-family: var(--font-mono);
         font-size: 0.9em;
       }
      @@ -142,7 +159,8 @@ pre code {
       }
       
       /* Lists */
      -ul, ol {
      +ul,
      +ol {
         margin-block: var(--space-md);
         padding-left: var(--space-lg);
       }
      @@ -249,3 +267,117 @@ article.post header {
         font-size: 1.25rem;
         color: var(--text-muted);
       }
      +
      +/* Syntax Highlighting */
      +:root {
      +  --hl-keyword: #d73a49;
      +  --hl-string: #22863a;
      +  --hl-comment: #6a737d;
      +  --hl-function: #6f42c1;
      +  --hl-type: #005cc5;
      +  --hl-number: #005cc5;
      +  --hl-operator: #d73a49;
      +  --hl-variable: #24292e;
      +  --hl-constant: #005cc5;
      +  --hl-property: #005cc5;
      +  --hl-punctuation: #24292e;
      +  --hl-attribute: #22863a;
      +}
      +
      +@media (prefers-color-scheme: dark) {
      +  :root {
      +    --hl-keyword: #f97583;
      +    --hl-string: #9ecbff;
      +    --hl-comment: #6a737d;
      +    --hl-function: #b392f0;
      +    --hl-type: #79b8ff;
      +    --hl-number: #79b8ff;
      +    --hl-operator: #f97583;
      +    --hl-variable: #e1e4e8;
      +    --hl-constant: #79b8ff;
      +    --hl-property: #79b8ff;
      +    --hl-punctuation: #e1e4e8;
      +    --hl-attribute: #85e89d;
      +  }
      +}
      +
      +.hl-keyword {
      +  color: var(--hl-keyword);
      +}
      +
      +.hl-string {
      +  color: var(--hl-string);
      +}
      +
      +.hl-comment {
      +  color: var(--hl-comment);
      +  font-style: italic;
      +}
      +
      +.hl-function {
      +  color: var(--hl-function);
      +}
      +
      +.hl-function-builtin {
      +  color: var(--hl-function);
      +}
      +
      +.hl-type {
      +  color: var(--hl-type);
      +}
      +
      +.hl-type-builtin {
      +  color: var(--hl-type);
      +}
      +
      +.hl-number {
      +  color: var(--hl-number);
      +}
      +
      +.hl-operator {
      +  color: var(--hl-operator);
      +}
      +
      +.hl-variable {
      +  color: var(--hl-variable);
      +}
      +
      +.hl-variable-builtin {
      +  color: var(--hl-variable);
      +}
      +
      +.hl-variable-parameter {
      +  color: var(--hl-variable);
      +}
      +
      +.hl-constant {
      +  color: var(--hl-constant);
      +}
      +
      +.hl-constant-builtin {
      +  color: var(--hl-constant);
      +}
      +
      +.hl-property {
      +  color: var(--hl-property);
      +}
      +
      +.hl-punctuation {
      +  color: var(--hl-punctuation);
      +}
      +
      +.hl-punctuation-bracket {
      +  color: var(--hl-punctuation);
      +}
      +
      +.hl-punctuation-delimiter {
      +  color: var(--hl-punctuation);
      +}
      +
      +.hl-attribute {
      +  color: var(--hl-attribute);
      +}
      +
      +.hl-constructor {
      +  color: var(--hl-type);
      +}
      \ No newline at end of file