Skip to content
Draft
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
411 changes: 410 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pulldown-cmark = { version = "=0.13.0", default-features = false, features = [
] }
pyo3 = { version = "=0.25.1", features = ["extension-module"] }
pythonize = "=0.25.0"
syntect = { version = "5.2", default-features = false, features = ["default-fancy"] }

[profile.release]
strip = true
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,29 @@ html = pyromark.html("# Hello world")
assert html == "<h1>Hello world</h1>\n"
```

### Convert Markdown to HTML with syntax highlighting

```python
import pyromark

# Basic HTML generation (original functionality)
html = pyromark.html('''
```python
def hello():
print("Hello, world!")
```
''')

# HTML generation with syntax highlighting (new feature!)
html = pyromark.html_with_syntax_highlighting('''
```python
def hello():
print("Hello, world!")
```
''')
# Returns HTML with beautifully highlighted code blocks
```

### Iterating over Markdown elements

```python
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ classifiers = [
"Programming Language :: Rust",
"Typing :: Typed",
]
dependencies = ["typing-extensions>=3.7.4.2"]
dependencies = [
"maturin>=1.9.2",
"typing-extensions>=3.7.4.2",
]
urls.documentation = "https://pyromark.readthedocs.io"
urls.repository = "https://github.com/monosans/pyromark"
scripts.pyromark = "pyromark._cli:main"
Expand Down
3 changes: 2 additions & 1 deletion python/pyromark/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
events,
events_with_range,
html,
html_with_syntax_highlighting,
)

__all__ = ("Markdown", "Options", "events", "events_with_range", "html")
__all__ = ("Markdown", "Options", "events", "events_with_range", "html", "html_with_syntax_highlighting")
11 changes: 10 additions & 1 deletion python/pyromark/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ def _parse_args(args: Optional[Sequence[str]], /) -> argparse.Namespace:
type=argparse.FileType("w", encoding="utf-8"),
help="output file path, default is stdout",
)
parser.add_argument(
"--syntax-highlighting",
action="store_true",
help="enable syntax highlighting for code blocks",
)
for opt_name in pyromark.Options.__members__:
parser.add_argument(
"--" + opt_name.lower().replace("_", "-"), action="store_true"
Expand All @@ -44,7 +49,11 @@ def main(args: Optional[Sequence[str]] = None, /) -> None:
if getattr(parsed_args, opt_name.lower()):
opts |= opt

html = pyromark.html(content, options=opts)
if parsed_args.syntax_highlighting:
html = pyromark.html_with_syntax_highlighting(content, options=opts)
else:
html = pyromark.html(content, options=opts)

if parsed_args.output is None:
print(html, end="")
else:
Expand Down
102 changes: 102 additions & 0 deletions src/common.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
use syntect::html::highlighted_html_for_string;
use syntect::parsing::SyntaxSet;
use syntect::highlighting::ThemeSet;

fn escape_html(text: &str) -> String {
text.chars()
.map(|c| match c {
'&' => "&amp;".to_string(),
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
'\'' => "&#x27;".to_string(),
_ => c.to_string(),
})
.collect()
}

pub struct PythonizeCustom;

impl<'py> pythonize::PythonizeTypes<'py> for PythonizeCustom {
Expand All @@ -18,6 +35,91 @@ pub fn html(markdown: &str, options: pulldown_cmark::Options) -> String {
html_output
}

pub fn html_with_syntax_highlighting(
markdown: &str,
options: pulldown_cmark::Options,
_syntax_theme: Option<&str>
) -> String {
let syntax_set = SyntaxSet::load_defaults_newlines();
let theme_set = ThemeSet::load_defaults();
let theme = &theme_set.themes["base16-ocean.dark"];

let parser = pulldown_cmark::Parser::new_ext(markdown, options);
let events: Vec<_> = parser.collect();

// Process events to find and highlight code blocks
let mut highlighted_events = Vec::new();
let mut i = 0;

while i < events.len() {
match &events[i] {
pulldown_cmark::Event::Start(pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Fenced(lang))) => {
// Skip the original start tag - we'll create our own HTML
i += 1;

// Collect all text content until we hit the end tag
let mut code_content = String::new();
while i < events.len() {
match &events[i] {
pulldown_cmark::Event::Text(text) => {
code_content.push_str(text);
i += 1;
}
pulldown_cmark::Event::End(pulldown_cmark::TagEnd::CodeBlock) => {
// Found the end tag, break out
i += 1; // Skip the end tag
break;
}
_ => {
// Skip any other events between start and end
i += 1;
}
}
}

// Apply syntax highlighting
let highlighted_html = if !lang.is_empty() {
if let Some(syntax) = syntax_set.find_syntax_by_token(lang) {
match highlighted_html_for_string(&code_content, &syntax_set, syntax, theme) {
Ok(html) => {
// Remove the outer <pre> tags from syntect's output and add our own structure
let html_trimmed = html
.trim_start_matches("<pre style=\"background-color:#2b303b;\">\n")
.trim_end_matches("\n</pre>\n")
.trim_end_matches("</pre>\n")
.trim_end_matches("\n</pre>")
.trim_end_matches("</pre>");
format!("<pre style=\"background-color:#2b303b;\"><code class=\"language-{}\">{}</code></pre>",
lang, html_trimmed)
},
Err(_) => format!("<pre><code class=\"language-{}\">{}</code></pre>",
lang, escape_html(&code_content))
}
} else {
format!("<pre><code class=\"language-{}\">{}</code></pre>",
lang, escape_html(&code_content))
}
} else {
format!("<pre><code>{}</code></pre>",
escape_html(&code_content))
};

// Add as raw HTML - this replaces the entire code block
highlighted_events.push(pulldown_cmark::Event::Html(highlighted_html.into()));
}
_ => {
// For all other events, just copy them
highlighted_events.push(events[i].clone());
i += 1;
}
}
}

let mut html_output = String::new();
pulldown_cmark::html::push_html(&mut html_output, highlighted_events.into_iter());
html_output
}

pub fn events(
markdown: &str,
options: pulldown_cmark::Options,
Expand Down
21 changes: 21 additions & 0 deletions src/function_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,24 @@ pub fn html(py: Python<'_>, markdown: &str, options: u32) -> String {
crate::common::html(markdown, crate::common::build_options(options))
})
}

/// Examples:
/// ```python
/// html = pyromark.html_with_syntax_highlighting(
/// "```python\nprint('hello')\n```",
/// options=(
/// pyromark.Options.ENABLE_TABLES
/// | pyromark.Options.ENABLE_MATH
/// | pyromark.Options.ENABLE_GFM
/// ),
/// syntax_theme="base16-ocean.dark"
/// )
/// # Returns HTML with syntax highlighted code blocks
/// ```
#[pyfunction]
#[pyo3(signature = (markdown, /, *, options = 0, syntax_theme = None))]
pub fn html_with_syntax_highlighting(py: Python<'_>, markdown: &str, options: u32, syntax_theme: Option<&str>) -> String {
py.allow_threads(move || {
crate::common::html_with_syntax_highlighting(markdown, crate::common::build_options(options), syntax_theme)
})
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ fn _pyromark(m: &Bound<'_, PyModule>) -> PyResult<()> {
m
)?)?;
m.add_function(wrap_pyfunction!(crate::function_api::html, m)?)?;
m.add_function(wrap_pyfunction!(crate::function_api::html_with_syntax_highlighting, m)?)?;
m.add_class::<crate::class_api::Markdown>()?;
Ok(())
}
60 changes: 60 additions & 0 deletions tests/test_pyromark.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,3 +382,63 @@ def test_cli_version(capsys: pytest.CaptureFixture[str]) -> None:
capture = capsys.readouterr()
assert not capture.err
assert capture.out == f"{pyromark.__version__}\n"


def test_syntax_highlighting() -> None:
"""Test the new syntax highlighting functionality."""
# Test basic Python syntax highlighting
python_code = '''```python
def hello():
print("Hello, world!")
```'''

html_normal = pyromark.html(python_code)
html_highlighted = pyromark.html_with_syntax_highlighting(python_code)

# Normal HTML should have language class but no color styles
assert '<code class="language-python">' in html_normal
assert 'style="color:' not in html_normal

# Highlighted HTML should have both language class and color styles
assert '<code class="language-python">' in html_highlighted
assert 'style="color:' in html_highlighted
assert 'style="background-color:#2b303b;"' in html_highlighted

# Test fallback for unknown language
unknown_code = '''```unknownlang
some code
```'''
html_unknown = pyromark.html_with_syntax_highlighting(unknown_code)
assert '<code class="language-unknownlang">' in html_unknown
assert 'some code' in html_unknown

# Test code block without language
plain_code = '''```
plain code
```'''
html_plain = pyromark.html_with_syntax_highlighting(plain_code)
assert '<pre' in html_plain
assert '<code>' in html_plain
assert 'plain code' in html_plain


def test_cli_syntax_highlighting(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
"""Test CLI syntax highlighting option."""
test_file = tmp_path / "test.md"
test_file.write_text('```python\nprint("hello")\n```', encoding="utf-8")

# Test without syntax highlighting
pyromark_cli((str(test_file),))
capture = capsys.readouterr()
assert not capture.err
assert '<code class="language-python">' in capture.out
assert 'style="color:' not in capture.out

# Test with syntax highlighting
pyromark_cli(("--syntax-highlighting", str(test_file)))
capture = capsys.readouterr()
assert not capture.err
assert '<code class="language-python">' in capture.out
assert 'style="color:' in capture.out
31 changes: 29 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.