mirror of
https://github.com/ankitects/anki.git
synced 2026-01-05 18:13:56 -05:00
feat: add native markdown template filter
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 <p> 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 <noreply@anthropic.com>
This commit is contained in:
parent
8f2144534b
commit
6063398ece
2 changed files with 232 additions and 4 deletions
|
|
@ -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: <table>...</table>
|
||||
/// ```
|
||||
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 <p> 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 <p>...</p> tags if present (for single-paragraph content)
|
||||
if let Cow::Owned(s) = rendered {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.starts_with("<p>") && trimmed.ends_with("</p>") && trimmed.matches("<p>").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("<strong>bold</strong>"));
|
||||
assert!(render_markdown("*italic*").contains("<em>italic</em>"));
|
||||
assert!(render_markdown("`code`").contains("<code>code</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("<table>"));
|
||||
assert!(html.contains("<th>Header 1</th>"));
|
||||
assert!(html.contains("<td>Cell 1</td>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strikethrough() {
|
||||
let result = render_markdown("~~deleted~~");
|
||||
assert!(result.contains("<del>deleted</del>"));
|
||||
}
|
||||
|
||||
#[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("<pre>"));
|
||||
assert!(html.contains("<code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_input() {
|
||||
assert_eq!(render_markdown(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inline_rendering() {
|
||||
let result = render_markdown_inline("**bold**");
|
||||
assert_eq!(result, "<strong>bold</strong>");
|
||||
assert!(!result.contains("<p>"));
|
||||
}
|
||||
|
||||
#[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("<p>"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <p> 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</a>
|
|||
"[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("<strong>bold</strong>"));
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
// Test with alias
|
||||
let (result, _) = apply_filters("*italic*", &["md"], "Field", &ctx);
|
||||
assert!(result.contains("<em>italic</em>"));
|
||||
}
|
||||
|
||||
#[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("<table>"));
|
||||
assert!(result.contains("<th>"));
|
||||
assert!(result.contains("<td>"));
|
||||
}
|
||||
|
||||
#[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("<del>deleted</del>"));
|
||||
}
|
||||
|
||||
#[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(), "<strong>bold</strong>");
|
||||
assert!(!result.contains("<p>"));
|
||||
}
|
||||
|
||||
#[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("<pre>"));
|
||||
assert!(result.contains("<code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_combined_with_other_filters() {
|
||||
let ctx = RenderContext {
|
||||
fields: &Default::default(),
|
||||
nonempty_fields: &Default::default(),
|
||||
frontside: None,
|
||||
card_ord: 0,
|
||||
partial_for_python: false,
|
||||
};
|
||||
|
||||
// Markdown then strip HTML
|
||||
let (result, _) = apply_filters("**bold**", &["markdown", "text"], "Field", &ctx);
|
||||
assert_eq!(result.trim(), "bold");
|
||||
assert!(!result.contains("<strong>"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue