From 2845ec9c5aac0f1b4a85651b68deb6806f02d853 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Mon, 6 Jul 2026 19:51:32 +0200 Subject: [PATCH] Restore best-effort Markdown shell-fence formatting (#796) --- actions/__init__.py | 2 +- actions/update_markdown_code_blocks.py | 88 +++++++++++++++++++----- tests/test_update_markdown_codeblocks.py | 50 +++++++------- 3 files changed, 98 insertions(+), 42 deletions(-) diff --git a/actions/__init__.py b/actions/__init__.py index a3d59d0..b4ca7c8 100644 --- a/actions/__init__.py +++ b/actions/__init__.py @@ -28,4 +28,4 @@ # ├── test_summarize_pr.py # └── ... -__version__ = "0.2.22" +__version__ = "0.2.23" diff --git a/actions/update_markdown_code_blocks.py b/actions/update_markdown_code_blocks.py index e3d0d20..cd0a35a 100644 --- a/actions/update_markdown_code_blocks.py +++ b/actions/update_markdown_code_blocks.py @@ -11,10 +11,16 @@ from pathlib import Path def extract_code_blocks(markdown_content): - """Extract Python code blocks from Markdown content using regex pattern matching.""" + """Extracts Python and Bash code blocks from Markdown content using regex pattern matching.""" + # Python code blocks py_pattern = r"^( *)```(?:python|py|\{[ ]*\.py[ ]*\.annotate[ ]*\})\n(.*?)\n\1```" py_code_blocks = re.compile(py_pattern, re.DOTALL | re.MULTILINE).findall(markdown_content) - return {"python": py_code_blocks} + + # Bash code blocks + bash_pattern = r"^( *)```(?:bash|sh|shell)\n(.*?)\n\1```" + bash_code_blocks = re.compile(bash_pattern, re.DOTALL | re.MULTILINE).findall(markdown_content) + + return {"python": py_code_blocks, "bash": bash_code_blocks} def remove_indentation(code_block, num_spaces): @@ -90,24 +96,51 @@ def format_code_with_ruff(temp_dir): print(f"ERROR running Python docstring formatter ❌ {e}") +def format_bash_with_prettier(temp_dir): + """Formats bash script files in the specified directory using prettier.""" + if not next(Path(temp_dir).rglob("*.sh"), None): + return + + try: + # Run prettier with explicit config path + result = subprocess.run( + "npx prettier --write --print-width=120 --plugin=$(npm root -g)/prettier-plugin-sh/lib/index.cjs ./**/*.sh", + shell=True, # must use shell=True to expand internal $(cmd) + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"ERROR running prettier-plugin-sh ❌ {result.stderr}") + else: + print("Completed bash formatting ✅") + except Exception as e: + print(f"ERROR running prettier-plugin-sh ❌ {e}") + + def process_markdown_string( markdown_content: str, process_python: bool = True, + process_bash: bool = True, verbose: bool = False, ) -> str: - """Format Python code blocks inside a Markdown string.""" + """Formats Python/Bash code blocks inside a Markdown string.""" formatted_markdown = markdown_content with tempfile.TemporaryDirectory() as temp_dir_name: temp_dir = Path(temp_dir_name) temp_md = temp_dir / "input.md" temp_md.write_text(markdown_content, encoding="utf-8") - markdown_snapshot, temp_files = process_markdown_file(temp_md, temp_dir, process_python, verbose) + markdown_snapshot, temp_files = process_markdown_file(temp_md, temp_dir, process_python, process_bash, verbose) if markdown_snapshot is None or temp_files is None: return formatted_markdown - if process_python and temp_files: + python_files_exist = process_python and any(code_type == "python" for _, _, _, code_type in temp_files) + bash_files_exist = process_bash and any(code_type == "bash" for _, _, _, code_type in temp_files) + + if python_files_exist: format_code_with_ruff(temp_dir) + if bash_files_exist: + format_bash_with_prettier(temp_dir) update_markdown_file(temp_md, markdown_snapshot, temp_files) formatted_markdown = temp_md.read_text(encoding="utf-8") @@ -115,34 +148,44 @@ def process_markdown_string( return formatted_markdown -def generate_temp_filename(file_path, index): +def generate_temp_filename(file_path, index, code_type): """Creates unique temp filename with full path info for debugging.""" stem = file_path.stem + code_letter = code_type[0] # 'p' for python, 'b' for bash path_part = str(file_path.parent).replace("/", "_").replace("\\", "_").replace(" ", "-") hash_val = hashlib.md5(f"{file_path}_{index}".encode()).hexdigest()[:6] - filename = f"{stem}_{path_part}_p{index}_{hash_val}.py" + ext = ".py" if code_type == "python" else ".sh" + filename = f"{stem}_{path_part}_{code_letter}{index}_{hash_val}{ext}" return re.sub(r"[^\w\-.]", "_", filename) -def process_markdown_file(file_path, temp_dir, process_python=True, verbose=False): +def process_markdown_file(file_path, temp_dir, process_python=True, process_bash=True, verbose=False): """Processes a Markdown file, extracting code blocks into temp files and returning the content and file info.""" try: markdown_content = Path(file_path).read_text(encoding="utf-8") code_blocks_by_type = extract_code_blocks(markdown_content) temp_files = [] + + # Process all code block types based on flags + code_types = [] if process_python: - for i, (num_spaces, code_block) in enumerate(code_blocks_by_type["python"]): + code_types.append(("python", 0)) + if process_bash: + code_types.append(("bash", 1000)) + + for code_type, offset in code_types: + for i, (num_spaces, code_block) in enumerate(code_blocks_by_type[code_type]): if verbose: - print(f"Extracting python code block {i} from {file_path}") + print(f"Extracting {code_type} code block {i} from {file_path}") num_spaces = len(num_spaces) code_without_indentation = remove_indentation(code_block, num_spaces) - temp_file_path = temp_dir / generate_temp_filename(file_path, i) + temp_file_path = temp_dir / generate_temp_filename(file_path, i + offset, code_type) with open(temp_file_path, "w", encoding="utf-8") as temp_file: temp_file.write(code_without_indentation) - temp_files.append((num_spaces, code_block, temp_file_path)) + temp_files.append((num_spaces, code_block, temp_file_path, code_type)) return markdown_content, temp_files @@ -153,13 +196,17 @@ def process_markdown_file(file_path, temp_dir, process_python=True, verbose=Fals def update_markdown_file(file_path, markdown_content, temp_files): """Updates a Markdown file with formatted code blocks.""" - for num_spaces, original_code_block, temp_file_path in temp_files: + for num_spaces, original_code_block, temp_file_path, code_type in temp_files: try: with open(temp_file_path, encoding="utf-8") as temp_file: formatted_code = temp_file.read().rstrip("\n") # Strip trailing newlines formatted_code_with_indentation = add_indentation(formatted_code, num_spaces) - for lang in ("python", "py", "{ .py .annotate }"): + # Define the language tags for each code type + lang_tags = {"python": ["python", "py", "{ .py .annotate }"], "bash": ["bash", "sh", "shell"]} + + # Replace the code blocks with the formatted version + for lang in lang_tags[code_type]: markdown_content = markdown_content.replace( f"{' ' * num_spaces}```{lang}\n{original_code_block}\n{' ' * num_spaces}```", f"{' ' * num_spaces}```{lang}\n{formatted_code_with_indentation}\n{' ' * num_spaces}```", @@ -174,8 +221,8 @@ def update_markdown_file(file_path, markdown_content, temp_files): print(f"Error writing file {file_path}: {e}") -def main(root_dir=Path.cwd(), process_python=True, verbose=False): - """Process Markdown files, format selected code blocks, and update the original files.""" +def main(root_dir=Path.cwd(), process_python=True, process_bash=True, verbose=False): + """Processes Markdown files, extracts and formats code blocks, and updates the original files.""" root_path = Path(root_dir) markdown_files = [markdown_file for markdown_file in root_path.rglob("*.md") if not markdown_file.is_symlink()] temp_dir = Path("temp_code_blocks") @@ -186,13 +233,18 @@ def main(root_dir=Path.cwd(), process_python=True, verbose=False): for markdown_file in markdown_files: if verbose: print(f"Processing {markdown_file}") - markdown_content, temp_files = process_markdown_file(markdown_file, temp_dir, process_python, verbose) + markdown_content, temp_files = process_markdown_file( + markdown_file, temp_dir, process_python, process_bash, verbose + ) if markdown_content and temp_files: all_temp_files.append((markdown_file, markdown_content, temp_files)) # Format code blocks based on flags if process_python: format_code_with_ruff(temp_dir) # Format Python files + if process_bash: + format_bash_with_prettier(temp_dir) # Format Bash files + # Update Markdown files with formatted code blocks for markdown_file, markdown_content, temp_files in all_temp_files: update_markdown_file(markdown_file, markdown_content, temp_files) @@ -202,4 +254,4 @@ def main(root_dir=Path.cwd(), process_python=True, verbose=False): if __name__ == "__main__": - main(process_python=True) + main(process_python=True, process_bash=True) diff --git a/tests/test_update_markdown_codeblocks.py b/tests/test_update_markdown_codeblocks.py index 6ff7dc9..956a2c3 100644 --- a/tests/test_update_markdown_codeblocks.py +++ b/tests/test_update_markdown_codeblocks.py @@ -6,6 +6,7 @@ from unittest.mock import mock_open, patch from actions.update_markdown_code_blocks import ( add_indentation, extract_code_blocks, + format_bash_with_prettier, generate_temp_filename, main, process_markdown_file, @@ -24,12 +25,20 @@ def test(): return True ``` +And some bash code: + +```bash +echo "Hello World" +``` """ code_blocks = extract_code_blocks(markdown_content) assert len(code_blocks["python"]) == 1 assert code_blocks["python"][0][1] == "def test():\n return True" + assert len(code_blocks["bash"]) == 1 + assert code_blocks["bash"][0][1] == 'echo "Hello World"' + def test_remove_indentation(): """Test removing indentation from code blocks.""" @@ -63,11 +72,16 @@ def test_generate_temp_filename(): """Test generating temporary filenames.""" file_path = Path("docs/guide.md") - filename = generate_temp_filename(file_path, 0) + filename = generate_temp_filename(file_path, 0, "python") assert "guide_docs_p0_" in filename assert filename.endswith(".py") + filename = generate_temp_filename(file_path, 1, "bash") + + assert "guide_docs_b1_" in filename + assert filename.endswith(".sh") + @patch("pathlib.Path.read_text") @patch("pathlib.Path.write_text") @@ -94,6 +108,16 @@ def test(): mock_file.assert_called_once() +def test_format_bash_skips_when_no_shell_files(tmp_path): + """Test bash formatter skips Prettier when no shell snippets were extracted.""" + (tmp_path / "snippet.py").write_text("print('ok')", encoding="utf-8") + + with patch("subprocess.run") as mock_run: + format_bash_with_prettier(tmp_path) + + mock_run.assert_not_called() + + def test_main_skips_symlinked_markdown(tmp_path): """Test Markdown formatter skips symlinks to avoid formatting the same content twice.""" target = tmp_path / "AGENTS.md" @@ -101,34 +125,14 @@ def test_main_skips_symlinked_markdown(tmp_path): (tmp_path / "CLAUDE.md").symlink_to(target) with patch("actions.update_markdown_code_blocks.process_markdown_file", return_value=("", [])) as mock_process: - main(root_dir=tmp_path, process_python=False) + main(root_dir=tmp_path, process_python=False, process_bash=False) mock_process.assert_called_once() assert mock_process.call_args.args[0] == target -def test_main_ignores_bash_blocks(tmp_path): - """Test Markdown formatting ignores shell fences because docs often use them for terminal output.""" - markdown = tmp_path / "example.md" - markdown.write_text( - """# Example - -```bash -Firmware Version: 4.23.0 (release,app,extended context switch buffer) -``` -""", - encoding="utf-8", - ) - - with patch("subprocess.run") as mock_run: - main(root_dir=tmp_path, process_python=False) - - mock_run.assert_not_called() - assert markdown.read_text(encoding="utf-8").endswith("```\n") - - def test_main_real_files(): """Test main function on actual repository Markdown files.""" # Run main on current directory which contains README.md and other Markdown files # This provides real-world test coverage of the entire pipeline - main(process_python=True, verbose=False) + main(process_python=True, process_bash=True, verbose=False)