From 6d5654c2148adf0e0ee9666b641caf5f282e0aac Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 8 Jun 2026 22:49:52 -0400 Subject: [PATCH 1/6] Add Click-based CLI for multimark --- src/multimark/__init__.py | 5 -- src/multimark/_build_cmark.py | 1 - src/multimark/_cli.py | 87 +++++++++++++++++++++++++++++++++++ src/multimark/_cmark.py | 1 - 4 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 src/multimark/_cli.py diff --git a/src/multimark/__init__.py b/src/multimark/__init__.py index 6be6101..6813e96 100644 --- a/src/multimark/__init__.py +++ b/src/multimark/__init__.py @@ -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 diff --git a/src/multimark/_build_cmark.py b/src/multimark/_build_cmark.py index 1d7248e..09618ca 100644 --- a/src/multimark/_build_cmark.py +++ b/src/multimark/_build_cmark.py @@ -1,4 +1,3 @@ -"""CFFI build script — compiles the vendored cmark-gfm C library + extensions.""" import os import glob from cffi import FFI diff --git a/src/multimark/_cli.py b/src/multimark/_cli.py new file mode 100644 index 0000000..34acc4c --- /dev/null +++ b/src/multimark/_cli.py @@ -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) diff --git a/src/multimark/_cmark.py b/src/multimark/_cmark.py index 933849a..d895359 100644 --- a/src/multimark/_cmark.py +++ b/src/multimark/_cmark.py @@ -1,4 +1,3 @@ -"""Low-level CFFI wrapper around the cmark-gfm C library.""" from __future__ import annotations from typing import Sequence From 16b2e417acdfe13b947805c73a7ed3243b2295b8 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 8 Jun 2026 22:50:02 -0400 Subject: [PATCH 2/6] Add CLI entry point and click dependency --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 533230c..0c17020 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ @@ -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" From 54d1e62562c6c63e12b74ff06318b1697f277015 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 8 Jun 2026 22:50:11 -0400 Subject: [PATCH 3/6] Enable CLI docs for multimark CLI --- great-docs.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/great-docs.yml b/great-docs.yml index a83178b..00b43f0 100644 --- a/great-docs.yml +++ b/great-docs.yml @@ -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 From c0f1799305aa863767fc9ed6f2e47e5e05bcb577 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 8 Jun 2026 22:51:03 -0400 Subject: [PATCH 4/6] Add command-line interface guide --- user_guide/06-cli.qmd | 163 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 user_guide/06-cli.qmd diff --git a/user_guide/06-cli.qmd b/user_guide/06-cli.qmd new file mode 100644 index 0000000..07ce8be --- /dev/null +++ b/user_guide/06-cli.qmd @@ -0,0 +1,163 @@ +--- +title: "Command-Line Interface" +guide-section: "Core Concepts" +tags: [CLI, Tutorial] +--- + +Multimark includes a command-line tool for quick Markdown conversions without writing Python code. It reads from a file or stdin and writes the rendered output to stdout or a file. + +## Basic Usage + +The simplest invocation converts Markdown to HTML: + +```bash +echo '# Hello **world**' | multimark +``` + +```html +

Hello world

+``` + +You can also pass a filename directly: + +```bash +multimark README.md +``` + +## Choosing an Output Format + +Use the `-t` / `--to` flag to select the output format. The available formats are +`html` (the default), `latex`, `man`, `commonmark`, and `xml`. + +```bash +echo '# Hello' | multimark --to latex +``` + +```latex +\section{Hello} +``` + +```bash +echo '**bold** and *italic*' | multimark --to xml +``` + +```xml + + + + + + bold + + and + + italic + + + +``` + +## Writing to a File + +Use `-o` / `--output` to write results to a file instead of stdout: + +```bash +multimark README.md --to html -o README.html +``` + +## Enabling GFM Extensions + +GFM extensions are enabled with the `-e` / `--extension` flag. It can be repeated to +enable multiple extensions at once. + +```bash +echo '| A | B |\n|---|---|\n| 1 | 2 |' | multimark -e table +``` + +The available extensions are `autolink`, `strikethrough`, `table`, `tagfilter`, and +`tasklist`. + +```bash +multimark doc.md -e table -e strikethrough -e autolink +``` + +## Rendering Options + +Several flags control how the Markdown is parsed and rendered: + +| Flag | Effect | +|------|--------| +| `--smart` | Convert straight quotes to curly, `--` to en-dash, `---` to em-dash, `...` to ellipsis | +| `--unsafe` | Allow raw HTML and potentially dangerous URLs to pass through | +| `--hardbreaks` | Render soft line breaks as hard breaks | +| `--sourcepos` | Add source position attributes (html and xml only) | +| `--footnotes` | Enable footnote syntax | + +These can be combined freely: + +```bash +echo '"Hello" -- world...' | multimark --smart +``` + +```html +

\u201cHello\u201d \u2013 world\u2026

+``` + +```bash +echo '
raw
' | multimark --unsafe +``` + +```html +
raw
+``` + +## Reading from stdin + +When no file argument is provided, multimark reads from stdin. This makes it easy to +use in shell pipelines: + +```bash +curl -s https://example.com/README.md | multimark --to html > output.html +``` + +```bash +cat doc.md | multimark --smart --to latex | tee output.tex +``` + +## Version + +Check the installed version with: + +```bash +multimark --version +``` + +## Full Help + +```bash +multimark --help +``` + +``` +Usage: multimark [OPTIONS] [FILE] + + 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. + +Options: + -t, --to [html|latex|man|commonmark|xml] + Output format. + -o, --output FILENAME Output file (stdout if omitted). + -e, --extension [autolink|strikethrough|table|tagfilter|tasklist] + Enable a GFM extension (repeatable). + --smart Use smart punctuation. + --unsafe Allow raw HTML and dangerous URLs. + --hardbreaks Render softbreaks as hard line breaks. + --sourcepos Include source position attributes (html/xml + only). + --footnotes Enable footnote parsing. + --version Show the version and exit. + --help Show this message and exit. +``` From 62203b5039a07b1100e9af081e85761041db8eae Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 8 Jun 2026 22:51:17 -0400 Subject: [PATCH 5/6] Renumber user_guide chapters --- user_guide/{06-security.qmd => 07-security.qmd} | 0 user_guide/{07-recipes.qmd => 08-recipes.qmd} | 0 user_guide/{08-performance.qmd => 09-performance.qmd} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename user_guide/{06-security.qmd => 07-security.qmd} (100%) rename user_guide/{07-recipes.qmd => 08-recipes.qmd} (100%) rename user_guide/{08-performance.qmd => 09-performance.qmd} (100%) diff --git a/user_guide/06-security.qmd b/user_guide/07-security.qmd similarity index 100% rename from user_guide/06-security.qmd rename to user_guide/07-security.qmd diff --git a/user_guide/07-recipes.qmd b/user_guide/08-recipes.qmd similarity index 100% rename from user_guide/07-recipes.qmd rename to user_guide/08-recipes.qmd diff --git a/user_guide/08-performance.qmd b/user_guide/09-performance.qmd similarity index 100% rename from user_guide/08-performance.qmd rename to user_guide/09-performance.qmd From 84ebd88bbe98102ab689cd34e44825f1b2cbd3a0 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 8 Jun 2026 22:57:49 -0400 Subject: [PATCH 6/6] Add CLI tests for multimark --- tests/spec_parser.py | 13 ---- tests/test_cli.py | 140 +++++++++++++++++++++++++++++++++++ tests/test_errors.py | 5 -- tests/test_extensions.py | 1 - tests/test_html.py | 1 - tests/test_latex.py | 1 - tests/test_man.py | 1 - tests/test_package.py | 1 - tests/test_param_combos.py | 5 -- tests/test_pathological.py | 5 -- tests/test_regressions.py | 7 -- tests/test_spec_html.py | 5 -- tests/test_spec_renderers.py | 11 --- tests/test_threads.py | 5 -- tests/test_xml.py | 1 - 15 files changed, 140 insertions(+), 62 deletions(-) create mode 100644 tests/test_cli.py diff --git a/tests/spec_parser.py b/tests/spec_parser.py index cd38948..9ec7162 100644 --- a/tests/spec_parser.py +++ b/tests/spec_parser.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..22bbbd0 --- /dev/null +++ b/tests/test_cli.py @@ -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 "

Hello

" 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 "bold" 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 "italic" 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 "

Title

" 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 "raw\n") + assert result.exit_code == 0 + assert "
raw
" in result.output + + def test_safe_by_default(self, runner): + result = runner.invoke(main, input="\n") + assert result.exit_code == 0 + assert "