"""HTML documentation generation for cyclopts apps.""" from typing import TYPE_CHECKING from cyclopts._markup import escape_html, extract_text from cyclopts.docs.base import ( apply_usage_name, build_command_chain, extract_description, extract_usage, filter_help_entries, format_usage_line, generate_anchor, iterate_commands, ) if TYPE_CHECKING: from cyclopts.core import App def _generate_html_toc( lines: list[str], app: "App", include_hidden: bool, app_name: str, prefix: str, depth: int = 0, ) -> None: """Recursively generate HTML table of contents.""" if not app._commands: return for name, subapp in iterate_commands(app, include_hidden): display_name = f"{prefix}{name}" if prefix else name full_path = f"{app_name}-{display_name.replace(' ', '-')}".lower() indent = " " * (depth + 1) lines.append(f'{indent}
  • {name}') if subapp._commands: lines.append(f"{indent} ") lines.append(f"{indent}
  • ") # CSS styles embedded as a string - clean, modern design DEFAULT_CSS = """ :root { --bg-color: #ffffff; --text-color: #333333; --border-color: #e0e0e0; --code-bg: #f5f5f5; --link-color: #0066cc; --header-bg: #f8f9fa; --required-color: #d73027; } * { box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: var(--text-color); background: var(--bg-color); max-width: 1200px; margin: 0 auto; padding: 20px; } h1, h2, h3, h4, h5, h6 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; line-height: 1.25; } h1 { font-size: 2em; border-bottom: 2px solid var(--border-color); padding-bottom: 0.3em; } h2 { font-size: 1.5em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; } h3 { font-size: 1.25em; } h4 { font-size: 1em; } code { background: var(--code-bg); padding: 2px 6px; border-radius: 3px; font-family: 'Courier New', Consolas, monospace; font-size: 0.9em; } pre { background: var(--code-bg); padding: 16px; border-radius: 6px; overflow-x: auto; font-family: 'Courier New', Consolas, monospace; font-size: 0.9em; } pre code { background: none; padding: 0; } .usage-block { margin: 16px 0; } .usage { background: #f8f9fa; border-left: 4px solid #0066cc; } .description, .app-description, .command-description { margin: 16px 0; color: var(--text-color); } .panel-description { margin: 12px 0; color: #666; } .help-panel { margin: 24px 0; } /* List styles for commands and parameters */ .commands-list, .parameters-list { list-style: none; padding-left: 0; margin: 16px 0; } .commands-list li, .parameters-list li { padding: 8px 0; border-bottom: 1px solid var(--border-color); } .commands-list li:last-child, .parameters-list li:last-child { border-bottom: none; } .commands-list code, .parameters-list code { font-weight: 600; } /* Metadata styling */ .parameter-metadata { display: inline-flex; gap: 8px; margin-left: 8px; flex-wrap: wrap; align-items: center; } .metadata-item { display: inline-block; padding: 2px 8px; font-size: 0.85em; border-radius: 4px; background: var(--code-bg); border: 1px solid var(--border-color); } .metadata-required { background: #fee; border-color: #fcc; color: #c00; font-weight: 600; } .metadata-default { background: #f0f8ff; border-color: #d0e8ff; color: #0066cc; } .metadata-env { background: #f0fff0; border-color: #d0ffd0; color: #080; } .metadata-choices { background: #fffaf0; border-color: #ffd0a0; color: #840; } .metadata-label { font-weight: 600; opacity: 0.8; text-transform: uppercase; font-size: 0.9em; } /* Table of Contents */ .table-of-contents { background: var(--header-bg); border-radius: 6px; padding: 16px; margin: 24px 0; } .table-of-contents h2 { margin-top: 0; border-bottom: none; padding-bottom: 0; } .table-of-contents ul { margin: 8px 0; padding-left: 24px; } .table-of-contents li { margin: 4px 0; } .table-of-contents a { color: var(--link-color); text-decoration: none; } .table-of-contents a:hover { text-decoration: underline; } /* General link styles */ a { color: var(--link-color); text-decoration: none; } a:hover { text-decoration: underline; } .commands-list a code { color: var(--link-color); } /* Back to top link */ .back-to-top { display: inline-block; margin-top: 8px; font-size: 0.9em; opacity: 0.7; } .back-to-top:hover { opacity: 1; } /* Responsive design */ @media (max-width: 768px) { body { padding: 10px; } .commands-list, .parameters-list { font-size: 0.9em; } } /* Dark mode support */ @media (prefers-color-scheme: dark) { :root { --bg-color: #1e1e1e; --text-color: #e0e0e0; --border-color: #444; --code-bg: #2d2d2d; --link-color: #66b3ff; --header-bg: #2d2d2d; } .usage { background: #2d2d2d; border-left-color: #66b3ff; } .table-of-contents { background: #2d2d2d; } .metadata-required { background: #4a2020; border-color: #6a3030; color: #ff9999; } .metadata-default { background: #20304a; border-color: #304060; color: #99ccff; } .metadata-env { background: #204a20; border-color: #306030; color: #99ff99; } .metadata-choices { background: #4a3020; border-color: #604030; color: #ffcc99; } } /* Command sections */ .command-section { margin-top: 32px; padding-top: 16px; border-top: 2px solid var(--border-color); } .command-section:first-child { border-top: none; } """ def generate_html_docs( app: "App", recursive: bool = True, include_hidden: bool = False, heading_level: int = 1, max_heading_level: int = 6, standalone: bool = True, custom_css: str | None = None, command_chain: list[str] | None = None, generate_toc: bool = True, flatten_commands: bool = False, usage_name: str | None = None, ) -> str: """Generate HTML documentation for a CLI application. Parameters ---------- app : App The cyclopts App instance to document. recursive : bool If True, generate documentation for all subcommands recursively. Default is True. include_hidden : bool If True, include hidden commands/parameters in documentation. Default is False. heading_level : int Starting heading level for the main application title. Default is 1. max_heading_level : int Maximum heading level to use. Headings deeper than this will be capped at this level. HTML supports levels 1-6. Default is 6. standalone : bool If True, generate a complete HTML document with , , etc. If False, generate only the body content. Default is True. custom_css : str Custom CSS to use instead of the default styles. command_chain : list[str] Internal parameter to track command hierarchy. Default is None. generate_toc : bool If True, generate a table of contents for multi-command apps. Default is True. flatten_commands : bool If True, generate all commands at the same heading level instead of nested. Default is False. usage_name : str | None Optional replacement for the root app name in every ``Usage:`` line (root and subcommands). Section headings and anchors continue to use ``app.name[0]``. Default is None. Returns ------- str The generated HTML documentation. """ from cyclopts.help.formatters.html import HtmlFormatter # Initialize command chain if not provided if command_chain is None: command_chain = [] # Build the main documentation lines = [] # Only add the outer div for standalone documents or root level if standalone or not command_chain: lines.append('
    ') # Determine the app name and full command path if not command_chain: # Root level - use app name or derive from sys.argv app_name = app.name[0] full_command = app_name title = app_name # Add title for all levels effective_level = min(heading_level, max_heading_level) lines.append(f'{title}') else: # Nested command - build full path app_name = command_chain[0] if command_chain else app.name[0] full_command = " ".join(command_chain) # Create anchor-friendly ID using shared logic anchor_id = generate_anchor(full_command) effective_level = min(heading_level, max_heading_level) lines.append('
    ') lines.append( f'{escape_html(full_command)}' ) # Add application description help_format = app.app_stack.resolve("help_format", fallback="restructuredtext") description = extract_description(app, help_format) if description: desc_text = extract_text(description, None) if desc_text: lines.append(f'
    {escape_html(desc_text)}
    ') # Generate table of contents if this is the root level and has commands if generate_toc and not command_chain and app._commands: lines.append('
    ') lines.append("

    Table of Contents

    ") lines.append("
      ") _generate_html_toc(lines, app, include_hidden, app_name, "", 0) lines.append("
    ") lines.append("
    ") # Add usage section if not suppressed usage = extract_usage(app) if usage: usage_level = min(heading_level + 1, max_heading_level) lines.append(f"Usage") lines.append('
    ') if isinstance(usage, str): usage_text = usage else: usage_text = extract_text(usage, None) display_chain = apply_usage_name(command_chain, usage_name) usage_text = format_usage_line(usage_text, display_chain, prefix="$") lines.append(f'
    {escape_html(usage_text)}
    ') lines.append("
    ") # Get help panels for the current app # Use app_stack context - if caller set up parent context, it will be stacked with app.app_stack([app]): help_panels_with_groups = app._assemble_help_panels([], help_format) # Render panels formatter = HtmlFormatter( heading_level=heading_level + 1, include_hidden=include_hidden, app_name=app_name, command_chain=command_chain, ) formatter.reset() for group, panel in help_panels_with_groups: if not include_hidden and group and not group.show: continue # Filter out entries based on include_hidden if not include_hidden: panel.entries = filter_help_entries(app, panel, include_hidden) if panel.entries: # Only render non-empty panels formatter(None, None, panel) panel_docs = formatter.get_output().strip() if panel_docs: lines.append(panel_docs) # Handle recursive documentation for subcommands if app._commands: # Iterate through registered commands for name, subapp in iterate_commands(app, include_hidden): # Build the command chain for this subcommand sub_command_chain = build_command_chain(command_chain, name, app_name) # Determine heading level for subcommand if flatten_commands: sub_heading_level = heading_level else: sub_heading_level = heading_level + 1 # Generate subcommand documentation lines.append('
    ') # Create anchor-friendly ID anchor_id = ( f"{app_name}-{'-'.join(sub_command_chain[1:])}".lower() if len(sub_command_chain) > 1 else f"{app_name}-{name}".lower() ) effective_sub_level = min(sub_heading_level, max_heading_level) lines.append( f'{escape_html(" ".join(sub_command_chain))}' ) # Get subapp help # Include parent app in the stack so default_parameter is properly inherited with subapp.app_stack([app, subapp]): sub_help_format = subapp.app_stack.resolve("help_format", fallback=help_format) sub_description = extract_description(subapp, sub_help_format) if sub_description: sub_desc_text = extract_text(sub_description, None) if sub_desc_text: lines.append(f'
    {escape_html(sub_desc_text)}
    ') # Generate usage for subcommand sub_usage = extract_usage(subapp) if sub_usage: if flatten_commands: usage_heading_level = heading_level + 1 else: usage_heading_level = heading_level + 2 usage_heading_level = min(usage_heading_level, max_heading_level) lines.append(f"Usage") lines.append('
    ') if isinstance(sub_usage, str): sub_usage_text = sub_usage else: sub_usage_text = extract_text(sub_usage, None) sub_display_chain = apply_usage_name(sub_command_chain, usage_name) sub_usage_text = format_usage_line(sub_usage_text, sub_display_chain, prefix="$") lines.append(f'
    {escape_html(sub_usage_text)}
    ') lines.append("
    ") # Only show subcommand panels if we're in recursive mode if recursive: # Get help panels for subcommand sub_panels = subapp._assemble_help_panels([], sub_help_format) # Render subcommand panels if flatten_commands: panel_heading_level = heading_level + 1 else: panel_heading_level = heading_level + 2 panel_heading_level = min(panel_heading_level, max_heading_level) sub_formatter = HtmlFormatter( heading_level=panel_heading_level, include_hidden=include_hidden, app_name=app_name, command_chain=sub_command_chain, ) for sub_group, sub_panel in sub_panels: if not include_hidden and sub_group and not sub_group.show: continue if not include_hidden: sub_panel.entries = filter_help_entries(subapp, sub_panel, include_hidden) if sub_panel.entries: sub_formatter(None, None, sub_panel) sub_panel_docs = sub_formatter.get_output().strip() if sub_panel_docs: lines.append(sub_panel_docs) # Recursively handle nested subcommands if recursive and subapp._commands: for nested_name, nested_app in iterate_commands(subapp, include_hidden): # Build nested command chain nested_chain = build_command_chain(sub_command_chain, nested_name, app_name) # Determine heading level for nested commands if flatten_commands: nested_heading_level = heading_level else: nested_heading_level = heading_level + 2 # Set up context for nested_app, then recurse # The recursive call's app_stack([app]) will stack on top of this with nested_app.app_stack([subapp, nested_app]): nested_docs = generate_html_docs( nested_app, recursive=recursive, include_hidden=include_hidden, heading_level=nested_heading_level, max_heading_level=max_heading_level, standalone=False, # Not standalone for nested custom_css=None, command_chain=nested_chain, # Pass the command chain generate_toc=False, # No TOC for nested commands flatten_commands=flatten_commands, usage_name=usage_name, ) lines.append(nested_docs) # Add back to top link if we're in a nested section if command_chain: lines.append('↑ Back to top') lines.append("
    ") # Close section if nested command if command_chain: lines.append("
    ") # Only close cli-documentation div for standalone or root if standalone or not command_chain: lines.append("
    ") # Close cli-documentation div # Join all lines into body content body_content = "\n".join(lines) # If standalone, wrap in complete HTML document if standalone: css = custom_css if custom_css else DEFAULT_CSS doc = f""" {escape_html(app_name)} - CLI Documentation {body_content} """ return doc else: return body_content