Skip to content
Merged
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
8 changes: 4 additions & 4 deletions great-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ tags:

# CLI Documentation
# -----------------
# cli:
# enabled: true # Enable CLI documentation
# module: my_package.cli # Module containing Click commands
# name: cli # Name of the Click command object
cli:
enabled: true
module: multimark._cli
name: main
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description = "Python bindings to cmark: CommonMark parsing and rendering (HTML,
readme = "README.md"
license = {text = "MIT AND BSD-2-Clause"}
requires-python = ">=3.9"
dependencies = ["cffi>=1.15"]
dependencies = ["cffi>=1.15", "click>=8.0"]
authors = [{name = "Rich Iannone"}]
keywords = ["markdown", "commonmark", "cmark-gfm", "html", "latex", "gfm"]
classifiers = [
Expand All @@ -27,6 +27,9 @@ classifiers = [
"Topic :: Text Processing :: Markup :: Markdown",
]

[project.scripts]
multimark = "multimark._cli:main"

[project.urls]
Homepage = "https://github.com/posit-dev/multimark"
Issues = "https://github.com/posit-dev/multimark/issues"
Expand Down
5 changes: 0 additions & 5 deletions src/multimark/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
"""multimark — Python bindings to the cmark-gfm CommonMark/GFM C library.

Provides parsing and rendering of CommonMark and GitHub Flavored Markdown
to HTML, LaTeX, groff man, XML, and normalized CommonMark.
"""
from enum import IntFlag
from importlib.metadata import PackageNotFoundError, version as _get_version

Expand Down
1 change: 0 additions & 1 deletion src/multimark/_build_cmark.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
"""CFFI build script — compiles the vendored cmark-gfm C library + extensions."""
import os
import glob
from cffi import FFI
Expand Down
87 changes: 87 additions & 0 deletions src/multimark/_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from __future__ import annotations

import click

from multimark import (
__version__,
markdown_to_html,
markdown_to_latex,
markdown_to_man,
markdown_to_commonmark,
markdown_to_xml,
VALID_EXTENSIONS,
)

RENDERERS = {
"html": markdown_to_html,
"latex": markdown_to_latex,
"man": markdown_to_man,
"commonmark": markdown_to_commonmark,
"xml": markdown_to_xml,
}


@click.command()
@click.argument("file", type=click.File("r"), default="-")
@click.option(
"-t",
"--to",
"format",
type=click.Choice(list(RENDERERS.keys()), case_sensitive=False),
default="html",
help="Output format.",
)
@click.option(
"-o",
"--output",
type=click.File("w"),
default="-",
help="Output file (stdout if omitted).",
)
@click.option(
"-e",
"--extension",
"extensions",
multiple=True,
type=click.Choice(sorted(VALID_EXTENSIONS), case_sensitive=False),
help="Enable a GFM extension (repeatable).",
)
@click.option("--smart", is_flag=True, help="Use smart punctuation.")
@click.option("--unsafe", is_flag=True, help="Allow raw HTML and dangerous URLs.")
@click.option("--hardbreaks", is_flag=True, help="Render softbreaks as hard line breaks.")
@click.option("--sourcepos", is_flag=True, help="Include source position attributes (html/xml only).")
@click.option("--footnotes", is_flag=True, help="Enable footnote parsing.")
@click.version_option(__version__, prog_name="multimark")
def main(
file,
format: str,
output,
extensions: tuple[str, ...],
smart: bool,
unsafe: bool,
hardbreaks: bool,
sourcepos: bool,
footnotes: bool,
) -> None:
"""Convert CommonMark/GFM Markdown to various output formats.

Reads Markdown from FILE (or stdin if omitted) and writes the converted
output to stdout or the file specified by --output.
"""
text = file.read()

renderer = RENDERERS[format]
kwargs: dict = dict(
extensions=list(extensions) or None,
smart=smart,
unsafe=unsafe,
hardbreaks=hardbreaks,
footnotes=footnotes,
)

# sourcepos is only supported by html and xml renderers
if format in ("html", "xml"):
kwargs["sourcepos"] = sourcepos

result = renderer(text, **kwargs)
output.write(result)
1 change: 0 additions & 1 deletion src/multimark/_cmark.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
"""Low-level CFFI wrapper around the cmark-gfm C library."""
from __future__ import annotations

from typing import Sequence
Expand Down
13 changes: 0 additions & 13 deletions tests/spec_parser.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,3 @@
"""Parser for CommonMark spec-format test fixtures.

The spec format uses 32 backtick delimiters around examples:

```````````````````````````````` example
markdown input
.
expected html output
````````````````````````````````

Section headings (# Heading) provide grouping context.
The → character represents a literal tab.
"""
from __future__ import annotations

import os
Expand Down
140 changes: 140 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import pytest
from click.testing import CliRunner

from multimark._cli import main
from multimark import __version__


@pytest.fixture
def runner():
return CliRunner()


class TestBasicConversion:
def test_stdin_to_html(self, runner):
result = runner.invoke(main, input="# Hello\n")
assert result.exit_code == 0
assert "<h1>Hello</h1>" in result.output

def test_file_argument(self, runner, tmp_path):
md_file = tmp_path / "test.md"
md_file.write_text("**bold**\n")
result = runner.invoke(main, [str(md_file)])
assert result.exit_code == 0
assert "<strong>bold</strong>" in result.output

def test_output_to_file(self, runner, tmp_path):
out_file = tmp_path / "out.html"
result = runner.invoke(main, ["-o", str(out_file)], input="*italic*\n")
assert result.exit_code == 0
assert "<em>italic</em>" in out_file.read_text()


class TestOutputFormats:
def test_html(self, runner):
result = runner.invoke(main, ["--to", "html"], input="# Title\n")
assert result.exit_code == 0
assert "<h1>Title</h1>" in result.output

def test_latex(self, runner):
result = runner.invoke(main, ["--to", "latex"], input="# Title\n")
assert result.exit_code == 0
assert "\\section{Title}" in result.output

def test_man(self, runner):
result = runner.invoke(main, ["--to", "man"], input="# Title\n")
assert result.exit_code == 0
assert ".SH" in result.output

def test_commonmark(self, runner):
result = runner.invoke(main, ["--to", "commonmark"], input="*italic*\n")
assert result.exit_code == 0
assert "*italic*" in result.output

def test_xml(self, runner):
result = runner.invoke(main, ["--to", "xml"], input="hello\n")
assert result.exit_code == 0
assert "<?xml" in result.output
assert "<text" in result.output


class TestOptions:
def test_smart(self, runner):
result = runner.invoke(main, ["--smart"], input='"Hello" -- world\n')
assert result.exit_code == 0
assert "\u201c" in result.output # curly open quote
assert "\u2013" in result.output # en-dash

def test_unsafe(self, runner):
result = runner.invoke(main, ["--unsafe"], input="<div>raw</div>\n")
assert result.exit_code == 0
assert "<div>raw</div>" in result.output

def test_safe_by_default(self, runner):
result = runner.invoke(main, input="<script>alert('x')</script>\n")
assert result.exit_code == 0
assert "<script>" not in result.output

def test_hardbreaks(self, runner):
result = runner.invoke(main, ["--hardbreaks"], input="line1\nline2\n")
assert result.exit_code == 0
assert "<br />" in result.output

def test_sourcepos_html(self, runner):
result = runner.invoke(main, ["--sourcepos"], input="hello\n")
assert result.exit_code == 0
assert "data-sourcepos" in result.output

def test_sourcepos_xml(self, runner):
result = runner.invoke(main, ["--to", "xml", "--sourcepos"], input="hello\n")
assert result.exit_code == 0
assert 'sourcepos="' in result.output

def test_sourcepos_ignored_for_latex(self, runner):
result = runner.invoke(main, ["--to", "latex", "--sourcepos"], input="# Hi\n")
assert result.exit_code == 0
assert "\\section{Hi}" in result.output

def test_footnotes(self, runner):
md = "Text[^1]\n\n[^1]: A footnote.\n"
result = runner.invoke(main, ["--footnotes"], input=md)
assert result.exit_code == 0
assert "footnote" in result.output.lower()


class TestExtensions:
def test_table(self, runner):
md = "| A | B |\n|---|---|\n| 1 | 2 |\n"
result = runner.invoke(main, ["-e", "table"], input=md)
assert result.exit_code == 0
assert "<table>" in result.output

def test_strikethrough(self, runner):
result = runner.invoke(main, ["-e", "strikethrough"], input="~~del~~\n")
assert result.exit_code == 0
assert "<del>del</del>" in result.output

def test_autolink(self, runner):
result = runner.invoke(main, ["-e", "autolink"], input="Visit https://example.com\n")
assert result.exit_code == 0
assert 'href="https://example.com"' in result.output

def test_multiple_extensions(self, runner):
md = "~~del~~\n\n| A |\n|---|\n| 1 |\n"
result = runner.invoke(main, ["-e", "strikethrough", "-e", "table"], input=md)
assert result.exit_code == 0
assert "<del>" in result.output
assert "<table>" in result.output


class TestMetadata:
def test_version(self, runner):
result = runner.invoke(main, ["--version"])
assert result.exit_code == 0
assert __version__ in result.output

def test_help(self, runner):
result = runner.invoke(main, ["--help"])
assert result.exit_code == 0
assert "Convert CommonMark/GFM" in result.output
assert "--to" in result.output
5 changes: 0 additions & 5 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
"""Tests for error handling and type safety.

Verify that our Python bindings raise clear errors for invalid inputs
rather than segfaulting or producing undefined behavior.
"""
import pytest

from multimark import (
Expand Down
1 change: 0 additions & 1 deletion tests/test_extensions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
"""Tests for GFM extensions (table, strikethrough, autolink, tagfilter, tasklist)."""
import pytest

from multimark import markdown_to_html, markdown_to_latex, markdown_to_xml, VALID_EXTENSIONS
Expand Down
1 change: 0 additions & 1 deletion tests/test_html.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
"""Smoke tests for options, edge cases, and behavior the spec doesn't cover."""
from multimark import markdown_to_html, Options


Expand Down
1 change: 0 additions & 1 deletion tests/test_latex.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
"""Smoke tests for LaTeX renderer — format-specific behavior and width parameter."""
from multimark import markdown_to_latex, Options


Expand Down
1 change: 0 additions & 1 deletion tests/test_man.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
"""Smoke tests for man page renderer — format-specific behavior and width parameter."""
from multimark import markdown_to_man, Options


Expand Down
1 change: 0 additions & 1 deletion tests/test_package.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
"""Smoke tests for package-level API: version, imports, cmark_version."""
from multimark import (
markdown_to_html,
markdown_to_latex,
Expand Down
5 changes: 0 additions & 5 deletions tests/test_param_combos.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
"""Comprehensive tests for parameter combinations.

Exercises the cross-product of boolean flags, options, extensions, and width
to verify correct interactions and absence of crashes.
"""
import itertools

import pytest
Expand Down
5 changes: 0 additions & 5 deletions tests/test_pathological.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
"""Tests for pathological and adversarial inputs.

These verify that cmark's protections against algorithmic complexity attacks
and stack overflow are working correctly through our bindings.
"""
import pytest

from multimark import (
Expand Down
7 changes: 0 additions & 7 deletions tests/test_regressions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
"""Regression tests derived from closed R commonmark issues.

These ensure bugs reported against the R package don't affect multimark.
Each test references the original GitHub issue for context.

See: https://github.com/r-lib/commonmark/issues?q=is%3Aissue+state%3Aclosed
"""
import pytest

from multimark import (
Expand Down
5 changes: 0 additions & 5 deletions tests/test_spec_html.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
"""Spec-driven tests: HTML rendering against the CommonMark specification.

Tests all examples from spec.txt, smart_punct.txt, and regression.txt.
Each example provides markdown input and expected HTML output.
"""
import pytest

from multimark import markdown_to_html, Options
Expand Down
11 changes: 0 additions & 11 deletions tests/test_spec_renderers.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,3 @@
"""Spec-driven tests: verify all renderers handle every spec example without error.

For non-HTML renderers we don't have expected output from the spec, but we verify:
1. No crashes or exceptions on any spec example
2. Non-empty output for non-empty input
3. Structural properties (XML well-formedness, LaTeX balanced envs)
4. Width parameter doesn't break output
5. Options (SOURCEPOS, SMART) don't crash
6. CommonMark roundtrip: markdown → commonmark → HTML == markdown → HTML
7. Determinism: same input always produces same output
"""
import re

import pytest
Expand Down
5 changes: 0 additions & 5 deletions tests/test_threads.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
"""Tests for thread safety.

cmark should be safe to call from multiple threads simultaneously
since each parse/render is independent (no shared mutable state).
"""
import concurrent.futures

from multimark import (
Expand Down
1 change: 0 additions & 1 deletion tests/test_xml.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
"""Smoke tests for XML renderer — structure, options, and edge cases."""
from multimark import markdown_to_xml, Options


Expand Down
Loading
Loading