diff --git a/great_docs/_apiref/_render/api_page.py b/great_docs/_apiref/_render/api_page.py index a14eced6..6db8a655 100644 --- a/great_docs/_apiref/_render/api_page.py +++ b/great_docs/_apiref/_render/api_page.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from functools import cached_property from typing import TYPE_CHECKING, cast @@ -22,11 +23,23 @@ from .doc import RenderDoc +# Member headings render at `h3`, so the table of contents must reach depth 3 +# to list them. +_MIN_MEMBER_TOC_DEPTH = 3 + +# Use the default site depth when the caller does not provide the merged value. +_DEFAULT_SITE_TOC_DEPTH = 2 + + +@dataclass class __RenderAPIPage(RenderPageMixin, RenderBase): """ Render an API page object (`content.Page`) """ + toc_depth: int = _DEFAULT_SITE_TOC_DEPTH + """Merged site `toc-depth` used to select a page override""" + def __post_init__(self): self.page = cast("Page", self.node) """Page in the documentation""" @@ -69,12 +82,15 @@ def render_metadata(self) -> BlockContent: # Derive the title of the page from the first (top-level) object obj = self.render_objs[0] title = obj._title # pyright: ignore[reportPrivateUsage] - return Meta( - { - "title": f"{title}", - "body-classes": "doc-api-page", - } - ) + metadata: dict[str, object] = { + "title": f"{title}", + "body-classes": "doc-api-page doc-py-api-page", + "shift-heading-level-by": 0, + } + # Add a page override only when the site depth excludes members. + if self.toc_depth < _MIN_MEMBER_TOC_DEPTH: + metadata["toc-depth"] = _MIN_MEMBER_TOC_DEPTH + return Meta(metadata) def render_body(self) -> BlockContent: """ diff --git a/great_docs/_apiref/_render/reference_page.py b/great_docs/_apiref/_render/reference_page.py index 7961dbef..22c76f49 100644 --- a/great_docs/_apiref/_render/reference_page.py +++ b/great_docs/_apiref/_render/reference_page.py @@ -57,14 +57,17 @@ def render_description(self) -> BlockContent: ) def render_metadata(self) -> BlockContent: - return Meta( - { - "title": self.api_ref.title, - "body-classes": "doc-reference doc-reference-index", - "page-navigation": False, - "html-table-processing": "none", - } - ) + metadata: dict[str, object] = { + "title": self.api_ref.title, + "body-classes": "doc-reference doc-py-reference", + "page-navigation": False, + "html-table-processing": "none", + "shift-heading-level-by": 0, + } + # Subtitle headings render at `h3`; preserve any deeper site setting. + if self.api_ref.site_toc_depth < 3: + metadata["toc-depth"] = 3 + return Meta(metadata) def render_body(self) -> BlockContent: """ diff --git a/great_docs/_apiref/api_reference.py b/great_docs/_apiref/api_reference.py index 97d242ff..02288654 100644 --- a/great_docs/_apiref/api_reference.py +++ b/great_docs/_apiref/api_reference.py @@ -35,6 +35,10 @@ # consumed; dropped before parsing so they neither reach `Settings` nor error. _REMOVED_KEYS = {"style", "renderer", "render_interlinks"} +# Use the default site depth when a bare API reference config supplies no site +# settings. +_DEFAULT_SITE_TOC_DEPTH = 2 + @dataclass class Settings: @@ -89,9 +93,12 @@ class APIReference: options: SpecOptions | None settings: Settings items: list[InventoryItem] + site_toc_depth: int def __init__(self, config: dict[str, Any] | str | Path) -> None: - block = self._select_block(config) + cfg = self._load_config(config) + self.site_toc_depth = self._read_site_toc_depth(cfg) + block = self._select_block(cfg) block = {k: v for k, v in block.items() if k not in _REMOVED_KEYS} self.settings = Settings.make(block) @@ -112,13 +119,84 @@ def __init__(self, config: dict[str, Any] | str | Path) -> None: self._resolver.current_package = self.package @staticmethod - def _select_block(config: dict[str, Any] | str | Path) -> dict[str, Any]: - """Select the `api-reference:` (or legacy `quartodoc:`) mapping from a config dict, file path, or full _quarto.yml""" - if isinstance(config, (str, Path)): - loaded = read_yaml(str(config)) - cfg: dict[str, Any] = cast("dict[str, Any]", loaded) if isinstance(loaded, dict) else {} - else: - cfg = config + def _load_config(config: dict[str, Any] | str | Path) -> dict[str, Any]: + """ + Load a configuration mapping + + Return mapping arguments unchanged. Read YAML paths and return an + empty mapping when the document's top-level value is not a mapping. + + Parameters + ---------- + config + Configuration mapping or YAML file path. + + Returns + ------- + Configuration mapping. + """ + if not isinstance(config, (str, Path)): + return config + loaded = read_yaml(str(config)) + return cast("dict[str, Any]", loaded) if isinstance(loaded, dict) else {} + + @staticmethod + def _read_site_toc_depth(cfg: dict[str, Any]) -> int: + """ + Read the configured table-of-contents depth + + Use the integer at `format.html.toc-depth` when available, then the + integer at `site.toc-depth`. Return the built-in default when neither + setting is an integer. + + Parameters + ---------- + cfg + Configuration mapping. + + Returns + ------- + Configured depth or the built-in default. + """ + format_config = cfg.get("format") + html = ( + cast("dict[str, Any]", format_config).get("html") + if isinstance(format_config, dict) + else None + ) + depth = cast("dict[str, Any]", html).get("toc-depth") if isinstance(html, dict) else None + if isinstance(depth, int): + return depth + + site = cfg.get("site") + depth = ( + cast("dict[str, Any]", site).get("toc-depth") if isinstance(site, dict) else None + ) + return depth if isinstance(depth, int) else _DEFAULT_SITE_TOC_DEPTH + + @staticmethod + def _select_block(cfg: dict[str, Any]) -> dict[str, Any]: + """ + Select the API reference configuration + + Select a non-empty `api-reference` mapping before the legacy + `quartodoc` mapping. Treat the full configuration as the API reference + mapping when both sections are empty or absent. + + Parameters + ---------- + cfg + Configuration mapping. + + Returns + ------- + Copy of the selected API reference mapping. + + Raises + ------ + KeyError + If the selected value is not a mapping or omits `package`. + """ block = cfg.get("api-reference") or cfg.get("quartodoc") or cfg if not isinstance(block, dict) or "package" not in block: raise KeyError("No `api-reference:` section found in your _quarto.yml.") @@ -182,6 +260,7 @@ def build(self, page_filter: str = "*") -> None: rewrite_all_pages=s.rewrite_all_pages, header_level=s.header_level, page_filter=page_filter, + site_toc_depth=self.site_toc_depth, ) write_typing_information(s.typing_module_paths, self) diff --git a/great_docs/_apiref/write.py b/great_docs/_apiref/write.py index 9fd7d17c..0df8da19 100644 --- a/great_docs/_apiref/write.py +++ b/great_docs/_apiref/write.py @@ -312,6 +312,7 @@ def write_pages( rewrite_all_pages: bool, header_level: int, page_filter: str, + site_toc_depth: int, ) -> None: """Write API doc pages to `/` @@ -335,6 +336,9 @@ def write_pages( page_filter : Glob pattern; pages whose path does not match are neither rendered nor written. `"*"` writes all pages. + site_toc_depth : + Merged site table-of-contents depth. A page overrides it only when + member headings require a greater depth. """ from ._render.api_page import RenderAPIPage @@ -344,7 +348,7 @@ def write_pages( continue _log.info(f"Rendering {page.path}") - rendered = str(RenderAPIPage(page, header_level)) + rendered = str(RenderAPIPage(page, header_level, toc_depth=site_toc_depth)) rendered = merge_frontmatter( rendered, {"page-navigation": False, "html-table-processing": "none"} diff --git a/great_docs/_mcp_docs.py b/great_docs/_mcp_docs.py index 4e4cfc01..1b91c325 100644 --- a/great_docs/_mcp_docs.py +++ b/great_docs/_mcp_docs.py @@ -598,7 +598,7 @@ def _generate_mcp_index_page( # Front matter lines.append("---") lines.append(f'title: "{get_translation("mcp_reference", language)}"') - lines.append("body-classes: doc-reference doc-mcp-reference doc-reference-index") + lines.append("body-classes: doc-reference doc-mcp-reference") lines.append("sidebar: mcp-reference") lines.append("page-navigation: false") lines.append("html-table-processing: none") @@ -750,7 +750,7 @@ def _generate_tool_page(tool: dict[str, Any], server_name: str, language: str = lines.append(f'title: "{name}"') lines.append("title-block-style: none") lines.append("bread-crumbs: false") - lines.append("body-classes: doc-api-page") + lines.append("body-classes: doc-api-page doc-mcp-api-page") lines.append("sidebar: mcp-reference") lines.append("page-navigation: false") lines.append("html-table-processing: none") @@ -807,9 +807,11 @@ def _generate_tool_page(tool: dict[str, Any], server_name: str, language: str = lines.append(":::") lines.append("") - # Parameters section — uses definition list format matching Python API style + # Match the Python API's definition-list format. Write this heading at `###` + # because MCP pages keep the site-wide shift. Disabling that shift prevents + # Quarto from hoisting the `.title` heading and creates a duplicate title. if properties: - lines.append(f"## {get_translation('mcp_parameters', language)} {{.doc-parameters}}") + lines.append(f"### {get_translation('mcp_parameters', language)} {{.doc-parameters}}") lines.append("") lines.append("::: {.doc-definition-items}") for param_name, param_info in properties.items(): @@ -863,7 +865,7 @@ def _generate_resource_page( lines.append(f'title: "{name}"') lines.append("title-block-style: none") lines.append("bread-crumbs: false") - lines.append("body-classes: doc-api-page") + lines.append("body-classes: doc-api-page doc-mcp-api-page") lines.append("sidebar: mcp-reference") lines.append("page-navigation: false") lines.append("html-table-processing: none") @@ -878,7 +880,7 @@ def _generate_resource_page( lines.append(":::") lines.append("") - lines.append(f"## {get_translation('mcp_details', language)} {{.doc-parameters}}") + lines.append(f"### {get_translation('mcp_details', language)} {{.doc-parameters}}") lines.append("") lines.append(f"**URI:** `{uri}`") lines.append("") @@ -903,7 +905,7 @@ def _generate_resource_template_page( lines.append(f'title: "{name}"') lines.append("title-block-style: none") lines.append("bread-crumbs: false") - lines.append("body-classes: doc-api-page") + lines.append("body-classes: doc-api-page doc-mcp-api-page") lines.append("sidebar: mcp-reference") lines.append("page-navigation: false") lines.append("html-table-processing: none") @@ -920,7 +922,7 @@ def _generate_resource_template_page( lines.append(":::") lines.append("") - lines.append(f"## {get_translation('mcp_details', language)} {{.doc-parameters}}") + lines.append(f"### {get_translation('mcp_details', language)} {{.doc-parameters}}") lines.append("") lines.append(f"**URI Template:** `{uri_template}`") lines.append("") @@ -934,7 +936,7 @@ def _generate_resource_template_page( variables = _re.findall(r"\{(\w+)\}", uri_template) if variables: lines.append( - f"## {get_translation('mcp_template_variables', language)} {{.doc-parameters}}" + f"### {get_translation('mcp_template_variables', language)} {{.doc-parameters}}" ) lines.append("") lines.append("::: {.doc-definition-items}") @@ -966,7 +968,7 @@ def _generate_prompt_page(prompt: dict[str, Any], server_name: str, language: st lines.append(f'title: "{name}"') lines.append("title-block-style: none") lines.append("bread-crumbs: false") - lines.append("body-classes: doc-api-page") + lines.append("body-classes: doc-api-page doc-mcp-api-page") lines.append("sidebar: mcp-reference") lines.append("page-navigation: false") lines.append("html-table-processing: none") @@ -982,7 +984,7 @@ def _generate_prompt_page(prompt: dict[str, Any], server_name: str, language: st lines.append("") if arguments: - lines.append(f"## {get_translation('mcp_arguments', language)} {{.doc-parameters}}") + lines.append(f"### {get_translation('mcp_arguments', language)} {{.doc-parameters}}") lines.append("") lines.append("::: {.doc-definition-items}") for arg in arguments: @@ -1010,7 +1012,7 @@ def _generate_prompt_page(prompt: dict[str, Any], server_name: str, language: st # Prompt message content if messages: - lines.append(f"## {get_translation('mcp_prompt_text', language)}") + lines.append(f"### {get_translation('mcp_prompt_text', language)}") lines.append("") for msg in messages: role = msg.get("role", "user") diff --git a/great_docs/assets/copy-page.js b/great_docs/assets/copy-page.js index 44db4d12..e3c03786 100644 --- a/great_docs/assets/copy-page.js +++ b/great_docs/assets/copy-page.js @@ -64,7 +64,7 @@ if (document.body.classList.contains('gd-blog-index')) return; // Skip reference index pages (API, CLI, MCP) - if (document.body.classList.contains('doc-reference-index')) return; + if (document.body.classList.contains('doc-reference')) return; // Create the widget container var widget = document.createElement('div'); diff --git a/great_docs/assets/great-docs.scss b/great_docs/assets/great-docs.scss index b421b170..fe4284e3 100644 --- a/great_docs/assets/great-docs.scss +++ b/great_docs/assets/great-docs.scss @@ -813,11 +813,13 @@ html.theme-loading body { /* General heading weight -- handled by SCSS @extend .fw-medium */ -/* Reduce top margin on reference page object titles */ -h2.title { +/* Keep reference object titles compact. The desktop `h1.title` rule otherwise + gives 1.25rem text a 2.3rem line height. */ +.doc-py-api-page h1.title { margin-top: 0.5rem; border-bottom: none; padding-bottom: 0; + font-size: 1.25rem; } .table a { @@ -1124,7 +1126,9 @@ dd > ol > li::marker { } /* API reference section headings (Parameters, Returns, Examples, etc.) */ -h2.doc-section { +/* Fallback sections use `h2` at the top level and `h4` inside members. Style + the `doc-section` class at every heading level. */ +:is(h1, h2, h3, h4, h5, h6).doc-section { font-size: 22px; border-bottom: none; padding-bottom: 0; @@ -1140,8 +1144,8 @@ h2.doc-section { margin-left: 0; } -/* Warnings section: h2 header above, body in callout box */ -.doc-section-warnings > h2 { +/* Place the warning heading above its callout body at every nesting depth. */ +.doc-section-warnings > :is(h1, h2, h3, h4, h5, h6) { color: Crimson; font-weight: 600; margin-bottom: 0.5rem; @@ -1150,7 +1154,7 @@ h2.doc-section { padding-left: 0; } -.doc-section-warnings > h2::before { +.doc-section-warnings > :is(h1, h2, h3, h4, h5, h6)::before { content: "⚠️ "; } @@ -1475,8 +1479,13 @@ a.sidebar-item-text.sidebar-link.text-start > span { /* CLI reference: present command groups (e.g. SKILL, TERMSHOW) as small uppercase pills so they read as command groups, distinct from leaf commands. Scoped to the - CLI reference pages via the doc-cli-reference body class. */ + CLI reference pages via the doc-cli-reference and doc-cli-api-page body + classes. */ body.doc-cli-reference #quarto-sidebar .sidebar-item-section + > .sidebar-item-container + > .sidebar-item-text + .menu-text, +body.doc-cli-api-page #quarto-sidebar .sidebar-item-section > .sidebar-item-container > .sidebar-item-text .menu-text { @@ -1494,7 +1503,8 @@ body.doc-cli-reference #quarto-sidebar .sidebar-item-section } /* Give each CLI command group a little breathing room above its pill. */ -body.doc-cli-reference #quarto-sidebar .sidebar-item-section { +body.doc-cli-reference #quarto-sidebar .sidebar-item-section, +body.doc-cli-api-page #quarto-sidebar .sidebar-item-section { margin-top: 0.4rem; } @@ -1502,6 +1512,10 @@ body.doc-cli-reference #quarto-sidebar .sidebar-item-section { padding-bottom above it), so it needs extra top margin to match the gap that later groups (e.g. TERMSHOW) get from the preceding group's padding-bottom. */ body.doc-cli-reference + #quarto-sidebar + li.sidebar-item:not(.sidebar-item-section) + + li.sidebar-item-section, +body.doc-cli-api-page #quarto-sidebar li.sidebar-item:not(.sidebar-item-section) + li.sidebar-item-section { @@ -2253,6 +2267,7 @@ div.sourceCode:hover > .gd-code-nav .gd-code-copy, align-items: center; gap: 0; padding-top: 0; + margin-top: 0 !important; padding-left: 0.5rem; color: #212529; white-space: nowrap; @@ -4008,7 +4023,7 @@ body.quarto-dark .doc-section-parameters dt code { } /* Warnings section dark mode */ -body.quarto-dark .doc-section-warnings > h2 { +body.quarto-dark .doc-section-warnings > :is(h1, h2, h3, h4, h5, h6) { color: #FCA5A5; } @@ -8413,8 +8428,9 @@ body.quarto-dark { vertical-align: middle; } -// Smaller icon in the mobile secondary nav title -h1.quarto-secondary-nav-title .gd-upcoming-icon svg { +// Keep this selector independent of heading level. Reference, CLI and MCP nav +// titles use `h5`; other pages use `h1`. +.quarto-secondary-nav-title .gd-upcoming-icon svg { width: 16px; height: 16px; } diff --git a/great_docs/assets/page-status-badges.js b/great_docs/assets/page-status-badges.js index d29ee024..59b3763e 100644 --- a/great_docs/assets/page-status-badges.js +++ b/great_docs/assets/page-status-badges.js @@ -189,8 +189,8 @@ ' .05 5 .05"/>'; titleEl.appendChild(icon); - // Also apply to mobile secondary nav title (visible at narrow widths) - var mobileTitle = document.querySelector("h1.quarto-secondary-nav-title"); + // Apply the icon to the mobile nav title at either heading level. + var mobileTitle = document.querySelector(".quarto-secondary-nav-title"); if (mobileTitle && mobileTitle !== titleEl) { mobileTitle.classList.add("gd-upcoming-title"); var mobileIcon = icon.cloneNode(true); diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index 1d076603..c8e7525b 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -1158,6 +1158,68 @@ def _replace_if_not_gt(match): return colgroup_pattern.sub(_replace_if_not_gt, html_content) +_BREADCRUMB_NAV_RE = re.compile( + r'', re.DOTALL +) +_NO_BREADCRUMBS_NAV_TITLE_RE = re.compile( + r'

.*?

', re.DOTALL +) + + +def replace_secondary_nav_title(html_content: str, label_html: str) -> str: + """ + Replace Quarto's secondary navigation title + + Quarto uses a breadcrumb `nav` when breadcrumbs are enabled and a bare + `h1` when they are disabled. Replace either form with the supplied `h5` + navigation label and remove any duplicate breadcrumb in the title block. + + Parameters + ---------- + html_content + Complete page HTML. + label_html + Navigation label markup. + + Returns + ------- + HTML with the secondary navigation title replaced. + """ + new_content, replaced = _BREADCRUMB_NAV_RE.subn(label_html, html_content, count=1) + if not replaced: + new_content, replaced = _NO_BREADCRUMBS_NAV_TITLE_RE.subn(label_html, html_content, count=1) + # Remove a second breadcrumb that Quarto may place in the title block. + return _BREADCRUMB_NAV_RE.sub("", new_content) + + +_HEADING_TAG_RE = re.compile(r"]*)?>") + + +def _fallback_section_level(html_content, pos): + """ + Select the heading level for a fallback docstring section + + Top-level sections render at `h2`. Sections within an `h3` member render + at `h4`. A preceding `h4` also indicates member context because fallback + translators run in sequence over the same content. + + Parameters + ---------- + html_content + Complete page HTML. + pos + Character offset where the fallback section starts. + + Returns + ------- + `4` after an `h3` or deeper heading; otherwise `2`. + """ + last_level = None + for m in _HEADING_TAG_RE.finditer(html_content, 0, pos): + last_level = int(m.group(1)) + return 4 if last_level and last_level >= 3 else 2 + + def translate_sphinx_fields(html_content): """ Convert Sphinx field-list directives into structured doc sections. @@ -1229,6 +1291,7 @@ def _build_sections(m): raises.append((name, body)) parts = [] + lvl = _fallback_section_level(html_content, m.start()) # ── Parameters section ─────────────────────────────────────────── if params: @@ -1252,8 +1315,8 @@ def _build_sections(m): dd = f"
\n

{pdesc}

\n
" if pdesc else "
" items.append(dt + "\n" + dd) parts.append( - '
\n' - f'

{_t("parameters", "Parameters")}

\n' + f'
\n' + f'{_t("parameters", "Parameters")}\n' "
\n" + "\n".join(items) + "\n
\n
" ) @@ -1276,8 +1339,8 @@ def _build_sections(m): dd = f"
\n

{rdesc}

\n
" if rdesc else "
" items.append(dt + "\n" + dd) parts.append( - '
\n' - f'

{_t("returns", "Returns")}

\n' + f'
\n' + f'{_t("returns", "Returns")}\n' "
\n" + "\n".join(items) + "\n
\n
" ) @@ -1289,8 +1352,8 @@ def _build_sections(m): dd = f"
\n

{desc}

\n
" if desc else "
" items.append(dt + "\n" + dd) parts.append( - '
\n' - f'

{_t("raises", "Raises")}

\n' + f'
\n' + f'{_t("raises", "Raises")}\n' "
\n" + "\n".join(items) + "\n
\n
" ) @@ -1312,9 +1375,9 @@ def translate_google_fields(html_content):

Raises: ValueError: desc. TypeError: desc.

Note: text

- Indented continuation text renders as `
` blocks adjacent to the section `

`. This - function detects both patterns and emits the same `

`/`

`/`
`/`
`/`
` markup - that the renderer produces for NumPy-style sections. + Indented continuation text renders as `
` blocks adjacent to the section `

`. + Translate both forms to the renderer's `

` and `

` structure, + using definition-list markup for fields. """ _PARAM_SECTIONS = {"Args", "Arguments", "Parameters", "Params"} @@ -1404,6 +1467,7 @@ def _replace(m): section = m.group("section") body = (m.group("body") or "").strip() pre_body = m.group("pre_body").strip() if m.group("pre_body") else None + lvl = _fallback_section_level(html_content, m.start()) # ── Args / Parameters ───────────────────────────────────────── if section in _PARAM_SECTIONS: @@ -1421,8 +1485,8 @@ def _replace(m): dd = f"
\n

{pdesc}

\n
" if pdesc else "
" items.append(f"{dt}\n{dd}") return ( - '
\n' - f'

{_t("parameters", "Parameters")}

\n' + f'
\n' + f'{_t("parameters", "Parameters")}\n' "
\n" + "\n".join(items) + "\n
\n
" ) @@ -1435,8 +1499,8 @@ def _replace(m): parts.append(_pre_to_html(pre_body)) content = "\n".join(parts) return ( - '
\n' - f'

{_t("returns", "Returns")}

\n' + f'
\n' + f'{_t("returns", "Returns")}\n' f"{content}\n
" ) @@ -1453,8 +1517,8 @@ def _replace(m): dd = f"
\n

{desc}

\n
" if desc else "
" items.append(f"{dt}\n{dd}") return ( - '
\n' - f'

{_t("raises", "Raises")}

\n' + f'
\n' + f'{_t("raises", "Raises")}\n' "
\n" + "\n".join(items) + "\n
\n
" ) @@ -1467,8 +1531,8 @@ def _replace(m): code_parts.append(pre_body) code = "\n".join(code_parts) return ( - '
\n' - f'

{_t("examples", "Examples")}

\n' + f'
\n' + f'{_t("examples", "Examples")}\n' f"
{code}
\n
" ) @@ -1491,8 +1555,8 @@ def _replace(m): full = _dbl_bt(full) content = f"

{full}

" if full else "" return ( - f'
\n' - f'

{display}

\n' + f'
\n' + f'{display}\n' f"{content}\n
" ) @@ -1564,8 +1628,8 @@ def translate_bold_section_headers(html_content):

Examples:

- This function converts those into the same `
`/`

` structure that the renderer uses - for NumPy-style sections so the page has a consistent look. + Translate these headings to the renderer's `
` and `

` + structure so all docstring styles share the same layout. """ # Map of recognized section names → CSS id / class suffix @@ -1599,9 +1663,10 @@ def _replace_header(m): slug = _SECTION_NAMES.get(name, name.lower().replace(" ", "-")) i18n_key = _BOLD_I18N_KEY.get(name) display = _t(i18n_key, name) if i18n_key else name + lvl = _fallback_section_level(html_content, m.start()) return ( - f'
\n' - f'

{display}

' + f'
\n' + f'{display}' ) html_content = re.sub( @@ -1981,93 +2046,6 @@ def fix_dataclass_attributes(content_str): # Convert back to lines for line-by-line processing content = content.splitlines(keepends=True) - # Determine the classification of each h1 tag based on its content - # Remove the literal text `Validate.` from the h1 tag - # TODO: Add line below stating the class name for the method - content = [ - line.replace( - '

Validate.', - '

', - ) - for line in content - ] - - # Add `()` only to functions and methods in the h1 title - # Uses object_types metadata when available, otherwise falls back to heuristics - _CALLABLE_TYPES = {"function", "method"} - - for i, line in enumerate(content): - # Use regex to find h1 tags (both class="title" and styled versions) - h1_match = re.search(r'', line) - - if not h1_match: - h1_match = re.search(r'', line) - - if h1_match: - # Extract the content of the h1 tag - start = h1_match.end() - end = line.find("

", start) - h1_content = line[start:end].strip() - - # Determine whether this item should get () - obj_type = object_types.get(item_name_from_file) - - # Replace the h1 tag with the modified content - content[i] = line[:start] + h1_content + line[end:] - - # Wrap bare h1 tags (those with style attribute but no quarto-title wrapper) in proper structure - for i, line in enumerate(content): - # Look for h1 tags with style attribute that aren't already wrapped - if "

\n{h1_content}\n\n' - content[i] = wrapped_h1 - - # Add a style attribute to the h1 tag to use a monospace font for code-like appearance - content = [ - line.replace( - '

', - "

", - ) - for line in content - ] - - # Some h1 tags may not have a class attribute, so we handle that case too - # But skip "Attributes" and "Methods" section headings — they should look like - # the Parameters section label (doc-section style), not code font. - _SECTION_HEADINGS = {"Attributes", "Methods"} - new_content = [] - for line in content: - if "

" in line: - # Check if this is a section heading like Attributes or Methods - h1_text_match = re.search(r"

(.*?)

", line) - if h1_text_match and h1_text_match.group(1).strip() in _SECTION_HEADINGS: - # Style like Parameters: use doc-section class instead of code font - line = line.replace( - "

", - '

', - ) - else: - line = line.replace( - "

", - "

", - ) - new_content.append(line) - content = new_content - # Fix return value formatting in individual function pages, removing the `:` before the # return value and adjusting the style of the parameter annotation separator content_str = "".join(content) @@ -2138,21 +2116,15 @@ def fix_dataclass_attributes(content_str): content = content_str.splitlines(keepends=True) - # Turn all h3 tags into h4 tags - content = [line.replace("", "

") for line in content] - - # Turn all h2 tags into h3 tags - content = [line.replace("", "

") for line in content] - # Add separator lines between class details and individual members, # and between individual member sections. # - Thin solid line after the Methods/Attributes summary table (before first member section) # - Dotted line between each individual member section for i, line in enumerate(content): - # Detect
— these are individual member sections - if "
in
) - # or is another level2 section close + # or closes another member section for j in range(i - 1, max(0, i - 5), -1): prev = content[j].strip() if not prev: @@ -2190,41 +2162,15 @@ def fix_dataclass_attributes(content_str): _api_label = _t("api", "API") _obj_name_match = re.search(r'([^<]+)', content_str) _display_name = _obj_name_match.group(1) if _obj_name_match else item_name_from_file + # Use `h5` because this label is navigation; the page content owns `h1`. _ref_title_html = ( - f'

' + f'

' f'{_api_label}' f'/' f'{html.escape(_display_name)}' - f"
" + f"

" ) - breadcrumb_pattern = r'' - # Replace only the first breadcrumb (in the secondary nav bar, outside
); - # a second breadcrumb may exist inside the title-block-header — remove it. - content_str = re.sub(breadcrumb_pattern, _ref_title_html, content_str, count=1, flags=re.DOTALL) - content_str = re.sub(breadcrumb_pattern, "", content_str, flags=re.DOTALL) - - # Shift all heading levels down by 1 within
content so that - # reference page titles use

instead of

, differentiating them - # from the top-level "Reference" heading on the index page. - main_start = content_str.find("") - if main_start != -1 and main_end != -1: - before = content_str[:main_start] - main_content = content_str[main_start : main_end + len("

")] - after = content_str[main_end + len("
") :] - - # Shift in reverse order (h5→h6, h4→h5, ..., h1→h2) to avoid - # double-shifting (e.g. h1→h2→h3). - for level in range(5, 0, -1): - main_content = main_content.replace(f"", f"") - main_content = re.sub( - rf'\bclass="level{level}\b', - f'class="level{level + 1}', - main_content, - ) - - content_str = before + main_content + after + content_str = replace_secondary_nav_title(content_str, _ref_title_html) content = content_str.splitlines(keepends=True) @@ -2272,60 +2218,29 @@ def convert_table_to_dl(match): # Remove redundant "API Reference" top-level nav item # Find the nav structure and flatten it by removing the top-level wrapper - nav_pattern = r'(]*>.*?]*>.*?

\s*
    \s*)
  • ]*href="[^"]*#api-reference"[^>]*>API Reference\s*]*>(.*?)
\s*(\s*)' + nav_pattern = ( + r'(]*>.*?]*>.*?

\s*
    \s*)
  • ]*href="[^"]*#api-reference"[^>]*>' + r'API Reference\s*]*>(.*?)
\s*(\s*)' + ) nav_replacement = r"\1\2\3" content = re.sub(nav_pattern, nav_replacement, content, flags=re.DOTALL) # Clean up Sphinx cross-reference roles in index descriptions content = translate_sphinx_roles(content) - # Shift section headings down by 1 within
so that category headings - # (Classes, Methods, etc.) render as

, visually subordinate to the - #

"Reference" page title. Skip the page title itself (class="title"). - main_start = content.find("") - if main_start != -1 and main_end != -1: - before = content[:main_start] - main_content = content[main_start : main_end + len("

")] - after = content[main_end + len("") :] - - # Protect the title heading from being shifted by replacing it with a - # temporary placeholder, then shifting everything else, then restoring. - title_pattern = re.compile(r'(]*>.*?)', re.DOTALL) - title_placeholder = "" - title_match = title_pattern.search(main_content) - if title_match: - saved_title = title_match.group(1) - main_content = main_content.replace(saved_title, title_placeholder, 1) - - for level in range(5, 0, -1): - main_content = main_content.replace(f"", f"") - main_content = re.sub( - rf'\bclass="level{level}\b', - f'class="level{level + 1}', - main_content, - ) - - # Restore the title heading - if title_match: - main_content = main_content.replace(title_placeholder, saved_title, 1) - - content = before + main_content + after - # Translate renderer-rendered headings, TOC, and sidebar on the index page content = translate_renderer_headings(content) # Replace breadcrumb with an "API / Index" title bar label + # Keep the navigation label below the page's `h1` title. _ref_idx_title = ( - '

' + '

' 'API' '/' 'Index' - "
" + "" ) - _bc_pat = r'' - content = re.sub(_bc_pat, _ref_idx_title, content, flags=re.DOTALL) + content = replace_secondary_nav_title(content, _ref_idx_title) with open(index_file, "w", encoding="utf-8") as file: file.write(content) @@ -2342,15 +2257,15 @@ def convert_table_to_dl(match): with open(mcp_index_file, "r", encoding="utf-8") as file: content = file.read() + # Keep the navigation label below the page's `h1` title. _mcp_idx_title = ( - '

' + '

' 'MCP' '/' 'Index' - "
" + "" ) - _bc_pat = r'' - content = re.sub(_bc_pat, _mcp_idx_title, content, flags=re.DOTALL) + content = replace_secondary_nav_title(content, _mcp_idx_title) with open(mcp_index_file, "w", encoding="utf-8") as file: file.write(content) @@ -2381,18 +2296,17 @@ def convert_table_to_dl(match): else os.path.basename(html_file).replace(".html", "") ) + # Keep the navigation label below the page's `h1` title. _mcp_title_html = ( - f'

' + f'

' f'MCP' f'/' f'{html.escape(_mcp_name)}' - f"
" + f"" ) - # MCP pages use bread-crumbs: false, so Quarto renders the title inside - # an h1.quarto-secondary-nav-title.no-breadcrumbs element - _h1_pat = r'

.*?

' - content = re.sub(_h1_pat, _mcp_title_html, content, count=1, flags=re.DOTALL) + # MCP pages disable breadcrumbs, so replace Quarto's bare `h1` form. + content = replace_secondary_nav_title(content, _mcp_title_html) with open(html_file, "w", encoding="utf-8") as file: file.write(content) @@ -2685,7 +2599,7 @@ def process_cli_reference_pages(): This adds the 'cli-title' class to h1 elements in CLI reference pages so they match the monospaced font style of API reference pages. """ - cli_html_files = glob.glob("_site/reference/cli/*.html") + cli_html_files = glob.glob("_site/reference/cli/**/*.html", recursive=True) if not cli_html_files: return @@ -2706,28 +2620,32 @@ def process_cli_reference_pages(): # Replace breadcrumb with a "CLI / great-docs cmd" title bar label _cli_label = _t("cli", "CLI") - _bc_pat = r'' + # Keep the navigation label below the page's `h1` title. if cmd_name != "index": - # Extract full command name from the page title (e.g., "great-docs init") - _title_match = re.search(r'

([^<]+)

', content) - _full_cmd = _title_match.group(1).strip() if _title_match else f"great-docs {cmd_name}" + # Read all text inside the title because the command name may be + # nested in a `span`. + _title_match = re.search(r'

(.*?)

', content, re.DOTALL) + if _title_match: + _full_cmd = html.unescape(re.sub(r"<[^>]+>", "", _title_match.group(1))).strip() + else: + _full_cmd = f"great-docs {cmd_name}" _cli_title_html = ( - f'

' + f'

' f'{_cli_label}' f'/' f'{html.escape(_full_cmd)}' - f"
" + f"" ) else: # CLI index: show "CLI / Index" to mirror the API reference index ("API / Index"). _cli_title_html = ( - f'

' + f'

' f'{_cli_label}' f'/' f'Index' - f"
" + f"" ) - content = re.sub(_bc_pat, _cli_title_html, content, flags=re.DOTALL) + content = replace_secondary_nav_title(content, _cli_title_html) with open(html_file, "w", encoding="utf-8") as file: file.write(content) diff --git a/great_docs/cli.py b/great_docs/cli.py index 6963c366..342c31c6 100644 --- a/great_docs/cli.py +++ b/great_docs/cli.py @@ -811,7 +811,7 @@ def scan(project_path: str | None, docs_dir: str | None, verbose: bool) -> None: click.echo(f"\n✅ Found in great-docs.yml ({len(reference_config)} section(s))") if verbose: for section in reference_config: - title = section.get("title", "Untitled") + title = section.get("title") or section.get("subtitle") or "Untitled" contents = section.get("contents", []) click.echo(f" • {title}: {len(contents)} item(s)") else: diff --git a/great_docs/core.py b/great_docs/core.py index abbd0320..a3c2db1c 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -424,9 +424,7 @@ def _prepare_build_directory(self) -> None: html = index_html.read_text(encoding="utf-8") if _MARIMO_AUTORUN_MARKER not in html: index_html.write_text( - html.replace( - "", _MARIMO_AUTORUN_SCRIPT + "", 1 - ), + html.replace("", _MARIMO_AUTORUN_SCRIPT + "", 1), encoding="utf-8", ) finally: @@ -4544,10 +4542,11 @@ def _generate_cli_index_page(self, cli_info: dict, entry_safe: str) -> str: # --- Front matter (mirrors the API reference index) --- lines.append("---") lines.append(f'title: "{title}"') - lines.append("body-classes: doc-reference doc-cli-reference doc-reference-index") + lines.append("body-classes: doc-reference doc-cli-reference") lines.append("sidebar: cli-reference") lines.append("page-navigation: false") lines.append("html-table-processing: none") + lines.append("shift-heading-level-by: 0") lines.append("---") lines.append("") @@ -4708,17 +4707,16 @@ def _generate_cli_command_page(self, cmd_info: dict, is_main: bool = False) -> s lines.append( f'title: "[{title}]{{.doc-object-name .doc-function .doc-label .{label_class}}}"' ) - lines.append("body-classes: doc-api-page doc-cli-reference") + lines.append("body-classes: doc-api-page doc-cli-api-page") lines.append("sidebar: cli-reference") lines.append("page-navigation: false") lines.append("html-table-processing: none") + # Front matter provides the page's only `h1`; render body sections at + # their written levels. + lines.append("shift-heading-level-by: 0") lines.append("---") lines.append("") - # --- Heading --- - lines.append(f"# [{title}]{{.doc-object-name .doc-function .doc-label .{label_class}}}") - lines.append("") - # --- Description --- description = cmd_info.get("description", "") or cmd_info.get("help", "") # Collect option names for auto-backticking in prose @@ -8773,7 +8771,8 @@ def _build_sections_from_reference_config( for section_config in reference_config: if not isinstance(section_config, dict): continue - title = section_config.get("title", "Untitled") + title = section_config.get("title") + subtitle = section_config.get("subtitle") desc = section_config.get("desc", "") contents_config = section_config.get("contents", []) @@ -8816,7 +8815,11 @@ def _build_sections_from_reference_config( if section_contents: sections.append( { - "title": title, + **( + {"subtitle": subtitle} + if subtitle is not None + else {"title": title or "Untitled"} + ), "desc": desc, "contents": section_contents, } @@ -9137,7 +9140,8 @@ def _create_api_sections_from_config(self, package_name: str) -> list | None: for section_config in reference_config: if not isinstance(section_config, dict): continue # pragma: no cover - title = section_config.get("title", "Untitled") + title = section_config.get("title") + subtitle = section_config.get("subtitle") desc = section_config.get("desc", "") contents_config = section_config.get("contents", []) @@ -9200,7 +9204,11 @@ def _create_api_sections_from_config(self, package_name: str) -> list | None: if section_contents: sections.append( { - "title": title, + **( + {"subtitle": subtitle} + if subtitle is not None + else {"title": title or "Untitled"} + ), "desc": desc, "contents": section_contents, } @@ -13419,7 +13427,8 @@ def _update_sidebar_from_sections(self) -> None: # Build sidebar structure from sections for section in sections: - section_entry = {"section": section["title"], "contents": []} + heading = section.get("title") or section.get("subtitle") or "" + section_entry = {"section": heading, "contents": []} # Add each item in the section for item in section.get("contents", []): @@ -13558,7 +13567,7 @@ def _generate_llms_txt(self) -> None: # Process each section for section in sections: - section_title = section.get("title", "") + section_title = section.get("title") or section.get("subtitle") or "" section_desc = section.get("desc", "") # Add section header as a comment or sub-heading if there are multiple sections @@ -13707,7 +13716,7 @@ def _generate_llms_full_txt(self) -> None: # Process each section for section in sections: - section_title = section.get("title", "") + section_title = section.get("title") or section.get("subtitle") or "" section_desc = section.get("desc", "") # Add section header @@ -13971,7 +13980,7 @@ def _generate_skill_md(self) -> None: lines.append("") for section in sections: - section_title = section.get("title", "") + section_title = section.get("title") or section.get("subtitle") or "" section_desc = section.get("desc", "") if section_title: diff --git a/test-packages/synthetic/catalog.py b/test-packages/synthetic/catalog.py index 7d7312c4..60223cba 100644 --- a/test-packages/synthetic/catalog.py +++ b/test-packages/synthetic/catalog.py @@ -414,6 +414,10 @@ "gdtest_complete_docstrings", # 205 # 206: Marimo notebook islands showcase "gdtest_marimo", # 206 + # 207: API reference pages without breadcrumbs + "gdtest_no_breadcrumbs", # 207 + # 208: MCP object pages + "gdtest_mcp", # 208 ] @@ -2312,6 +2316,15 @@ "On the Reference page you should see Constants, Classes, and Functions " "sections with rich rendered docstrings throughout." ), + "gdtest_no_breadcrumbs": ( + "Mirror gdtest_minimal with two functions and NumPy docstrings. Disable " + "breadcrumbs site-wide through the `site` passthrough. Reference object " + "pages and the index must still show the API title bar and exactly one h1." + ), + "gdtest_mcp": ( + "Document the Great Docs MCP server through an empty Python package. " + "Exercise rendered tool, resource, resource-template and prompt pages." + ), } diff --git a/test-packages/synthetic/specs/gdtest_mcp.py b/test-packages/synthetic/specs/gdtest_mcp.py new file mode 100644 index 00000000..b6a141b2 --- /dev/null +++ b/test-packages/synthetic/specs/gdtest_mcp.py @@ -0,0 +1,69 @@ +""" +Rendered MCP reference fixture +""" + +SPEC = { + "name": "gdtest_mcp", + "description": "Reference pages for every MCP object category", + "dimensions": ["A1", "B1", "C1", "D4", "E6", "F6", "G1", "H7"], + "pyproject_toml": { + "project": { + "name": "gdtest-mcp", + "version": "0.1.0", + "description": "Synthetic MCP reference package", + }, + "build-system": { + "requires": ["setuptools"], + "build-backend": "setuptools.build_meta", + }, + }, + "files": { + "gdtest_mcp/__init__.py": '''\ + """Synthetic package for rendered MCP reference coverage""" + + __version__ = "0.1.0" + __all__ = [] + ''', + "gdtest_mcp/server.py": "from great_docs.mcp import server\n", + "README.md": """\ + # gdtest-mcp + + Exercise rendered documentation for every MCP object category. + """, + }, + "config": { + "exclude": ["server"], + "mcp": { + "enabled": True, + "module": "gdtest_mcp.server", + "server_var": "server", + "name": "GDG MCP Server", + }, + }, + "expected": { + "detected_name": "gdtest-mcp", + "detected_module": "gdtest_mcp", + "detected_parser": "numpy", + "has_user_guide": False, + "mcp_enabled": True, + "coverage_exclude": [ + "ref", + "nodoc", + "bigcl", + "ug", + "supp", + "title", + "badge", + "sig", + "desc", + "param", + "pmatch", + "ret", + "refidx", + "sechdg", + "sbar", + "sbsec", + "hdg", + ], + }, +} diff --git a/test-packages/synthetic/specs/gdtest_mixed_docs.py b/test-packages/synthetic/specs/gdtest_mixed_docs.py index 1deba489..5231ad6c 100644 --- a/test-packages/synthetic/specs/gdtest_mixed_docs.py +++ b/test-packages/synthetic/specs/gdtest_mixed_docs.py @@ -58,6 +58,30 @@ def convert(self, data: str) -> str: """ return data + def is_valid(self, data: str) -> bool: + """Return whether this converter accepts the data + + Args: + data: Data string to validate. + + Returns: + Whether `data` is non-empty. + """ + return len(data) > 0 + + def merge(self, other: "Converter") -> "Converter": + """ + Merge settings from another converter + + :param other: Converter whose settings to copy. + :returns: New converter with the copied format. + + **Notes**:: + + The format from `other` replaces the current format. + """ + return Converter(fmt=other.fmt) + def encode(data: str, encoding: str = "utf-8") -> bytes: """ diff --git a/test-packages/synthetic/specs/gdtest_no_breadcrumbs.py b/test-packages/synthetic/specs/gdtest_no_breadcrumbs.py new file mode 100644 index 00000000..88282702 --- /dev/null +++ b/test-packages/synthetic/specs/gdtest_no_breadcrumbs.py @@ -0,0 +1,105 @@ +""" +Reference pages with site-wide breadcrumbs disabled + +Dimensions: Q1 +Focus: Verify that API object pages and the index retain an API navigation + label and one `h1` when `bread-crumbs: false` removes breadcrumb markup. +""" + +SPEC = { + "name": "gdtest_no_breadcrumbs", + "description": "API reference pages with site-wide breadcrumbs disabled", + "dimensions": ["Q1"], + # ── Project metadata ───────────────────────────────────────────── + "pyproject_toml": { + "project": { + "name": "gdtest-no-breadcrumbs", + "version": "0.1.0", + "description": "Synthetic package with breadcrumbs disabled site-wide", + }, + "build-system": { + "requires": ["setuptools"], + "build-backend": "setuptools.build_meta", + }, + }, + # ── great-docs.yml ─────────────────────────────────────────────── + "config": { + "site": {"bread-crumbs": False}, + }, + # ── Source files ────────────────────────────────────────────────── + "files": { + "gdtest_no_breadcrumbs/__init__.py": '''\ + """Synthetic package with breadcrumbs disabled site-wide""" + + __version__ = "0.1.0" + __all__ = ["greet", "add"] + + + def greet(name: str) -> str: + """ + Return a greeting for a name + + Parameters + ---------- + name + Name to greet. + + Returns + ------- + Greeting string. + """ + return f"Hello, {name}!" + + + def add(a: int, b: int) -> int: + """ + Add two numbers + + Parameters + ---------- + a + First number. + b + Second number. + + Returns + ------- + Sum of `a` and `b`. + """ + return a + b + ''', + "README.md": """\ + # gdtest-no-breadcrumbs + + Exercise reference pages with breadcrumbs disabled site-wide. + + ## Installation + + ```bash + pip install gdtest-no-breadcrumbs + ``` + + ## Usage + + ```python + from gdtest_no_breadcrumbs import greet, add + + greet("World") + add(1, 2) + ``` + """, + }, + # ── Expected outcomes ───────────────────────────────────────────── + "expected": { + "detected_name": "gdtest-no-breadcrumbs", + "detected_module": "gdtest_no_breadcrumbs", + "detected_parser": "numpy", + "export_names": ["greet", "add"], + "num_exports": 2, + "section_titles": ["Functions"], + "has_user_guide": False, + "has_license_page": False, + "has_citation_page": False, + "coverage_exclude": ["nodoc", "bigcl", "ug", "supp"], + }, +} diff --git a/test-packages/synthetic/specs/gdtest_ref_sectioned.py b/test-packages/synthetic/specs/gdtest_ref_sectioned.py index 3af5e523..cebf8834 100644 --- a/test-packages/synthetic/specs/gdtest_ref_sectioned.py +++ b/test-packages/synthetic/specs/gdtest_ref_sectioned.py @@ -1,19 +1,22 @@ """ -gdtest_ref_sectioned — Reference with 4 named sections. +Reference configuration with titled and subtitled sections Dimensions: P5 -Focus: Reference config with four distinct named sections, each containing two functions. +Focus: Exercise four titled sections and one subtitled section. """ SPEC = { "name": "gdtest_ref_sectioned", - "description": "Reference with 4 named sections, each containing two functions.", + "description": ( + "Four titled reference sections with two functions each, plus one " + "subtitled section." + ), "dimensions": ["P5"], "pyproject_toml": { "project": { "name": "gdtest-ref-sectioned", "version": "0.1.0", - "description": "Test reference with 4 named sections.", + "description": "Synthetic reference with four titled sections and one subtitled section.", }, "build-system": { "requires": ["setuptools"], @@ -54,10 +57,17 @@ {"name": "from_string"}, ], }, + { + "subtitle": "Miscellaneous", + "desc": "Odds and ends", + "contents": [ + {"name": "format_label"}, + ], + }, ], }, "files": { - "gdtest_ref_sectioned/__init__.py": '"""Test package for reference with 4 named sections."""\n\nfrom .constructors import create_layout, create_widget\nfrom .transformers import resize, rotate\nfrom .validators import check_bounds, check_type\nfrom .utilities import from_string, to_string\n\n__all__ = [\n "check_bounds", "check_type", "create_layout", "create_widget",\n "from_string", "resize", "rotate", "to_string",\n]\n', + "gdtest_ref_sectioned/__init__.py": '"""Synthetic package with four titled and one subtitled reference section"""\n\nfrom .constructors import create_layout, create_widget\nfrom .transformers import resize, rotate\nfrom .validators import check_bounds, check_type\nfrom .utilities import from_string, to_string\nfrom .misc import format_label\n\n__all__ = [\n "check_bounds", "check_type", "create_layout", "create_widget",\n "format_label", "from_string", "resize", "rotate", "to_string",\n]\n', "gdtest_ref_sectioned/constructors.py": ''' """Constructor functions for creating widgets and layouts.""" @@ -257,7 +267,36 @@ def from_string(text: str) -> object: except ValueError: return text ''', - "README.md": ("# gdtest-ref-sectioned\n\nTest reference with 4 named sections.\n"), + "gdtest_ref_sectioned/misc.py": ''' + """Helpers grouped under the subtitled reference section""" + + + def format_label(name: str, upper: bool = False) -> str: + """ + Format a display label + + Parameters + ---------- + name + Name to format. + upper + Convert the label to uppercase. + + Returns + ------- + Formatted label. + + Examples + -------- + >>> format_label("widget") + 'widget' + """ + return name.upper() if upper else name + ''', + "README.md": ( + "# gdtest-ref-sectioned\n\n" + "Exercise four titled reference sections and one subtitled section.\n" + ), }, "expected": { "detected_name": "gdtest-ref-sectioned", @@ -268,12 +307,13 @@ def from_string(text: str) -> object: "check_type", "create_layout", "create_widget", + "format_label", "from_string", "resize", "rotate", "to_string", ], - "num_exports": 8, + "num_exports": 9, "coverage_exclude": ["nodoc", "bigcl", "ug", "supp", "sechdg", "sbsec", "hdg"], }, } diff --git a/tests/renderer/test_api_reference.py b/tests/renderer/test_api_reference.py index f9be9ef3..be98bcfe 100644 --- a/tests/renderer/test_api_reference.py +++ b/tests/renderer/test_api_reference.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + from great_docs._apiref import spec from great_docs._apiref.api_reference import APIReference, Settings @@ -63,6 +65,26 @@ def test_options_stored_as_is(): assert ref.options == opts +def test_toc_depth_reads_generated_quarto_config(tmp_path: Path): + config = tmp_path / "_quarto.yml" + config.write_text( + "api-reference:\n package: pkg\nformat:\n html:\n toc-depth: 4\n", + encoding="utf-8", + ) + ref = APIReference(config) + assert ref.site_toc_depth == 4 + + +def test_toc_depth_reads_source_config(): + ref = APIReference( + { + "api-reference": {"package": "pkg"}, + "site": {"toc-depth": 5}, + } + ) + assert ref.site_toc_depth == 5 + + def test_settings_defaults(): s = Settings() assert s.dir == "reference" diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index ae27c432..ec9f51b5 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -135,7 +135,7 @@ def _get_badge_text(soup: "BeautifulSoup") -> str | None: The q renderer renders badges as ```` inside the title heading. Returns the badge type lowered, or None. """ - title = soup.select_one("h1.title, h2.title") + title = soup.select_one("h1.title") if title is None: return None @@ -384,7 +384,7 @@ def test_reference_pages_have_title(pkg_name: str): continue soup = _load_html(page) - title = soup.select_one("h1.title, h2.title") + title = soup.select_one("h1.title") assert title is not None, f"{page.name} missing .title heading" assert name in title.get_text(), ( f"{page.name} title doesn't contain {name!r}: {title.get_text()!r}" @@ -524,6 +524,383 @@ def test_footer_text_not_in_header(pkg_name: str): ) +@requires_bs4 +@pytest.mark.parametrize( + "pkg_name", + ["gdtest_minimal", "gdtest_google", "gdtest_sphinx", "gdtest_mixed_docs", "gdtest_no_breadcrumbs"], +) +def test_reference_page_heading_levels(pkg_name: str): + """ + Verify the reference object-page heading hierarchy + + Each page has one `h1` title, `h2` docstring sections and member groups, + and `h3` individual members. The no-breadcrumb fixture also verifies that + Quarto's bare navigation `h1` is demoted to `h5`. + """ + ref = _ref_dir(pkg_name) + if not ref.exists(): + pytest.skip(f"No reference dir for {pkg_name}") + + pages = [p for p in ref.glob("*.html") if p.name != "index.html"] + assert pages, f"{pkg_name}: no reference object pages" + + sections_checked = 0 + members_checked = 0 + for page in pages: + soup = _load_html(page) + + title = soup.select_one("h1.title") + assert title is not None, ( + f"{page.name}: missing h1.title page title" + ) + assert soup.select_one("h2.title") is None, ( + f"{page.name}: page title was shifted to h2.title" + ) + + # Require one document-level `h1`: the page title. The navigation label + # must remain `h5`, even when breadcrumbs are disabled. + page_h1s = soup.select("h1") + assert len(page_h1s) == 1, ( + f"{page.name}: expected exactly one h1 in the whole document, " + f"found {len(page_h1s)}" + ) + nav_title = soup.select_one("h5.quarto-secondary-nav-title.gd-ref-title") + assert nav_title is not None, ( + f"{page.name}: missing h5.gd-ref-title navigation label" + ) + + main = soup.select_one("main") + assert main is not None, f"{page.name}: no
element" + main_h1s = main.select("h1") + title_h1s = [h for h in main_h1s if "title" in (h.get("class") or [])] + assert len(title_h1s) == 1, ( + f"{page.name}: expected exactly one h1.title inside
, " + f"found {len(title_h1s)} (of {len(main_h1s)} h1 elements total)" + ) + + # Check only page-level sections. A member's own Parameters or Returns + # section is correctly nested at `level4` within its `level3` member. + for section in soup.select("section.level2.doc-parameters, section.level2.doc-methods"): + heading = section.select_one("h1, h2, h3, h4, h5, h6") + assert heading is not None, f"{page.name}: section has no heading" + assert heading.name == "h2", ( + f"{page.name}: expected h2 section heading, found {heading.name}" + ) + sections_checked += 1 + + for member in soup.select( + "section.doc-methods section.level3, section.doc-attributes section.level3" + ): + heading = member.select_one("h1, h2, h3, h4, h5, h6") + assert heading is not None, f"{page.name}: member has no heading" + assert heading.name == "h3", ( + f"{page.name}: expected h3 member heading, found {heading.name}" + ) + members_checked += 1 + + assert sections_checked > 0, f"{pkg_name}: no docstring sections were checked" + # Packages without classes may contain no member sections. Skip them here; + # the companion test verifies member coverage across the full fixture set. + if members_checked == 0: + pytest.skip(f"{pkg_name}: no member sections to check") + + +@requires_bs4 +def test_reference_page_heading_levels_exercises_member_check(): + """ + Verify that the fixture set exercises member heading checks + + This fails if every parametrised package loses its class members and the + `h3` member assertion stops running. + """ + found_members = False + for pkg_name in ("gdtest_minimal", "gdtest_google", "gdtest_sphinx", "gdtest_mixed_docs"): + ref = _ref_dir(pkg_name) + if not ref.exists(): + continue + for page in ref.glob("*.html"): + if page.name == "index.html": + continue + soup = _load_html(page) + if soup.select( + "section.doc-methods section.level3, section.doc-attributes section.level3" + ): + found_members = True + break + if found_members: + break + + assert found_members, ( + "no parametrised package has a member section; the h3 member-heading " + "assertion is not exercised" + ) + + +@requires_bs4 +@pytest.mark.parametrize("pkg_name", ["gdtest_minimal", "gdtest_google", "gdtest_no_breadcrumbs"]) +def test_reference_index_heading_levels(pkg_name: str): + """Verify that reference index group headings are `h2` elements""" + index = _ref_dir(pkg_name) / "index.html" + if not index.exists(): + pytest.skip(f"No reference index for {pkg_name}") + + soup = _load_html(index) + + title = soup.select_one("h1.title") + assert title is not None, "reference index is missing its h1.title" + + # Require one page title and an `h5` navigation label with or without + # breadcrumbs. + page_h1s = soup.select("h1") + assert len(page_h1s) == 1, ( + f"reference index: expected exactly one h1, found {len(page_h1s)}" + ) + nav_title = soup.select_one("h5.quarto-secondary-nav-title.gd-ref-title") + assert nav_title is not None, "reference index is missing its h5.gd-ref-title label" + + groups = soup.select("h1.doc-group, h2.doc-group, h3.doc-group, h4.doc-group") + assert groups, "reference index contains no group headings" + for heading in groups: + assert heading.name == "h2", ( + f"expected h2 group heading {heading.get_text(strip=True)!r}, " + f"found {heading.name}" + ) + + +@requires_bs4 +def test_reference_index_subtitle_renders_as_h3_in_toc(): + """ + Verify subtitle-only reference sections in the index + + The fixture's final section has `subtitle` instead of `title`. It must + render as `h3.doc-group` and appear in the table of contents beside the + `h2` titled sections. + """ + index = _ref_dir("gdtest_ref_sectioned") / "index.html" + if not index.exists(): + pytest.skip("No reference index for gdtest_ref_sectioned") + + soup = _load_html(index) + + subtitle_heading = soup.select_one("h3.doc-group") + assert subtitle_heading is not None, "subtitled section has no h3.doc-group heading" + assert subtitle_heading.get_text(strip=True) == "Miscellaneous" + + # Titled sections remain at `h2`. + title_headings = soup.select("h2.doc-group") + assert {h.get_text(strip=True) for h in title_headings} == { + "Constructors", + "Transformers", + "Validators", + "Utilities", + } + + toc = soup.select_one("nav#TOC") + assert toc is not None, "reference index is missing nav#TOC" + toc_entries = {a.get_text(strip=True) for a in toc.select("a")} + assert "Miscellaneous" in toc_entries, ( + "subtitled section is missing from the table of contents" + ) + + +@requires_bs4 +def test_class_page_toc_lists_members(): + """ + Verify that a class page table of contents lists members + + Members render at `h3` and therefore require a table-of-contents depth of + 3. The Converter fixture supplies three methods to check. + """ + converter = _ref_dir("gdtest_mixed_docs") / "Converter.html" + if not converter.exists(): + pytest.skip("No Converter page for gdtest_mixed_docs") + + soup = _load_html(converter) + + toc = soup.select_one("nav#TOC") + assert toc is not None, "Converter page is missing nav#TOC" + toc_entries = {a.get_text(strip=True) for a in toc.select("a")} + for member in ("convert()", "is_valid()", "merge()"): + assert member in toc_entries, ( + f"member {member!r} is missing from the Converter page's table of contents" + ) + + +def _section_body(text: str, heading_prefix: str, title: str, *, source_name: str) -> str: + """ + Extract the text below a matching heading + + Stop at the next heading of the same level so tests can distinguish an + entry in the expected section from one in an adjacent section. + + Parameters + ---------- + text + Complete Markdown document. + heading_prefix + Markdown heading marker, including its trailing space. + title + Heading text that starts the section. + source_name + Document name for assertion failures. + + Returns + ------- + Text between the matching heading and the next peer heading. + """ + pattern = re.compile(rf"^{re.escape(heading_prefix)}(.+)$", re.MULTILINE) + matches = list(pattern.finditer(text)) + starts = {match.group(1).strip(): match.end() for match in matches} + assert title in starts, f"{source_name}: missing {heading_prefix!r}{title} heading" + start = starts[title] + later_starts = [match.start() for match in matches if match.start() > start] + end = min(later_starts) if later_starts else len(text) + return text[start:end] + + +@requires_bs4 +def test_subtitle_only_section_heading_in_llms_outputs(): + """ + Verify subtitle-only headings in machine-readable outputs + + The Miscellaneous section uses `subtitle` instead of `title`. Its + `format_label` entry must appear below that heading, not below the preceding + Utilities heading, in `llms.txt`, `llms-full.txt` and `skill.md`. + """ + site = _RENDERED_DIR / "gdtest_ref_sectioned" / "great-docs" / "_site" + llms_txt = site / "llms.txt" + llms_full = site / "llms-full.txt" + skill_md = site / "skill.md" + for path in (llms_txt, llms_full, skill_md): + if not path.exists(): + pytest.skip(f"{path.name} not built for gdtest_ref_sectioned") + + for path, heading_prefix in ( + (llms_txt, "#### "), + (llms_full, "## "), + (skill_md, "### "), + ): + text = path.read_text(encoding="utf-8") + misc_body = _section_body(text, heading_prefix, "Miscellaneous", source_name=path.name) + utilities_body = _section_body(text, heading_prefix, "Utilities", source_name=path.name) + assert "format_label" in misc_body, ( + f"{path.name}: format_label is missing below the Miscellaneous heading" + ) + assert "format_label" not in utilities_body, ( + f"{path.name}: format_label appears in the preceding Utilities section" + ) + + +@requires_bs4 +def test_fallback_docstring_section_nested_in_member_renders_as_h4(): + """ + Verify that fallback sections follow member nesting + + `Converter.is_valid` renders at `h3`, so its fallback Parameters and + Returns sections must render at `h4`. The top-level `validate` function's + fallback sections remain at `h2`. + """ + converter = _ref_dir("gdtest_mixed_docs") / "Converter.html" + if not converter.exists(): + pytest.skip("No Converter page for gdtest_mixed_docs") + + soup = _load_html(converter) + + is_valid_section = soup.select_one("section#is_valid") + assert is_valid_section is not None, "is_valid member section is missing" + assert is_valid_section.get("class") and "level3" in is_valid_section["class"], ( + "is_valid member section is not level3" + ) + + fallback_headings = is_valid_section.select("section.doc-section h1, " + "section.doc-section h2, section.doc-section h3, section.doc-section h4") + assert fallback_headings, "is_valid has no fallback sections to check" + for heading in fallback_headings: + assert heading.name == "h4", ( + f"expected h4 fallback section {heading.get_text(strip=True)!r} " + f"inside is_valid, found {heading.name}" + ) + + validate_page = _ref_dir("gdtest_mixed_docs") / "validate.html" + if validate_page.exists(): + validate_soup = _load_html(validate_page) + top_level_headings = validate_soup.select("section.doc-section h2") + assert top_level_headings, "validate has no top-level h2 fallback sections" + + +@requires_bs4 +def test_chained_fallback_translators_stay_at_h4_in_member(): + """ + Verify that chained fallback sections remain nested + + `Converter.merge` triggers field and bold-section fallbacks in sequence. + The first emits an `h4`; the second must treat that heading as member + context and emit another `h4`. + """ + converter = _ref_dir("gdtest_mixed_docs") / "Converter.html" + if not converter.exists(): + pytest.skip("No Converter page for gdtest_mixed_docs") + + soup = _load_html(converter) + + merge_section = soup.select_one("section#merge") + assert merge_section is not None, "merge member section is missing" + assert merge_section.get("class") and "level3" in merge_section["class"], ( + "merge member section is not level3" + ) + + fallback_headings = merge_section.select( + "section.doc-section h1, section.doc-section h2, " + "section.doc-section h3, section.doc-section h4" + ) + assert fallback_headings, "merge has no fallback sections to check" + for heading in fallback_headings: + assert heading.name == "h4", ( + f"expected h4 fallback section {heading.get_text(strip=True)!r} " + f"inside merge, found {heading.name}" + ) + section_names = {h.get_text(strip=True) for h in fallback_headings} + assert "Notes" in section_names, "merge's chained Notes section is missing" + + +@requires_bs4 +@pytest.mark.parametrize( + ("pkg_name", "page_name"), + [("gdtest_mixed_docs", "Converter.html"), ("gdtest_sphinx", "Timer.html")], +) +def test_member_separator_rules_present(pkg_name: str, page_name: str): + """ + Verify class member separators + + Class pages contain one solid rule after the member summary and one dotted + rule between each pair of `level3` members. Derive the expected count from + the rendered members so fixtures can add methods without changing the test. + """ + page = _ref_dir(pkg_name) / page_name + if not page.exists(): + pytest.skip(f"No {page_name} page for {pkg_name}") + + soup = _load_html(page) + members = soup.select( + "section.doc-methods section.level3, section.doc-attributes section.level3" + ) + if not members: + pytest.skip(f"{page_name}: no member sections to check") + + rules = soup.select("hr") + solid_rules = [r for r in rules if "solid" in (r.get("style") or "")] + dotted_rules = [r for r in rules if "dotted" in (r.get("style") or "")] + + assert len(solid_rules) == 1, ( + f"{page_name}: expected exactly one solid rule after the members " + f"summary table, found {len(solid_rules)}" + ) + assert len(dotted_rules) == len(members) - 1, ( + f"{page_name}: expected {len(members) - 1} dotted rules between " + f"{len(members)} members, found {len(dotted_rules)}" + ) + + # ═══════════════════════════════════════════════════════════════════════════════ # R2: Docstring Rendering — parameters, returns, raises, examples # ═══════════════════════════════════════════════════════════════════════════════ @@ -1733,6 +2110,111 @@ def test_cli_sidebar_has_cli_section(): ) +@pytest.mark.dedicated +@requires_bs4 +@pytest.mark.parametrize("pkg_name", ["gdtest_cli_click", "gdtest_cli_nested"]) +def test_cli_command_page_heading_levels(pkg_name: str): + """ + Verify the CLI command-page heading hierarchy + + Front matter provides one `h1` title. Arguments, Options and Commands + sections render at `h2`. The nested fixture covers group and leaf commands. + """ + cli_dir = _ref_dir(pkg_name) / "cli" + if not cli_dir.exists(): + pytest.skip(f"No reference/cli/ directory for {pkg_name}") + + pages = [p for p in cli_dir.rglob("*.html") if p.name != "index.html"] + assert pages, f"{pkg_name}: no CLI command pages" + + sections_checked = 0 + for page in pages: + soup = _load_html(page) + + title = soup.select_one("h1.title") + assert title is not None, f"{page.name}: missing h1.title page title" + + page_h1s = soup.select("h1") + assert len(page_h1s) == 1, ( + f"{page.name}: expected exactly one h1 in the whole document, " + f"found {len(page_h1s)}" + ) + + for section in soup.select("section.level2.doc-parameters"): + heading = section.select_one("h1, h2, h3, h4, h5, h6") + assert heading is not None, f"{page.name}: section has no heading" + assert heading.name == "h2", ( + f"{page.name}: expected h2 section heading, found {heading.name}" + ) + sections_checked += 1 + + assert sections_checked > 0, f"{pkg_name}: no CLI command sections were checked" + + +@pytest.mark.dedicated +@requires_bs4 +@pytest.mark.parametrize( + ("pkg_name", "page_rel_path"), + [ + ("gdtest_cli_click", "gdtest_cli.html"), + ("gdtest_cli_nested", "config/get.html"), + ], +) +def test_cli_title_bar_label_matches_command_name(pkg_name: str, page_rel_path: str): + """ + Verify that CLI navigation labels use full command names + + Compare the `h5` navigation label with the marked-up `h1` title on flat + and nested command pages. + """ + cli_dir = _ref_dir(pkg_name) / "cli" + page = cli_dir / page_rel_path + if not page.exists(): + pytest.skip(f"No {page_rel_path} for {pkg_name}") + + soup = _load_html(page) + + title = soup.select_one("h1.title") + assert title is not None, f"{page_rel_path}: missing h1.title page title" + command_name = title.get_text(strip=True) + + label = soup.select_one("h5.gd-ref-title .gd-ref-title-name") + assert label is not None, f"{page_rel_path}: navigation label is missing" + + assert label.get_text(strip=True) == command_name, ( + f"{page_rel_path}: title bar label {label.get_text(strip=True)!r} " + f"does not match the command name {command_name!r}" + ) + + +@pytest.mark.dedicated +@requires_bs4 +def test_cli_index_heading_levels(): + """Verify that CLI index group headings are `h2` elements""" + pkg = "gdtest_cli_nested" + index = _ref_dir(pkg) / "cli" / "index.html" + if not index.exists(): + pytest.skip(f"No CLI index for {pkg}") + + soup = _load_html(index) + + title = soup.select_one("h1.title") + assert title is not None, "CLI reference index is missing its h1.title" + + page_h1s = soup.select("h1") + assert len(page_h1s) == 1, ( + f"CLI reference index: expected exactly one h1, found {len(page_h1s)}" + ) + + groups = soup.select("h1.doc-group, h2.doc-group, h3.doc-group, h4.doc-group") + assert groups, "CLI reference index contains no group headings" + for heading in groups: + assert heading.name == "h2", ( + f"expected h2 group heading {heading.get_text(strip=True)!r}, " + f"found {heading.name}" + ) + + @pytest.mark.dedicated def test_cli_sidebar_structure_flat(): """Flat CLI sidebar in _quarto.yml should contain only path strings.""" @@ -1755,8 +2237,8 @@ def test_cli_sidebar_structure_flat(): contents = cli_section.get("contents", []) assert len(contents) >= 1 - # All items should be plain path strings — no section dicts - for item in contents: + # The first item is the labelled CLI index; the remaining items are paths. + for item in contents[1:]: assert isinstance(item, str), f"Flat CLI sidebar should only have path strings, got: {item}" @@ -1783,9 +2265,9 @@ def test_cli_sidebar_structure_nested(): contents = cli_section.get("contents", []) assert len(contents) >= 3, f"Expected at least 3 items (index + 2 groups), got {len(contents)}" - # First item should be the main CLI index page - assert contents[0] == "reference/cli/index.qmd", ( - f"First sidebar item should be the CLI index, got: {contents[0]}" + # Require the labelled CLI index as the first item. + assert contents[0] == {"text": "CLI Index", "href": "reference/cli/index.qmd"}, ( + f"First sidebar item should be the labelled CLI index link, got: {contents[0]}" ) # Remaining items for groups should be section dicts @@ -1859,6 +2341,94 @@ def test_cli_sidebar_no_raw_qmd_paths_in_nested(): ) +# ═══════════════════════════════════════════════════════════════════════════════ +# R4: MCP documentation +# ═══════════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.dedicated +@requires_bs4 +def test_mcp_page_heading_levels(): + """ + Verify the MCP object-page heading hierarchy + + Quarto hoists each `.title` heading into the page header. The result must + contain one `h1`, with object details and parameters at `h2`. + """ + pkg = "gdtest_mcp" + mcp_dir = _ref_dir(pkg) / "mcp" + if not mcp_dir.exists(): + pytest.skip(f"No reference/mcp/ directory for {pkg}") + + expected_pages = { + "gd_build.html": "mcp-tool", + "resource_build_log.html": "mcp-resource", + "template_reference_symbol.html": "mcp-resource-template", + "prompt_setup_docs.html": "mcp-prompt", + } + for filename, label_class in expected_pages.items(): + page = mcp_dir / filename + assert page.exists(), f"{pkg}: missing {filename}" + soup = _load_html(page) + assert soup.select_one(f"h1.title .doc-label-{label_class}") is not None, ( + f"{filename}: missing {label_class} title label" + ) + + pages = [p for p in mcp_dir.glob("*.html") if p.name != "index.html"] + assert pages, f"{pkg}: no MCP pages" + + sections_checked = 0 + for page in pages: + soup = _load_html(page) + + title = soup.select_one("h1.title") + assert title is not None, f"{page.name}: missing h1.title page title" + + page_h1s = soup.select("h1") + assert len(page_h1s) == 1, ( + f"{page.name}: expected exactly one h1 in the whole document, " + f"found {len(page_h1s)}" + ) + + for section in soup.select("section.level2.doc-parameters"): + heading = section.select_one("h1, h2, h3, h4, h5, h6") + assert heading is not None, f"{page.name}: section has no heading" + assert heading.name == "h2", ( + f"{page.name}: expected h2 section heading, found {heading.name}" + ) + sections_checked += 1 + + assert sections_checked > 0, f"{pkg}: no MCP object sections were checked" + + +@pytest.mark.dedicated +@requires_bs4 +def test_mcp_index_heading_levels(): + """Verify that MCP index group headings are `h2` elements""" + pkg = "gdtest_mcp" + index = _ref_dir(pkg) / "mcp" / "index.html" + if not index.exists(): + pytest.skip(f"No MCP index for {pkg}") + + soup = _load_html(index) + + title = soup.select_one("h1.title") + assert title is not None, "MCP reference index is missing its h1.title" + + page_h1s = soup.select("h1") + assert len(page_h1s) == 1, ( + f"MCP reference index: expected exactly one h1, found {len(page_h1s)}" + ) + + groups = soup.select("h1.doc-group, h2.doc-group, h3.doc-group, h4.doc-group") + assert groups, "MCP reference index contains no group headings" + for heading in groups: + assert heading.name == "h2", ( + f"expected h2 group heading {heading.get_text(strip=True)!r}, " + f"found {heading.name}" + ) + + # ═══════════════════════════════════════════════════════════════════════════════ # R4: Math Rendering # ═══════════════════════════════════════════════════════════════════════════════ @@ -3932,7 +4502,7 @@ def test_copy_page_widget_does_not_overlap_long_titles(): assert "copy-page.js" in content, f"{page.name}: copy-page.js script missing" # Title should exist and contain the object name - title_el = soup.select_one("h2.title, h1.title") + title_el = soup.select_one("h1.title") assert title_el is not None, f"{page.name}: no title element found" title_text = title_el.get_text(strip=True) @@ -3940,10 +4510,11 @@ def test_copy_page_widget_does_not_overlap_long_titles(): f"{page.name}: title text too short ({title_text!r}), expected long name" ) - # The title should render in monospace font (code convention for API names) - style = title_el.get("style", "") - assert "monospace" in style or "SFMono" in style, ( - f"{page.name}: title should use monospace font for code-like names" + # The title's object name is styled as code by `span.doc-object-name` + # in great-docs.scss, not by an inline style attribute. + name_span = title_el.select_one("span.doc-object-name") + assert name_span is not None, ( + f"{page.name}: title has no span.doc-object-name to style as code" ) diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index 594d7b2b..9040ef30 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -11768,7 +11768,7 @@ def test_generate_cli_index_page_auto_layout(): page = docs._generate_cli_index_page(cli_info, "tool") # Front matter mirrors the API reference index, plus the CLI scoping class. - assert "body-classes: doc-reference doc-cli-reference doc-reference-index" in page + assert "body-classes: doc-reference doc-cli-reference" in page assert "sidebar: cli-reference" in page # Root command link is present with the group pill. assert "[tool](tool.qmd){.doc-function .doc-label .doc-label-cli-group}" in page