"docs/backends/trtllm/README.md" did not exist on "56d91ee962d945f2eab29dbc094c5d8f429070cd"
convert_callouts.py 13.1 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
#!/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:
    <Note>This highlights additional context or supplementary information</Note>
    <Tip>This suggests a helpful tip</Tip>
    <Info>This draws attention to important information</Info>
    <Warning>This raises a warning to watch out for</Warning>
    <Error>This indicates a potential error</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<indent>[ \t]*)>[ \t]*\[!(?P<type>NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*\n"
    r"(?P<content>(?:(?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}</{fern_tag}>\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}</{fern_tag}>\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",
        "<Note>This is a note.</Note>\n",
    )

    # Test 2: Simple TIP conversion
    test(
        "Simple TIP - single line",
        "> [!TIP]\n> This is a tip.\n",
        "<Tip>This is a tip.</Tip>\n",
    )

    # Test 3: IMPORTANT -> Info mapping
    test(
        "IMPORTANT -> Info mapping",
        "> [!IMPORTANT]\n> This is important.\n",
        "<Info>This is important.</Info>\n",
    )

    # Test 4: WARNING conversion
    test(
        "WARNING conversion",
        "> [!WARNING]\n> This is a warning.\n",
        "<Warning>This is a warning.</Warning>\n",
    )

    # Test 5: CAUTION -> Error mapping
    test(
        "CAUTION -> Error mapping",
        "> [!CAUTION]\n> This is a caution.\n",
        "<Error>This is a caution.</Error>\n",
    )

    # Test 6: Multi-line content (should use multi-line format)
    test(
        "Multi-line content",
        "> [!NOTE]\n> Line one.\n> Line two.\n",
        "<Note>\nLine one.\nLine two.\n</Note>\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"<Note>\n{long_content}\n</Note>\n",
    )

    # Test 8: Case insensitivity
    test(
        "Case insensitivity (lowercase)",
        "> [!note]\n> Lowercase note.\n",
        "<Note>Lowercase note.</Note>\n",
    )

    test(
        "Case insensitivity (mixed case)",
        "> [!NoTe]\n> Mixed case note.\n",
        "<Note>Mixed case note.</Note>\n",
    )

    # Test 9: Indented admonition
    test(
        "Indented admonition",
        "  > [!NOTE]\n  > Indented note.\n",
        "  <Note>Indented note.</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",
        "<Note>First note.</Note>\n\nSome text.\n\n<Tip>A tip.</Tip>\n",
    )

    # Test 11: Admonition with markdown formatting
    test(
        "Admonition with markdown formatting",
        "> [!NOTE]\n> This has **bold** and `code`.\n",
        "<Note>This has **bold** and `code`.</Note>\n",
    )

    # Test 12: Admonition with link
    test(
        "Admonition with link",
        "> [!TIP]\n> See [the docs](https://example.com).\n",
        "<Tip>See [the docs](https://example.com).</Tip>\n",
    )

    # Test 13: Empty content after type
    test(
        "Content on same line as blockquote marker",
        "> [!NOTE]\n>\n",
        "<Note></Note>\n",
    )

    # Test 14: Content with extra spaces
    test(
        "Content with leading space preserved",
        "> [!NOTE]\n>  Two spaces before.\n",
        "<Note> Two spaces before.</Note>\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\n<Warning>Be careful!</Warning>\n\nMore text.\n",
    )

    # Test 17: Tab-indented admonition
    test(
        "Tab-indented admonition",
        "\t> [!NOTE]\n\t> Tab indented.\n",
        "\t<Note>Tab indented.</Note>\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",
        "<Note>\nLine one.\nLine two.\n</Note>\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()