diff --git a/README.md b/README.md index 9a44600..6ebe71c 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ Which is rendered as: ```bash usage: markdown-code-runner [-h] [-o OUTPUT] [-d] [-v] - [--no-backtick-standardize] + [--no-backtick-standardize] [-s] [-n] input Automatically update Markdown files with code block output. @@ -375,6 +375,10 @@ options: --no-backtick-standardize Disable backtick standardization (default: enabled for separate output files, disabled for in-place) + -s, --standardize Post-process to standardize ALL code fences, removing + 'markdown-code-runner' modifiers + -n, --no-execute Skip code execution entirely (useful with + --standardize for compatibility processing only) ``` diff --git a/docs/docs_gen.py b/docs/docs_gen.py index 07288a7..bf097ed 100755 --- a/docs/docs_gen.py +++ b/docs/docs_gen.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# ruff: noqa: T201, S603, S607 +# ruff: noqa: S603, S607 """Documentation generation utilities for Markdown Code Runner. Provides functions to extract sections from README.md and transform @@ -133,7 +133,7 @@ def _run_markdown_code_runner(files: list[Path], repo_root: Path) -> bool: rel_path = file.relative_to(repo_root) print(f"Updating {rel_path}...", end=" ", flush=True) result = subprocess.run( - ["markdown-code-runner", str(file)], + ["markdown-code-runner", "--standardize", str(file)], check=False, capture_output=True, text=True, diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 8b0b268..41dfb25 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -61,6 +61,44 @@ By default, when writing to a separate output file, the `markdown-code-runner` t markdown-code-runner README.md -o output.md --no-backtick-standardize ``` +### Standardize All Code Fences (`-s`, `--standardize`) + +Post-process the output to standardize ALL code fences, removing `markdown-code-runner` modifiers from language identifiers. This is useful for compatibility with markdown processors like mkdocs and pandoc that don't understand the `python markdown-code-runner` syntax. + +```bash +markdown-code-runner README.md --standardize +``` + +This transforms code fences like: + +````markdown +```python markdown-code-runner +print('hello') +``` +```` + +Into standard code fences: + +````markdown +```python +print('hello') +``` +```` + +### Skip Code Execution (`-n`, `--no-execute`) + +Skip code execution entirely. This is useful when you only want to standardize code fences without running any code. + +```bash +markdown-code-runner README.md --no-execute --standardize +``` + +This combination is particularly useful for: + +- Preparing files for external markdown processors +- Converting files without re-running code blocks +- Creating compatible output from existing processed files + ### Version (`-v`, `--version`) Display the installed version. diff --git a/docs/usage/python-api.md b/docs/usage/python-api.md index a46cbda..6716184 100644 --- a/docs/usage/python-api.md +++ b/docs/usage/python-api.md @@ -25,6 +25,8 @@ def update_markdown_file( *, verbose: bool = False, backtick_standardize: bool = True, + execute: bool = True, + standardize: bool = False, ) -> None: """Rewrite a Markdown file by executing and updating code blocks. @@ -37,7 +39,14 @@ def update_markdown_file( verbose : bool If True, print every line that is processed. backtick_standardize : bool - If True, clean up markdown-code-runner string from backtick code blocks. + If True, clean up markdown-code-runner string from executed backtick code blocks. + execute : bool + If True, execute code blocks and update output sections. + If False, skip code execution (useful with standardize=True). + standardize : bool + If True, post-process to standardize ALL code fences in the output, + removing 'markdown-code-runner' modifiers. This is useful for + compatibility with markdown processors like mkdocs and pandoc. """ ``` @@ -51,6 +60,7 @@ def process_markdown( *, verbose: bool = False, backtick_standardize: bool = True, + execute: bool = True, ) -> list[str]: """Execute code blocks in a list of Markdown-formatted strings. @@ -61,7 +71,10 @@ def process_markdown( verbose If True, print every line that is processed. backtick_standardize - If True, clean up markdown-code-runner string from backtick code blocks. + If True, clean up markdown-code-runner string from executed backtick code blocks. + execute + If True, execute code blocks and update output sections. + If False, return content unchanged. Returns ------- @@ -70,6 +83,29 @@ def process_markdown( """ ``` +### `standardize_code_fences` + +Utility function to strip `markdown-code-runner` modifiers from code fence language identifiers. + +```python +def standardize_code_fences(content: str) -> str: + """Strip markdown-code-runner modifiers from all code fence language identifiers. + + This is useful for making markdown files compatible with standard markdown + processors like mkdocs and pandoc. + + Parameters + ---------- + content + The markdown content as a string. + + Returns + ------- + str + The content with all code fence modifiers stripped. + """ +``` + ## Examples ### Basic Usage @@ -117,3 +153,35 @@ for md_file in docs_dir.rglob("*.md"): print(f"Processing {md_file}...") update_markdown_file(md_file) ``` + +### Standardizing Code Fences for External Processors + +```python +from markdown_code_runner import update_markdown_file + +# Execute code AND standardize all code fences +update_markdown_file("README.md", "docs/README.md", standardize=True) + +# Only standardize without executing code +update_markdown_file("README.md", "docs/README.md", execute=False, standardize=True) +``` + +### Using the Standardize Function Directly + +```python +from markdown_code_runner import standardize_code_fences + +content = """ +```python markdown-code-runner +print('hello') +``` +""" + +# Remove markdown-code-runner modifiers +clean_content = standardize_code_fences(content) +print(clean_content) +# Output: +# ```python +# print('hello') +# ``` +``` diff --git a/markdown_code_runner.py b/markdown_code_runner.py index 75ce79e..cac33da 100644 --- a/markdown_code_runner.py +++ b/markdown_code_runner.py @@ -161,6 +161,42 @@ def _bold(text: str) -> str: return f"{bold}{text}{reset}" +def standardize_code_fences(content: str) -> str: + """Strip markdown-code-runner modifiers from all code fence language identifiers. + + This is useful for making markdown files compatible with standard markdown + processors like mkdocs and pandoc, which don't understand the + ``python markdown-code-runner`` syntax. + + Parameters + ---------- + content + The markdown content as a string. + + Returns + ------- + str + The content with all code fence modifiers stripped. + + Examples + -------- + >>> text = '''```python markdown-code-runner + ... print("hello") + ... ```''' + >>> print(standardize_code_fences(text)) + ```python + print("hello") + ``` + + """ + return re.sub( + r"^(```\w+)\s+markdown-code-runner(?:\s+\S+=\S+)*\s*$", + r"\1", + content, + flags=re.MULTILINE, + ) + + def _extract_backtick_options(line: str) -> dict[str, str]: """Extract extra information from a line.""" match = re.search(r"```(?P\w+)", line) @@ -310,6 +346,7 @@ def process_markdown( *, verbose: bool = False, backtick_standardize: bool = True, + execute: bool = True, ) -> list[str]: """Executes code blocks in a list of Markdown-formatted strings and returns the modified list. @@ -321,6 +358,9 @@ def process_markdown( If True, print every line that is processed. backtick_standardize If True, clean up markdown-code-runner string from backtick code blocks. + execute + If True, execute code blocks and update output sections. + If False, return content unchanged (useful with post-processing standardization). Returns ------- @@ -329,6 +369,9 @@ def process_markdown( """ assert isinstance(content, list), "Input must be a list" + if not execute: + return content + state = ProcessingState(backtick_standardize=backtick_standardize) for i, line in enumerate(content): @@ -339,12 +382,14 @@ def process_markdown( return state.new_lines -def update_markdown_file( +def update_markdown_file( # noqa: PLR0913 input_filepath: Path | str, output_filepath: Path | str | None = None, *, verbose: bool = False, backtick_standardize: bool = True, + execute: bool = True, + standardize: bool = False, ) -> None: """Rewrite a Markdown file by executing and updating code blocks. @@ -357,7 +402,14 @@ def update_markdown_file( verbose : bool If True, print every line that is processed. backtick_standardize : bool - If True, clean up markdown-code-runner string from backtick code blocks. + If True, clean up markdown-code-runner string from executed backtick code blocks. + execute : bool + If True, execute code blocks and update output sections. + If False, skip code execution (useful with standardize=True). + standardize : bool + If True, post-process to standardize ALL code fences in the output, + removing ``markdown-code-runner`` modifiers. This is useful for + compatibility with markdown processors like mkdocs and pandoc. """ if isinstance(input_filepath, str): # pragma: no cover @@ -370,8 +422,16 @@ def update_markdown_file( original_lines, verbose=verbose, backtick_standardize=backtick_standardize, + execute=execute, ) updated_content = "\n".join(new_lines).rstrip() + "\n" + + # Post-process to standardize all code fences if requested + if standardize: + if verbose: + print("Standardizing all code fences...") + updated_content = standardize_code_fences(updated_content) + if verbose: print(f"Writing output to: {output_filepath}") output_filepath = ( @@ -418,6 +478,20 @@ def main() -> None: help="Disable backtick standardization (default: enabled for separate output files, disabled for in-place)", default=False, ) + parser.add_argument( + "-s", + "--standardize", + action="store_true", + help="Post-process to standardize ALL code fences, removing 'markdown-code-runner' modifiers", + default=False, + ) + parser.add_argument( + "-n", + "--no-execute", + action="store_true", + help="Skip code execution entirely (useful with --standardize for compatibility processing only)", + default=False, + ) args = parser.parse_args() @@ -434,6 +508,8 @@ def main() -> None: output_filepath, verbose=args.verbose, backtick_standardize=backtick_standardize, + execute=not args.no_execute, + standardize=args.standardize, ) diff --git a/tests/test_backtick_stdz.py b/tests/test_backtick_stdz.py index b296263..d04e336 100644 --- a/tests/test_backtick_stdz.py +++ b/tests/test_backtick_stdz.py @@ -2,10 +2,13 @@ from pathlib import Path +import pytest + from markdown_code_runner import ( ProcessingState, _extract_backtick_options, process_markdown, + standardize_code_fences, update_markdown_file, ) @@ -161,3 +164,223 @@ def test_process_backticks_start() -> None: line = "```javascript some other content" result = state._process_start_markers(line) assert result is None + + +def test_standardize_code_fences() -> None: + """Test the standardize_code_fences utility function.""" + # Basic case + content = """# Example +```python markdown-code-runner +print('hello') +``` +Some text +```javascript +console.log('hi') +```""" + expected = """# Example +```python +print('hello') +``` +Some text +```javascript +console.log('hi') +```""" + assert standardize_code_fences(content) == expected + + # With options + content = """```python markdown-code-runner filename=test.py debug=true +code here +```""" + expected = """```python +code here +```""" + assert standardize_code_fences(content) == expected + + # Multiple occurrences + content = """```python markdown-code-runner +first +``` +text +```bash markdown-code-runner +second +``` +more text +```rust markdown-code-runner filename=test.rs +third +```""" + expected = """```python +first +``` +text +```bash +second +``` +more text +```rust +third +```""" + assert standardize_code_fences(content) == expected + + # Text references should be preserved + content = """Using `markdown-code-runner` is easy. +```python markdown-code-runner +code +```""" + expected = """Using `markdown-code-runner` is easy. +```python +code +```""" + assert standardize_code_fences(content) == expected + + +def test_process_markdown_execute_flag() -> None: + """Test process_markdown with execute=False.""" + input_lines = [ + "# Test", + "```python markdown-code-runner", + "print('hello')", + "```", + "", + "old output", + "", + ] + + # With execute=False, content should pass through unchanged + output = process_markdown(input_lines, execute=False) + assert output == input_lines + + # With execute=True (default), code should be executed + output = process_markdown(input_lines, execute=True, backtick_standardize=False) + assert "hello" in "\n".join(output) + assert "old output" not in "\n".join(output) + + +def test_update_markdown_file_execute_flag(tmp_path: Path) -> None: + """Test update_markdown_file with execute=False.""" + input_file = tmp_path / "test.md" + + content = """# Test +```python markdown-code-runner +print('hello') +``` + +old output +""" + + input_file.write_text(content) + + # Test with execute=False - content should be unchanged + update_markdown_file(input_file, execute=False) + result = input_file.read_text() + assert "old output" in result # Output not updated + + # Restore content + input_file.write_text(content) + + # Test with execute=True (default) - code should run + update_markdown_file(input_file, execute=True, backtick_standardize=False) + result = input_file.read_text() + assert "hello" in result + assert "old output" not in result + + +def test_update_markdown_file_standardize_flag(tmp_path: Path) -> None: + """Test update_markdown_file with standardize=True.""" + input_file = tmp_path / "test.md" + output_file = tmp_path / "output.md" + + # Content with code fence and text reference to markdown-code-runner + content = """# Test +Using `markdown-code-runner` is great! +```python markdown-code-runner +print('hello') +``` + +old output + +```bash markdown-code-runner +echo "test" +``` + +old bash output +""" + + input_file.write_text(content) + + # Test with standardize=True - all code fences should be cleaned + update_markdown_file(input_file, output_file, standardize=True) + result = output_file.read_text() + + # Code fences should be standardized + assert "```python\n" in result + assert "```bash\n" in result + assert "```python markdown-code-runner" not in result + assert "```bash markdown-code-runner" not in result + + # Text references should be preserved + assert "`markdown-code-runner`" in result + + # Code should have executed + assert "hello" in result + assert "test" in result + assert "old output" not in result + + +def test_update_markdown_file_standardize_without_execute(tmp_path: Path) -> None: + """Test update_markdown_file with standardize=True and execute=False.""" + input_file = tmp_path / "test.md" + output_file = tmp_path / "output.md" + + content = """# Test +```python markdown-code-runner +print('hello') +``` + +old output +""" + + input_file.write_text(content) + + # Test with standardize=True and execute=False + update_markdown_file(input_file, output_file, execute=False, standardize=True) + result = output_file.read_text() + + # Code fences should be standardized + assert "```python\n" in result + assert "```python markdown-code-runner" not in result + + # Code should NOT have executed (execute=False) + # The auto-generated warning should not appear + assert "old output" in result + assert "auto-generated" not in result + + +def test_update_markdown_file_standardize_verbose( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test update_markdown_file with standardize=True and verbose=True.""" + input_file = tmp_path / "test.md" + output_file = tmp_path / "output.md" + + content = """# Test +```python markdown-code-runner +print('hello') +``` + +old output +""" + + input_file.write_text(content) + + # Test with standardize=True and verbose=True + update_markdown_file( + input_file, + output_file, + execute=False, + standardize=True, + verbose=True, + ) + + captured = capsys.readouterr() + assert "Standardizing all code fences" in captured.out diff --git a/tests/test_main_app.py b/tests/test_main_app.py index c72fbb1..2aebdf2 100644 --- a/tests/test_main_app.py +++ b/tests/test_main_app.py @@ -206,6 +206,8 @@ def test_main_no_arguments(tmp_path: Path) -> None: output=None, verbose=False, no_backtick_standardize=True, + standardize=False, + no_execute=False, ), ): main() @@ -227,6 +229,8 @@ def test_main_filepath_argument(tmp_path: Path) -> None: output=str(output_filepath), verbose=False, no_backtick_standardize=True, + standardize=False, + no_execute=False, ), ): main() @@ -248,6 +252,8 @@ def test_main_debug_mode(capfd: pytest.CaptureFixture, tmp_path: Path) -> None: output=str(output_filepath), verbose=True, no_backtick_standardize=True, + standardize=False, + no_execute=False, ), ): main()