From 6063398ece607329771a535468bb3ece132eb8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ambroise=20Par=C3=A9?= Date: Fri, 2 Jan 2026 18:14:46 +0100 Subject: [PATCH] feat: add native markdown template filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add {{markdown:Field}} and {{md:Field}} template filters that convert markdown syntax to HTML using the existing pulldown-cmark dependency. Features: - GFM tables support (| col1 | col2 |) - Strikethrough (~~text~~) - Task lists (- [ ] and - [x]) - Footnotes ([^1]) - Fenced code blocks with language hints - Smart punctuation Also adds {{markdown-inline:Field}} / {{md-inline:Field}} variants that strip outer

tags for inline content. The filter leverages Anki's existing pulldown-cmark 0.13.0 dependency, requiring no new dependencies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- rslib/src/markdown.rs | 130 ++++++++++++++++++++++++++++++++-- rslib/src/template_filters.rs | 106 +++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 4 deletions(-) diff --git a/rslib/src/markdown.rs b/rslib/src/markdown.rs index 88bb211f3..3f1da808a 100644 --- a/rslib/src/markdown.rs +++ b/rslib/src/markdown.rs @@ -1,12 +1,134 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use std::borrow::Cow; + use pulldown_cmark::html; +use pulldown_cmark::Options; use pulldown_cmark::Parser; -pub(crate) fn render_markdown(markdown: &str) -> String { - let mut buf = String::with_capacity(markdown.len()); - let parser = Parser::new(markdown); +/// Render markdown to HTML with GitHub Flavored Markdown extensions. +/// +/// Enabled extensions: +/// - Tables (GFM) +/// - Strikethrough (~~text~~) +/// - Task lists (- [ ] and - [x]) +/// - Footnotes +/// +/// # Example +/// ``` +/// let html = render_markdown("| A | B |\n|---|---|\n| 1 | 2 |"); +/// // Returns: ...
+/// ``` +pub(crate) fn render_markdown(markdown: &str) -> Cow<'_, str> { + // Return early if input is empty or contains no markdown-like syntax + if markdown.is_empty() { + return Cow::Borrowed(markdown); + } + + let mut options = Options::empty(); + // Enable GFM tables: | col1 | col2 | + options.insert(Options::ENABLE_TABLES); + // Enable strikethrough: ~~deleted~~ + options.insert(Options::ENABLE_STRIKETHROUGH); + // Enable task lists: - [ ] todo, - [x] done + options.insert(Options::ENABLE_TASKLISTS); + // Enable footnotes: [^1] and [^1]: footnote text + options.insert(Options::ENABLE_FOOTNOTES); + // Enable smart punctuation: "quotes" -> curly quotes, -- -> en-dash, --- -> em-dash + options.insert(Options::ENABLE_SMART_PUNCTUATION); + + let mut buf = String::with_capacity(markdown.len() * 2); + let parser = Parser::new_ext(markdown, options); html::push_html(&mut buf, parser); - buf + + Cow::Owned(buf) +} + +/// Render markdown without wrapping paragraphs in

tags. +/// Useful for inline content where paragraph tags would break layout. +pub(crate) fn render_markdown_inline(markdown: &str) -> Cow<'_, str> { + let rendered = render_markdown(markdown); + + // Strip outer

...

tags if present (for single-paragraph content) + if let Cow::Owned(s) = rendered { + let trimmed = s.trim(); + if trimmed.starts_with("

") && trimmed.ends_with("

") && trimmed.matches("

").count() == 1 { + let inner = &trimmed[3..trimmed.len() - 4]; + return Cow::Owned(inner.to_string()); + } + Cow::Owned(s) + } else { + rendered + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_markdown() { + assert!(render_markdown("**bold**").contains("bold")); + assert!(render_markdown("*italic*").contains("italic")); + assert!(render_markdown("`code`").contains("code")); + } + + #[test] + fn test_tables() { + let table = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |"; + let html = render_markdown(table); + assert!(html.contains("")); + assert!(html.contains("")); + assert!(html.contains("")); + } + + #[test] + fn test_strikethrough() { + let result = render_markdown("~~deleted~~"); + assert!(result.contains("deleted")); + } + + #[test] + fn test_task_lists() { + let tasks = "- [ ] Todo\n- [x] Done"; + let html = render_markdown(tasks); + assert!(html.contains("type=\"checkbox\"")); + assert!(html.contains("checked")); + } + + #[test] + fn test_footnotes() { + let text = "Text with footnote[^1].\n\n[^1]: Footnote content."; + let html = render_markdown(text); + assert!(html.contains("footnote")); + } + + #[test] + fn test_code_blocks() { + let code = "```rust\nfn main() {}\n```"; + let html = render_markdown(code); + assert!(html.contains("
"));
+        assert!(html.contains("bold");
+        assert!(!result.contains("

")); + } + + #[test] + fn test_multiline_keeps_paragraphs() { + let text = "First paragraph.\n\nSecond paragraph."; + let result = render_markdown_inline(text); + // Multiple paragraphs should keep their structure + assert!(result.contains("

")); + } } diff --git a/rslib/src/template_filters.rs b/rslib/src/template_filters.rs index 66e9ecb37..47624328e 100644 --- a/rslib/src/template_filters.rs +++ b/rslib/src/template_filters.rs @@ -10,6 +10,8 @@ use regex::Regex; use crate::cloze::cloze_filter; use crate::cloze::cloze_only_filter; +use crate::markdown::render_markdown; +use crate::markdown::render_markdown_inline; use crate::template::RenderContext; use crate::text::strip_html; @@ -86,6 +88,11 @@ fn apply_filter( "hint" => hint_filter(text, field_name), "cloze" => cloze_filter(text, context), "cloze-only" => cloze_only_filter(text, context), + // Markdown filter: converts markdown syntax to HTML + // Supports GFM tables, strikethrough, task lists, footnotes, code blocks + "markdown" | "md" => render_markdown(text), + // Inline markdown: same as markdown but strips outer

tags + "markdown-inline" | "md-inline" => render_markdown_inline(text), // an empty filter name (caused by using two colons) is ignored "" => text.into(), _ => { @@ -300,4 +307,103 @@ field "[anki:tts lang=en_US voices=Bob,Jane]foo[/anki:tts]" ); } + + // Markdown filter tests + #[test] + fn markdown_basic() { + let ctx = RenderContext { + fields: &Default::default(), + nonempty_fields: &Default::default(), + frontside: None, + card_ord: 0, + partial_for_python: false, + }; + + // Test bold + let (result, remaining) = apply_filters("**bold**", &["markdown"], "Field", &ctx); + assert!(result.contains("bold")); + assert!(remaining.is_empty()); + + // Test with alias + let (result, _) = apply_filters("*italic*", &["md"], "Field", &ctx); + assert!(result.contains("italic")); + } + + #[test] + fn markdown_tables() { + let ctx = RenderContext { + fields: &Default::default(), + nonempty_fields: &Default::default(), + frontside: None, + card_ord: 0, + partial_for_python: false, + }; + + let table = "| A | B |\n|---|---|\n| 1 | 2 |"; + let (result, _) = apply_filters(table, &["markdown"], "Field", &ctx); + assert!(result.contains("

Header 1Cell 1
")); + assert!(result.contains("
")); + assert!(result.contains("")); + } + + #[test] + fn markdown_strikethrough() { + let ctx = RenderContext { + fields: &Default::default(), + nonempty_fields: &Default::default(), + frontside: None, + card_ord: 0, + partial_for_python: false, + }; + + let (result, _) = apply_filters("~~deleted~~", &["markdown"], "Field", &ctx); + assert!(result.contains("deleted")); + } + + #[test] + fn markdown_inline() { + let ctx = RenderContext { + fields: &Default::default(), + nonempty_fields: &Default::default(), + frontside: None, + card_ord: 0, + partial_for_python: false, + }; + + let (result, _) = apply_filters("**bold**", &["md-inline"], "Field", &ctx); + assert_eq!(result.trim(), "bold"); + assert!(!result.contains("

")); + } + + #[test] + fn markdown_code_blocks() { + let ctx = RenderContext { + fields: &Default::default(), + nonempty_fields: &Default::default(), + frontside: None, + card_ord: 0, + partial_for_python: false, + }; + + let code = "```python\nprint('hello')\n```"; + let (result, _) = apply_filters(code, &["markdown"], "Field", &ctx); + assert!(result.contains("

"));
+        assert!(result.contains(""));
+    }
 }