#!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Convert GitHub-style admonitions to Fern's admonition format. GitHub admonitions look like: > [!NOTE] > Useful information that users should know. > [!TIP] > Helpful advice for doing things better. > [!IMPORTANT] > Key information users need to know. > [!WARNING] > Urgent info that needs immediate user attention. > [!CAUTION] > Advises about risks or negative outcomes. Fern admonitions look like: This highlights additional context or supplementary information This suggests a helpful tip This draws attention to important information This raises a warning to watch out for This indicates a potential error This script can be used when syncing docs from the main docs/ folder to the fern/pages/ folder for the docs-website branch. Usage: # Convert a single file python convert_callouts.py input.md output.md # Convert a single file in-place python convert_callouts.py input.md # Convert all markdown files in a directory python convert_callouts.py --dir /path/to/pages # Convert from stdin to stdout cat input.md | python convert_callouts.py - # Run tests python convert_callouts.py --test """ import argparse import re import sys from pathlib import Path # Mapping from GitHub alert types to Fern admonition tags # GitHub types: NOTE, TIP, IMPORTANT, WARNING, CAUTION # Fern types: Info, Warning, Success, Error, Note, Launch, Tip, Check GITHUB_TO_FERN_MAPPING = { "NOTE": "Note", "TIP": "Tip", "IMPORTANT": "Info", # IMPORTANT -> Info (draws attention to important info) "WARNING": "Warning", "CAUTION": "Error", # CAUTION -> Error (indicates potential error/risk) } # Regex pattern to match GitHub-style admonitions # Matches: # > [!TYPE] # > Content line 1 # > Content line 2 # (ends at a blank line or non-blockquote line) GITHUB_ADMONITION_PATTERN = re.compile( r"^(?P[ \t]*)>[ \t]*\[!(?PNOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*\n" r"(?P(?:(?P=indent)>[ \t]*.*\n?)+)", re.MULTILINE | re.IGNORECASE, ) def extract_blockquote_content(content: str, indent: str) -> str: """ Extract the actual content from blockquote lines. Args: content: The raw blockquote content (lines starting with '>') indent: The leading indentation before '>' Returns: The content without blockquote markers, preserving internal formatting. """ lines = content.split("\n") extracted_lines = [] for line in lines: # Remove the indent and blockquote marker if line.startswith(indent + ">"): # Remove indent + '>' and optional single space after stripped = line[len(indent) + 1 :] if stripped.startswith(" "): stripped = stripped[1:] extracted_lines.append(stripped) elif line.strip() == "": # Empty line ends the blockquote break else: # Non-blockquote line ends the content break # Join and strip trailing whitespace, but preserve internal newlines result = "\n".join(extracted_lines).rstrip() return result def convert_single_admonition(match: re.Match) -> str: """ Convert a single GitHub admonition match to Fern format. Args: match: A regex match object containing the admonition Returns: The converted Fern-style admonition """ indent = match.group("indent") alert_type = match.group("type").upper() raw_content = match.group("content") # Get the Fern tag for this alert type fern_tag = GITHUB_TO_FERN_MAPPING.get(alert_type, "Note") # Extract the actual content from blockquote lines content = extract_blockquote_content(raw_content, indent) # Handle multi-line content # For Fern, we can either: # 1. Keep it on one line (for short content) # 2. Use multi-line format (for longer content) content_lines = content.split("\n") if len(content_lines) == 1 and len(content) < 100: # Single short line - keep on one line return f"{indent}<{fern_tag}>{content}\n" else: # Multi-line content - format with proper indentation # Fern supports multi-line content within the tags formatted_content = "\n".join(content_lines) return f"{indent}<{fern_tag}>\n{formatted_content}\n{indent}\n" def convert_admonitions(text: str) -> str: """ Convert all GitHub-style admonitions in the text to Fern format. Args: text: The markdown text containing GitHub admonitions Returns: The text with admonitions converted to Fern format """ return GITHUB_ADMONITION_PATTERN.sub(convert_single_admonition, text) def process_file(input_path: Path, output_path: Path | None = None) -> None: """ Process a single markdown file, converting admonitions. Args: input_path: Path to the input file output_path: Path to the output file (None for in-place) """ content = input_path.read_text(encoding="utf-8") converted = convert_admonitions(content) if output_path is None: output_path = input_path output_path.write_text(converted, encoding="utf-8") def process_directory(dir_path: Path, recursive: bool = True) -> int: """ Process all markdown files in a directory. Args: dir_path: Path to the directory recursive: Whether to process subdirectories Returns: Number of files processed """ pattern = "**/*.md" if recursive else "*.md" files = list(dir_path.glob(pattern)) count = 0 for file_path in files: print(f"Processing: {file_path}") process_file(file_path) count += 1 return count def run_tests(): """Run all test cases for the convert_admonitions function.""" import textwrap passed = 0 failed = 0 def test(name: str, input_text: str, expected: str): nonlocal passed, failed result = convert_admonitions(input_text) if result == expected: print(f" PASS: {name}") passed += 1 else: print(f" FAIL: {name}") print(f" Input:\n{textwrap.indent(repr(input_text), ' ')}") print(f" Expected:\n{textwrap.indent(repr(expected), ' ')}") print(f" Got:\n{textwrap.indent(repr(result), ' ')}") failed += 1 print("Running tests...\n") # Test 1: Simple NOTE conversion (short content -> single line) test( "Simple NOTE - single line", "> [!NOTE]\n> This is a note.\n", "This is a note.\n", ) # Test 2: Simple TIP conversion test( "Simple TIP - single line", "> [!TIP]\n> This is a tip.\n", "This is a tip.\n", ) # Test 3: IMPORTANT -> Info mapping test( "IMPORTANT -> Info mapping", "> [!IMPORTANT]\n> This is important.\n", "This is important.\n", ) # Test 4: WARNING conversion test( "WARNING conversion", "> [!WARNING]\n> This is a warning.\n", "This is a warning.\n", ) # Test 5: CAUTION -> Error mapping test( "CAUTION -> Error mapping", "> [!CAUTION]\n> This is a caution.\n", "This is a caution.\n", ) # Test 6: Multi-line content (should use multi-line format) test( "Multi-line content", "> [!NOTE]\n> Line one.\n> Line two.\n", "\nLine one.\nLine two.\n\n", ) # Test 7: Long single line (>100 chars -> multi-line format) long_content = "A" * 101 test( "Long single line -> multi-line format", f"> [!NOTE]\n> {long_content}\n", f"\n{long_content}\n\n", ) # Test 8: Case insensitivity test( "Case insensitivity (lowercase)", "> [!note]\n> Lowercase note.\n", "Lowercase note.\n", ) test( "Case insensitivity (mixed case)", "> [!NoTe]\n> Mixed case note.\n", "Mixed case note.\n", ) # Test 9: Indented admonition test( "Indented admonition", " > [!NOTE]\n > Indented note.\n", " Indented note.\n", ) # Test 10: Multiple admonitions in one text test( "Multiple admonitions", "> [!NOTE]\n> First note.\n\nSome text.\n\n> [!TIP]\n> A tip.\n", "First note.\n\nSome text.\n\nA tip.\n", ) # Test 11: Admonition with markdown formatting test( "Admonition with markdown formatting", "> [!NOTE]\n> This has **bold** and `code`.\n", "This has **bold** and `code`.\n", ) # Test 12: Admonition with link test( "Admonition with link", "> [!TIP]\n> See [the docs](https://example.com).\n", "See [the docs](https://example.com).\n", ) # Test 13: Empty content after type test( "Content on same line as blockquote marker", "> [!NOTE]\n>\n", "\n", ) # Test 14: Content with extra spaces test( "Content with leading space preserved", "> [!NOTE]\n> Two spaces before.\n", " Two spaces before.\n", ) # Test 15: No conversion needed (not an admonition) test( "Regular blockquote (no conversion)", "> This is just a regular blockquote.\n", "> This is just a regular blockquote.\n", ) # Test 16: Admonition in middle of document test( "Admonition in middle of document", "# Header\n\nSome paragraph.\n\n> [!WARNING]\n> Be careful!\n\nMore text.\n", "# Header\n\nSome paragraph.\n\nBe careful!\n\nMore text.\n", ) # Test 17: Tab-indented admonition test( "Tab-indented admonition", "\t> [!NOTE]\n\t> Tab indented.\n", "\tTab indented.\n", ) # Test 18: Multi-line with blank line in content (ends at blank) test( "Multi-line ending at blank line", "> [!NOTE]\n> Line one.\n> Line two.\n\nAfter.\n", "\nLine one.\nLine two.\n\n\nAfter.\n", ) print(f"\n{'='*50}") print(f"Results: {passed} passed, {failed} failed") print(f"{'='*50}") return failed == 0 def main(): parser = argparse.ArgumentParser( description="Convert GitHub-style admonitions to Fern format", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument( "input", nargs="?", help="Input file path, or '-' for stdin. If --dir is used, this is ignored.", ) parser.add_argument( "output", nargs="?", help="Output file path. If omitted, modifies input in-place (except for stdin).", ) parser.add_argument( "--dir", "-d", type=Path, help="Process all markdown files in the specified directory", ) parser.add_argument( "--no-recursive", action="store_true", help="Don't process subdirectories when using --dir", ) parser.add_argument( "--test", "-t", action="store_true", help="Run test cases", ) args = parser.parse_args() if args.test: # Run tests success = run_tests() sys.exit(0 if success else 1) if args.dir: # Process directory if not args.dir.is_dir(): print(f"Error: {args.dir} is not a directory", file=sys.stderr) sys.exit(1) count = process_directory(args.dir, recursive=not args.no_recursive) print(f"Processed {count} file(s)") elif args.input == "-" or args.input is None: # Read from stdin, write to stdout content = sys.stdin.read() converted = convert_admonitions(content) sys.stdout.write(converted) else: # Process single file input_path = Path(args.input) if not input_path.is_file(): print(f"Error: {input_path} is not a file", file=sys.stderr) sys.exit(1) output_path = Path(args.output) if args.output else None process_file(input_path, output_path) if output_path: print(f"Converted: {input_path} -> {output_path}") else: print(f"Converted: {input_path} (in-place)") if __name__ == "__main__": main()