diff --git a/python/xhtmlmd/__init__.py b/python/xhtmlmd/__init__.py index 66af028..87b8056 100644 --- a/python/xhtmlmd/__init__.py +++ b/python/xhtmlmd/__init__.py @@ -3,10 +3,10 @@ __all__ = ["to_xhtml", "render", "blocks", "rewrite"] -def to_xhtml(markdown: str, *, math: str = "brackets", tagfilter: bool = False, balance: bool = False, callbacks: dict | None = None, +def to_xhtml(markdown: str, *, math: str = "brackets", mustache: bool = True, tagfilter: bool = False, balance: bool = False, callbacks: dict | None = None, max_inline_depth: int | None = None, max_block_depth: int | None = None, max_link_paren_depth: int | None = None) -> str: "Render Markdown to an XHTML fragment." - return _to_xhtml(markdown, math=math, tagfilter=tagfilter, balance=balance, callbacks=callbacks, max_inline_depth=max_inline_depth, + return _to_xhtml(markdown, math=math, mustache=mustache, tagfilter=tagfilter, balance=balance, callbacks=callbacks, max_inline_depth=max_inline_depth, max_block_depth=max_block_depth, max_link_paren_depth=max_link_paren_depth) diff --git a/src/inline.rs b/src/inline.rs index bb3f43d..1a771c9 100644 --- a/src/inline.rs +++ b/src/inline.rs @@ -193,6 +193,14 @@ fn parse_inner(src: &str, ctx: &InlineContext<'_>, depth: usize) -> Vec let mut failed = FailedScans::default(); let mut i = 0; while i < src.len() { + if ctx.options.mustache && starts(src, i, "{{") { + if let Some((item, next)) = mustache(src, i) { + scanner.flush_text(); + scanner.push_inline(item); + i = next; + continue; + } + } if starts(src, i, "\\[") && matches!(ctx.options.math, MathMode::Brackets | MathMode::Dollars) { @@ -420,6 +428,9 @@ fn plain_text_fast_path(src: &str, ctx: &InlineContext<'_>) -> bool { if src.contains("==") { return false; } + if ctx.options.mustache && src.contains("{{") { + return false; + } if ctx.options.math == MathMode::Dollars && src.contains('$') { return false; } @@ -437,6 +448,24 @@ fn plain_text_fast_path(src: &str, ctx: &InlineContext<'_>) -> bool { !can_link_or_span } +fn mustache(src: &str, i: usize) -> Option<(Inline, usize)> { + let end = src[i + 2..].find("}}")? + i + 4; + let body = src[i + 2..end - 2].trim_start(); + let class = match body.chars().next() { + Some('#' | '^' | '/') => "mustache.section", + Some('!') => "mustache.comment", + Some('>') => "mustache.partial", + _ => "mustache.placeholder", + }; + Some(( + Inline::Span { + attrs: Attr::with_class(class), + children: vec![Inline::Html(src[i..end].to_string())], + }, + end, + )) +} + struct InlineScanner<'a, 'b> { src: &'a str, ctx: &'b InlineContext<'b>, diff --git a/src/lib.rs b/src/lib.rs index 6bb3fb1..c8c449a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub enum MathMode { #[derive(Clone, Debug)] pub struct Options { pub math: MathMode, + pub mustache: bool, pub tagfilter: bool, pub balance: bool, pub max_inline_depth: usize, @@ -45,6 +46,7 @@ impl Default for Options { fn default() -> Self { Self { math: MathMode::Brackets, + mustache: true, tagfilter: false, balance: false, max_inline_depth: 64, diff --git a/src/python.rs b/src/python.rs index 4d4e672..377f553 100644 --- a/src/python.rs +++ b/src/python.rs @@ -14,6 +14,7 @@ use crate::{MathMode, Options}; markdown, *, math = "brackets", + mustache = true, tagfilter = false, balance = false, callbacks = None, @@ -24,6 +25,7 @@ use crate::{MathMode, Options}; fn to_xhtml( markdown: &str, math: &str, + mustache: bool, tagfilter: bool, balance: bool, callbacks: Option>, @@ -33,6 +35,7 @@ fn to_xhtml( ) -> PyResult { let mut options = Options { math: parse_math_mode(math)?, + mustache, tagfilter, balance, ..Options::default() diff --git a/tests/test_python.py b/tests/test_python.py index d627ddc..f367955 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -37,6 +37,21 @@ def test_invalid_math_mode_raises(): with pytest.raises(ValueError, match="math must be"): to_xhtml("x", math="inline") +def test_mustache_placeholders_are_opaque_and_can_be_disabled(): + src = "{{name.name}} {{#items}} {{/items}} {{^empty}} {{/empty}} {{! comment}} {{> partial}} {{*markdown*}}" + html = to_xhtml(src) + assert html.count('class="mustache.placeholder"') == 2 + assert html.count('class="mustache.section"') == 4 + assert html.count('class="mustache.comment"') == 1 + assert html.count('class="mustache.partial"') == 1 + assert '{{name.name}}' in html + assert '{{#items}}' in html + assert '{{! comment}}' in html + assert '{{> partial}}' in html + assert "{{*markdown*}}" in html and "markdown" not in html + assert_html(to_xhtml("{{*markdown*}}", mustache=False), "

{{markdown}}

") + + def test_node_callback_can_override_heading(): calls = []