diff --git a/CHANGES b/CHANGES index d615dc0c7a..c65567baef 100644 --- a/CHANGES +++ b/CHANGES @@ -35,6 +35,17 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +### Documentation + +#### Linkable CLI arguments and options (#1010) + +CLI documentation now supports direct linking to specific arguments: + +- **Linkable options**: Each `--option` and positional argument has a permanent URL anchor (e.g., `cli/load.html#-d`) +- **Headerlinks**: Hover over any argument to reveal a ¶ link for easy sharing +- **Visual styling**: Argument names displayed with syntax-highlighted backgrounds for better readability +- **Default value badges**: Default values shown as styled inline code (e.g., `auto`) + ## tmuxp 1.64.0 (2026-01-24) ### Documentation diff --git a/docs/_ext/sphinx_argparse_neo/nodes.py b/docs/_ext/sphinx_argparse_neo/nodes.py index 55039db639..f34f864f9c 100644 --- a/docs/_ext/sphinx_argparse_neo/nodes.py +++ b/docs/_ext/sphinx_argparse_neo/nodes.py @@ -26,6 +26,48 @@ from sphinx_argparse_neo.utils import strip_ansi # noqa: E402 +def _generate_argument_id(names: list[str], id_prefix: str = "") -> str: + """Generate unique ID for an argument based on its names. + + Creates a slug-style ID suitable for HTML anchors by: + 1. Stripping leading dashes from option names + 2. Joining multiple names with hyphens + 3. Prepending optional prefix for namespace isolation + + Parameters + ---------- + names : list[str] + List of argument names (e.g., ["-L", "--socket-name"]). + id_prefix : str + Optional prefix for uniqueness (e.g., "shell" -> "shell-L-socket-name"). + + Returns + ------- + str + A slug-style ID suitable for HTML anchors. + + Examples + -------- + >>> _generate_argument_id(["-L"]) + 'L' + >>> _generate_argument_id(["--help"]) + 'help' + >>> _generate_argument_id(["-v", "--verbose"]) + 'v-verbose' + >>> _generate_argument_id(["-L"], "shell") + 'shell-L' + >>> _generate_argument_id(["filename"]) + 'filename' + >>> _generate_argument_id([]) + '' + """ + clean_names = [name.lstrip("-") for name in names if name.lstrip("-")] + if not clean_names: + return "" + name_part = "-".join(clean_names) + return f"{id_prefix}-{name_part}" if id_prefix else name_part + + def _token_to_css_class(token_type: t.Any) -> str: """Map a Pygments token type to its CSS class abbreviation. @@ -419,6 +461,9 @@ def visit_argparse_argument_html( - Positional arguments get class 'nl' (Name.Label) - Metavars get class 'nv' (Name.Variable) + The argument is wrapped in a container div with a unique ID for linking. + A headerlink anchor (¶) is added for direct navigation. + Parameters ---------- self : HTML5Translator @@ -428,11 +473,28 @@ def visit_argparse_argument_html( """ names: list[str] = node.get("names", []) metavar = node.get("metavar") + id_prefix: str = node.get("id_prefix", "") + + # Generate unique ID for this argument + arg_id = _generate_argument_id(names, id_prefix) + + # Open wrapper div with ID for linking + if arg_id: + self.body.append(f'
\n') + else: + self.body.append('
\n') # Build the argument signature with syntax highlighting highlighted_sig = _highlight_argument_names(names, metavar, self.encode) - self.body.append(f'
{highlighted_sig}
\n') + # Add headerlink anchor inside dt for navigation + headerlink = "" + if arg_id: + headerlink = f'' + + self.body.append( + f'
{highlighted_sig}{headerlink}
\n' + ) self.body.append('
') # Add help text @@ -447,6 +509,7 @@ def depart_argparse_argument_html( """Depart argparse_argument node - close argument entry. Adds default, choices, and type information if present. + Default values are wrapped in ```` for styled display. Parameters ---------- @@ -460,7 +523,8 @@ def depart_argparse_argument_html( default = node.get("default_string") if default is not None: - metadata.append(f"Default: {self.encode(default)}") + # Wrap default value in nv span for yellow/italic styling + metadata.append(f'Default: {self.encode(default)}') choices = node.get("choices") if choices: @@ -480,6 +544,8 @@ def depart_argparse_argument_html( self.body.append(f'

{meta_str}

') self.body.append("
\n") + # Close wrapper div + self.body.append("
\n") def visit_argparse_subcommands_html( diff --git a/docs/_ext/sphinx_argparse_neo/renderer.py b/docs/_ext/sphinx_argparse_neo/renderer.py index 69272b5896..f6c313f9f1 100644 --- a/docs/_ext/sphinx_argparse_neo/renderer.py +++ b/docs/_ext/sphinx_argparse_neo/renderer.py @@ -358,13 +358,18 @@ def render_group_section( section += nodes.title(title, title) # Create the styled group container (with empty title - section provides it) - group_node = self.render_group(group, include_title=False) + # Pass id_prefix to render_group so arguments get unique IDs + group_node = self.render_group(group, include_title=False, id_prefix=id_prefix) section += group_node return section def render_group( - self, group: ArgumentGroup, include_title: bool = True + self, + group: ArgumentGroup, + include_title: bool = True, + *, + id_prefix: str = "", ) -> argparse_group: """Render an argument group. @@ -376,6 +381,10 @@ def render_group( Whether to include the title in the group node. When False, the title is assumed to come from a parent section node. Default is True for backwards compatibility. + id_prefix : str + Optional prefix for argument IDs (e.g., "shell" -> "shell-h"). + Used to ensure unique IDs when multiple argparse directives exist + on the same page. Returns ------- @@ -397,23 +406,29 @@ def render_group( # Add individual arguments for arg in group.arguments: - arg_node = self.render_argument(arg) + arg_node = self.render_argument(arg, id_prefix=id_prefix) group_node.append(arg_node) # Add mutually exclusive groups for mutex in group.mutually_exclusive: - mutex_nodes = self.render_mutex_group(mutex) + mutex_nodes = self.render_mutex_group(mutex, id_prefix=id_prefix) group_node.extend(mutex_nodes) return group_node - def render_argument(self, arg: ArgumentInfo) -> argparse_argument: + def render_argument( + self, arg: ArgumentInfo, *, id_prefix: str = "" + ) -> argparse_argument: """Render a single argument. Parameters ---------- arg : ArgumentInfo The argument to render. + id_prefix : str + Optional prefix for the argument ID (e.g., "shell" -> "shell-L"). + Used to ensure unique IDs when multiple argparse directives exist + on the same page. Returns ------- @@ -425,6 +440,7 @@ def render_argument(self, arg: ArgumentInfo) -> argparse_argument: arg_node["help"] = arg.help arg_node["metavar"] = arg.metavar arg_node["required"] = arg.required + arg_node["id_prefix"] = id_prefix if self.config.show_defaults: arg_node["default_string"] = arg.default_string @@ -438,7 +454,7 @@ def render_argument(self, arg: ArgumentInfo) -> argparse_argument: return arg_node def render_mutex_group( - self, mutex: MutuallyExclusiveGroup + self, mutex: MutuallyExclusiveGroup, *, id_prefix: str = "" ) -> list[argparse_argument]: """Render a mutually exclusive group. @@ -446,6 +462,8 @@ def render_mutex_group( ---------- mutex : MutuallyExclusiveGroup The mutually exclusive group. + id_prefix : str + Optional prefix for argument IDs (e.g., "shell" -> "shell-h"). Returns ------- @@ -454,7 +472,7 @@ def render_mutex_group( """ result: list[argparse_argument] = [] for arg in mutex.arguments: - arg_node = self.render_argument(arg) + arg_node = self.render_argument(arg, id_prefix=id_prefix) # Mark as part of mutex group arg_node["mutex"] = True arg_node["mutex_required"] = mutex.required diff --git a/docs/_static/css/argparse-highlight.css b/docs/_static/css/argparse-highlight.css index dce008dc36..a0a77ec795 100644 --- a/docs/_static/css/argparse-highlight.css +++ b/docs/_static/css/argparse-highlight.css @@ -21,21 +21,25 @@ Inline Role Styles ========================================================================== */ +/* + * Shared monospace font for all CLI inline roles + */ +.cli-option, +.cli-metavar, +.cli-command, +.cli-default, +.cli-choice { + font-family: var(--font-stack--monospace); +} + /* * CLI Options * * Long options (--verbose) and short options (-h) both use teal color. */ -.cli-option { - font-family: var(--font-stack--monospace); -} - -.cli-option-long { - color: #56B6C2; -} - +.cli-option-long, .cli-option-short { - color: #56B6C2; + color: #56b6c2; } /* @@ -45,21 +49,24 @@ * Yellow/amber to indicate "replace me" - distinct from flags (teal). */ .cli-metavar { - font-family: var(--font-stack--monospace); - color: #E5C07B; - font-style: italic; + color: #e5c07b; + font-style: italic; } /* - * CLI Commands + * CLI Commands and Choices * - * Subcommand names like sync, add, list. - * Green (same as choices) - they're enumerated command options. + * Both use green to indicate valid enumerated values. + * Commands: subcommand names like sync, add, list + * Choices: enumeration values like json, yaml, table */ +.cli-command, +.cli-choice { + color: #98c379; +} + .cli-command { - font-family: var(--font-stack--monospace); - color: #98C379; - font-weight: bold; + font-weight: bold; } /* @@ -69,20 +76,8 @@ * Subtle styling to not distract from options. */ .cli-default { - font-family: var(--font-stack--monospace); - color: #CCCED4; - font-style: italic; -} - -/* - * CLI Choice Values - * - * Choice enumeration values like json, yaml, table. - * Green color to show they are valid literal values. - */ -.cli-choice { - font-family: var(--font-stack--monospace); - color: #98C379; + color: #ccced4; + font-style: italic; } /* ========================================================================== @@ -98,55 +93,55 @@ .highlight-argparse .gh, .highlight-argparse-usage .gh, .highlight-argparse-help .gh { - color: #61AFEF; - font-weight: bold; + color: #61afef; + font-weight: bold; } /* Section headers like "positional arguments:", "options:" - neutral bright */ .highlight-argparse .gs, .highlight-argparse-help .gs { - color: #E5E5E5; - font-weight: bold; + color: #e5e5e5; + font-weight: bold; } /* Long options --foo - teal */ .highlight-argparse .nt, .highlight-argparse-usage .nt, .highlight-argparse-help .nt { - color: #56B6C2; - font-weight: normal; + color: #56b6c2; + font-weight: normal; } /* Short options -h - teal (same as long) */ .highlight-argparse .na, .highlight-argparse-usage .na, .highlight-argparse-help .na { - color: #56B6C2; - font-weight: normal; + color: #56b6c2; + font-weight: normal; } /* Metavar placeholders FILE, PATH - yellow/amber italic */ .highlight-argparse .nv, .highlight-argparse-usage .nv, .highlight-argparse-help .nv { - color: #E5C07B; - font-style: italic; + color: #e5c07b; + font-style: italic; } /* Command/program names - purple bold */ .highlight-argparse .nl, .highlight-argparse-usage .nl, .highlight-argparse-help .nl { - color: #C678DD; - font-weight: bold; + color: #c678dd; + font-weight: bold; } /* Subcommands - bold green */ .highlight-argparse .nf, .highlight-argparse-usage .nf, .highlight-argparse-help .nf { - color: #98C379; - font-weight: bold; + color: #98c379; + font-weight: bold; } /* Choice values - green */ @@ -156,22 +151,22 @@ .highlight-argparse .nc, .highlight-argparse-usage .nc, .highlight-argparse-help .nc { - color: #98C379; + color: #98c379; } /* Punctuation [], {}, () - neutral gray */ .highlight-argparse .p, .highlight-argparse-usage .p, .highlight-argparse-help .p { - color: #CCCED4; + color: #ccced4; } /* Operators like | - neutral gray */ .highlight-argparse .o, .highlight-argparse-usage .o, .highlight-argparse-help .o { - color: #CCCED4; - font-weight: normal; + color: #ccced4; + font-weight: normal; } /* ========================================================================== @@ -195,56 +190,56 @@ /* Usage block container - match Pygments monokai background and code block styling */ pre.argparse-usage { - background: #272822; /* Monokai - same as .highlight */ - padding: .625rem .875rem; - line-height: 1.5; - border-radius: .2rem; - scrollbar-color: var(--color-foreground-border) transparent; - scrollbar-width: thin; + background: var(--argparse-code-background); + padding: 0.625rem 0.875rem; + line-height: 1.5; + border-radius: 0.2rem; + scrollbar-color: var(--color-foreground-border) transparent; + scrollbar-width: thin; } .argparse-usage .gh { - color: #61AFEF; - font-weight: bold; + color: #61afef; + font-weight: bold; } .argparse-usage .nt { - color: #56B6C2; - font-weight: normal; + color: #56b6c2; + font-weight: normal; } .argparse-usage .na { - color: #56B6C2; - font-weight: normal; + color: #56b6c2; + font-weight: normal; } .argparse-usage .nv { - color: #E5C07B; - font-style: italic; + color: #e5c07b; + font-style: italic; } .argparse-usage .nl { - color: #C678DD; - font-weight: bold; + color: #c678dd; + font-weight: bold; } .argparse-usage .nf { - color: #98C379; - font-weight: bold; + color: #98c379; + font-weight: bold; } .argparse-usage .no, .argparse-usage .nc { - color: #98C379; + color: #98c379; } .argparse-usage .o { - color: #CCCED4; - font-weight: normal; + color: #ccced4; + font-weight: normal; } .argparse-usage .p { - color: #CCCED4; + color: #ccced4; } /* @@ -254,21 +249,121 @@ pre.argparse-usage { * semantic spans for options and metavars. */ .argparse-argument-name .nt { - color: #56B6C2; - font-weight: normal; + color: #56b6c2; + font-weight: normal; } .argparse-argument-name .na { - color: #56B6C2; - font-weight: normal; + color: #56b6c2; + font-weight: normal; } .argparse-argument-name .nv { - color: #E5C07B; - font-style: italic; + color: #e5c07b; + font-style: italic; } .argparse-argument-name .nl { - color: #C678DD; - font-weight: bold; + color: #c678dd; + font-weight: bold; +} + +/* ========================================================================== + Argument Wrapper and Linking Styles + ========================================================================== */ + +/* + * Argparse-specific code background (monokai #272822) + * Uses a custom variable to avoid affecting Furo's --color-inline-code-background. + */ +:root { + --argparse-code-background: #272822; +} + +/* + * Background styling for argument names - subtle background like code.literal + * This provides visual weight and hierarchy for argument definitions. + */ +.argparse-argument-name { + background: var(--argparse-code-background); + border-radius: 0.2rem; + padding: 0.485rem 0.875rem; + font-family: var(--font-stack--monospace); + width: fit-content; + position: relative; +} + +/* + * Wrapper for linking - enables scroll-margin for fixed header navigation + * and :target pseudo-class for highlighting when linked. + */ +.argparse-argument-wrapper { + scroll-margin-top: 2.5rem; +} + +/* + * Headerlink anchor (¶) - hidden until hover + * Positioned outside the dt element to the right. + * Follows Sphinx documentation convention for linkable headings. + */ +.argparse-argument-name .headerlink { + visibility: hidden; + position: absolute; + right: -1.5rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-foreground-secondary); + text-decoration: none; +} + +/* + * Show headerlink on hover or when targeted via URL fragment + */ +.argparse-argument-wrapper:hover .headerlink, +.argparse-argument-wrapper:target .headerlink { + visibility: visible; +} + +.argparse-argument-name .headerlink:hover:not(:visited) { + color: var(--color-foreground-primary); +} + +/* + * Light mode headerlink color overrides + */ +body:not([data-theme="dark"]) .argparse-argument-name .headerlink { + color: #9ca0a5; + + &:hover:not(:visited) { + color: #cfd0d0; + } +} + +/* + * Highlight when targeted via URL fragment + * Uses Furo's highlight-on-target color for consistency. + */ +.argparse-argument-wrapper:target .argparse-argument-name { + background-color: var(--color-highlight-on-target); +} + +/* + * Default value styling in metadata + * Styled like inline code with monokai background. + */ +.argparse-argument-meta .nv { + background: var(--argparse-code-background); + border-radius: 0.2rem; + padding: 0.1405rem 0.3rem; + font-family: var(--font-stack--monospace); + font-size: var(--font-size--small); + color: #e5c07b; +} + +/* + * Help text description + * Adds spacing above for visual separation from argument name. + */ +.argparse-argument-help { + padding-block-start: 0.5rem; } diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index b420cee944..00a15fdc22 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -1,20 +1,21 @@ .sidebar-tree p.indented-block { - padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0 - var(--sidebar-item-spacing-horizontal); - margin-bottom: 0; + padding: var(--sidebar-item-spacing-vertical) + var(--sidebar-item-spacing-horizontal) 0 + var(--sidebar-item-spacing-horizontal); + margin-bottom: 0; } .sidebar-tree p.indented-block span.indent { - margin-left: var(--sidebar-item-spacing-horizontal); - display: block; + margin-left: var(--sidebar-item-spacing-horizontal); + display: block; } .sidebar-tree p.indented-block .project-name { - font-size: var(--sidebar-item-font-size); - font-weight: bold; - margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); + font-size: var(--sidebar-item-font-size); + font-weight: bold; + margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); } .sidebar-tree .active { - font-weight: bold; + font-weight: bold; } diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_nodes.py b/tests/docs/_ext/sphinx_argparse_neo/test_nodes.py index c473c9974c..e30306b6a7 100644 --- a/tests/docs/_ext/sphinx_argparse_neo/test_nodes.py +++ b/tests/docs/_ext/sphinx_argparse_neo/test_nodes.py @@ -2,8 +2,13 @@ from __future__ import annotations +import re +import typing as t + +import pytest from docutils import nodes from sphinx_argparse_neo.nodes import ( + _generate_argument_id, argparse_argument, argparse_group, argparse_program, @@ -257,3 +262,264 @@ def test_full_node_tree() -> None: assert isinstance(program.children[1], argparse_group) assert isinstance(program.children[2], argparse_group) assert isinstance(program.children[3], argparse_subcommands) + + +# --- ID generation tests --- + + +def test_generate_argument_id_short_option() -> None: + """Test ID generation for short option.""" + assert _generate_argument_id(["-L"]) == "L" + + +def test_generate_argument_id_long_option() -> None: + """Test ID generation for long option.""" + assert _generate_argument_id(["--help"]) == "help" + + +def test_generate_argument_id_multiple_names() -> None: + """Test ID generation for argument with multiple names.""" + assert _generate_argument_id(["-v", "--verbose"]) == "v-verbose" + + +def test_generate_argument_id_with_prefix() -> None: + """Test ID generation with prefix for namespace isolation.""" + assert _generate_argument_id(["-L"], "shell") == "shell-L" + assert _generate_argument_id(["--help"], "load") == "load-help" + + +def test_generate_argument_id_positional() -> None: + """Test ID generation for positional argument.""" + assert _generate_argument_id(["filename"]) == "filename" + + +def test_generate_argument_id_empty() -> None: + """Test ID generation with empty names list.""" + assert _generate_argument_id([]) == "" + + +def test_generate_argument_id_prefix_no_names() -> None: + """Test that prefix alone doesn't create ID when no names.""" + assert _generate_argument_id([], "shell") == "" + + +# --- HTML rendering tests using NamedTuple for parametrization --- + + +class ArgumentHTMLCase(t.NamedTuple): + """Test case for argument HTML rendering.""" + + test_id: str + names: list[str] + metavar: str | None + help_text: str | None + default: str | None + id_prefix: str + expected_patterns: list[str] # Regex patterns to match + + +ARGUMENT_HTML_CASES = [ + ArgumentHTMLCase( + test_id="short_option_with_metavar", + names=["-L"], + metavar="socket-name", + help_text="pass-through for tmux -L", + default="None", + id_prefix="shell", + expected_patterns=[ + r'
', + r'
', + r'-L', + r'socket-name', + r'', + r'Default: None', + r"
", + ], + ), + ArgumentHTMLCase( + test_id="long_option", + names=["--help"], + metavar=None, + help_text="show help", + default=None, + id_prefix="", + expected_patterns=[ + r'--help', + r'id="help"', + r'href="#help"', + ], + ), + ArgumentHTMLCase( + test_id="positional_argument", + names=["filename"], + metavar=None, + help_text="input file", + default=None, + id_prefix="", + expected_patterns=[ + r'filename', + r'id="filename"', + ], + ), + ArgumentHTMLCase( + test_id="multiple_names", + names=["-v", "--verbose"], + metavar=None, + help_text="Enable verbose mode", + default=None, + id_prefix="load", + expected_patterns=[ + r'id="load-v-verbose"', + r'-v', + r'--verbose', + r'href="#load-v-verbose"', + ], + ), +] + + +class MockTranslator: + """Mock HTML5Translator for testing HTML generation.""" + + def __init__(self) -> None: + """Initialize mock translator.""" + self.body: list[str] = [] + + def encode(self, text: str) -> str: + """HTML encode text.""" + return str(text).replace("&", "&").replace("<", "<").replace(">", ">") + + +def render_argument_to_html( + names: list[str], + metavar: str | None, + help_text: str | None, + default: str | None, + id_prefix: str, +) -> str: + """Render an argument node to HTML string for testing. + + Parameters + ---------- + names + Argument names (e.g., ["-v", "--verbose"]). + metavar + Optional metavar (e.g., "FILE"). + help_text + Help text for the argument. + default + Default value string. + id_prefix + Prefix for the argument ID. + + Returns + ------- + str + HTML string from the mock translator's body. + """ + from sphinx_argparse_neo.nodes import ( + depart_argparse_argument_html, + visit_argparse_argument_html, + ) + + node = argparse_argument() + node["names"] = names + node["metavar"] = metavar + node["help"] = help_text + node["default_string"] = default + node["id_prefix"] = id_prefix + + translator = MockTranslator() + visit_argparse_argument_html(translator, node) + depart_argparse_argument_html(translator, node) + + return "".join(translator.body) + + +@pytest.mark.parametrize( + "case", + ARGUMENT_HTML_CASES, + ids=lambda c: c.test_id, +) +def test_argument_html_rendering(case: ArgumentHTMLCase) -> None: + """Test HTML output for argument nodes.""" + html = render_argument_to_html( + names=case.names, + metavar=case.metavar, + help_text=case.help_text, + default=case.default, + id_prefix=case.id_prefix, + ) + + for pattern in case.expected_patterns: + assert re.search(pattern, html), f"Pattern not found: {pattern}\nHTML: {html}" + + +def test_argument_wrapper_has_id() -> None: + """Verify wrapper div has correct ID attribute.""" + html = render_argument_to_html( + names=["-f", "--file"], + metavar="PATH", + help_text="Input file", + default=None, + id_prefix="convert", + ) + + assert 'id="convert-f-file"' in html + assert '
None: + """Verify headerlink anchor exists with correct href.""" + html = render_argument_to_html( + names=["--output"], + metavar="FILE", + help_text="Output file", + default=None, + id_prefix="freeze", + ) + + assert '' in html + + +def test_default_value_styled() -> None: + """Verify default value is wrapped in nv span.""" + html = render_argument_to_html( + names=["--format"], + metavar=None, + help_text="Output format", + default="json", + id_prefix="", + ) + + assert 'Default: json' in html + + +def test_wrapper_div_closed() -> None: + """Verify wrapper div is properly closed.""" + html = render_argument_to_html( + names=["-v"], + metavar=None, + help_text="Verbose", + default=None, + id_prefix="", + ) + + # Count opening and closing div tags + open_divs = html.count("") + assert open_divs == close_divs, f"Unbalanced divs in HTML: {html}" + + +def test_argument_no_id_prefix() -> None: + """Test argument rendering without ID prefix.""" + html = render_argument_to_html( + names=["--debug"], + metavar=None, + help_text="Enable debug mode", + default=None, + id_prefix="", + ) + + assert 'id="debug"' in html + assert 'href="#debug"' in html