File size: 9,769 Bytes
53dbcc1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""MkDocs plugin for automatic Cyclopts CLI documentation."""

import re
from typing import TYPE_CHECKING, Any

import yaml
from attrs import define, field, validators

from cyclopts.docs.markdown import generate_markdown_docs
from cyclopts.utils import import_app

if TYPE_CHECKING:
    from mkdocs.config.defaults import MkDocsConfig
    from mkdocs.structure.files import Files
    from mkdocs.structure.pages import Page

from mkdocs.config import base
from mkdocs.config import config_options as c
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin, get_plugin_logger

logger = get_plugin_logger(__name__)


@define(kw_only=True)
class DirectiveOptions:
    """Configuration for the ::: cyclopts directive."""

    module: str = field(validator=validators.instance_of(str))
    heading_level: int = field(default=2, validator=validators.instance_of(int))
    max_heading_level: int = field(default=6, validator=validators.instance_of(int))
    commands: list[str] | None = field(default=None, validator=validators.optional(validators.instance_of(list)))
    exclude_commands: list[str] | None = field(
        default=None, validator=validators.optional(validators.instance_of(list))
    )
    recursive: bool = field(default=True, validator=validators.instance_of(bool))
    include_hidden: bool = field(default=False, validator=validators.instance_of(bool))
    flatten_commands: bool = field(default=False, validator=validators.instance_of(bool))
    generate_toc: bool = field(default=True, validator=validators.instance_of(bool))
    code_block_title: bool = field(default=False, validator=validators.instance_of(bool))
    skip_preamble: bool = field(default=False, validator=validators.instance_of(bool))
    usage_name: str | None = field(default=None, validator=validators.optional(validators.instance_of(str)))

    @classmethod
    def from_directive_block(
        cls,
        directive_text: str,
        *,
        default_heading_level: int | None = None,
        default_max_heading_level: int | None = None,
    ) -> "DirectiveOptions":
        """Parse options from a ::: cyclopts directive block.

        Expected format:
            ::: cyclopts
                module: myapp.cli:app
                heading_level: 2
                max_heading_level: 6
                recursive: true
                commands:
                  - cmd1
                  - cmd2

        Parameters
        ----------
        directive_text : str
            The directive text to parse.
        default_heading_level : int | None
            Default heading level from plugin config. Used if :heading-level: not specified.
        default_max_heading_level : int | None
            Default max heading level from plugin config. Used if :max-heading-level: not specified.
        """
        lines = directive_text.strip().split("\n")

        # Remove the ::: cyclopts line
        if lines and lines[0].strip().startswith("::: cyclopts"):
            lines = lines[1:]

        yaml_content = "\n".join(lines)
        options = yaml.safe_load(yaml_content) or {}

        if not isinstance(options, dict):
            raise TypeError("Invalid YAML in ::: cyclopts directive: expected a dictionary")

        if "module" not in options:
            raise ValueError('The "module" option is required for ::: cyclopts directive')

        if default_heading_level is not None:
            options.setdefault("heading_level", default_heading_level)

        if default_max_heading_level is not None:
            options.setdefault("max_heading_level", default_max_heading_level)

        # Convert keys with dashes to underscores
        normalized_options = {key.replace("-", "_"): value for key, value in options.items()}

        try:
            return cls(**normalized_options)
        except TypeError as e:
            raise ValueError(f"Error creating DirectiveOptions: {e}") from e


# Regex to match ::: cyclopts directive blocks
# The pattern matches:
# - "^::: cyclopts\n" - the directive start on its own line
# - "(?:[ \t]+.*\n?)*" - zero or more indented YAML lines (with optional trailing newline for EOF)
DIRECTIVE_PATTERN = re.compile(
    r"^::: cyclopts\n(?:[ \t]+.*\n?)*",
    re.MULTILINE,
)


def process_cyclopts_directives(markdown: str, plugin_config: Any) -> str:
    """Process all ::: cyclopts directives in markdown content.

    Parameters
    ----------
    markdown : str
        The markdown content containing ::: cyclopts directives.
    plugin_config : CycloptsPluginConfig
        The plugin configuration with default values. If None, uses DirectiveOptions defaults.

    Returns
    -------
    str
        The markdown content with directives replaced by generated documentation.
    """
    # Find all code blocks to exclude from processing
    code_blocks = []

    # Find fenced code blocks (triple backticks or tildes)
    fenced_pattern = re.compile(r"^[`~]{3,}.*?^[`~]{3,}", re.MULTILINE | re.DOTALL)
    for match in fenced_pattern.finditer(markdown):
        code_blocks.append((match.start(), match.end()))

    # Find indented code blocks (lines starting with 4 spaces or tab)
    # Indented code blocks are preceded by a blank line and consist of lines starting with 4 spaces/tab
    lines = markdown.split("\n")
    in_indented_block = False
    block_start = 0
    current_pos = 0

    for i, line in enumerate(lines):
        line_len = len(line) + 1  # +1 for the newline

        # Check if this line starts an indented code block
        if not in_indented_block:
            # Previous line must be blank (or be the first line)
            prev_blank = i == 0 or not lines[i - 1].strip()
            # Current line must start with 4 spaces or a tab and have content
            is_indented = (line.startswith("    ") or line.startswith("\t")) and line.strip()

            if prev_blank and is_indented:
                in_indented_block = True
                block_start = current_pos
        else:
            # Check if we're still in the indented block
            is_indented = (line.startswith("    ") or line.startswith("\t")) and line.strip()
            is_blank = not line.strip()

            # End block if we hit a non-indented, non-blank line
            if not is_indented and not is_blank:
                code_blocks.append((block_start, current_pos))
                in_indented_block = False

        current_pos += line_len

    # If we ended while still in an indented block, add it
    if in_indented_block:
        code_blocks.append((block_start, current_pos))

    def is_in_code_block(pos: int) -> bool:
        """Check if a position is inside a code block."""
        for start, end in code_blocks:
            if start <= pos < end:
                return True
        return False

    def replace_directive(match: re.Match) -> str:
        # Skip if this match is inside a code block
        if is_in_code_block(match.start()):
            return match.group(0)

        directive_text = match.group(0)

        try:
            default_heading = plugin_config.default_heading_level if plugin_config else None
            default_max_heading = plugin_config.default_max_heading_level if plugin_config else None
            options = DirectiveOptions.from_directive_block(
                directive_text,
                default_heading_level=default_heading,
                default_max_heading_level=default_max_heading,
            )

            app = import_app(options.module)

            markdown_docs = generate_markdown_docs(
                app,
                recursive=options.recursive,
                include_hidden=options.include_hidden,
                heading_level=options.heading_level,
                max_heading_level=options.max_heading_level,
                generate_toc=options.generate_toc,
                flatten_commands=options.flatten_commands,
                commands_filter=options.commands,
                exclude_commands=options.exclude_commands,
                no_root_title=True,  # Skip root title in plugin context
                code_block_title=options.code_block_title,
                skip_preamble=options.skip_preamble,
                usage_name=options.usage_name,
            )

            return markdown_docs

        except Exception as e:
            raise PluginError(f"Error processing ::: cyclopts directive: {e}") from e

    # Replace all directives in the markdown
    processed = DIRECTIVE_PATTERN.sub(replace_directive, markdown)
    return processed


class CycloptsPluginConfig(base.Config):  # type: ignore[misc]
    """Configuration schema for the Cyclopts MkDocs plugin."""

    default_heading_level = c.Type(int, default=2)  # type: ignore[attr-defined]
    default_max_heading_level = c.Type(int, default=6)  # type: ignore[attr-defined]


class CycloptsPlugin(BasePlugin[CycloptsPluginConfig]):  # type: ignore[misc]
    """MkDocs plugin to generate Cyclopts CLI documentation.

    Usage in mkdocs.yml:
        plugins:
          - cyclopts:
              default_heading_level: 2

    Usage in Markdown files:
        ::: cyclopts
            :module: myapp.cli:app
            :heading-level: 2
            :recursive: true
            :commands: init, build
            :exclude-commands: debug
    """

    def on_page_markdown(self, markdown: str, *, page: "Page", config: "MkDocsConfig", files: "Files", **kwargs) -> str:
        """Process ::: cyclopts directives in markdown content.

        This event is called after the page's markdown is loaded from file
        but before it's converted to HTML.
        """
        if "::: cyclopts" not in markdown:
            return markdown

        return process_cyclopts_directives(markdown, self.config)